From c539e6fd832567e8e4a53e22fe80fb44b48391c5 Mon Sep 17 00:00:00 2001 From: iven Date: Sun, 31 May 2026 20:52:19 +0800 Subject: [PATCH] feat: initialize Nuanji (Warm Notes) project - Base platform from base.git (ERP base: auth, core, config, message, workflow, plugin) - Created erp-diary module skeleton (lib.rs, dto.rs, error.rs, event.rs, state.rs) - Integrated erp-diary into workspace and erp-server - Added DiaryModule registration in main.rs - Added DiaryState FromRef in state.rs - Diary routes mounted (empty routes, ready for implementation) - Product design spec v1.2 preserved in docs/ - Implementation plan preserved in plans/ Cargo check: OK Cargo test: OK (78+ base tests passing) --- .gitignore | 111 + CLAUDE.md | 535 ++ Cargo.lock | 6752 +++++++++++++++++ Cargo.toml | 117 + Dockerfile | 113 + config/default.toml | 81 + crates/erp-auth/Cargo.toml | 29 + crates/erp-auth/src/auth_state.rs | 83 + crates/erp-auth/src/dto.rs | 506 ++ crates/erp-auth/src/entity/department.rs | 68 + crates/erp-auth/src/entity/mod.rs | 12 + crates/erp-auth/src/entity/organization.rs | 40 + crates/erp-auth/src/entity/permission.rs | 37 + crates/erp-auth/src/entity/position.rs | 42 + crates/erp-auth/src/entity/role.rs | 44 + crates/erp-auth/src/entity/role_permission.rs | 53 + crates/erp-auth/src/entity/user.rs | 59 + crates/erp-auth/src/entity/user_credential.rs | 41 + crates/erp-auth/src/entity/user_department.rs | 54 + crates/erp-auth/src/entity/user_role.rs | 51 + crates/erp-auth/src/entity/user_token.rs | 44 + crates/erp-auth/src/entity/wechat_user.rs | 41 + crates/erp-auth/src/error.rs | 105 + crates/erp-auth/src/handler/auth_handler.rs | 192 + crates/erp-auth/src/handler/mod.rs | 5 + crates/erp-auth/src/handler/org_handler.rs | 460 ++ crates/erp-auth/src/handler/role_handler.rs | 320 + crates/erp-auth/src/handler/user_handler.rs | 322 + crates/erp-auth/src/handler/wechat_handler.rs | 86 + crates/erp-auth/src/lib.rs | 11 + crates/erp-auth/src/middleware/jwt_auth.rs | 273 + crates/erp-auth/src/middleware/mod.rs | 4 + crates/erp-auth/src/module.rs | 372 + crates/erp-auth/src/service/auth_service.rs | 414 + crates/erp-auth/src/service/dept_service.rs | 414 + crates/erp-auth/src/service/mod.rs | 11 + crates/erp-auth/src/service/org_service.rs | 494 ++ crates/erp-auth/src/service/password.rs | 56 + .../src/service/permission_service.rs | 38 + .../erp-auth/src/service/position_service.rs | 259 + crates/erp-auth/src/service/role_service.rs | 370 + crates/erp-auth/src/service/seed.rs | 547 ++ crates/erp-auth/src/service/token_service.rs | 326 + crates/erp-auth/src/service/user_service.rs | 629 ++ crates/erp-auth/src/service/wechat_service.rs | 575 ++ crates/erp-config/Cargo.toml | 20 + crates/erp-config/src/config_state.rs | 11 + crates/erp-config/src/dto.rs | 693 ++ crates/erp-config/src/entity/dictionary.rs | 35 + .../erp-config/src/entity/dictionary_item.rs | 42 + crates/erp-config/src/entity/menu.rs | 43 + crates/erp-config/src/entity/menu_role.rs | 38 + crates/erp-config/src/entity/mod.rs | 6 + .../erp-config/src/entity/numbering_rule.rs | 34 + crates/erp-config/src/entity/setting.rs | 27 + crates/erp-config/src/error.rs | 143 + .../src/handler/dictionary_handler.rs | 360 + .../src/handler/language_handler.rs | 142 + crates/erp-config/src/handler/menu_handler.rs | 263 + crates/erp-config/src/handler/mod.rs | 6 + .../src/handler/numbering_handler.rs | 220 + .../erp-config/src/handler/setting_handler.rs | 169 + .../erp-config/src/handler/theme_handler.rs | 176 + crates/erp-config/src/lib.rs | 10 + crates/erp-config/src/module.rs | 267 + .../src/service/dictionary_service.rs | 628 ++ crates/erp-config/src/service/menu_service.rs | 600 ++ crates/erp-config/src/service/mod.rs | 4 + .../src/service/numbering_service.rs | 747 ++ .../erp-config/src/service/setting_service.rs | 447 ++ crates/erp-core/Cargo.toml | 26 + crates/erp-core/src/aggregate.rs | 38 + crates/erp-core/src/audit.rs | 67 + crates/erp-core/src/audit_service.rs | 285 + crates/erp-core/src/crypto/engine.rs | 48 + crates/erp-core/src/crypto/hmac_index.rs | 24 + crates/erp-core/src/crypto/key_manager.rs | 225 + crates/erp-core/src/crypto/masking.rs | 113 + crates/erp-core/src/crypto/mod.rs | 234 + crates/erp-core/src/entity/audit_log.rs | 29 + .../erp-core/src/entity/dead_letter_event.rs | 27 + crates/erp-core/src/entity/domain_event.rs | 24 + crates/erp-core/src/entity/mod.rs | 4 + crates/erp-core/src/entity/processed_event.rs | 18 + crates/erp-core/src/error.rs | 188 + crates/erp-core/src/events.rs | 458 ++ crates/erp-core/src/lib.rs | 19 + crates/erp-core/src/module.rs | 357 + crates/erp-core/src/rbac.rs | 102 + crates/erp-core/src/request_info.rs | 54 + crates/erp-core/src/sanitize.rs | 218 + crates/erp-core/src/sea_orm_ext.rs | 17 + crates/erp-core/src/test_helpers.rs | 37 + crates/erp-core/src/types.rs | 188 + crates/erp-diary/Cargo.toml | 21 + crates/erp-diary/src/dto.rs | 125 + crates/erp-diary/src/entity/mod.rs | 2 + crates/erp-diary/src/error.rs | 75 + crates/erp-diary/src/event.rs | 61 + crates/erp-diary/src/handler/mod.rs | 2 + crates/erp-diary/src/lib.rs | 112 + crates/erp-diary/src/service/mod.rs | 2 + crates/erp-diary/src/state.rs | 13 + crates/erp-message/Cargo.toml | 23 + crates/erp-message/src/dto.rs | 517 ++ crates/erp-message/src/entity/message.rs | 58 + .../src/entity/message_subscription.rs | 32 + .../src/entity/message_template.rs | 37 + crates/erp-message/src/entity/mod.rs | 3 + crates/erp-message/src/error.rs | 144 + .../src/handler/message_handler.rs | 194 + crates/erp-message/src/handler/mod.rs | 4 + crates/erp-message/src/handler/sse_handler.rs | 322 + .../src/handler/subscription_handler.rs | 60 + .../src/handler/template_handler.rs | 140 + crates/erp-message/src/lib.rs | 10 + crates/erp-message/src/message_state.rs | 9 + crates/erp-message/src/module.rs | 1283 ++++ .../src/service/message_service.rs | 570 ++ crates/erp-message/src/service/mod.rs | 3 + .../src/service/subscription_service.rs | 155 + .../src/service/template_service.rs | 296 + crates/erp-plugin/Cargo.toml | 30 + crates/erp-plugin/src/data_dto.rs | 331 + crates/erp-plugin/src/data_service.rs | 1907 +++++ crates/erp-plugin/src/dto.rs | 68 + crates/erp-plugin/src/dynamic_table.rs | 1759 +++++ crates/erp-plugin/src/engine.rs | 875 +++ crates/erp-plugin/src/entity/market_entry.rs | 45 + crates/erp-plugin/src/entity/market_review.rs | 33 + crates/erp-plugin/src/entity/mod.rs | 5 + crates/erp-plugin/src/entity/plugin.rs | 54 + crates/erp-plugin/src/entity/plugin_entity.rs | 43 + .../src/entity/plugin_event_subscription.rs | 30 + crates/erp-plugin/src/error.rs | 55 + crates/erp-plugin/src/handler/data_handler.rs | 1121 +++ .../erp-plugin/src/handler/market_handler.rs | 386 + crates/erp-plugin/src/handler/mod.rs | 3 + .../erp-plugin/src/handler/plugin_handler.rs | 510 ++ crates/erp-plugin/src/host.rs | 438 ++ crates/erp-plugin/src/lib.rs | 26 + crates/erp-plugin/src/manifest.rs | 1809 +++++ crates/erp-plugin/src/module.rs | 200 + crates/erp-plugin/src/notification.rs | 109 + crates/erp-plugin/src/plugin_validator.rs | 317 + crates/erp-plugin/src/service.rs | 1136 +++ crates/erp-plugin/src/state.rs | 52 + crates/erp-plugin/wit/plugin.wit | 54 + crates/erp-server/Cargo.toml | 50 + crates/erp-server/config/default.toml | 69 + crates/erp-server/migration/Cargo.toml | 8 + crates/erp-server/migration/src/lib.rs | 120 + .../src/m20260410_000001_create_tenant.rs | 69 + .../src/m20260411_000002_create_users.rs | 104 + ...20260411_000003_create_user_credentials.rs | 127 + .../m20260411_000004_create_user_tokens.rs | 140 + .../src/m20260411_000005_create_roles.rs | 101 + .../m20260411_000006_create_permissions.rs | 103 + ...20260411_000007_create_role_permissions.rs | 115 + .../src/m20260411_000008_create_user_roles.rs | 111 + .../m20260411_000009_create_organizations.rs | 116 + .../m20260411_000010_create_departments.rs | 146 + .../src/m20260411_000011_create_positions.rs | 125 + .../m20260412_000012_create_dictionaries.rs | 92 + ...20260412_000013_create_dictionary_items.rs | 118 + .../src/m20260412_000014_create_menus.rs | 124 + .../src/m20260412_000015_create_menu_roles.rs | 96 + .../src/m20260412_000016_create_settings.rs | 94 + ...m20260412_000017_create_numbering_rules.rs | 136 + ...60412_000018_create_process_definitions.rs | 138 + ...0260412_000019_create_process_instances.rs | 144 + .../src/m20260412_000020_create_tokens.rs | 85 + .../src/m20260412_000021_create_tasks.rs | 155 + ...0260412_000022_create_process_variables.rs | 96 + ...0260413_000023_create_message_templates.rs | 105 + .../src/m20260413_000024_create_messages.rs | 162 + ...413_000025_create_message_subscriptions.rs | 112 + .../src/m20260413_000026_create_audit_logs.rs | 81 + ...4_000027_fix_unique_indexes_soft_delete.rs | 92 + ...14_000028_add_standard_fields_to_tokens.rs | 74 + ...dd_standard_fields_to_process_variables.rs | 82 + ...4_000032_fix_settings_unique_index_null.rs | 65 + ...15_000030_add_version_to_message_tables.rs | 104 + .../m20260416_000031_create_domain_events.rs | 84 + .../src/m20260417_000033_create_plugins.rs | 248 + ...20260417_000034_seed_plugin_permissions.rs | 84 + ...60418_000035_pg_trgm_and_entity_columns.rs | 85 + ...0036_add_data_scope_to_role_permissions.rs | 37 + ...20260419_000037_create_user_departments.rs | 94 + ...20260419_000039_entity_registry_columns.rs | 51 + .../src/m20260419_000040_plugin_market.rs | 146 + .../src/m20260419_000041_plugin_user_views.rs | 63 + .../m20260423_000043_create_wechat_users.rs | 88 + ...260427_000062_create_tenant_crypto_keys.rs | 102 + .../m20260427_000084_domain_events_cleanup.rs | 128 + .../src/m20260427_000085_processed_events.rs | 81 + .../m20260427_000086_enable_rls_all_tables.rs | 75 + .../m20260427_000087_audit_logs_hash_chain.rs | 51 + .../src/m20260428_000088_rls_policy_strict.rs | 82 + .../src/m20260428_000089_blind_indexes.rs | 93 + .../m20260428_000091_dead_letter_events.rs | 76 + .../m20260504_000106_create_api_clients.rs | 116 + ..._000144_enforce_version_optimistic_lock.rs | 115 + .../m20260518_000149_fix_admin_permissions.rs | 78 + ...29_000169_supplement_rls_for_new_tables.rs | 65 + crates/erp-server/src/config.rs | 156 + crates/erp-server/src/db.rs | 16 + crates/erp-server/src/handlers/analytics.rs | 65 + crates/erp-server/src/handlers/audit_log.rs | 156 + .../erp-server/src/handlers/crypto_admin.rs | 76 + crates/erp-server/src/handlers/health.rs | 135 + crates/erp-server/src/handlers/mod.rs | 6 + crates/erp-server/src/handlers/openapi.rs | 25 + crates/erp-server/src/handlers/upload.rs | 220 + crates/erp-server/src/main.rs | 820 ++ .../src/middleware/frozen_module.rs | 37 + crates/erp-server/src/middleware/metrics.rs | 126 + crates/erp-server/src/middleware/mod.rs | 4 + .../erp-server/src/middleware/rate_limit.rs | 326 + .../erp-server/src/middleware/tenant_rls.rs | 50 + crates/erp-server/src/outbox.rs | 137 + crates/erp-server/src/state.rs | 121 + crates/erp-server/src/tasks.rs | 125 + crates/erp-server/tests/integration.rs | 8 + .../tests/integration/auth_tests.rs | 129 + .../tests/integration/plugin_tests.rs | 213 + .../erp-server/tests/integration/test_db.rs | 114 + .../tests/integration/workflow_tests.rs | 247 + crates/erp-workflow/Cargo.toml | 21 + crates/erp-workflow/src/dto.rs | 252 + crates/erp-workflow/src/engine/executor.rs | 704 ++ crates/erp-workflow/src/engine/expression.rs | 431 ++ crates/erp-workflow/src/engine/mod.rs | 5 + crates/erp-workflow/src/engine/model.rs | 385 + crates/erp-workflow/src/engine/parser.rs | 491 ++ crates/erp-workflow/src/engine/timeout.rs | 77 + crates/erp-workflow/src/entity/mod.rs | 5 + .../src/entity/process_definition.rs | 43 + .../src/entity/process_instance.rs | 59 + .../src/entity/process_variable.rs | 46 + crates/erp-workflow/src/entity/task.rs | 65 + crates/erp-workflow/src/entity/token.rs | 40 + crates/erp-workflow/src/error.rs | 128 + .../src/handler/definition_handler.rs | 200 + .../src/handler/instance_handler.rs | 206 + crates/erp-workflow/src/handler/mod.rs | 3 + .../erp-workflow/src/handler/task_handler.rs | 199 + crates/erp-workflow/src/lib.rs | 16 + crates/erp-workflow/src/module.rs | 549 ++ .../src/service/ai_workflow_seed.rs | 205 + .../src/service/definition_service.rs | 417 + .../src/service/instance_service.rs | 424 ++ crates/erp-workflow/src/service/mod.rs | 4 + .../erp-workflow/src/service/task_service.rs | 445 ++ crates/erp-workflow/src/workflow_state.rs | 11 + dev.ps1 | 226 + docker/.env.example | 6 + docker/.env.production.example | 70 + docker/.gitignore | 1 + docker/backup.sh | 95 + docker/docker-compose.cloud.yml | 40 + docker/docker-compose.production.yml | 173 + docker/docker-compose.yml | 50 + docker/nginx/nginx.conf | 96 + docker/nginx/ssl/.gitignore | 3 + docker/nginx/ssl/.gitkeep | 8 + docker/prometheus/alerts.yml | 103 + docker/prometheus/prometheus.yml | 32 + docker/restore.sh | 43 + .../2026-05-31-nuanji-warm-notes-design.md | 779 ++ permissions.yaml | 317 + scripts/api_test.sh | 394 + scripts/api_test_health_alert.py | 477 ++ scripts/api_test_mp.py | 255 + scripts/api_test_patient.py | 512 ++ scripts/check-api-paths.sh | 136 + scripts/check-permissions.sh | 117 + scripts/demo-seed.sql | 271 + scripts/e2e_appointment_test.py | 488 ++ .../e2e_test_followup_consultation_points.py | 550 ++ scripts/fix-rate-trend.sh | 120 + scripts/gen-permissions.js | 155 + scripts/mpsync.ps1 | 62 + scripts/mpsync.sh | 76 + scripts/seed-dialysis-articles.mjs | 261 + 285 files changed, 59156 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 config/default.toml create mode 100644 crates/erp-auth/Cargo.toml create mode 100644 crates/erp-auth/src/auth_state.rs create mode 100644 crates/erp-auth/src/dto.rs create mode 100644 crates/erp-auth/src/entity/department.rs create mode 100644 crates/erp-auth/src/entity/mod.rs create mode 100644 crates/erp-auth/src/entity/organization.rs create mode 100644 crates/erp-auth/src/entity/permission.rs create mode 100644 crates/erp-auth/src/entity/position.rs create mode 100644 crates/erp-auth/src/entity/role.rs create mode 100644 crates/erp-auth/src/entity/role_permission.rs create mode 100644 crates/erp-auth/src/entity/user.rs create mode 100644 crates/erp-auth/src/entity/user_credential.rs create mode 100644 crates/erp-auth/src/entity/user_department.rs create mode 100644 crates/erp-auth/src/entity/user_role.rs create mode 100644 crates/erp-auth/src/entity/user_token.rs create mode 100644 crates/erp-auth/src/entity/wechat_user.rs create mode 100644 crates/erp-auth/src/error.rs create mode 100644 crates/erp-auth/src/handler/auth_handler.rs create mode 100644 crates/erp-auth/src/handler/mod.rs create mode 100644 crates/erp-auth/src/handler/org_handler.rs create mode 100644 crates/erp-auth/src/handler/role_handler.rs create mode 100644 crates/erp-auth/src/handler/user_handler.rs create mode 100644 crates/erp-auth/src/handler/wechat_handler.rs create mode 100644 crates/erp-auth/src/lib.rs create mode 100644 crates/erp-auth/src/middleware/jwt_auth.rs create mode 100644 crates/erp-auth/src/middleware/mod.rs create mode 100644 crates/erp-auth/src/module.rs create mode 100644 crates/erp-auth/src/service/auth_service.rs create mode 100644 crates/erp-auth/src/service/dept_service.rs create mode 100644 crates/erp-auth/src/service/mod.rs create mode 100644 crates/erp-auth/src/service/org_service.rs create mode 100644 crates/erp-auth/src/service/password.rs create mode 100644 crates/erp-auth/src/service/permission_service.rs create mode 100644 crates/erp-auth/src/service/position_service.rs create mode 100644 crates/erp-auth/src/service/role_service.rs create mode 100644 crates/erp-auth/src/service/seed.rs create mode 100644 crates/erp-auth/src/service/token_service.rs create mode 100644 crates/erp-auth/src/service/user_service.rs create mode 100644 crates/erp-auth/src/service/wechat_service.rs create mode 100644 crates/erp-config/Cargo.toml create mode 100644 crates/erp-config/src/config_state.rs create mode 100644 crates/erp-config/src/dto.rs create mode 100644 crates/erp-config/src/entity/dictionary.rs create mode 100644 crates/erp-config/src/entity/dictionary_item.rs create mode 100644 crates/erp-config/src/entity/menu.rs create mode 100644 crates/erp-config/src/entity/menu_role.rs create mode 100644 crates/erp-config/src/entity/mod.rs create mode 100644 crates/erp-config/src/entity/numbering_rule.rs create mode 100644 crates/erp-config/src/entity/setting.rs create mode 100644 crates/erp-config/src/error.rs create mode 100644 crates/erp-config/src/handler/dictionary_handler.rs create mode 100644 crates/erp-config/src/handler/language_handler.rs create mode 100644 crates/erp-config/src/handler/menu_handler.rs create mode 100644 crates/erp-config/src/handler/mod.rs create mode 100644 crates/erp-config/src/handler/numbering_handler.rs create mode 100644 crates/erp-config/src/handler/setting_handler.rs create mode 100644 crates/erp-config/src/handler/theme_handler.rs create mode 100644 crates/erp-config/src/lib.rs create mode 100644 crates/erp-config/src/module.rs create mode 100644 crates/erp-config/src/service/dictionary_service.rs create mode 100644 crates/erp-config/src/service/menu_service.rs create mode 100644 crates/erp-config/src/service/mod.rs create mode 100644 crates/erp-config/src/service/numbering_service.rs create mode 100644 crates/erp-config/src/service/setting_service.rs create mode 100644 crates/erp-core/Cargo.toml create mode 100644 crates/erp-core/src/aggregate.rs create mode 100644 crates/erp-core/src/audit.rs create mode 100644 crates/erp-core/src/audit_service.rs create mode 100644 crates/erp-core/src/crypto/engine.rs create mode 100644 crates/erp-core/src/crypto/hmac_index.rs create mode 100644 crates/erp-core/src/crypto/key_manager.rs create mode 100644 crates/erp-core/src/crypto/masking.rs create mode 100644 crates/erp-core/src/crypto/mod.rs create mode 100644 crates/erp-core/src/entity/audit_log.rs create mode 100644 crates/erp-core/src/entity/dead_letter_event.rs create mode 100644 crates/erp-core/src/entity/domain_event.rs create mode 100644 crates/erp-core/src/entity/mod.rs create mode 100644 crates/erp-core/src/entity/processed_event.rs create mode 100644 crates/erp-core/src/error.rs create mode 100644 crates/erp-core/src/events.rs create mode 100644 crates/erp-core/src/lib.rs create mode 100644 crates/erp-core/src/module.rs create mode 100644 crates/erp-core/src/rbac.rs create mode 100644 crates/erp-core/src/request_info.rs create mode 100644 crates/erp-core/src/sanitize.rs create mode 100644 crates/erp-core/src/sea_orm_ext.rs create mode 100644 crates/erp-core/src/test_helpers.rs create mode 100644 crates/erp-core/src/types.rs create mode 100644 crates/erp-diary/Cargo.toml create mode 100644 crates/erp-diary/src/dto.rs create mode 100644 crates/erp-diary/src/entity/mod.rs create mode 100644 crates/erp-diary/src/error.rs create mode 100644 crates/erp-diary/src/event.rs create mode 100644 crates/erp-diary/src/handler/mod.rs create mode 100644 crates/erp-diary/src/lib.rs create mode 100644 crates/erp-diary/src/service/mod.rs create mode 100644 crates/erp-diary/src/state.rs create mode 100644 crates/erp-message/Cargo.toml create mode 100644 crates/erp-message/src/dto.rs create mode 100644 crates/erp-message/src/entity/message.rs create mode 100644 crates/erp-message/src/entity/message_subscription.rs create mode 100644 crates/erp-message/src/entity/message_template.rs create mode 100644 crates/erp-message/src/entity/mod.rs create mode 100644 crates/erp-message/src/error.rs create mode 100644 crates/erp-message/src/handler/message_handler.rs create mode 100644 crates/erp-message/src/handler/mod.rs create mode 100644 crates/erp-message/src/handler/sse_handler.rs create mode 100644 crates/erp-message/src/handler/subscription_handler.rs create mode 100644 crates/erp-message/src/handler/template_handler.rs create mode 100644 crates/erp-message/src/lib.rs create mode 100644 crates/erp-message/src/message_state.rs create mode 100644 crates/erp-message/src/module.rs create mode 100644 crates/erp-message/src/service/message_service.rs create mode 100644 crates/erp-message/src/service/mod.rs create mode 100644 crates/erp-message/src/service/subscription_service.rs create mode 100644 crates/erp-message/src/service/template_service.rs create mode 100644 crates/erp-plugin/Cargo.toml create mode 100644 crates/erp-plugin/src/data_dto.rs create mode 100644 crates/erp-plugin/src/data_service.rs create mode 100644 crates/erp-plugin/src/dto.rs create mode 100644 crates/erp-plugin/src/dynamic_table.rs create mode 100644 crates/erp-plugin/src/engine.rs create mode 100644 crates/erp-plugin/src/entity/market_entry.rs create mode 100644 crates/erp-plugin/src/entity/market_review.rs create mode 100644 crates/erp-plugin/src/entity/mod.rs create mode 100644 crates/erp-plugin/src/entity/plugin.rs create mode 100644 crates/erp-plugin/src/entity/plugin_entity.rs create mode 100644 crates/erp-plugin/src/entity/plugin_event_subscription.rs create mode 100644 crates/erp-plugin/src/error.rs create mode 100644 crates/erp-plugin/src/handler/data_handler.rs create mode 100644 crates/erp-plugin/src/handler/market_handler.rs create mode 100644 crates/erp-plugin/src/handler/mod.rs create mode 100644 crates/erp-plugin/src/handler/plugin_handler.rs create mode 100644 crates/erp-plugin/src/host.rs create mode 100644 crates/erp-plugin/src/lib.rs create mode 100644 crates/erp-plugin/src/manifest.rs create mode 100644 crates/erp-plugin/src/module.rs create mode 100644 crates/erp-plugin/src/notification.rs create mode 100644 crates/erp-plugin/src/plugin_validator.rs create mode 100644 crates/erp-plugin/src/service.rs create mode 100644 crates/erp-plugin/src/state.rs create mode 100644 crates/erp-plugin/wit/plugin.wit create mode 100644 crates/erp-server/Cargo.toml create mode 100644 crates/erp-server/config/default.toml create mode 100644 crates/erp-server/migration/Cargo.toml create mode 100644 crates/erp-server/migration/src/lib.rs create mode 100644 crates/erp-server/migration/src/m20260410_000001_create_tenant.rs create mode 100644 crates/erp-server/migration/src/m20260411_000002_create_users.rs create mode 100644 crates/erp-server/migration/src/m20260411_000003_create_user_credentials.rs create mode 100644 crates/erp-server/migration/src/m20260411_000004_create_user_tokens.rs create mode 100644 crates/erp-server/migration/src/m20260411_000005_create_roles.rs create mode 100644 crates/erp-server/migration/src/m20260411_000006_create_permissions.rs create mode 100644 crates/erp-server/migration/src/m20260411_000007_create_role_permissions.rs create mode 100644 crates/erp-server/migration/src/m20260411_000008_create_user_roles.rs create mode 100644 crates/erp-server/migration/src/m20260411_000009_create_organizations.rs create mode 100644 crates/erp-server/migration/src/m20260411_000010_create_departments.rs create mode 100644 crates/erp-server/migration/src/m20260411_000011_create_positions.rs create mode 100644 crates/erp-server/migration/src/m20260412_000012_create_dictionaries.rs create mode 100644 crates/erp-server/migration/src/m20260412_000013_create_dictionary_items.rs create mode 100644 crates/erp-server/migration/src/m20260412_000014_create_menus.rs create mode 100644 crates/erp-server/migration/src/m20260412_000015_create_menu_roles.rs create mode 100644 crates/erp-server/migration/src/m20260412_000016_create_settings.rs create mode 100644 crates/erp-server/migration/src/m20260412_000017_create_numbering_rules.rs create mode 100644 crates/erp-server/migration/src/m20260412_000018_create_process_definitions.rs create mode 100644 crates/erp-server/migration/src/m20260412_000019_create_process_instances.rs create mode 100644 crates/erp-server/migration/src/m20260412_000020_create_tokens.rs create mode 100644 crates/erp-server/migration/src/m20260412_000021_create_tasks.rs create mode 100644 crates/erp-server/migration/src/m20260412_000022_create_process_variables.rs create mode 100644 crates/erp-server/migration/src/m20260413_000023_create_message_templates.rs create mode 100644 crates/erp-server/migration/src/m20260413_000024_create_messages.rs create mode 100644 crates/erp-server/migration/src/m20260413_000025_create_message_subscriptions.rs create mode 100644 crates/erp-server/migration/src/m20260413_000026_create_audit_logs.rs create mode 100644 crates/erp-server/migration/src/m20260414_000027_fix_unique_indexes_soft_delete.rs create mode 100644 crates/erp-server/migration/src/m20260414_000028_add_standard_fields_to_tokens.rs create mode 100644 crates/erp-server/migration/src/m20260414_000029_add_standard_fields_to_process_variables.rs create mode 100644 crates/erp-server/migration/src/m20260414_000032_fix_settings_unique_index_null.rs create mode 100644 crates/erp-server/migration/src/m20260415_000030_add_version_to_message_tables.rs create mode 100644 crates/erp-server/migration/src/m20260416_000031_create_domain_events.rs create mode 100644 crates/erp-server/migration/src/m20260417_000033_create_plugins.rs create mode 100644 crates/erp-server/migration/src/m20260417_000034_seed_plugin_permissions.rs create mode 100644 crates/erp-server/migration/src/m20260418_000035_pg_trgm_and_entity_columns.rs create mode 100644 crates/erp-server/migration/src/m20260418_000036_add_data_scope_to_role_permissions.rs create mode 100644 crates/erp-server/migration/src/m20260419_000037_create_user_departments.rs create mode 100644 crates/erp-server/migration/src/m20260419_000039_entity_registry_columns.rs create mode 100644 crates/erp-server/migration/src/m20260419_000040_plugin_market.rs create mode 100644 crates/erp-server/migration/src/m20260419_000041_plugin_user_views.rs create mode 100644 crates/erp-server/migration/src/m20260423_000043_create_wechat_users.rs create mode 100644 crates/erp-server/migration/src/m20260427_000062_create_tenant_crypto_keys.rs create mode 100644 crates/erp-server/migration/src/m20260427_000084_domain_events_cleanup.rs create mode 100644 crates/erp-server/migration/src/m20260427_000085_processed_events.rs create mode 100644 crates/erp-server/migration/src/m20260427_000086_enable_rls_all_tables.rs create mode 100644 crates/erp-server/migration/src/m20260427_000087_audit_logs_hash_chain.rs create mode 100644 crates/erp-server/migration/src/m20260428_000088_rls_policy_strict.rs create mode 100644 crates/erp-server/migration/src/m20260428_000089_blind_indexes.rs create mode 100644 crates/erp-server/migration/src/m20260428_000091_dead_letter_events.rs create mode 100644 crates/erp-server/migration/src/m20260504_000106_create_api_clients.rs create mode 100644 crates/erp-server/migration/src/m20260513_000144_enforce_version_optimistic_lock.rs create mode 100644 crates/erp-server/migration/src/m20260518_000149_fix_admin_permissions.rs create mode 100644 crates/erp-server/migration/src/m20260529_000169_supplement_rls_for_new_tables.rs create mode 100644 crates/erp-server/src/config.rs create mode 100644 crates/erp-server/src/db.rs create mode 100644 crates/erp-server/src/handlers/analytics.rs create mode 100644 crates/erp-server/src/handlers/audit_log.rs create mode 100644 crates/erp-server/src/handlers/crypto_admin.rs create mode 100644 crates/erp-server/src/handlers/health.rs create mode 100644 crates/erp-server/src/handlers/mod.rs create mode 100644 crates/erp-server/src/handlers/openapi.rs create mode 100644 crates/erp-server/src/handlers/upload.rs create mode 100644 crates/erp-server/src/main.rs create mode 100644 crates/erp-server/src/middleware/frozen_module.rs create mode 100644 crates/erp-server/src/middleware/metrics.rs create mode 100644 crates/erp-server/src/middleware/mod.rs create mode 100644 crates/erp-server/src/middleware/rate_limit.rs create mode 100644 crates/erp-server/src/middleware/tenant_rls.rs create mode 100644 crates/erp-server/src/outbox.rs create mode 100644 crates/erp-server/src/state.rs create mode 100644 crates/erp-server/src/tasks.rs create mode 100644 crates/erp-server/tests/integration.rs create mode 100644 crates/erp-server/tests/integration/auth_tests.rs create mode 100644 crates/erp-server/tests/integration/plugin_tests.rs create mode 100644 crates/erp-server/tests/integration/test_db.rs create mode 100644 crates/erp-server/tests/integration/workflow_tests.rs create mode 100644 crates/erp-workflow/Cargo.toml create mode 100644 crates/erp-workflow/src/dto.rs create mode 100644 crates/erp-workflow/src/engine/executor.rs create mode 100644 crates/erp-workflow/src/engine/expression.rs create mode 100644 crates/erp-workflow/src/engine/mod.rs create mode 100644 crates/erp-workflow/src/engine/model.rs create mode 100644 crates/erp-workflow/src/engine/parser.rs create mode 100644 crates/erp-workflow/src/engine/timeout.rs create mode 100644 crates/erp-workflow/src/entity/mod.rs create mode 100644 crates/erp-workflow/src/entity/process_definition.rs create mode 100644 crates/erp-workflow/src/entity/process_instance.rs create mode 100644 crates/erp-workflow/src/entity/process_variable.rs create mode 100644 crates/erp-workflow/src/entity/task.rs create mode 100644 crates/erp-workflow/src/entity/token.rs create mode 100644 crates/erp-workflow/src/error.rs create mode 100644 crates/erp-workflow/src/handler/definition_handler.rs create mode 100644 crates/erp-workflow/src/handler/instance_handler.rs create mode 100644 crates/erp-workflow/src/handler/mod.rs create mode 100644 crates/erp-workflow/src/handler/task_handler.rs create mode 100644 crates/erp-workflow/src/lib.rs create mode 100644 crates/erp-workflow/src/module.rs create mode 100644 crates/erp-workflow/src/service/ai_workflow_seed.rs create mode 100644 crates/erp-workflow/src/service/definition_service.rs create mode 100644 crates/erp-workflow/src/service/instance_service.rs create mode 100644 crates/erp-workflow/src/service/mod.rs create mode 100644 crates/erp-workflow/src/service/task_service.rs create mode 100644 crates/erp-workflow/src/workflow_state.rs create mode 100644 dev.ps1 create mode 100644 docker/.env.example create mode 100644 docker/.env.production.example create mode 100644 docker/.gitignore create mode 100644 docker/backup.sh create mode 100644 docker/docker-compose.cloud.yml create mode 100644 docker/docker-compose.production.yml create mode 100644 docker/docker-compose.yml create mode 100644 docker/nginx/nginx.conf create mode 100644 docker/nginx/ssl/.gitignore create mode 100644 docker/nginx/ssl/.gitkeep create mode 100644 docker/prometheus/alerts.yml create mode 100644 docker/prometheus/prometheus.yml create mode 100644 docker/restore.sh create mode 100644 docs/superpowers/specs/2026-05-31-nuanji-warm-notes-design.md create mode 100644 permissions.yaml create mode 100644 scripts/api_test.sh create mode 100644 scripts/api_test_health_alert.py create mode 100644 scripts/api_test_mp.py create mode 100644 scripts/api_test_patient.py create mode 100644 scripts/check-api-paths.sh create mode 100644 scripts/check-permissions.sh create mode 100644 scripts/demo-seed.sql create mode 100644 scripts/e2e_appointment_test.py create mode 100644 scripts/e2e_test_followup_consultation_points.py create mode 100644 scripts/fix-rate-trend.sh create mode 100644 scripts/gen-permissions.js create mode 100644 scripts/mpsync.ps1 create mode 100644 scripts/mpsync.sh create mode 100644 scripts/seed-dialysis-articles.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18420e1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,111 @@ +# Rust +/target/ +**/*.rs.bk + +# Node +node_modules/ +dist/ + +# Tauri +apps/desktop/src-tauri/target/ + +# IDE +.vscode/ +.idea/ +*.swp + +# Environment +.env +*.env.local + +# OS +.DS_Store +Thumbs.db + +# Docker data +docker/postgres_data/ +docker/redis_data/ + +# Test artifacts +.test_token +test-results/ + +# Build outputs +apps/miniprogram/dist-h5/ + +# Runtime uploads +uploads/ + +# Temp logs +_server_out.txt +*.heapsnapshot +perf-trace-*.json +docs/debug-*.png + +# Development env +.env.development +docker/docker-compose.override.yml +.agents/skills/ +.claude/skills/ +.kiro/skills/ +.trae/skills/ +.windsurf/skills/ +skills/ + +# Logs +.logs/ +*.log + +# Playwright reports +**/playwright-report/ + +# Plans +plans/ + +# MCP config +.mcp.json + +# Superpowers temp +.superpowers/brainstorm/ + +# Test temp files +.test_token* +chi_sim.traineddata + +# Local settings +.claude/settings.local.json +tools/ + +# Temp/debug files +_temp/ +tmp/ +screenshots/ +server-log.txt +snapshot_*.txt +_*.txt +_server_*.txt +tmp_*.txt +direct_*.txt +server_*.txt +server_combined.txt +out.txt +_wx_login.json +.claude/settings.json + +# Trace/debug JSON +trace-*.json + +# Graphify knowledge graph (regenerated locally) +graphify-out/ + +# Native miniprogram (separate project) +apps/mp-native/ + +# Misc untracked +err.txt +uploads/g:/hms/.superpowers/ +.claude/skills/design-handoff/node_modules/ +.design/config.yml +.superpowers/ +target/ +node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0baf9bc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,535 @@ +@wiki/index.md +整个项目对话都使用中文进行,包括文档、代码注释、事件名称等。 + +# HMS 健康管理平台 — 协作与实现规则 + +> **Health Management System (HMS)** — 面向体检中心/医疗机构的综合健康管理平台。从 ERP 底座分叉独立,继承身份权限/工作流/消息/配置等基础能力,`erp-health` 原生模块承载医疗业务。 + +> **当前阶段: erp-health 模块开发。** 设计规格已确认,开始实施。 + +## 1. 项目定位 + +### 1.1 这是什么 + +一个 **健康管理 + ERP 基础设施** 架构的医疗 SaaS 平台: + +- **医疗核心** — 患者管理、健康数据、预约排班、随访管理、咨询管理(原生 Rust 模块 erp-health) +- **基础底座** — 身份权限、工作流引擎、消息中心、系统配置(继承自 ERP) +- **多租户 + 私有化** — 默认 SaaS 共享数据库隔离,支持独立 schema 私有部署 +- **Web 优先** — 浏览器 SPA 是 PC 管理后台主力,小程序(患者端/医护端)独立开发 + +### 1.2 决策原则 + +**任何改动都要问:这对健康管理平台的医疗业务和可扩展性有帮助吗?** + +- ✅ 完善模块接口和 trait 定义 → 最高优先 +- ✅ 确保多租户隔离的正确性 → 最高优先 +- ✅ 按计划推进 Phase 交付物 → 高优先 +- ✅ 清晰的模块边界和事件契约 → 高优先 +- ❌ 跳过 Phase 顺序提前实现远期功能 → 禁止 +- ❌ 在模块间创建直接耦合 → 永远不做 +- ❌ 硬编码租户 ID 或绕过多租户中间件 → 永远不做 +- ❌ 过度设计未来才需要的能力 → 永远不做 + +### 1.3 架构铁律 + +| 约束 | 原因 | +|------|------| +| 模块间只通过事件总线和 trait 通信 | 保证模块可独立拆分为微服务 | +| 所有数据表必须含 `tenant_id` | 多租户是核心能力,不可事后补 | +| 使用 UUID v7 作为主键 | 时间排序 + 唯一性,分布式友好 | +| 软删除,不硬删除 | ERP 数据不可丢失,审计追溯需要 | +| 所有 API 使用 `/api/v1/` 前缀 | 版本化是 SaaS 产品的基本要求 | + +--- + +## 2. 工作风格 + +### 2.1 按计划推进 + +- **严格按 Phase 顺序执行** — Phase 2 依赖 Phase 1 的基础设施 +- **每个 Task 完成后立即提交** — 不积压,保持可追溯 +- **先测试后实现** — TDD 流程:写失败测试 → 实现 → 通过 → 提交 + +### 2.2 分步编写文档(强制) + +编写计划、设计文档、实施报告等长文档时,**必须分步编写**,禁止一次性输出全文: + +1. **先写大纲** — 确认文档结构和章节划分 +2. **逐章编写** — 每次只写 1-2 个章节,写完确认后继续下一章 +3. **最终整合** — 所有章节完成后合并为完整文档 + +**原因:** 上下文过长会导致输出截断或卡死。分步编写保证每步都能完整输出,且用户可以中途调整方向。 + +**适用范围:** 超过 200 行的文档、实施计划、设计规格、技术报告等。简短的 bugfix 说明、单页 wiki 更新不受此限制。 + +### 2.3 讨论记录 + +每次发散式讨论(brainstorming、方案探索、需求梳理、技术选型等)**必须建立独立文档**: + +- **存放位置:** `docs/discussions/YYYY-MM-DD-{主题简称}.md` +- **文档格式:** + ```markdown + # {讨论主题} + > 日期: YYYY-MM-DD | 参与者: ... + + ## 背景 + 为什么会有这次讨论 + + ## 讨论要点 + - 要点 1 + - 要点 2 + + ## 结论 / 待定 + 达成的共识或遗留问题 + ``` +- **时机:** 讨论结束后立即创建,不要积压。如果讨论横跨多个主题,拆分为多份文档。 +- **用途:** 作为后续实施的输入和决策追溯的依据,避免"之前讨论过但忘了结论"。 + +### 2.4 模块化思维 + +开发任何功能时先问: + +1. **它属于哪个模块?** — 不确定就放到 `erp-core` 共享层 +2. **它的接口是什么?** — 先定义 trait,再实现 +3. **它需要发什么事件?** — 跨模块通知必须走事件总线 +4. **其他模块怎么发现它?** — 通过 `ErpModule` trait 注册 + +### 2.5 闭环工作法(强制) + +每次改动**必须**按顺序完成以下步骤,不允许跳过: + +0. **阅读 Wiki(强制起点)** — 收到任何任务后,**先读 wiki 再动手**: + - 读取 `wiki/index.md` 了解项目全貌和当前进度 + - 根据任务涉及的范围,读取相关 wiki 页面(`wiki/infrastructure.md`、`wiki/testing.md`、`wiki/wasm-plugin.md` 等) + - wiki 中包含实际的环境配置(数据库连接、端口、登录凭据、启动方式),不看 wiki 就无法正确验证 + - **违反此步骤 = 盲目工作,浪费时间去猜环境配置,产出不可信** +1. **现状确认(强制)** — 动手之前,先检查代码里已经有什么: + - 用 Grep/Glob/Read 工具搜索相关文件,确认哪些能力已存在 + - 明确列出"已有"和"缺失",不允许凭印象断言缺失 + - 如果不确定现有实现状态,停下来问用户,不要编造 + - 违反此步骤 = 所有后续工作可能脱离实际,白费力气 +2. **理解需求** — 确认改动的目标模块和影响范围 +3. **最小实现** — 只改必要的代码,保持模块边界 +4. **验证通过** — 必须全部通过才可继续: + - `cargo check` — 编译无错误 + - `cargo test --workspace` — 所有测试通过(有相关测试时) + - 功能验证 — 启动后端 + 前端服务,在浏览器中实际操作验证改动生效(涉及 API 或 UI 时) + - `pnpm build` — 前端生产构建通过(涉及前端时) +5. **提交 + 文档 + 推送(三合一,强制)** — 验证通过后按顺序执行: + - a. 按 §5 规范提交代码 + - b. 检查本次变更是否触发 wiki 更新(见下方 wiki 更新触发条件),触发则更新后单独 `docs(wiki)` 提交 + - c. `git push` 立即推送,不允许"等一下再推" + - **禁止连续 5 个非 docs 提交而不更新 wiki 关键数字** + +#### wiki 更新触发条件(步骤 5b 的判定标准) + +以下任一条件满足时,**必须**更新 wiki 后才能继续下一任务: + +- **fix 提交** → `wiki/index.md` 症状导航新增条目或标记"已修复" +- **feat 提交(新功能)** → `wiki/index.md` 关键数字更新 + 对应模块 wiki 页更新(实体数/路由数/端点数等) +- **数据库迁移变化** → 关键数字中的迁移数/表数更新 +- **API 路由变化** → 路由数更新 +- **测试数量变化** → 测试数/断言数更新 +- **连续 5 个代码提交** → 强制做一次 wiki/index.md 关键数字全文校正(对比代码实际数量) + +**铁律:** +- **步骤 0 阅读 Wiki 是绝对起点** — 不读 wiki 就开干 = 连环境配置都不知道,所有验证步骤都是空谈。 +- **步骤 1 现状确认是强制起点** — 不检查就开干 = 脱离实际,所有产出不可信。 +- **步骤 4 功能验证必须实际操作** — 只看编译通过不算验证,必须启动服务、在浏览器中确认功能正常。 +- **步骤 5 三合一是强制流程** — 提交后必须检查 wiki、必须推送,缺一不可。 +- **每次新会话开始时,先检查是否有未推送的提交并立即推送**。 + +### 2.6 Feature DoD — 功能完成定义(强制) + +> 历史数据显示 24% 的提交是 fix,根因是缺少统一的完成标准。 +> 每个功能标记"完成"前,**必须**逐项检查以下清单,不允许跳过。 + +#### 后端 + +- [ ] Entity 包含所有标准字段(`id`/`tenant_id`/`created_at`/`updated_at`/`created_by`/`updated_by`/`deleted_at`/`version`) +- [ ] Handler 添加 `require_permission` 权限守卫 +- [ ] 权限码已写入 seed 迁移(每个实体 `.list` + `.manage`,权限码前缀与实体名一致) +- [ ] utoipa 注解已添加(`#[derive(utoipa::OpenApi)]` + path/response schema) +- [ ] Service 层核心路径有单元/集成测试 +- [ ] 多租户隔离正确(所有查询含 `tenant_id` 过滤,无手写 SQL 拼接) +- [ ] 输入验证完整(必填字段 + 格式校验 + 长度限制) +- [ ] 错误处理统一(`AppError`,不 panic,不 unwrap 生产代码) +- [ ] 关键操作有 `tracing` 日志(info/warn/error 级别合理) + +#### 前端(Web) + +- [ ] API 路径与后端 OpenAPI spec 一致(不手写路径,从 `api/health/` 模块调用) +- [ ] 路由声明权限码(`permissions: [...]`),与后端 handler 一致 +- [ ] 菜单配置已更新(`parent_id` 正确 + `permission` 字段 + `menu_roles` 关联) +- [ ] 错误状态有用户友好提示(不显示原始 error message) +- [ ] 不使用 `any` 类型(用 `unknown` + 类型守卫) + +#### 前端(小程序) + +- [ ] Service 层接口契约与后端 DTO 一致(字段名/类型/结构体) +- [ ] 登录态处理正确(`useDidShow` 恢复认证、退出清理 Storage) +- [ ] 页面间数据通过 API 获取,不用 Storage 传递 +- [ ] 长者模式适配完成(字号 ≥ 22px) +- [ ] 图片使用合法 URL(HTTPS 或相对路径,不用 HTTP) + +#### 安全 + +- [ ] 新增端点有权限声明(默认拒绝,不是默认放行) +- [ ] 敏感数据有脱敏/加密处理(PII 字段走 AES-256-GCM) +- [ ] 用户输入已验证和消毒(防 SQL 注入、XSS、命令注入、路径穿越) +- [ ] 无 CORS 通配符、无硬编码密钥、无 fallback 默认密钥 +- [ ] 日志中无敏感数据输出(密码、token、身份证号、手机号等) +- [ ] 文件上传有 MIME 类型验证 + 大小限制 + 路径穿越防护 +- [ ] API 响应不暴露内部实现细节(数据库错误、堆栈跟踪、文件路径) +- [ ] 速率限制已配置(认证端点更严格) +- [ ] 密钥通过环境变量注入,`.env.example` 已同步更新 + +#### 文档一致性 + +- [ ] `wiki/index.md` 关键数字与代码实际状态一致(迁移数、路由数、实体数、测试数等) +- [ ] 新增/修复的 bug 已记录在症状导航中(含根因+解决方案) +- [ ] 新增功能已记录在对应模块 wiki 页面中(实体、端点、事件等) +- [ ] wiki 页面的"最后更新"日期已刷新为当天 + +#### 端到端验证 + +- [ ] `cargo check` 全 workspace 通过 +- [ ] `cargo test` 全部通过 +- [ ] 浏览器中手动验证功能正常(列表/创建/编辑/删除/权限拦截) +- [ ] 小程序中验证(涉及小程序页面时) +- [ ] 相关路由权限按角色测试通过(至少 admin + 只读角色) +- [ ] 本地提交已推送到远程仓库 + +--- + +## 3. 实现规则 + +### 3.1 错误处理 + +- **跨 crate 边界**:使用 `thiserror` 定义类型化错误,转换为 `AppError` +- **crate 内部**:可以使用 `anyhow`,但**永远不**跨越 crate 边界 +- **数据库错误**:通过 `From` 自动转换为 `AppError` +- **验证错误**:包含字段级详情,方便 UI 渲染 + +### 3.2 数据库操作 + +- 所有 SeaORM Entity 必须包含:`id`, `tenant_id`, `created_at`, `updated_at`, `created_by`, `updated_by`, `deleted_at`, `version` +- 查询时**始终**带 `tenant_id` 过滤(中间件自动注入) +- 更新时检查 `version` 字段实现乐观锁 +- 删除使用软删除(设置 `deleted_at`) + +### 3.3 API 设计 + +- 所有端点使用 `/api/v1/` 前缀 +- 响应统一使用 `ApiResponse` 包装 +- 分页使用 `Pagination` + `PaginatedResponse` +- utoipa 自动生成 OpenAPI 文档 +- 租户 ID 从 JWT 中间件注入,**不在** API 路径中传递(管理员接口除外) + +#### 新增 API 端点安全检查(强制) + +> 默认拒绝是安全基线 — 绝大多数安全修复源于默认放行模式。 +> 新增端点时**必须**逐项确认: + +- [ ] 端点已添加 `require_permission` 权限守卫(非公开端点) +- [ ] 公开端点已显式标记为 `public`(不继承认证中间件) +- [ ] 路由使用 `.nest()` 注册带中间件的子路由(禁止 `.merge()` 防止中间件泄漏) +- [ ] 敏感操作有速率限制 +- [ ] 无 `format!` 拼接 SQL — 所有查询使用 SeaORM 参数化 +- [ ] FHIR/第三方端点有 `tenant_id` 和 `allowed_patient_ids` 范围过滤 +- [ ] 无硬编码密钥或 fallback 默认值 + +#### 前后端接口同步检查(强制) + +> 前后端接口不一致是高频 bug 来源 — 任何 DTO 变更必须双向同步。 +> 后端 DTO 变更时**必须**同步检查前端: + +- [ ] DTO 字段名变更 → 前端 TypeScript 接口同步更新 +- [ ] DTO 新增必填字段 → 前端表单和请求体同步更新 +- [ ] API 路径变更 → 前端 `api/` 模块路径同步更新 +- [ ] 返回数据结构变更(数组/对象/嵌套)→ 前端解析逻辑同步更新 +- [ ] 枚举值变更 → 前端类型定义和 UI 映射同步更新 +- [ ] 后端新增端点 → 前端 API 模块同步添加调用函数,不允许留空 + +#### DTO 输入校验检查(强制) + +> DTO 输入校验是安全防线 — 缺失校验等于暴露攻击面,Update 和 Create 必须对称。 +> 新增/修改 DTO 时**必须**逐项确认: + +- [ ] 所有请求结构体已 `derive(Validate)`(包括 Update\*Req、查询参数) +- [ ] Update\*Req 与 Create\*Req 校验对称(不允许 Update 降级) +- [ ] 字符串字段有 `#[validate(length(min, max))]` +- [ ] 枚举/类型字段有 `#[validate(custom)]` 限制合法值 +- [ ] 集合字段有 `#[validate(length(min = 1))]` 非空检查 +- [ ] 数值范围字段有 `#[validate(range(min, max))]` +- [ ] URL 字段有 SSRF 防护(禁止 localhost/内网地址,仅 http/https) +- [ ] 密码字段有 `max = 128` 防止 DoS +- [ ] handler 层已调用 `req.validate().map_err(|e| AppError::Validation(e.to_string()))?` + +### 3.4 事件总线 + +- 模块间通信**只能**通过 `EventBus` +- 事件必须持久化到 `domain_events` 表(outbox 模式) +- 事件处理失败记录到 dead-letter 存储 +- 事件类型命名:`{模块}.{动作}` 如 `user.created`, `workflow.task.completed` +- **铁律:每个事件必须有至少一个消费者,否则功能不算完成。** 新增事件发布时必须同步实现消费者和对应测试。详见 `docs/discussions/2026-04-28-architecture-retrospective.md` §4。 + +### 3.5 Rust 代码规范 + +```rust +// 命名:snake_case (函数/变量), PascalCase (类型/trait), SCREAMING_SNAKE (常量) +// 模块公开接口通过 lib.rs 统一导出 +// 每个 public 函数和 trait 必须有文档注释 +// 异步函数返回 Result 时使用 AppResult 类型别名 +// 数据库操作使用 SeaORM 的 Entity + Model + Relation 模式 +``` + +### 3.6 TypeScript / React 代码规范 + +```typescript +// 避免 any,优先 unknown + 类型守卫 +// 函数组件 + hooks +// 复杂状态收敛到 Zustand store +// API 调用封装到独立的 service 层,不在组件中直接 fetch +// 使用 Ant Design 组件,不自行实现已有组件 +// 国际化文案使用 i18n key,不硬编码中文 +``` + +### 3.7 安全规范 + +#### 密钥与凭据管理 + +- 所有密钥、token、密码**必须**通过环境变量或密钥管理服务注入,**禁止**硬编码在源码中 +- 开发环境密钥**必须**与生产环境严格隔离(`cfg(debug_assertions)` 编译期防护) +- 生产密钥**禁止**有 fallback 默认值,缺失时启动 panic +- 新增密钥时必须同步更新 `.env.example` 和 `wiki/infrastructure.md` + +#### 依赖安全 + +- 新增依赖前**必须**检查已知漏洞(`cargo audit` / `npm audit`) +- 禁止引入有未修补高危漏洞的依赖版本 +- 定期更新依赖到最新安全补丁版本 + +#### 数据安全 + +- PII 数据(姓名、身份证号、手机号、地址等)**必须**加密存储(AES-256-GCM) +- 日志中**禁止**输出 PII 数据和认证凭据(密码、token、session key) +- 敏感操作(登录、权限变更、数据导出、批量删除)**必须**记录审计日志(操作者、时间、目标、结果) +- 文件上传**必须**验证 MIME 类型 + 限制文件大小 + 防止路径穿越(文件名 sanitize) + +#### 传输安全 + +- 生产环境**必须**强制 HTTPS,**禁止**降级到 HTTP +- HTTP 响应**必须**包含安全头(HSTS、CSP、X-Frame-Options、X-Content-Type-Options、Permissions-Policy) +- SSE/WebSocket 长连接认证 token 不通过 URL query 参数传递(使用 header 或 cookie) +- API 响应**禁止**暴露内部实现细节(堆栈跟踪、数据库错误、文件路径、SQL 语句) + +#### 认证与授权 + +- 密码**必须**使用单向哈希(bcrypt/argon2),**禁止**明文或可逆加密存储 +- JWT **必须**设置合理过期时间,支持 token 吊销机制 +- 敏感操作(删除数据、权限变更)需要二次确认 +- 权限检查在 handler 层执行,**禁止**仅依赖前端隐藏控制访问 +- `tenant_id` **必须**从 JWT 中间件注入,**禁止**信任客户端传递的值 + +#### 速率限制 + +- 所有 API 端点**必须**配置速率限制 +- 认证相关端点(登录、注册、密码重置)限制更严格 +- 批量操作和数据导出需要独立的速率限制策略 +- 速率限制超出时返回 429 状态码,响应包含 `Retry-After` header + +--- + +## 4. 测试与验证 + +### 4.1 测试要求 + +| 测试类型 | 覆盖目标 | 工具 | +|----------|---------|------| +| 单元测试 | 每个 service 函数 | `#[cfg(test)]` + `tokio::test` | +| 集成测试 | API 端点 → 数据库 | Testcontainers + 真实 PostgreSQL | +| 多租户测试 | 数据隔离验证 | 独立测试 crate | +| E2E 测试 | 前端关键流程 | Playwright | +| 插件测试 | 动态表 CRUD + 租户隔离 | Testcontainers | + +### 4.2 验证命令 + +```bash +# Rust 编译检查 +cargo check + +# Rust 全量测试 +cargo test --workspace + +# 后端服务启动 +cd crates/erp-server && cargo run + +# Docker 环境 +cd docker && docker compose up -d + +# 桌面端开发 +cd apps/desktop && pnpm tauri dev + +# 数据库迁移检查 +docker exec erp-postgres psql -U erp -c "\dt" +``` + +### 4.3 Phase 完成标准 + +每个 Phase 完成时必须满足: + +- [ ] `cargo check` 全 workspace 通过 +- [ ] `cargo test` 全部通过 +- [ ] PostgreSQL 服务正常运行,迁移自动执行 +- [ ] 所有迁移可正/反向执行 +- [ ] API 端点可通过 Swagger UI 测试 +- [ ] 桌面端可正常启动并展示对应 UI +- [ ] 所有代码已提交 + +--- + +## 5. 提交规范 + +``` +(): +``` + +**类型:** +- `feat` — 新功能 +- `fix` — 修复问题 +- `refactor` — 重构 +- `docs` — 文档更新 +- `test` — 测试相关 +- `chore` — 杂项(构建、配置等) +- `perf` — 性能优化 + +**Scope 对应 crate 或模块名:** + +| scope | 范围 | +|-------|------| +| `core` | erp-core | +| `auth` | erp-auth | +| `workflow` | erp-workflow | +| `message` | erp-message | +| `config` | erp-config | +| `server` | erp-server | +| `health` | erp-health | +| `ai` | erp-ai | +| `dialysis` | erp-dialysis | +| `plugin` | erp-plugin / erp-plugin-prototype / erp-plugin-test-sample | +| `assessment` | erp-plugin-assessment | +| `crm` | erp-plugin-crm | +| `inventory` | erp-plugin-inventory | +| `web` | Web 前端 | +| `ui` | React 组件 | +| `db` | 数据库迁移 | +| `docker` | Docker 配置 | + +**示例:** + +``` +feat(auth): 添加用户管理 CRUD +feat(core): 实现事件总线和模块注册 +fix(server): 修复数据库连接池配置 +refactor(auth): 拆分 RBAC 和 ABAC 权限模型 +chore(docker): 添加 PostgreSQL 健康检查 +``` + +--- + +## 6. 反模式警告 + +- ❌ **不要**不看 wiki 就开干 — wiki 包含环境配置、数据库连接、启动方式、已知问题,不看就做等于盲猜,浪费时间且产出不可信 +- ❌ **不要**在业务 crate 之间创建直接依赖 — 只通过事件和 trait 通信 +- ❌ **不要**跳过多租户中间件 — 所有数据操作必须带 `tenant_id` 过滤 +- ❌ **不要**硬编码配置值 — 使用 config.toml + 环境变量 +- ❌ **不要**跳过迁移直接建表 — 所有 schema 变更通过 SeaORM Migration +- ❌ **不要**在前端组件中直接调用 HTTP — 封装到 service 层 +- ❌ **不要**使用 `anyhow` 跨越 crate 边界 — 内部可用,对外必须转 `AppError` +- ❌ **不要**假设只有单租户 — 从第一天就按多租户设计 +- ❌ **不要**提前实现远期功能 — 严格按 Phase 计划推进 +- ❌ **不要**忽略 `version` 字段 — 所有更新操作必须检查乐观锁 +- ❌ **不要**在动态表 SQL 中拼接用户输入 — 使用 `sanitize_identifier` 防注入 +- ❌ **不要**在插件 crate 中直接依赖 erp-auth — 权限注册用 raw SQL,保持模块边界 +- ❌ **不要**在 plugin.toml 中使用与实体名不一致的权限码 — `permissions[].code` 前缀必须与 `schema.entities[].name` 完全一致(如实体 `customer_tag` → 权限码 `customer_tag.list`/`customer_tag.manage`,不能写成 `tag.manage`),否则页面 403 +- ❌ **不要**漏掉实体的 `.list` 权限 — 每个实体必须同时声明 `.list` 和 `.manage`,缺少 `.list` 导致列表页 403 +- ❌ **不要**跳过验证直接提交 — 编译/测试/功能验证必须全部通过 +- ❌ **不要**提交后忘记推送 — 不推送等于没完成,远程仓库必须同步。每次新会话开始先检查未推送提交 +- ❌ **不要**忘记更新文档 — 涉及架构、接口、模块变化时必须同步更新相关文档 +- ❌ **不要**连续 5 个代码提交不更新 wiki — wiki 是团队唯一真相源,过期数据比没有数据更有害 +- ❌ **不要**修复 bug 后跳过症状导航更新 — 每个修复都应该帮助未来遇到同类问题的人快速定位根因 +- ❌ **不要**新增功能后不更新 wiki 关键数字 — 迁移数/路由数/实体数/测试数必须与代码同步,否则 wiki 指标表就是废数据 +- ❌ **不要**一次性输出长文档 — 超过 200 行的文档必须分步编写(先大纲 → 逐章 → 整合),否则会因上下文过长卡死 +- ❌ **不要**忽略讨论记录 — 每次发散式讨论结束后必须建立文档到 `docs/discussions/`,不要口头确认后就忘 +- ❌ **不要**跳过 Feature DoD — 每个功能标记"完成"前必须通过 §2.6 检查清单,不全面验证就提交将导致后续反复修复 +- ❌ **不要**新增端点时默认放行 — 所有端点默认拒绝访问,必须显式声明权限 +- ❌ **不要**后端 DTO 变更不同步前端 — 字段名/路径/类型变更时必须同步更新前端 TypeScript 接口 +- ❌ **不要**写 Update DTO 时省略校验 — Update*Req 必须与 Create*Req 校验对称(Validate derive / 枚举 custom / Vec min=1 / 密码 max=128) +- ❌ **不要**URL 字段不做 SSRF 防护 — 禁止 localhost/127.0.0.1/内网地址,仅允许 http/https 协议 +- ❌ **不要**handler 层跳过 `.validate()` — 所有 `Json` handler 函数体第一行必须调 `req.validate().map_err(\|e\| AppError::Validation(e.to_string()))?` +- ❌ **不要**在日志中输出敏感数据 — 密码、token、身份证号、手机号等 PII 信息禁止写入日志 +- ❌ **不要**信任客户端传递的 `tenant_id` — 必须从 JWT 中间件注入,客户端可伪造 +- ❌ **不要**在生产代码中使用 `unwrap()` — 必须处理所有错误,使用 `?` 或 `map_err` +- ❌ **不要**将内部错误信息返回给客户端 — 数据库错误、堆栈跟踪、文件路径等必须转换为用户友好的错误消息 +- ❌ **不要**使用 HTTP 传输敏感数据 — 生产环境必须 HTTPS +- ❌ **不要**跳过依赖安全检查 — 新增依赖前运行 `cargo audit` / `npm audit`,禁止引入有高危漏洞的版本 +- ❌ **不要**文件上传不做安全处理 — 必须验证 MIME 类型 + 限制大小 + sanitize 文件名防路径穿越 + +### 场景化指令 + +- 当遇到**新增模块** → 实现 `ErpModule` trait,在 `erp-server` 注册 +- 当遇到**跨模块通信** → 定义事件类型,通过 `EventBus` 发布/订阅 +- 当遇到**数据查询** → 确保包含 `tenant_id` 过滤,检查软删除条件 +- 当遇到**新增 API** → 添加 utoipa 注解,确保 OpenAPI 文档同步 +- 当遇到**新增表** → 创建 SeaORM migration + Entity,包含所有标准字段 +- 当遇到**新增页面** → 使用 Ant Design 组件,i18n key 引用文案 +- 当遇到**新增业务模块插件** → 参考 `wiki/wasm-plugin.md` 的插件制作完整流程和 `.claude/skills/plugin-development/SKILL.md`,创建 cdylib crate + 实现 Guest trait + 编译为 WASM Component。**权限码必须与实体名一致(每个实体声明 `.list` + `.manage`)** +- 当遇到**新增/修改 DTO** → 参考 `wiki/architecture.md` §4 DTO 输入校验规范:`derive(Validate)` + 字段级校验 + handler 层 `validate()` 调用 + 单元测试 + +--- + +## 7. 详细参考(wiki) + +以下内容已从本文件迁移到 wiki,需要时查阅: + +| 主题 | wiki 页面 | +|------|----------| +| 目录结构、crate 依赖、技术栈 | `wiki/architecture.md` §2 | +| 模块开发规范、ErpModule trait、迁移规范 | `wiki/architecture.md` §3 | +| 安全注意事项(认证/多租户/通用) | `wiki/architecture.md` §4 | +| UI 布局规范 | `wiki/frontend.md` §2 | +| 常用命令(Rust/前端/数据库/WASM) | `wiki/infrastructure.md` §3 | +| 设计文档索引 | `wiki/index.md` | +| 开发进度、模块状态 | `wiki/index.md` 关键数字 | +| 环境配置、连接信息、登录凭据 | `wiki/infrastructure.md` §2 | + +## graphify — 代码知识图谱 + +> 项目知识图谱位于 `graphify-out/`,当前规模:18,517 节点 / 22,666 边 / 1,841 社区(纯 AST 解析,无 API 成本)。 +> 工具:`python -m graphify`(已安装 graphifyy 0.8.18)。 + +### 开发流程中的使用场景 + +| 时机 | 命令 | 目的 | +|------|------|------| +| **接手新任务,理解代码关系** | `graphify query "概念名"` | 搜索相关节点,比 Grep 更精准(按调用/引用/包含关系) | +| **排查 bug,追踪调用链** | `graphify path "A" "B"` | 查找两个模块/函数间的最短路径 | +| **理解某个模块的职责** | `graphify explain "模块名"` | 自然语言解释节点及其邻居 | +| **代码改动后** | `graphify update .` | 增量更新图谱(AST-only,秒级完成) | +| **宏观架构审查** | 读 `graphify-out/GRAPH_REPORT.md` | 全局社区结构、跨文件关系概览 | + +### 使用优先级(融入 §2.5 闭环工作法) + +在 §2.5 步骤 1「现状确认」中,**优先使用 graphify 替代盲目 Grep**: + +1. **先 `graphify query`** — 精确定位相关节点和社区(比 Grep 返回更结构化的结果) +2. **再 `graphify path`** — 确认模块间依赖路径(避免遗漏间接依赖) +3. **最后 Grep/Glob/Read** — 确认 graphify 发现的具体文件内容 + +### 注意事项 + +- `graphify update .` 纯本地 AST 解析,不消耗 LLM token,每次代码改动后都可以运行 +- 查询结果比 GRAPH_REPORT.md 更精准,优先使用 query/path/explain,仅在需要全局视图时读报告 +- 首次生成需几分钟(1712 文件),后续增量更新秒级完成 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b6e244a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,6752 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "ammonia" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "tendril", + "url", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "bitmaps" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" +dependencies = [ + "typenum", +] + +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cap-fs-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes", + "windows-sys 0.52.0", +] + +[[package]] +name = "cap-net-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7" +dependencies = [ + "cap-primitives", + "cap-std", + "rustix 1.1.4", + "smallvec", +] + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix 1.1.4", + "rustix-linux-procfs", + "windows-sys 0.52.0", + "winx", +] + +[[package]] +name = "cap-rand" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d" +dependencies = [ + "ambient-authority", + "rand 0.8.5", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix 1.1.4", +] + +[[package]] +name = "cap-time-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80" +dependencies = [ + "ambient-authority", + "cap-primitives", + "iana-time-zone", + "once_cell", + "rustix 1.1.4", + "winx", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "config" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" +dependencies = [ + "async-trait", + "convert_case", + "json5", + "nom", + "pathdiff", + "ron", + "rust-ini", + "serde", + "serde_json", + "toml 0.8.23", + "yaml-rust2", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cranelift-assembler-x64" +version = "0.130.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046d4b584c3bb9b5eb500c8f29549bec36be11000f1ba2a927cef3d1a9875691" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.130.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9b194a7870becb1490366fc0ae392ccd188065ff35f8391e77ac659db6fb977" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.130.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb6a4ab44c6b371e661846b97dab687387a60ac4e2f864e2d4257284aad9e889" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.130.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b7a44150c2f471a94023482bda1902710746e4bed9f9973d60c5a94319b06d" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.130.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b06598133b1dd76758b8b95f8d6747c124124aade50cea96a3d88b962da9fa" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.16.1", + "libm", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.130.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6190e2e7bcf0a678da2f715363d34ed530fedf7a2f0ab75edaefef72a70465ff" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck 0.5.0", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.130.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f583cf203d1aa8b79560e3b01f929bdacf9070b015eec4ea9c46e22a3f83e4a0" + +[[package]] +name = "cranelift-control" +version = "0.130.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803159df35cc398ae54473c150b16d6c77e92ab2948be638488de126a3328fbc" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.130.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e417257082d88087f5bcce677525bdaa8322b88dd7f175ed1a1fd41d546c" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.130.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14db6b0e0e4994c581092df78d837be2072578f7cb2528f96a6cf895e56dee63" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.130.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec66ea5025c7317383699778282ac98741d68444f956e3b1d7b62f12b7216e67" + +[[package]] +name = "cranelift-native" +version = "0.130.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "373ade56438e6232619d85678477d0a88a31b3581936e0503e61e96b546b0800" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.130.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef53619d3cd5c78fd998c6d9420547af26b72e6456f94c2a8a2334cb76b42baa" + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + +[[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.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[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 = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", + "unicode-xid", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "directories-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erp-auth" +version = "0.1.0" +dependencies = [ + "aes", + "argon2", + "async-trait", + "axum", + "base64 0.22.1", + "cbc", + "chrono", + "dashmap", + "erp-core", + "hex", + "jsonwebtoken", + "redis", + "reqwest", + "sea-orm", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "utoipa", + "uuid", + "validator", +] + +[[package]] +name = "erp-config" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "chrono", + "erp-core", + "sea-orm", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "utoipa", + "uuid", + "validator", +] + +[[package]] +name = "erp-core" +version = "0.1.0" +dependencies = [ + "aes-gcm", + "ammonia", + "anyhow", + "async-trait", + "axum", + "base64 0.22.1", + "chrono", + "dashmap", + "hex", + "hmac", + "rand 0.8.5", + "sea-orm", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "tracing", + "utoipa", + "uuid", +] + +[[package]] +name = "erp-diary" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "chrono", + "erp-auth", + "erp-core", + "sea-orm", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "utoipa", + "uuid", + "validator", +] + +[[package]] +name = "erp-message" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-stream", + "async-trait", + "axum", + "chrono", + "erp-core", + "futures", + "sea-orm", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "utoipa", + "uuid", + "validator", +] + +[[package]] +name = "erp-plugin" +version = "0.1.0" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "chrono", + "csv", + "dashmap", + "erp-core", + "moka", + "regex", + "rust_xlsxwriter", + "sea-orm", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.18", + "tokio", + "toml 0.8.23", + "tracing", + "utoipa", + "uuid", + "validator", + "wasmtime", + "wasmtime-wasi", +] + +[[package]] +name = "erp-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "chrono", + "config", + "erp-auth", + "erp-config", + "erp-core", + "erp-diary", + "erp-message", + "erp-plugin", + "erp-server-migration", + "erp-workflow", + "futures", + "hex", + "hmac", + "metrics", + "metrics-exporter-prometheus", + "moka", + "redis", + "sea-orm", + "serde", + "serde_json", + "sha2", + "sqlx", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "utoipa", + "uuid", +] + +[[package]] +name = "erp-server-migration" +version = "0.1.0" +dependencies = [ + "sea-orm-migration", + "tokio", +] + +[[package]] +name = "erp-workflow" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "chrono", + "erp-core", + "reqwest", + "sea-orm", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "utoipa", + "uuid", + "validator", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.52.0", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "flume" +version = "0.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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix 1.1.4", + "windows-sys 0.52.0", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxprof-processed-profile" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" +dependencies = [ + "bitflags", + "debugid", + "rustc-hash", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "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 = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[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 = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "im-rc" +version = "15.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1955a75fa080c677d3972822ec4bad316169ab1cfc6c257a942c2265dbe5fe" +dependencies = [ + "bitmaps", + "rand_core 0.6.4", + "rand_xoshiro 0.6.0", + "sized-chunks", + "typenum", + "version_check", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "inherent" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.52.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "ittapi" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" +dependencies = [ + "anyhow", + "ittapi-sys", + "log", +] + +[[package]] +name = "ittapi-sys" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" +dependencies = [ + "cc", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.184" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.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 = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "serde", + "winapi", +] + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix 1.1.4", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metrics" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5312e9ba3771cfa961b585728215e3d972c950a3eed9252aa093d6301277e8" +dependencies = [ + "ahash 0.8.12", + "portable-atomic", +] + +[[package]] +name = "metrics-exporter-prometheus" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd7399781913e5393588a8d8c6a2867bf85fb38eaf2502fdce465aad2dc6f034" +dependencies = [ + "base64 0.22.1", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "indexmap", + "ipnet", + "metrics", + "metrics-util", + "quanta", + "thiserror 1.0.69", + "tokio", + "tracing", +] + +[[package]] +name = "metrics-util" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8496cc523d1f94c1385dd8f0f0c2c480b2b8aeccb5b7e4485ad6365523ae376" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.15.5", + "metrics", + "quanta", + "rand 0.9.4", + "rand_xoshiro 0.7.0", + "sketches-ddsketch", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "object" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc" +dependencies = [ + "crc32fast", + "hashbrown 0.16.1", + "indexmap", + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +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 = "openssl" +version = "0.10.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[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 = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "pgvector" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc58e2d255979a31caa7cabfa7aac654af0354220719ab7a68520ae7a91e8c0b" +dependencies = [ + "serde", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "version_check", + "yansi", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "pulley-interpreter" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010dec3755eb61b2f1051ecb3611b718460b7a74c131e474de2af20a845938af" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad360c32e85ca4b083ac0e2b6856e8f11c3d5060dafa7d5dc57b370857fa3018" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xoshiro" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand_xoshiro" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redis" +version = "0.27.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09d8f99a4090c89cc489a94833c901ead69bfbf3877b4867d5482e321ee875bc" +dependencies = [ + "arc-swap", + "async-trait", + "backon", + "bytes", + "combine", + "futures", + "futures-util", + "itertools 0.13.0", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2 0.5.10", + "tokio", + "tokio-util", + "url", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "regalloc2" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "952ddbfc6f9f64d006c3efd8c9851a6ba2f2b944ba94730db255d55006e0ffda" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.15.5", + "log", + "rustc-hash", + "smallvec", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rkyv" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2297bf9c81a3f0dc96bc9521370b88f054168c29826a75e89c55ff196e7ed6a1" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7b42d4b8d06048d3ac8db0eb31bcb942cbeb709f0b5f2b2ebde398d3038f5" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64 0.21.7", + "bitflags", + "serde", + "serde_derive", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-ini" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0698206bcb8882bf2a9ecb4c1e7785db57ff052297085a6efd4fe42302068a" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rust_decimal" +version = "1.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce901f9a19d251159075a4c37af514c3b8ef99c22e02dd8c19161cf397ee94a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rust_xlsxwriter" +version = "0.82.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d61a82de4e7b30fc427909f2c5aafaada88cc7ae8316edabae435f74341f9278" +dependencies = [ + "zip", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.4", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "aws-lc-rs", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sea-bae" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f694a6ab48f14bc063cfadff30ab551d3c7e46d8f81836c51989d548f44a2a25" +dependencies = [ + "heck 0.4.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "sea-orm" +version = "1.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dc312fedd460a47ea563911761d254a84e7b51d8cc73ec92c929e78f33fa957" +dependencies = [ + "async-stream", + "async-trait", + "bigdecimal", + "chrono", + "derive_more", + "futures-util", + "log", + "mac_address", + "ouroboros", + "pgvector", + "rust_decimal", + "sea-orm-macros", + "sea-query", + "sea-query-binder", + "serde", + "serde_json", + "sqlx", + "strum", + "thiserror 2.0.18", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sea-orm-cli" +version = "1.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da80ebcdb44571e86f03a2bdcb5532136a87397f366f38bbce64673fc5e6a450" +dependencies = [ + "chrono", + "clap", + "dotenvy", + "glob", + "regex", + "sea-schema", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "sea-orm-macros" +version = "1.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b9a3f90e336ec74803e8eb98c61bc98754c1adfba3b4f84d946237b752b1c88" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "sea-bae", + "syn 2.0.117", + "unicode-ident", +] + +[[package]] +name = "sea-orm-migration" +version = "1.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c577f2959277e936c1d08109acd1e08fc36a95ef29ec028190ba82cad8f96e" +dependencies = [ + "async-trait", + "clap", + "dotenvy", + "sea-orm", + "sea-orm-cli", + "sea-schema", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sea-query" +version = "0.32.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" +dependencies = [ + "bigdecimal", + "chrono", + "inherent", + "ordered-float", + "rust_decimal", + "sea-query-derive", + "serde_json", + "time", + "uuid", +] + +[[package]] +name = "sea-query-binder" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608" +dependencies = [ + "bigdecimal", + "chrono", + "rust_decimal", + "sea-query", + "serde_json", + "sqlx", + "time", + "uuid", +] + +[[package]] +name = "sea-query-derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab" +dependencies = [ + "darling", + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.117", + "thiserror 2.0.18", +] + +[[package]] +name = "sea-schema" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2239ff574c04858ca77485f112afea1a15e53135d3097d0c86509cef1def1338" +dependencies = [ + "futures", + "sea-query", + "sea-query-binder", + "sea-schema-derive", + "sqlx", +] + +[[package]] +name = "sea-schema-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdc8729c37fdbf88472f97fd470393089f997a909e535ff67c544d18cfccf0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "sized-chunks" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" +dependencies = [ + "bitmaps", + "typenum", +] + +[[package]] +name = "sketches-ddsketch" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "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", + "bigdecimal", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink 0.10.0", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rust_decimal", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[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.117", +] + +[[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", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.117", + "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", + "bigdecimal", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "rust_decimal", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "time", + "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", + "bigdecimal", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "num-bigint", + "once_cell", + "rand 0.8.5", + "rust_decimal", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "time", + "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.18", + "time", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "system-interface" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" +dependencies = [ + "bitflags", + "cap-fs-ext", + "cap-std", + "fd-lock", + "io-lifetimes", + "rustix 0.38.44", + "windows-sys 0.52.0", + "winx", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.51.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.1", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.1", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "iri-string", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +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.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "validator" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0b4a29d8709210980a09379f27ee31549b73292c87ab9899beee1c0d3be6303" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bac855a2ce6f843beb229757e6e570a42e837bcb15e5f449dd48d5747d41bf77" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-compose" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd23d12cc95c451c1306db5bc63075fbebb612bb70c53b4237b1ce5bc178343" +dependencies = [ + "anyhow", + "heck 0.5.0", + "im-rc", + "indexmap", + "log", + "petgraph", + "serde", + "serde_derive", + "serde_yaml", + "smallvec", + "wasm-encoder 0.245.1", + "wasmparser 0.245.1", + "wat", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9dca005e69bf015e45577e415b9af8c67e8ee3c0e38b5b0add5aa92581ed5c" +dependencies = [ + "leb128fmt", + "wasmparser 0.245.1", +] + +[[package]] +name = "wasm-encoder" +version = "0.246.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61fb705ce81adde29d2a8e99d87995e39a6e927358c91398f374474746070ef7" +dependencies = [ + "leb128fmt", + "wasmparser 0.246.2", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder 0.244.0", + "wasmparser 0.244.0", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "wasmparser" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" +dependencies = [ + "bitflags", + "hashbrown 0.16.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.246.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71cde4757396defafd25417cfb36aa3161027d06d865b0c24baaae229aac005d" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41517a3716fbb8ccf46daa9c1325f760fcbff5168e75c7392288e410b91ac8" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.245.1", +] + +[[package]] +name = "wasmtime" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce205cd643d661b5ba5ba4717e13730262e8cdbc8f2eacbc7b906d45c1a74026" +dependencies = [ + "addr2line", + "async-trait", + "bitflags", + "bumpalo", + "cc", + "cfg-if", + "encoding_rs", + "futures", + "fxprof-processed-profile", + "gimli", + "ittapi", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rayon", + "rustix 1.1.4", + "semver", + "serde", + "serde_derive", + "serde_json", + "smallvec", + "target-lexicon", + "tempfile", + "wasm-compose", + "wasm-encoder 0.245.1", + "wasmparser 0.245.1", + "wasmtime-environ", + "wasmtime-internal-cache", + "wasmtime-internal-component-macro", + "wasmtime-internal-component-util", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wasmtime-internal-winch", + "wat", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-environ" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b8b78abf3677d4a0a5db82e5015b4d085ff3a1b8b472cbb8c70d4b769f019ce" +dependencies = [ + "anyhow", + "cpp_demangle", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.16.1", + "indexmap", + "log", + "object", + "postcard", + "rustc-demangle", + "semver", + "serde", + "serde_derive", + "sha2", + "smallvec", + "target-lexicon", + "wasm-encoder 0.245.1", + "wasmparser 0.245.1", + "wasmprinter", + "wasmtime-internal-component-util", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-cache" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e4fd4103ba413c0da2e636f73490c6c8e446d708cbde7573703941bc3d6a448" +dependencies = [ + "base64 0.22.1", + "directories-next", + "log", + "postcard", + "rustix 1.1.4", + "serde", + "serde_derive", + "sha2", + "toml 0.9.12+spec-1.1.0", + "wasmtime-environ", + "windows-sys 0.61.2", + "zstd", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d3d6914f34be2f9d78d8ee9f422e834dfc204e71ccce697205fae95fed87892" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser 0.245.1", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3751b0616b914fdd87fe1bf804694a078f321b000338e6476bc48a4d6e454f21" + +[[package]] +name = "wasmtime-internal-core" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22632b187e1b0716f1b9ac57ad29013bed33175fcb19e10bb6896126f82fac67" +dependencies = [ + "anyhow", + "hashbrown 0.16.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b3ca07b3e0bb3429674b173b5800577719d600774dd81bff58f775c0aaa64ee" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools 0.14.0", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.245.1", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c8b2c9704eb1f33ead025ec16038277ccb63d0a14c31e99d5b765d7c36da55" +dependencies = [ + "cc", + "cfg-if", + "libc", + "rustix 1.1.4", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d950310d07391d34369f62c48336ebb14eacbd4d6f772bb5f349c24e838e0664" +dependencies = [ + "cc", + "object", + "rustix 1.1.4", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3606662c156962d096be3127b8b8ae8ee2f8be3f896dad29259ff01ddb64abfd" +dependencies = [ + "cfg-if", + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75eef0747e52dc545b075f64fd0e0cc237ae738e641266b1970e07e2d744bc32" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "log", + "object", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b0a5dab02a8fb527f547855ecc0e05f9fdc3d5bd57b8b080349408f9a6cece" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "wasmtime-internal-winch" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8007342bd12ff400293a817973f7ecd6f1d9a8549a53369a9c1af357166f1f1e" +dependencies = [ + "cranelift-codegen", + "gimli", + "log", + "object", + "target-lexicon", + "wasmparser 0.245.1", + "wasmtime-environ", + "wasmtime-internal-cranelift", + "winch-codegen", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7900c3e3c1d6e475bc225d73b02d6d5484815f260022e6964dca9558e50dd01a" +dependencies = [ + "anyhow", + "bitflags", + "heck 0.5.0", + "indexmap", + "wit-parser 0.245.1", +] + +[[package]] +name = "wasmtime-wasi" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3e3ddcfad69e9eb025bd19bff70dad45bafe1d6eacd134c0ffdfc4c161d045" +dependencies = [ + "async-trait", + "bitflags", + "bytes", + "cap-fs-ext", + "cap-net-ext", + "cap-rand", + "cap-std", + "cap-time-ext", + "fs-set-times", + "futures", + "io-extras", + "io-lifetimes", + "rustix 1.1.4", + "system-interface", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "wasmtime", + "wasmtime-wasi-io", + "wiggle", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-wasi-io" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ca5dd3b9f04a851c422d05f333366722742da46bff9369ae0191f32cf83565a" +dependencies = [ + "async-trait", + "bytes", + "futures", + "tracing", + "wasmtime", +] + +[[package]] +name = "wast" +version = "35.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" +dependencies = [ + "leb128", +] + +[[package]] +name = "wast" +version = "246.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe3fe8e3bf88ad96d031b4181ddbd64634b17cb0d06dfc3de589ef43591a9a62" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.246.2", +] + +[[package]] +name = "wat" +version = "1.246.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd7fda1199b94fff395c2d19a153f05dbe7807630316fa9673367666fd2ad8c" +dependencies = [ + "wast 246.0.2", +] + +[[package]] +name = "web-sys" +version = "0.3.94" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "wiggle" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1b1135efc8e5a008971897bea8d41ca56d8d501d4efb807842ae0a1c78f639" +dependencies = [ + "bitflags", + "thiserror 2.0.18", + "tracing", + "wasmtime", + "wasmtime-environ", + "wiggle-macro", +] + +[[package]] +name = "wiggle-generate" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7bc2b0d50ec8773b44fbfe1da6cb5cc44a92deaf8483233dcf0831e6db33172" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasmtime-environ", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6c7d44ea552e1fbfdcd7a2cd83f5c2d1e803d5b1a11e3462c06888b77f455f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "wiggle-generate", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "winch-codegen" +version = "43.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb9f45f7172a2628c8317766e427babc0a400f9d10b1c0f0b0617c5ed5b79de6" +dependencies = [ + "cranelift-assembler-x64", + "cranelift-codegen", + "gimli", + "regalloc2", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.245.1", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.52.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder 0.244.0", + "wasm-metadata", + "wasmparser 0.244.0", + "wit-parser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.244.0", +] + +[[package]] +name = "wit-parser" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330698718e82983499419494dd1e3d7811a457a9bf9f69734e8c5f07a2547929" +dependencies = [ + "anyhow", + "hashbrown 0.16.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.245.1", +] + +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast 35.0.2", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + +[[package]] +name = "yaml-rust2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink 0.8.4", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.18", + "zopfli", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..64466b9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,117 @@ +[workspace] +resolver = "2" +members = [ + "crates/erp-core", + "crates/erp-server", + "crates/erp-auth", + "crates/erp-workflow", + "crates/erp-message", + "crates/erp-config", + "crates/erp-server/migration", + "crates/erp-plugin", + "crates/erp-diary", +] + +[workspace.package] +version = "0.1.0" +edition = "2024" +license = "MIT" + +[workspace.dependencies] +# Async +tokio = { version = "1", features = ["full"] } + +# Web +axum = { version = "0.8", features = ["multipart"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["cors", "trace", "compression-gzip", "fs", "set-header"] } + +# Database +sea-orm = { version = "1.1", features = [ + "sqlx-postgres", "runtime-tokio-rustls", "macros", "with-uuid", "with-chrono", "with-json" +] } +sea-orm-migration = { version = "1.1", features = ["sqlx-postgres", "runtime-tokio-rustls"] } +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "uuid"] } + +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# UUID & Time +uuid = { version = "1", features = ["v7", "serde"] } +chrono = { version = "0.4", features = ["serde"] } + +# Error handling +thiserror = "2" +anyhow = "1" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +# Config +config = "0.14" + +# Redis +redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } + +# JWT +jsonwebtoken = "9" + +# Password hashing +argon2 = "0.5" + +# Cryptographic hashing (token storage) +sha2 = "0.10" + +# API docs +utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] } +# utoipa-swagger-ui 需要下载 GitHub 资源,网络受限时暂不使用 +# utoipa-swagger-ui = { version = "8", features = ["axum"] } + +# Validation +validator = { version = "0.19", features = ["derive"] } + +# Async trait +async-trait = "0.1" + +# HTTP client +reqwest = { version = "0.12", features = ["json", "stream"] } + +# Crypto +aes = "0.8" +cbc = "0.1" +hex = "0.4" +regex-lite = "0.1" + +# CSV and Excel export +csv = "1" +rust_xlsxwriter = "0.82" + +# Internal crates +erp-core = { path = "crates/erp-core" } +erp-auth = { path = "crates/erp-auth" } +erp-workflow = { path = "crates/erp-workflow" } +erp-message = { path = "crates/erp-message" } +erp-config = { path = "crates/erp-config" } +erp-plugin = { path = "crates/erp-plugin" } +erp-diary = { path = "crates/erp-diary" } + +# Async streaming +futures = "0.3" +tokio-stream = "0.1" +async-stream = "0.3" +dashmap = "6" + +# Template engine +handlebars = "6" + +# HTML sanitization +ammonia = "4" + +# Document parsing +pdf-extract = "0.7" + +# Metrics +metrics = "0.24" +metrics-exporter-prometheus = "0.16" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b22a9da --- /dev/null +++ b/Dockerfile @@ -0,0 +1,113 @@ +# ============================== +# Stage 1: Build Rust backend +# ============================== +FROM rust:1-bookworm AS rust-builder + +WORKDIR /app + +# 先复制依赖文件以利用 Docker 缓存 +COPY Cargo.toml Cargo.lock ./ +COPY crates/erp-core/Cargo.toml crates/erp-core/Cargo.toml +COPY crates/erp-auth/Cargo.toml crates/erp-auth/Cargo.toml +COPY crates/erp-config/Cargo.toml crates/erp-config/Cargo.toml +COPY crates/erp-workflow/Cargo.toml crates/erp-workflow/Cargo.toml +COPY crates/erp-message/Cargo.toml crates/erp-message/Cargo.toml +COPY crates/erp-plugin/Cargo.toml crates/erp-plugin/Cargo.toml +COPY crates/erp-health/Cargo.toml crates/erp-health/Cargo.toml +COPY crates/erp-ai/Cargo.toml crates/erp-ai/Cargo.toml +COPY crates/erp-dialysis/Cargo.toml crates/erp-dialysis/Cargo.toml +COPY crates/erp-server/Cargo.toml crates/erp-server/Cargo.toml +COPY crates/erp-server/migration/Cargo.toml crates/erp-server/migration/Cargo.toml +COPY crates/erp-plugin-prototype/Cargo.toml crates/erp-plugin-prototype/Cargo.toml +COPY crates/erp-plugin-test-sample/Cargo.toml crates/erp-plugin-test-sample/Cargo.toml +COPY crates/erp-plugin-assessment/Cargo.toml crates/erp-plugin-assessment/Cargo.toml +COPY crates/erp-plugin-crm/Cargo.toml crates/erp-plugin-crm/Cargo.toml +COPY crates/erp-plugin-freelance/Cargo.toml crates/erp-plugin-freelance/Cargo.toml +COPY crates/erp-plugin-inventory/Cargo.toml crates/erp-plugin-inventory/Cargo.toml +COPY crates/erp-plugin-itops/Cargo.toml crates/erp-plugin-itops/Cargo.toml + +# 创建空的 lib.rs/main.rs 占位以缓存依赖 +RUN mkdir -p crates/erp-core/src && echo "" > crates/erp-core/src/lib.rs \ + && mkdir -p crates/erp-auth/src && echo "" > crates/erp-auth/src/lib.rs \ + && mkdir -p crates/erp-config/src && echo "" > crates/erp-config/src/lib.rs \ + && mkdir -p crates/erp-workflow/src && echo "" > crates/erp-workflow/src/lib.rs \ + && mkdir -p crates/erp-message/src && echo "" > crates/erp-message/src/lib.rs \ + && mkdir -p crates/erp-plugin/src && echo "" > crates/erp-plugin/src/lib.rs \ + && mkdir -p crates/erp-health/src && echo "" > crates/erp-health/src/lib.rs \ + && mkdir -p crates/erp-ai/src && echo "" > crates/erp-ai/src/lib.rs \ + && mkdir -p crates/erp-dialysis/src && echo "" > crates/erp-dialysis/src/lib.rs \ + && mkdir -p crates/erp-server/src && echo "fn main(){}" > crates/erp-server/src/main.rs \ + && mkdir -p crates/erp-server/migration/src && echo "" > crates/erp-server/migration/src/lib.rs \ + && for crate in erp-plugin-prototype erp-plugin-test-sample erp-plugin-assessment erp-plugin-crm erp-plugin-freelance erp-plugin-inventory erp-plugin-itops; do \ + mkdir -p crates/$crate/src && echo "" > crates/$crate/src/lib.rs; \ + done + +# 构建依赖(仅当 Cargo.toml/Cargo.lock 变化时重新编译) +RUN cargo build --release -p erp-server 2>/dev/null || true + +# 复制实际源码 +COPY crates/ crates/ + +# 重新构建(增量编译,只编译业务代码) +RUN cargo build --release -p erp-server + +# ============================== +# Stage 2: Build frontend +# ============================== +FROM node:20-alpine AS frontend-builder + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@latest --activate + +COPY apps/web/package.json apps/web/pnpm-lock.yaml ./apps/web/ + +RUN cd apps/web && pnpm install --frozen-lockfile + +COPY apps/web/ ./apps/web/ + +RUN cd apps/web && pnpm build + +# ============================== +# Stage 3: Production runtime +# ============================== +FROM debian:bookworm-slim AS runtime + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# 复制 Rust 二进制 +COPY --from=rust-builder /app/target/release/erp-server /app/erp-server + +# 复制配置文件 +COPY config/ /app/config/ + +# 复制前端构建产物(可通过 volume 暴露给 OpenResty) +COPY --from=frontend-builder /app/apps/web/dist/ /app/static/ + +# 创建上传目录 +RUN mkdir -p /app/uploads + +# 非特权用户运行 +RUN useradd -r -s /bin/false appuser \ + && chown -R appuser:appuser /app +USER appuser + +# 环境变量(运行时通过 docker-compose / .env 覆盖) +ENV ERP__SERVER__HOST=0.0.0.0 +ENV ERP__SERVER__PORT=3000 +ENV ERP__SERVER__METRICS_PORT=9090 +ENV ERP__STORAGE__UPLOAD_DIR=/app/uploads + +EXPOSE 3000 9090 + +VOLUME ["/app/uploads", "/app/static"] + +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:3000/api/v1/health || exit 1 + +ENTRYPOINT ["/app/erp-server"] diff --git a/config/default.toml b/config/default.toml new file mode 100644 index 0000000..84dda02 --- /dev/null +++ b/config/default.toml @@ -0,0 +1,81 @@ +[server] +host = "0.0.0.0" +port = 3000 + +[database] +url = "__MUST_SET_VIA_ENV__" +max_connections = 20 +min_connections = 5 + +[redis] +url = "__MUST_SET_VIA_ENV__" + +[jwt] +secret = "__MUST_SET_VIA_ENV__" +access_token_ttl = "15m" +refresh_token_ttl = "7d" + +[auth] +super_admin_password = "__MUST_SET_VIA_ENV__" + +[log] +level = "info" + +[cors] +# Comma-separated allowed origins. Use "*" for development only. +allowed_origins = "http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:3000" + +[wechat] +appid = "__MUST_SET_VIA_ENV__" +secret = "__MUST_SET_VIA_ENV__" +# dev_mode = true 跳过 jscode2session,允许微信开发者工具模拟器登录 +# 生产环境必须为 false(默认) +dev_mode = false + +[health] +aes_key = "__MUST_SET_VIA_ENV__" +hmac_key = "__MUST_SET_VIA_ENV__" + +[crypto] +kek = "__MUST_SET_VIA_ENV__" + +[ai] +default_provider = "claude" +api_key = "" +base_url = "https://api.anthropic.com" +model = "claude-sonnet-4-6" +max_tokens = 2048 +temperature = 0.3 +cache_ttl_seconds = 604800 +rate_limit_patient_daily = 10 +quota_check_enabled = true + +[ai.providers.claude] +provider_type = "claude" +api_key_env = "ANTHROPIC_API_KEY" +base_url = "https://api.anthropic.com" +default_model = "claude-sonnet-4-6" +max_tokens = 2048 +temperature = 0.3 +is_enabled = true + +[ai.providers.openai] +provider_type = "openai" +api_key_env = "OPENAI_API_KEY" +base_url = "https://api.openai.com" +default_model = "gpt-4o" +max_tokens = 2048 +temperature = 0.3 +is_enabled = false + +[ai.providers.ollama] +provider_type = "ollama" +base_url = "http://localhost:11434" +default_model = "qwen2.5:7b" +max_tokens = 2048 +temperature = 0.3 +is_enabled = false + +[storage] +upload_dir = "./uploads" +max_file_size = "10MB" diff --git a/crates/erp-auth/Cargo.toml b/crates/erp-auth/Cargo.toml new file mode 100644 index 0000000..4db2620 --- /dev/null +++ b/crates/erp-auth/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "erp-auth" +version.workspace = true +edition.workspace = true + +[dependencies] +erp-core.workspace = true +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +axum.workspace = true +sea-orm.workspace = true +tracing.workspace = true +thiserror.workspace = true +jsonwebtoken.workspace = true +argon2.workspace = true +sha2.workspace = true +validator.workspace = true +utoipa.workspace = true +async-trait.workspace = true +reqwest.workspace = true +aes.workspace = true +cbc.workspace = true +hex.workspace = true +base64 = "0.22" +redis.workspace = true +dashmap.workspace = true diff --git a/crates/erp-auth/src/auth_state.rs b/crates/erp-auth/src/auth_state.rs new file mode 100644 index 0000000..2c70fd5 --- /dev/null +++ b/crates/erp-auth/src/auth_state.rs @@ -0,0 +1,83 @@ +use erp_core::crypto::PiiCrypto; +use erp_core::events::EventBus; +use sea_orm::DatabaseConnection; +use uuid::Uuid; + +/// Auth-specific state extracted from the server's AppState via `FromRef`. +/// +/// This avoids a circular dependency between erp-auth and erp-server. +/// The server crate implements `FromRef for AuthState` so that +/// Axum handlers in erp-auth can extract `State` directly. +/// +/// Contains everything the auth handlers need: +/// - Database connection for user/credential lookups +/// - EventBus for publishing domain events +/// - JWT configuration for token signing and validation +/// - Default tenant ID for the bootstrap phase +#[derive(Clone)] +pub struct AuthState { + pub db: DatabaseConnection, + pub event_bus: EventBus, + pub jwt_secret: String, + pub access_ttl_secs: i64, + pub refresh_ttl_secs: i64, + pub default_tenant_id: Uuid, + pub wechat_appid: String, + pub wechat_secret: String, + pub wechat_dev_mode: bool, + pub redis: Option, + pub crypto: PiiCrypto, +} + +/// Parse a human-readable TTL string (e.g. "15m", "7d", "1h", "900s") into seconds. +/// +/// Falls back to parsing the raw string as seconds if no unit suffix is recognized. +pub fn parse_ttl(ttl: &str) -> i64 { + let ttl = ttl.trim(); + if let Some(num) = ttl.strip_suffix('s') { + num.parse::().unwrap_or(900) + } else if let Some(num) = ttl.strip_suffix('m') { + num.parse::().map(|n| n * 60).unwrap_or(900) + } else if let Some(num) = ttl.strip_suffix('h') { + num.parse::().map(|n| n * 3600).unwrap_or(900) + } else if let Some(num) = ttl.strip_suffix('d') { + num.parse::().map(|n| n * 86400).unwrap_or(900) + } else { + ttl.parse::().unwrap_or(900) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_ttl_seconds() { + assert_eq!(parse_ttl("900s"), 900); + } + + #[test] + fn parse_ttl_minutes() { + assert_eq!(parse_ttl("15m"), 900); + } + + #[test] + fn parse_ttl_hours() { + assert_eq!(parse_ttl("1h"), 3600); + } + + #[test] + fn parse_ttl_days() { + assert_eq!(parse_ttl("7d"), 604800); + } + + #[test] + fn parse_ttl_raw_number() { + assert_eq!(parse_ttl("300"), 300); + } + + #[test] + fn parse_ttl_fallback_on_invalid() { + assert_eq!(parse_ttl("invalid"), 900); + } +} diff --git a/crates/erp-auth/src/dto.rs b/crates/erp-auth/src/dto.rs new file mode 100644 index 0000000..3366718 --- /dev/null +++ b/crates/erp-auth/src/dto.rs @@ -0,0 +1,506 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; +use validator::Validate; + +use erp_core::sanitize::{sanitize_option, sanitize_string}; + +// --- Auth DTOs --- + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct LoginReq { + #[validate(length(min = 1, message = "用户名不能为空"))] + pub username: String, + #[validate(length(min = 1, max = 128, message = "密码长度需在1-128之间"))] + pub password: String, + /// 客户端类型: "miniprogram" 允许患者角色登录 + #[serde(default)] + pub client_type: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct LoginResp { + pub access_token: String, + pub refresh_token: String, + pub expires_in: u64, + pub user: UserResp, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct RefreshReq { + pub refresh_token: String, +} + +// --- Wechat DTOs --- + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct WechatLoginReq { + #[validate(length(min = 1, message = "code 不能为空"))] + pub code: String, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct WechatLoginResp { + pub bound: bool, + pub openid: String, + pub token: Option, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct WechatBindPhoneReq { + #[validate(length(min = 1, message = "openid 不能为空"))] + pub openid: String, + #[validate(length(min = 1, message = "encrypted_data 不能为空"))] + pub encrypted_data: String, + #[validate(length(min = 1, message = "iv 不能为空"))] + pub iv: String, +} + +/// 修改密码请求 +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct ChangePasswordReq { + #[validate(length(min = 1, message = "当前密码不能为空"))] + pub current_password: String, + #[validate(length(min = 6, max = 128, message = "新密码长度需在6-128之间"))] + pub new_password: String, +} + +/// 管理员重置用户密码请求 +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct ResetPasswordReq { + #[validate(length(min = 6, max = 128, message = "新密码长度需在6-128之间"))] + pub new_password: String, + #[validate(range(min = 0))] + pub version: i32, +} + +// --- User DTOs --- + +#[derive(Debug, Serialize, ToSchema)] +pub struct UserResp { + pub id: Uuid, + pub username: String, + pub email: Option, + pub phone: Option, + pub display_name: Option, + pub avatar_url: Option, + pub status: String, + pub roles: Vec, + pub version: i32, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct CreateUserReq { + #[validate(length(min = 1, max = 50))] + pub username: String, + #[validate(length(min = 6, max = 128))] + pub password: String, + #[validate(email)] + pub email: Option, + #[validate(length(max = 20))] + pub phone: Option, + #[validate(length(max = 100))] + pub display_name: Option, +} + +impl CreateUserReq { + /// 清理所有用户输入字段中的 HTML 标签,防止存储型 XSS。 + pub fn sanitize(&mut self) { + self.username = sanitize_string(&self.username); + self.email = sanitize_option(self.email.take()); + self.phone = sanitize_option(self.phone.take()); + self.display_name = sanitize_option(self.display_name.take()); + } +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct UpdateUserReq { + #[validate(email)] + pub email: Option, + #[validate(length(max = 20))] + pub phone: Option, + #[validate(length(max = 100))] + pub display_name: Option, + #[validate(length(min = 1, max = 20))] + pub status: Option, + pub version: i32, +} + +impl UpdateUserReq { + /// 清理所有用户输入字段中的 HTML 标签,防止存储型 XSS。 + pub fn sanitize(&mut self) { + self.email = sanitize_option(self.email.take()); + self.phone = sanitize_option(self.phone.take()); + self.display_name = sanitize_option(self.display_name.take()); + } +} + +// --- Role DTOs --- + +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct RoleResp { + pub id: Uuid, + pub name: String, + pub code: String, + pub description: Option, + pub is_system: bool, + pub version: i32, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct CreateRoleReq { + #[validate(length(min = 1, max = 50))] + pub name: String, + #[validate(length(min = 1, max = 50))] + pub code: String, + pub description: Option, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct UpdateRoleReq { + #[validate(length(min = 1, max = 50))] + pub name: Option, + pub description: Option, + pub version: i32, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct AssignRolesReq { + #[validate(length(min = 1, message = "至少需要分配一个角色"))] + pub role_ids: Vec, +} + +// --- Permission DTOs --- + +#[derive(Debug, Serialize, ToSchema)] +pub struct PermissionResp { + pub id: Uuid, + pub code: String, + pub name: String, + pub resource: String, + pub action: String, + pub description: Option, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct AssignPermissionsReq { + #[validate(length(min = 1, message = "至少需要分配一个权限"))] + pub permission_ids: Vec, +} + +// --- Organization DTOs --- + +#[derive(Debug, Serialize, ToSchema)] +pub struct OrganizationResp { + pub id: Uuid, + pub name: String, + pub code: Option, + pub parent_id: Option, + pub path: Option, + pub level: i32, + pub sort_order: i32, + pub children: Vec, + pub version: i32, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct CreateOrganizationReq { + #[validate(length(min = 1))] + pub name: String, + pub code: Option, + pub parent_id: Option, + pub sort_order: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateOrganizationReq { + pub name: Option, + pub code: Option, + pub sort_order: Option, + pub version: i32, +} + +// --- Department DTOs --- + +#[derive(Debug, Serialize, ToSchema)] +pub struct DepartmentResp { + pub id: Uuid, + pub org_id: Uuid, + pub name: String, + pub code: Option, + pub parent_id: Option, + pub manager_id: Option, + pub path: Option, + pub sort_order: i32, + pub children: Vec, + pub version: i32, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct CreateDepartmentReq { + #[validate(length(min = 1))] + pub name: String, + pub code: Option, + pub parent_id: Option, + pub manager_id: Option, + pub sort_order: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateDepartmentReq { + pub name: Option, + pub code: Option, + pub manager_id: Option, + pub sort_order: Option, + pub version: i32, +} + +// --- Position DTOs --- + +#[derive(Debug, Serialize, ToSchema)] +pub struct PositionResp { + pub id: Uuid, + pub dept_id: Uuid, + pub name: String, + pub code: Option, + pub level: i32, + pub sort_order: i32, + pub version: i32, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct CreatePositionReq { + #[validate(length(min = 1))] + pub name: String, + pub code: Option, + pub level: Option, + pub sort_order: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdatePositionReq { + pub name: Option, + pub code: Option, + pub level: Option, + pub sort_order: Option, + pub version: i32, +} + +#[cfg(test)] +mod tests { + use super::*; + use validator::Validate; + + #[test] + fn login_req_valid() { + let req = LoginReq { + username: "admin".to_string(), + password: "password123".to_string(), + client_type: None, + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn login_req_empty_username_fails() { + let req = LoginReq { + username: "".to_string(), + password: "password123".to_string(), + client_type: None, + }; + let result = req.validate(); + assert!(result.is_err()); + } + + #[test] + fn change_password_req_valid() { + let req = ChangePasswordReq { + current_password: "oldPassword123".to_string(), + new_password: "newPassword456".to_string(), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn change_password_req_empty_current_fails() { + let req = ChangePasswordReq { + current_password: "".to_string(), + new_password: "newPassword456".to_string(), + }; + assert!(req.validate().is_err()); + } + + #[test] + fn change_password_req_short_new_fails() { + let req = ChangePasswordReq { + current_password: "oldPassword123".to_string(), + new_password: "12345".to_string(), // min 6 + }; + assert!(req.validate().is_err()); + } + + #[test] + fn change_password_req_long_new_fails() { + let req = ChangePasswordReq { + current_password: "oldPassword123".to_string(), + new_password: "a".repeat(129), // max 128 + }; + assert!(req.validate().is_err()); + } + + #[test] + fn login_req_empty_password_fails() { + let req = LoginReq { + username: "admin".to_string(), + password: "".to_string(), + client_type: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_user_req_valid() { + let req = CreateUserReq { + username: "alice".to_string(), + password: "secret123".to_string(), + email: Some("alice@example.com".to_string()), + phone: None, + display_name: Some("Alice".to_string()), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_user_req_short_password_fails() { + let req = CreateUserReq { + username: "bob".to_string(), + password: "12345".to_string(), // min 6 + email: None, + phone: None, + display_name: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_user_req_empty_username_fails() { + let req = CreateUserReq { + username: "".to_string(), + password: "secret123".to_string(), + email: None, + phone: None, + display_name: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_user_req_invalid_email_fails() { + let req = CreateUserReq { + username: "charlie".to_string(), + password: "secret123".to_string(), + email: Some("not-an-email".to_string()), + phone: None, + display_name: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_user_req_long_username_fails() { + let req = CreateUserReq { + username: "a".repeat(51), // max 50 + password: "secret123".to_string(), + email: None, + phone: None, + display_name: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_role_req_valid() { + let req = CreateRoleReq { + name: "管理员".to_string(), + code: "admin".to_string(), + description: Some("系统管理员".to_string()), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_role_req_empty_name_fails() { + let req = CreateRoleReq { + name: "".to_string(), + code: "admin".to_string(), + description: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_role_req_empty_code_fails() { + let req = CreateRoleReq { + name: "管理员".to_string(), + code: "".to_string(), + description: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_org_req_valid() { + let req = CreateOrganizationReq { + name: "总部".to_string(), + code: Some("HQ".to_string()), + parent_id: None, + sort_order: Some(0), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_org_req_empty_name_fails() { + let req = CreateOrganizationReq { + name: "".to_string(), + code: None, + parent_id: None, + sort_order: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dept_req_valid() { + let req = CreateDepartmentReq { + name: "技术部".to_string(), + code: Some("TECH".to_string()), + parent_id: None, + manager_id: None, + sort_order: Some(1), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_position_req_valid() { + let req = CreatePositionReq { + name: "高级工程师".to_string(), + code: Some("SENIOR".to_string()), + level: Some(3), + sort_order: None, + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_position_req_empty_name_fails() { + let req = CreatePositionReq { + name: "".to_string(), + code: None, + level: None, + sort_order: None, + }; + assert!(req.validate().is_err()); + } +} diff --git a/crates/erp-auth/src/entity/department.rs b/crates/erp-auth/src/entity/department.rs new file mode 100644 index 0000000..41c9686 --- /dev/null +++ b/crates/erp-auth/src/entity/department.rs @@ -0,0 +1,68 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "departments")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub org_id: Uuid, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub manager_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + pub sort_order: i32, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::organization::Entity", + from = "Column::OrgId", + to = "super::organization::Column::Id", + on_delete = "Restrict" + )] + Organization, + #[sea_orm(has_many = "super::position::Entity")] + Position, + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::ManagerId", + to = "super::user::Column::Id", + on_delete = "SetNull" + )] + Manager, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Organization.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Position.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Manager.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-auth/src/entity/mod.rs b/crates/erp-auth/src/entity/mod.rs new file mode 100644 index 0000000..6884281 --- /dev/null +++ b/crates/erp-auth/src/entity/mod.rs @@ -0,0 +1,12 @@ +pub mod department; +pub mod organization; +pub mod permission; +pub mod position; +pub mod role; +pub mod role_permission; +pub mod user; +pub mod user_credential; +pub mod user_department; +pub mod user_role; +pub mod user_token; +pub mod wechat_user; diff --git a/crates/erp-auth/src/entity/organization.rs b/crates/erp-auth/src/entity/organization.rs new file mode 100644 index 0000000..bf8369f --- /dev/null +++ b/crates/erp-auth/src/entity/organization.rs @@ -0,0 +1,40 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "organizations")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + pub level: i32, + pub sort_order: i32, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::department::Entity")] + Department, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Department.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-auth/src/entity/permission.rs b/crates/erp-auth/src/entity/permission.rs new file mode 100644 index 0000000..6045eea --- /dev/null +++ b/crates/erp-auth/src/entity/permission.rs @@ -0,0 +1,37 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "permissions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub code: String, + pub name: String, + pub resource: String, + pub action: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::role_permission::Entity")] + RolePermission, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::RolePermission.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-auth/src/entity/position.rs b/crates/erp-auth/src/entity/position.rs new file mode 100644 index 0000000..cea648a --- /dev/null +++ b/crates/erp-auth/src/entity/position.rs @@ -0,0 +1,42 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "positions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub dept_id: Uuid, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + pub level: i32, + pub sort_order: i32, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::department::Entity", + from = "Column::DeptId", + to = "super::department::Column::Id", + on_delete = "Restrict" + )] + Department, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Department.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-auth/src/entity/role.rs b/crates/erp-auth/src/entity/role.rs new file mode 100644 index 0000000..a54e140 --- /dev/null +++ b/crates/erp-auth/src/entity/role.rs @@ -0,0 +1,44 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "roles")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub name: String, + pub code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub is_system: bool, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::role_permission::Entity")] + RolePermission, + #[sea_orm(has_many = "super::user_role::Entity")] + UserRole, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::RolePermission.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::UserRole.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-auth/src/entity/role_permission.rs b/crates/erp-auth/src/entity/role_permission.rs new file mode 100644 index 0000000..814e90c --- /dev/null +++ b/crates/erp-auth/src/entity/role_permission.rs @@ -0,0 +1,53 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "role_permissions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub role_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub permission_id: Uuid, + pub tenant_id: Uuid, + /// 行级数据权限范围: all, self, department, department_tree + pub data_scope: String, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::role::Entity", + from = "Column::RoleId", + to = "super::role::Column::Id", + on_delete = "Cascade" + )] + Role, + #[sea_orm( + belongs_to = "super::permission::Entity", + from = "Column::PermissionId", + to = "super::permission::Column::Id", + on_delete = "Cascade" + )] + Permission, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Role.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Permission.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-auth/src/entity/user.rs b/crates/erp-auth/src/entity/user.rs new file mode 100644 index 0000000..fc90ef1 --- /dev/null +++ b/crates/erp-auth/src/entity/user.rs @@ -0,0 +1,59 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub username: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub phone: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub avatar_url: Option, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_login_at: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::user_credential::Entity")] + UserCredential, + #[sea_orm(has_many = "super::user_token::Entity")] + UserToken, + #[sea_orm(has_many = "super::user_role::Entity")] + UserRole, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::UserCredential.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::UserToken.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::UserRole.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-auth/src/entity/user_credential.rs b/crates/erp-auth/src/entity/user_credential.rs new file mode 100644 index 0000000..ce99e23 --- /dev/null +++ b/crates/erp-auth/src/entity/user_credential.rs @@ -0,0 +1,41 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "user_credentials")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub user_id: Uuid, + pub credential_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub credential_data: Option, + pub verified: bool, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-auth/src/entity/user_department.rs b/crates/erp-auth/src/entity/user_department.rs new file mode 100644 index 0000000..8af309d --- /dev/null +++ b/crates/erp-auth/src/entity/user_department.rs @@ -0,0 +1,54 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "user_departments")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub department_id: Uuid, + pub tenant_id: Uuid, + pub is_primary: bool, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_delete = "Cascade" + )] + User, + #[sea_orm( + belongs_to = "super::department::Entity", + from = "Column::DepartmentId", + to = "super::department::Column::Id", + on_delete = "Cascade" + )] + Department, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Department.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-auth/src/entity/user_role.rs b/crates/erp-auth/src/entity/user_role.rs new file mode 100644 index 0000000..915ef8e --- /dev/null +++ b/crates/erp-auth/src/entity/user_role.rs @@ -0,0 +1,51 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "user_roles")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub role_id: Uuid, + pub tenant_id: Uuid, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_delete = "Cascade" + )] + User, + #[sea_orm( + belongs_to = "super::role::Entity", + from = "Column::RoleId", + to = "super::role::Column::Id", + on_delete = "Cascade" + )] + Role, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Role.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-auth/src/entity/user_token.rs b/crates/erp-auth/src/entity/user_token.rs new file mode 100644 index 0000000..63f2eaf --- /dev/null +++ b/crates/erp-auth/src/entity/user_token.rs @@ -0,0 +1,44 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "user_tokens")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub user_id: Uuid, + pub token_hash: String, + pub token_type: String, + pub expires_at: DateTimeUtc, + #[serde(skip_serializing_if = "Option::is_none")] + pub revoked_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub device_info: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-auth/src/entity/wechat_user.rs b/crates/erp-auth/src/entity/wechat_user.rs new file mode 100644 index 0000000..1731a8f --- /dev/null +++ b/crates/erp-auth/src/entity/wechat_user.rs @@ -0,0 +1,41 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "wechat_users")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub openid: String, + #[sea_orm(column_name = "union_id")] + pub union_id: Option, + pub user_id: Uuid, + pub phone: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Option, + pub updated_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::UserId", + to = "super::user::Column::Id", + on_delete = "Cascade" + )] + User, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::User.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-auth/src/error.rs b/crates/erp-auth/src/error.rs new file mode 100644 index 0000000..f8be07b --- /dev/null +++ b/crates/erp-auth/src/error.rs @@ -0,0 +1,105 @@ +use erp_core::error::AppError; + +/// Auth module error types +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + #[error("用户名或密码错误")] + InvalidCredentials, + + #[error("Token 已过期")] + TokenExpired, + + #[error("Token 已被吊销")] + TokenRevoked, + + #[error("用户已被{0}")] + UserDisabled(String), + + #[error("密码哈希错误")] + HashError(String), + + #[error("JWT 错误: {0}")] + JwtError(#[from] jsonwebtoken::errors::Error), + + #[error("数据库错误: {0}")] + DbError(String), + + #[error("{0}")] + Validation(String), + + #[error("{0}")] + Forbidden(String), + + #[error("版本冲突: 数据已被其他操作修改,请刷新后重试")] + VersionMismatch, +} + +impl From for AppError { + fn from(err: AuthError) -> Self { + match err { + AuthError::InvalidCredentials => AppError::Unauthorized, + AuthError::TokenExpired => AppError::Unauthorized, + AuthError::TokenRevoked => AppError::Unauthorized, + AuthError::UserDisabled(s) => AppError::Forbidden(s), + AuthError::Validation(s) => AppError::Validation(s), + AuthError::Forbidden(s) => AppError::Forbidden(s), + AuthError::DbError(_) => AppError::Internal(err.to_string()), + AuthError::HashError(_) => AppError::Internal(err.to_string()), + AuthError::JwtError(_) => AppError::Unauthorized, + AuthError::VersionMismatch => AppError::VersionMismatch, + } + } +} + +pub type AuthResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + use erp_core::error::AppError; + + #[test] + fn auth_error_invalid_credentials_maps_to_unauthorized() { + let app: AppError = AuthError::InvalidCredentials.into(); + match app { + AppError::Unauthorized => {} + other => panic!("Expected Unauthorized, got {:?}", other), + } + } + + #[test] + fn auth_error_token_expired_maps_to_unauthorized() { + let app: AppError = AuthError::TokenExpired.into(); + match app { + AppError::Unauthorized => {} + other => panic!("Expected Unauthorized, got {:?}", other), + } + } + + #[test] + fn auth_error_user_disabled_maps_to_forbidden() { + let app: AppError = AuthError::UserDisabled("已禁用".to_string()).into(); + match app { + AppError::Forbidden(msg) => assert_eq!(msg, "已禁用"), + other => panic!("Expected Forbidden, got {:?}", other), + } + } + + #[test] + fn auth_error_hash_error_maps_to_internal() { + let app: AppError = AuthError::HashError("argon2 failed".to_string()).into(); + match app { + AppError::Internal(_) => {} + other => panic!("Expected Internal, got {:?}", other), + } + } + + #[test] + fn auth_error_validation_maps_to_validation() { + let app: AppError = AuthError::Validation("用户名已存在".to_string()).into(); + match app { + AppError::Validation(msg) => assert_eq!(msg, "用户名已存在"), + other => panic!("Expected Validation, got {:?}", other), + } + } +} diff --git a/crates/erp-auth/src/handler/auth_handler.rs b/crates/erp-auth/src/handler/auth_handler.rs new file mode 100644 index 0000000..99a32de --- /dev/null +++ b/crates/erp-auth/src/handler/auth_handler.rs @@ -0,0 +1,192 @@ +use axum::Extension; +use axum::extract::{FromRef, State}; +use axum::http::HeaderMap; +use axum::response::Json; +use validator::Validate; + +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, TenantContext}; + +use crate::auth_state::AuthState; +use crate::dto::{ChangePasswordReq, LoginReq, LoginResp, RefreshReq}; +use crate::service::auth_service::{AuthService, JwtConfig, RequestInfo}; + +/// 从请求头中提取客户端信息。 +fn extract_request_info(headers: &HeaderMap) -> RequestInfo { + let ip = headers + .get("x-forwarded-for") + .or_else(|| headers.get("x-real-ip")) + .and_then(|v| v.to_str().ok()) + .map(|s| s.split(',').next().unwrap_or(s).trim().to_string()); + let user_agent = headers + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + RequestInfo { ip, user_agent } +} + +#[utoipa::path( + post, + path = "/api/v1/auth/login", + request_body = LoginReq, + responses( + (status = 200, description = "登录成功", body = ApiResponse), + (status = 400, description = "请求参数错误"), + (status = 401, description = "用户名或密码错误"), + ), + tag = "认证" +)] +/// POST /api/v1/auth/login +/// +/// Authenticates a user with username and password, returning access and refresh tokens. +/// +/// During the bootstrap phase, the tenant_id is taken from `AuthState::default_tenant_id`. +/// In production, this will come from a tenant-resolution middleware. +pub async fn login( + State(state): State, + headers: HeaderMap, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let req_info = extract_request_info(&headers); + let tenant_id = state.default_tenant_id; + + let jwt_config = JwtConfig { + secret: &state.jwt_secret, + access_ttl_secs: state.access_ttl_secs, + refresh_ttl_secs: state.refresh_ttl_secs, + }; + + let resp = AuthService::login( + tenant_id, + &req.username, + &req.password, + &state.db, + &jwt_config, + &state.event_bus, + Some(&req_info), + req.client_type.as_deref(), + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + post, + path = "/api/v1/auth/refresh", + request_body = RefreshReq, + responses( + (status = 200, description = "刷新成功", body = ApiResponse), + (status = 401, description = "刷新令牌无效或已过期"), + ), + tag = "认证" +)] +/// POST /api/v1/auth/refresh +/// +/// Validates an existing refresh token, revokes it (rotation), and issues +/// a new access + refresh token pair. +pub async fn refresh( + State(state): State, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let jwt_config = JwtConfig { + secret: &state.jwt_secret, + access_ttl_secs: state.access_ttl_secs, + refresh_ttl_secs: state.refresh_ttl_secs, + }; + + let resp = AuthService::refresh(&req.refresh_token, &state.db, &jwt_config).await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + post, + path = "/api/v1/auth/logout", + responses( + (status = 200, description = "已成功登出"), + (status = 401, description = "未授权"), + ), + security(("bearer_auth" = [])), + tag = "认证" +)] +/// POST /api/v1/auth/logout +/// +/// Revokes all refresh tokens for the authenticated user, effectively +/// logging them out on all devices. +pub async fn logout( + State(state): State, + headers: HeaderMap, + Extension(ctx): Extension, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let req_info = extract_request_info(&headers); + AuthService::logout(ctx.user_id, ctx.tenant_id, &state.db, Some(&req_info)).await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("已成功登出".to_string()), + })) +} + +#[utoipa::path( + post, + path = "/api/v1/auth/change-password", + request_body = ChangePasswordReq, + responses( + (status = 200, description = "密码修改成功,需重新登录"), + (status = 400, description = "当前密码不正确"), + (status = 401, description = "未授权"), + ), + security(("bearer_auth" = [])), + tag = "认证" +)] +/// POST /api/v1/auth/change-password +/// +/// 修改当前登录用户的密码。修改成功后所有已签发的 refresh token 将被吊销, +/// 用户需要在所有设备上重新登录。 +pub async fn change_password( + State(state): State, + headers: HeaderMap, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let req_info = extract_request_info(&headers); + AuthService::change_password( + ctx.user_id, + ctx.tenant_id, + &req.current_password, + &req.new_password, + &state.db, + Some(&req_info), + ) + .await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("密码修改成功,请重新登录".to_string()), + })) +} diff --git a/crates/erp-auth/src/handler/mod.rs b/crates/erp-auth/src/handler/mod.rs new file mode 100644 index 0000000..70ab8b2 --- /dev/null +++ b/crates/erp-auth/src/handler/mod.rs @@ -0,0 +1,5 @@ +pub mod auth_handler; +pub mod org_handler; +pub mod role_handler; +pub mod user_handler; +pub mod wechat_handler; diff --git a/crates/erp-auth/src/handler/org_handler.rs b/crates/erp-auth/src/handler/org_handler.rs new file mode 100644 index 0000000..87bd09b --- /dev/null +++ b/crates/erp-auth/src/handler/org_handler.rs @@ -0,0 +1,460 @@ +use axum::Extension; +use axum::extract::{FromRef, Path, State}; +use axum::response::Json; +use validator::Validate; + +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, TenantContext}; +use uuid::Uuid; + +use crate::auth_state::AuthState; +use crate::dto::{ + CreateDepartmentReq, CreateOrganizationReq, CreatePositionReq, DepartmentResp, + OrganizationResp, PositionResp, UpdateDepartmentReq, UpdateOrganizationReq, UpdatePositionReq, +}; +use crate::service::dept_service::DeptService; +use crate::service::org_service::OrgService; +use crate::service::position_service::PositionService; +use erp_core::rbac::require_permission; + +// --- Organization handlers --- + +#[utoipa::path( + get, + path = "/api/v1/organizations", + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "组织管理" +)] +/// GET /api/v1/organizations +/// +/// List all organizations within the current tenant as a nested tree. +/// Requires the `organization.list` permission. +pub async fn list_organizations( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "organization.list")?; + + let tree = OrgService::get_tree(ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(tree))) +} + +#[utoipa::path( + post, + path = "/api/v1/organizations", + request_body = CreateOrganizationReq, + responses( + (status = 200, description = "创建成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "组织管理" +)] +/// POST /api/v1/organizations +/// +/// Create a new organization within the current tenant. +/// Requires the `organization.create` permission. +pub async fn create_organization( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "organization.create")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let org = OrgService::create( + ctx.tenant_id, + ctx.user_id, + &req, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(org))) +} + +#[utoipa::path( + put, + path = "/api/v1/organizations/{id}", + params(("id" = Uuid, Path, description = "组织ID")), + request_body = UpdateOrganizationReq, + responses( + (status = 200, description = "更新成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "组织不存在"), + ), + security(("bearer_auth" = [])), + tag = "组织管理" +)] +/// PUT /api/v1/organizations/{id} +/// +/// Update editable organization fields (name, code, sort_order). +/// Requires the `organization.update` permission. +pub async fn update_organization( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "organization.update")?; + + let org = OrgService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?; + Ok(Json(ApiResponse::ok(org))) +} + +#[utoipa::path( + delete, + path = "/api/v1/organizations/{id}", + params(("id" = Uuid, Path, description = "组织ID")), + responses( + (status = 200, description = "组织已删除"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "组织不存在"), + ), + security(("bearer_auth" = [])), + tag = "组织管理" +)] +/// DELETE /api/v1/organizations/{id} +/// +/// Soft-delete an organization by ID. +/// Requires the `organization.delete` permission. +pub async fn delete_organization( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "organization.delete")?; + + OrgService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("组织已删除".to_string()), + })) +} + +// --- Department handlers --- + +#[utoipa::path( + get, + path = "/api/v1/organizations/{org_id}/departments", + params(("org_id" = Uuid, Path, description = "组织ID")), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "组织管理" +)] +/// GET /api/v1/organizations/{org_id}/departments +/// +/// List all departments for an organization as a nested tree. +/// Requires the `department.list` permission. +pub async fn list_departments( + State(state): State, + Extension(ctx): Extension, + Path(org_id): Path, +) -> Result>>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "department.list")?; + + let tree = DeptService::list_tree(org_id, ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(tree))) +} + +#[utoipa::path( + post, + path = "/api/v1/organizations/{org_id}/departments", + params(("org_id" = Uuid, Path, description = "组织ID")), + request_body = CreateDepartmentReq, + responses( + (status = 200, description = "创建成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "组织管理" +)] +/// POST /api/v1/organizations/{org_id}/departments +/// +/// Create a new department under the specified organization. +/// Requires the `department.create` permission. +pub async fn create_department( + State(state): State, + Extension(ctx): Extension, + Path(org_id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "department.create")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let dept = DeptService::create( + org_id, + ctx.tenant_id, + ctx.user_id, + &req, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(dept))) +} + +#[utoipa::path( + put, + path = "/api/v1/departments/{id}", + params(("id" = Uuid, Path, description = "部门ID")), + request_body = UpdateDepartmentReq, + responses( + (status = 200, description = "更新成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "部门不存在"), + ), + security(("bearer_auth" = [])), + tag = "组织管理" +)] +/// PUT /api/v1/departments/{id} +/// +/// Update editable department fields (name, code, manager_id, sort_order). +/// Requires the `department.update` permission. +pub async fn update_department( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "department.update")?; + + let dept = DeptService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?; + Ok(Json(ApiResponse::ok(dept))) +} + +#[utoipa::path( + delete, + path = "/api/v1/departments/{id}", + params(("id" = Uuid, Path, description = "部门ID")), + responses( + (status = 200, description = "部门已删除"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "部门不存在"), + ), + security(("bearer_auth" = [])), + tag = "组织管理" +)] +/// DELETE /api/v1/departments/{id} +/// +/// Soft-delete a department by ID. +/// Requires the `department.delete` permission. +pub async fn delete_department( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "department.delete")?; + + DeptService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("部门已删除".to_string()), + })) +} + +// --- Position handlers --- + +#[utoipa::path( + get, + path = "/api/v1/departments/{dept_id}/positions", + params(("dept_id" = Uuid, Path, description = "部门ID")), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "组织管理" +)] +/// GET /api/v1/departments/{dept_id}/positions +/// +/// List all positions for a department. +/// Requires the `position.list` permission. +pub async fn list_positions( + State(state): State, + Extension(ctx): Extension, + Path(dept_id): Path, +) -> Result>>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "position.list")?; + + let positions = PositionService::list(dept_id, ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(positions))) +} + +#[utoipa::path( + post, + path = "/api/v1/departments/{dept_id}/positions", + params(("dept_id" = Uuid, Path, description = "部门ID")), + request_body = CreatePositionReq, + responses( + (status = 200, description = "创建成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "组织管理" +)] +/// POST /api/v1/departments/{dept_id}/positions +/// +/// Create a new position under the specified department. +/// Requires the `position.create` permission. +pub async fn create_position( + State(state): State, + Extension(ctx): Extension, + Path(dept_id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "position.create")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let pos = PositionService::create( + dept_id, + ctx.tenant_id, + ctx.user_id, + &req, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(pos))) +} + +#[utoipa::path( + put, + path = "/api/v1/positions/{id}", + params(("id" = Uuid, Path, description = "岗位ID")), + request_body = UpdatePositionReq, + responses( + (status = 200, description = "更新成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "岗位不存在"), + ), + security(("bearer_auth" = [])), + tag = "组织管理" +)] +/// PUT /api/v1/positions/{id} +/// +/// Update editable position fields (name, code, level, sort_order). +/// Requires the `position.update` permission. +pub async fn update_position( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "position.update")?; + + let pos = PositionService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?; + Ok(Json(ApiResponse::ok(pos))) +} + +#[utoipa::path( + delete, + path = "/api/v1/positions/{id}", + params(("id" = Uuid, Path, description = "岗位ID")), + responses( + (status = 200, description = "岗位已删除"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "岗位不存在"), + ), + security(("bearer_auth" = [])), + tag = "组织管理" +)] +/// DELETE /api/v1/positions/{id} +/// +/// Soft-delete a position by ID. +/// Requires the `position.delete` permission. +pub async fn delete_position( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "position.delete")?; + + PositionService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("岗位已删除".to_string()), + })) +} diff --git a/crates/erp-auth/src/handler/role_handler.rs b/crates/erp-auth/src/handler/role_handler.rs new file mode 100644 index 0000000..0e3bcfa --- /dev/null +++ b/crates/erp-auth/src/handler/role_handler.rs @@ -0,0 +1,320 @@ +use axum::Extension; +use axum::extract::{FromRef, Path, Query, State}; +use axum::response::Json; +use validator::Validate; + +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext}; +use uuid::Uuid; + +use crate::auth_state::AuthState; +use crate::dto::{AssignPermissionsReq, CreateRoleReq, PermissionResp, RoleResp, UpdateRoleReq}; +use crate::service::permission_service::PermissionService; +use crate::service::role_service::RoleService; +use erp_core::rbac::require_permission; + +#[utoipa::path( + get, + path = "/api/v1/roles", + params(Pagination), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "角色管理" +)] +/// GET /api/v1/roles +/// +/// List roles within the current tenant with pagination. +/// Requires the `role.list` permission. +pub async fn list_roles( + State(state): State, + Extension(ctx): Extension, + Query(pagination): Query, +) -> Result>>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "role.list")?; + + let (roles, total) = RoleService::list(ctx.tenant_id, &pagination, &state.db).await?; + + let page = pagination.page.unwrap_or(1); + let page_size = pagination.limit(); + let total_pages = total.div_ceil(page_size); + + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: roles, + total, + page, + page_size, + total_pages, + }))) +} + +#[utoipa::path( + post, + path = "/api/v1/roles", + request_body = CreateRoleReq, + responses( + (status = 200, description = "创建成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "角色管理" +)] +/// POST /api/v1/roles +/// +/// Create a new role within the current tenant. +/// Requires the `role.create` permission. +pub async fn create_role( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "role.create")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let role = RoleService::create( + ctx.tenant_id, + ctx.user_id, + &req.name, + &req.code, + &req.description, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(role))) +} + +#[utoipa::path( + get, + path = "/api/v1/roles/{id}", + params(("id" = Uuid, Path, description = "角色ID")), + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "角色不存在"), + ), + security(("bearer_auth" = [])), + tag = "角色管理" +)] +/// GET /api/v1/roles/:id +/// +/// Fetch a single role by ID within the current tenant. +/// Requires the `role.read` permission. +pub async fn get_role( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "role.read")?; + + let role = RoleService::get_by_id(id, ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(role))) +} + +#[utoipa::path( + put, + path = "/api/v1/roles/{id}", + params(("id" = Uuid, Path, description = "角色ID")), + request_body = UpdateRoleReq, + responses( + (status = 200, description = "更新成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "角色不存在"), + ), + security(("bearer_auth" = [])), + tag = "角色管理" +)] +/// PUT /api/v1/roles/:id +/// +/// Update editable role fields (name, description). +/// Requires the `role.update` permission. +pub async fn update_role( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "role.update")?; + + let role = RoleService::update( + id, + ctx.tenant_id, + ctx.user_id, + &req.name, + &req.description, + req.version, + &state.db, + ) + .await?; + Ok(Json(ApiResponse::ok(role))) +} + +#[utoipa::path( + delete, + path = "/api/v1/roles/{id}", + params(("id" = Uuid, Path, description = "角色ID")), + responses( + (status = 200, description = "角色已删除"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "角色不存在"), + ), + security(("bearer_auth" = [])), + tag = "角色管理" +)] +/// DELETE /api/v1/roles/:id +/// +/// Soft-delete a role by ID within the current tenant. +/// System roles cannot be deleted. +/// Requires the `role.delete` permission. +pub async fn delete_role( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "role.delete")?; + + RoleService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("角色已删除".to_string()), + })) +} + +#[utoipa::path( + post, + path = "/api/v1/roles/{id}/permissions", + params(("id" = Uuid, Path, description = "角色ID")), + request_body = AssignPermissionsReq, + responses( + (status = 200, description = "权限分配成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "角色不存在"), + ), + security(("bearer_auth" = [])), + tag = "角色管理" +)] +/// POST /api/v1/roles/:id/permissions +/// +/// Replace all permission assignments for a role. +/// Requires the `role.update` permission. +pub async fn assign_permissions( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "role.update")?; + + RoleService::assign_permissions( + id, + ctx.tenant_id, + ctx.user_id, + &req.permission_ids, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("权限分配成功".to_string()), + })) +} + +#[utoipa::path( + get, + path = "/api/v1/roles/{id}/permissions", + params(("id" = Uuid, Path, description = "角色ID")), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "角色不存在"), + ), + security(("bearer_auth" = [])), + tag = "角色管理" +)] +/// GET /api/v1/roles/:id/permissions +/// +/// Fetch all permissions assigned to a role. +/// Requires the `role.read` permission. +pub async fn get_role_permissions( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "role.read")?; + + let perms = RoleService::get_role_permissions(id, ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(perms))) +} + +#[utoipa::path( + get, + path = "/api/v1/permissions", + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "权限管理" +)] +/// GET /api/v1/permissions +/// +/// List all permissions within the current tenant. +/// Requires the `permission.list` permission. +pub async fn list_permissions( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "permission.list")?; + + let perms = PermissionService::list(ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(perms))) +} diff --git a/crates/erp-auth/src/handler/user_handler.rs b/crates/erp-auth/src/handler/user_handler.rs new file mode 100644 index 0000000..42b47ec --- /dev/null +++ b/crates/erp-auth/src/handler/user_handler.rs @@ -0,0 +1,322 @@ +use axum::Extension; +use axum::extract::{FromRef, Path, Query, State}; +use axum::response::Json; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use validator::Validate; + +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext}; +use uuid::Uuid; + +use crate::auth_state::AuthState; +use crate::dto::{CreateUserReq, ResetPasswordReq, RoleResp, UpdateUserReq, UserResp}; +use crate::service::user_service::UserService; +use erp_core::rbac::require_permission; + +/// Query parameters for user list endpoint. +#[derive(Debug, Deserialize, IntoParams)] +pub struct UserListParams { + pub page: Option, + pub page_size: Option, + /// Optional search term — filters by username (case-insensitive contains). + pub search: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/users", + params(UserListParams), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "用户管理" +)] +/// GET /api/v1/users +/// +/// List users within the current tenant with pagination and optional search. +/// Requires the `user.list` permission. +pub async fn list_users( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "user.list")?; + + let pagination = Pagination { + page: params.page, + page_size: params.page_size, + }; + let (users, total) = UserService::list( + ctx.tenant_id, + &pagination, + params.search.as_deref(), + &state.db, + ) + .await?; + + let page = pagination.page.unwrap_or(1); + let page_size = pagination.limit(); + let total_pages = total.div_ceil(page_size); + + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: users, + total, + page, + page_size, + total_pages, + }))) +} + +#[utoipa::path( + post, + path = "/api/v1/users", + request_body = CreateUserReq, + responses( + (status = 200, description = "创建成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "用户管理" +)] +/// POST /api/v1/users +/// +/// Create a new user within the current tenant. +/// Requires the `user.create` permission. +pub async fn create_user( + State(state): State, + Extension(ctx): Extension, + Json(mut req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "user.create")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + req.sanitize(); + + let user = UserService::create( + ctx.tenant_id, + ctx.user_id, + &req, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(user))) +} + +#[utoipa::path( + get, + path = "/api/v1/users/{id}", + params(("id" = Uuid, Path, description = "用户ID")), + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "用户不存在"), + ), + security(("bearer_auth" = [])), + tag = "用户管理" +)] +/// GET /api/v1/users/:id +/// +/// Fetch a single user by ID within the current tenant. +/// Requires the `user.read` permission. +pub async fn get_user( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "user.read")?; + + let user = UserService::get_by_id(id, ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(user))) +} + +#[utoipa::path( + put, + path = "/api/v1/users/{id}", + params(("id" = Uuid, Path, description = "用户ID")), + request_body = UpdateUserReq, + responses( + (status = 200, description = "更新成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "用户不存在"), + ), + security(("bearer_auth" = [])), + tag = "用户管理" +)] +/// PUT /api/v1/users/:id +/// +/// Update editable user fields. +/// Requires the `user.update` permission. +pub async fn update_user( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(mut req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "user.update")?; + + req.sanitize(); + + let user = UserService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?; + Ok(Json(ApiResponse::ok(user))) +} + +#[utoipa::path( + delete, + path = "/api/v1/users/{id}", + params(("id" = Uuid, Path, description = "用户ID")), + responses( + (status = 200, description = "用户已删除"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "用户不存在"), + ), + security(("bearer_auth" = [])), + tag = "用户管理" +)] +/// DELETE /api/v1/users/:id +/// +/// Soft-delete a user by ID within the current tenant. +/// Requires the `user.delete` permission. +pub async fn delete_user( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "user.delete")?; + + UserService::delete(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus).await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("用户已删除".to_string()), + })) +} + +/// Assign roles request body. +#[derive(Debug, Deserialize, ToSchema)] +pub struct AssignRolesReq { + pub role_ids: Vec, +} + +/// Assign roles response. +#[derive(Debug, Serialize, ToSchema)] +pub struct AssignRolesResp { + pub roles: Vec, +} + +#[utoipa::path( + post, + path = "/api/v1/users/{id}/roles", + params(("id" = Uuid, Path, description = "用户ID")), + request_body = AssignRolesReq, + responses( + (status = 200, description = "角色分配成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "用户不存在"), + ), + security(("bearer_auth" = [])), + tag = "用户管理" +)] +/// POST /api/v1/users/:id/roles +/// +/// Replace all role assignments for a user within the current tenant. +/// Requires the `user.update` permission. +pub async fn assign_roles( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "user.update")?; + + let roles = + UserService::assign_roles(id, ctx.tenant_id, ctx.user_id, &req.role_ids, &state.db).await?; + + Ok(Json(ApiResponse::ok(AssignRolesResp { roles }))) +} + +#[utoipa::path( + post, + path = "/api/v1/users/{id}/reset-password", + params(("id" = Uuid, Path, description = "用户ID")), + request_body = ResetPasswordReq, + responses( + (status = 200, description = "密码重置成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "用户不存在"), + ), + security(("bearer_auth" = [])), + tag = "用户管理" +)] +/// POST /api/v1/users/{id}/reset-password +/// +/// 管理员重置指定用户密码。需要 `user.reset-password` 权限。 +pub async fn reset_password( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "user.reset-password")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + UserService::reset_password( + id, + ctx.tenant_id, + ctx.user_id, + &req.new_password, + req.version, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("密码重置成功".to_string()), + })) +} diff --git a/crates/erp-auth/src/handler/wechat_handler.rs b/crates/erp-auth/src/handler/wechat_handler.rs new file mode 100644 index 0000000..739635a --- /dev/null +++ b/crates/erp-auth/src/handler/wechat_handler.rs @@ -0,0 +1,86 @@ +use axum::extract::{FromRef, State}; +use axum::response::Json; +use validator::Validate; + +use erp_core::error::AppError; +use erp_core::types::ApiResponse; + +use crate::auth_state::AuthState; +use crate::dto::{LoginResp, WechatBindPhoneReq, WechatLoginReq, WechatLoginResp}; +use crate::service::wechat_service::WechatService; + +#[utoipa::path( + post, + path = "/api/v1/auth/wechat/login", + request_body = WechatLoginReq, + responses( + (status = 200, description = "微信登录成功", body = ApiResponse), + (status = 400, description = "请求参数错误"), + ), + tag = "认证" +)] +/// POST /api/v1/auth/wechat/login +/// +/// 微信小程序登录:用 code 换 openid,查询绑定状态。 +/// 已绑定用户直接返回 JWT,未绑定用户返回 openid 供后续绑定。 +pub async fn wechat_login( + State(state): State, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + tracing::info!( + code = %req.code, + tenant_id = %state.default_tenant_id, + has_appid = !state.wechat_appid.is_empty(), + has_secret = !state.wechat_secret.is_empty(), + "微信登录请求" + ); + + // TODO: 多租户微信登录需要设计租户解析策略(如 per-appid 映射或登录后选择租户) + let tenant_id = state.default_tenant_id; + let resp = WechatService::login(&state, tenant_id, &req.code).await?; + tracing::info!( + bound = resp.bound, + has_token = resp.token.is_some(), + "微信登录结果" + ); + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + post, + path = "/api/v1/auth/wechat/bind-phone", + request_body = WechatBindPhoneReq, + responses( + (status = 200, description = "绑定成功", body = ApiResponse), + (status = 400, description = "请求参数错误"), + ), + tag = "认证" +)] +/// POST /api/v1/auth/wechat/bind-phone +/// +/// 微信手机号绑定:解密手机号,创建/关联 user,签发 JWT。 +pub async fn wechat_bind_phone( + State(state): State, + Json(req): Json, +) -> Result>, AppError> +where + AuthState: FromRef, + S: Clone + Send + Sync + 'static, +{ + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + // TODO: 多租户微信登录需要设计租户解析策略 + let tenant_id = state.default_tenant_id; + let resp = + WechatService::bind_phone(&state, tenant_id, &req.openid, &req.encrypted_data, &req.iv) + .await?; + Ok(Json(ApiResponse::ok(resp))) +} diff --git a/crates/erp-auth/src/lib.rs b/crates/erp-auth/src/lib.rs new file mode 100644 index 0000000..350cacf --- /dev/null +++ b/crates/erp-auth/src/lib.rs @@ -0,0 +1,11 @@ +pub mod auth_state; +pub mod dto; +pub mod entity; +pub mod error; +pub mod handler; +pub mod middleware; +pub mod module; +pub mod service; + +pub use auth_state::AuthState; +pub use module::AuthModule; diff --git a/crates/erp-auth/src/middleware/jwt_auth.rs b/crates/erp-auth/src/middleware/jwt_auth.rs new file mode 100644 index 0000000..a16f409 --- /dev/null +++ b/crates/erp-auth/src/middleware/jwt_auth.rs @@ -0,0 +1,273 @@ +use axum::body::Body; +use axum::http::Request; +use axum::middleware::Next; +use axum::response::Response; +use dashmap::DashMap; +use erp_core::error::AppError; +use erp_core::request_info::REQUEST_INFO; +use erp_core::request_info::RequestInfo; +use erp_core::types::{DataScope, TenantContext}; + +use crate::service::token_service::TokenService; + +type DeptIds = Vec; +type DataScopes = std::collections::HashMap; +type ScopeCacheEntry = (DeptIds, DataScopes, std::time::Instant); + +/// 用户权限数据缓存(user_id -> (department_ids, data_scopes, cached_at)) +/// DashMap 分片并发,读写无锁竞争 +static USER_SCOPE_CACHE: std::sync::LazyLock> = + std::sync::LazyLock::new(DashMap::new); + +/// Access Token 吊销黑名单(token_hash -> 过期时间戳) +/// key = SHA-256(token) 前 16 字符,value = token 的 exp 时间戳 +/// 惰性清理:检查时自动移除过期条目 +static TOKEN_BLACKLIST: std::sync::LazyLock> = + std::sync::LazyLock::new(DashMap::new); + +const SCOPE_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60); + +/// 吊销单个 access token(直到其自然过期) +pub fn revoke_access_token(token: &str, exp: i64) { + let hash = token_hash(token); + TOKEN_BLACKLIST.insert(hash, exp); +} + +/// 吊销用户所有 token(清除权限缓存,强制下次请求重新认证) +pub fn revoke_all_user_tokens(user_id: uuid::Uuid) { + USER_SCOPE_CACHE.remove(&user_id); +} + +/// 检查 token 是否已被吊销 +fn is_token_revoked(token: &str, _exp: i64) -> bool { + let now = chrono::Utc::now().timestamp(); + // 惰性清理过期条目 + if TOKEN_BLACKLIST.len() > 10_000 { + TOKEN_BLACKLIST.retain(|_, exp_ts| *exp_ts > now); + } + let hash = token_hash(token); + match TOKEN_BLACKLIST.get(&hash) { + Some(exp_ts) => { + if *exp_ts <= now { + drop(exp_ts); + TOKEN_BLACKLIST.remove(&hash); + false + } else { + true + } + } + None => false, + } +} + +fn token_hash(token: &str) -> String { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + token.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +/// JWT authentication middleware function. +/// +/// Extracts the `Bearer` token from the `Authorization` header, validates it +/// using `TokenService::decode_token`, and injects a `TenantContext` into the +/// request extensions so downstream handlers can access tenant/user identity. +/// +/// 同时提取请求的 IP 地址和 User-Agent,通过 task_local 传递给审计服务, +/// 使所有审计日志自动记录来源信息。 +/// +/// The `jwt_secret` parameter is passed explicitly by the server crate at +/// middleware construction time, avoiding any circular dependency between +/// erp-auth and erp-server. +/// +/// When `db` is provided, the middleware queries `user_departments` to populate +/// `department_ids` in the `TenantContext`. If `db` is `None` or the query fails, +/// `department_ids` defaults to an empty list (equivalent to "all" data scope). +/// +/// # Errors +/// +/// Returns `AppError::Unauthorized` if: +/// - The `Authorization` header is missing +/// - The header value does not start with `"Bearer "` +/// - The token cannot be decoded or has expired +/// - The token type is not "access" +pub async fn jwt_auth_middleware_fn( + jwt_secret: String, + db: Option, + req: Request, + next: Next, +) -> Result { + // 优先从 Authorization 头提取 token; + // 回退到 URL query parameter ?token=xxx(SSE/EventSource 无法设置自定义头) + let token = req + .headers() + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|h| h.strip_prefix("Bearer ")) + .map(String::from) + .or_else(|| { + req.uri().query().and_then(|q| { + q.split('&') + .find_map(|pair| pair.strip_prefix("token=")) + .map(String::from) + }) + }) + .ok_or(AppError::Unauthorized)?; + + let claims = + TokenService::decode_token(&token, &jwt_secret).map_err(|_| AppError::Unauthorized)?; + + // 检查 token 是否已被吊销(密码修改/管理员强制下线) + if is_token_revoked(&token, claims.exp) { + return Err(AppError::Unauthorized); + } + + // Verify this is an access token, not a refresh token + if claims.token_type != "access" { + return Err(AppError::Unauthorized); + } + + // 查询用户所属部门 ID 列表 + 权限数据范围(带 60 秒缓存) + let cached = USER_SCOPE_CACHE.get(&claims.sub).and_then(|entry| { + let (_, _, at) = entry.value(); + if at.elapsed() < SCOPE_CACHE_TTL { + let (depts, scopes, _) = entry.value(); + Some((depts.clone(), scopes.clone())) + } else { + drop(entry); + USER_SCOPE_CACHE.remove(&claims.sub); + None + } + }); + let (department_ids, permission_data_scopes) = match cached { + Some(hit) => hit, + None => fetch_and_cache_scopes(claims.sub, claims.tid, &db).await, + }; + + // 提取请求来源信息(IP + User-Agent),用于审计日志 + let request_info = RequestInfo::from_headers(req.headers()); + + let ctx = TenantContext { + tenant_id: claims.tid, + user_id: claims.sub, + roles: claims.roles, + permissions: claims.permissions, + department_ids, + permission_data_scopes, + }; + + // Reconstruct the request with the TenantContext injected into extensions. + // We cannot borrow `req` mutably after reading headers, so we rebuild. + let (parts, body) = req.into_parts(); + let mut req = Request::from_parts(parts, body); + req.extensions_mut().insert(ctx); + + // 在 task_local scope 中运行后续处理,审计服务可自动读取请求信息 + Ok(REQUEST_INFO.scope(request_info, next.run(req)).await) +} + +/// 查询用户所属的所有部门 ID(通过 user_departments 关联表) +async fn fetch_user_department_ids( + user_id: uuid::Uuid, + tenant_id: uuid::Uuid, + db: &sea_orm::DatabaseConnection, +) -> Vec { + use crate::entity::user_department; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + user_department::Entity::find() + .filter(user_department::Column::UserId.eq(user_id)) + .filter(user_department::Column::TenantId.eq(tenant_id)) + .filter(user_department::Column::DeletedAt.is_null()) + .all(db) + .await + .map(|rows| rows.into_iter().map(|r| r.department_id).collect()) + .unwrap_or_else(|e| { + tracing::warn!(error = %e, "查询用户部门列表失败,默认为空"); + vec![] + }) +} + +/// 查询用户每个权限的数据范围(从 role_permissions 表) +async fn fetch_permission_data_scopes( + user_id: uuid::Uuid, + tenant_id: uuid::Uuid, + db: &sea_orm::DatabaseConnection, +) -> std::collections::HashMap { + use sea_orm::ConnectionTrait; + + let sql = r#" + SELECT p.code, MIN( + CASE rp.data_scope + WHEN 'all' THEN 0 + WHEN 'department_tree' THEN 1 + WHEN 'department' THEN 2 + WHEN 'self' THEN 3 + ELSE 0 + END + ) AS scope_rank, + MIN(rp.data_scope) AS data_scope + FROM user_roles ur + JOIN role_permissions rp ON ur.role_id = rp.role_id AND ur.tenant_id = rp.tenant_id + JOIN permissions p ON rp.permission_id = p.id + WHERE ur.user_id = $1 + AND ur.tenant_id = $2 + AND ur.deleted_at IS NULL + AND rp.deleted_at IS NULL + GROUP BY p.code + "#; + + let stmt = sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [user_id.into(), tenant_id.into()], + ); + + match db.query_all(stmt).await { + Ok(rows) => { + let mut scopes = std::collections::HashMap::new(); + for row in rows { + if let (Ok(code), Ok(scope)) = ( + row.try_get_by_index::(0), + row.try_get_by_index::(2), + ) { + scopes.insert(code, DataScope::parse_scope(&scope)); + } + } + scopes + } + Err(e) => { + tracing::warn!(error = %e, "查询权限数据范围失败,默认全部 All"); + std::collections::HashMap::new() + } + } +} + +/// 从 DB 查询部门 + 权限范围,并写入缓存 +async fn fetch_and_cache_scopes( + user_id: uuid::Uuid, + tenant_id: uuid::Uuid, + db: &Option, +) -> ( + Vec, + std::collections::HashMap, +) { + let depts = match db { + Some(conn) => fetch_user_department_ids(user_id, tenant_id, conn).await, + None => vec![], + }; + let scopes = match db { + Some(conn) => fetch_permission_data_scopes(user_id, tenant_id, conn).await, + None => std::collections::HashMap::new(), + }; + USER_SCOPE_CACHE.insert( + user_id, + (depts.clone(), scopes.clone(), std::time::Instant::now()), + ); + // 惰性淘汰过期条目,防止 DashMap 无限增长 + if USER_SCOPE_CACHE.len() > 500 { + let now = std::time::Instant::now(); + USER_SCOPE_CACHE.retain(|_, (_, _, at)| now.duration_since(*at) < SCOPE_CACHE_TTL); + } + (depts, scopes) +} diff --git a/crates/erp-auth/src/middleware/mod.rs b/crates/erp-auth/src/middleware/mod.rs new file mode 100644 index 0000000..12217aa --- /dev/null +++ b/crates/erp-auth/src/middleware/mod.rs @@ -0,0 +1,4 @@ +pub mod jwt_auth; + +pub use erp_core::rbac::{require_any_permission, require_permission, require_role}; +pub use jwt_auth::{jwt_auth_middleware_fn, revoke_access_token, revoke_all_user_tokens}; diff --git a/crates/erp-auth/src/module.rs b/crates/erp-auth/src/module.rs new file mode 100644 index 0000000..7fb07a5 --- /dev/null +++ b/crates/erp-auth/src/module.rs @@ -0,0 +1,372 @@ +use axum::Router; +use uuid::Uuid; + +use erp_core::error::AppResult; +use erp_core::events::EventBus; +use erp_core::module::{ErpModule, PermissionDescriptor}; + +use crate::handler::{auth_handler, org_handler, role_handler, user_handler, wechat_handler}; + +/// Auth module implementing the `ErpModule` trait. +/// +/// Manages identity, authentication, and user CRUD within the ERP platform. +/// This module has no dependencies on other business modules. +pub struct AuthModule; + +impl AuthModule { + pub fn new() -> Self { + Self + } + + /// Build public (unauthenticated) routes for the auth module. + /// + /// These routes do not require a valid JWT token. + /// The caller wraps this into whatever state type the application uses. + pub fn public_routes() -> Router + where + crate::auth_state::AuthState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + Router::new() + .route("/auth/login", axum::routing::post(auth_handler::login)) + .route( + "/auth/wechat/login", + axum::routing::post(wechat_handler::wechat_login), + ) + .route( + "/auth/wechat/bind-phone", + axum::routing::post(wechat_handler::wechat_bind_phone), + ) + } + + /// Refresh token routes — public but with higher rate limit (30/min vs 5/min for login). + pub fn refresh_routes() -> Router + where + crate::auth_state::AuthState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + Router::new().route("/auth/refresh", axum::routing::post(auth_handler::refresh)) + } + + /// Build protected (authenticated) routes for the auth module. + /// + /// These routes require a valid JWT token, verified by the middleware layer. + /// The caller wraps this into whatever state type the application uses. + pub fn protected_routes() -> Router + where + crate::auth_state::AuthState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + Router::new() + .route("/auth/logout", axum::routing::post(auth_handler::logout)) + .route( + "/auth/change-password", + axum::routing::post(auth_handler::change_password), + ) + .route( + "/users", + axum::routing::get(user_handler::list_users).post(user_handler::create_user), + ) + .route( + "/users/{id}", + axum::routing::get(user_handler::get_user) + .put(user_handler::update_user) + .delete(user_handler::delete_user), + ) + .route( + "/users/{id}/roles", + axum::routing::post(user_handler::assign_roles), + ) + .route( + "/users/{id}/reset-password", + axum::routing::post(user_handler::reset_password), + ) + .route( + "/roles", + axum::routing::get(role_handler::list_roles).post(role_handler::create_role), + ) + // 精确匹配 /roles/permissions,必须在 /roles/{id} 之前注册 + .route( + "/roles/permissions", + axum::routing::get(role_handler::list_permissions), + ) + .route( + "/roles/{id}", + axum::routing::get(role_handler::get_role) + .put(role_handler::update_role) + .delete(role_handler::delete_role), + ) + .route( + "/roles/{id}/permissions", + axum::routing::get(role_handler::get_role_permissions) + .post(role_handler::assign_permissions), + ) + .route( + "/permissions", + axum::routing::get(role_handler::list_permissions), + ) + // Organization routes + .route( + "/organizations", + axum::routing::get(org_handler::list_organizations) + .post(org_handler::create_organization), + ) + .route( + "/organizations/{id}", + axum::routing::put(org_handler::update_organization) + .delete(org_handler::delete_organization), + ) + // Department routes (nested under organization) + .route( + "/organizations/{org_id}/departments", + axum::routing::get(org_handler::list_departments) + .post(org_handler::create_department), + ) + .route( + "/departments/{id}", + axum::routing::put(org_handler::update_department) + .delete(org_handler::delete_department), + ) + // Position routes (nested under department) + .route( + "/departments/{dept_id}/positions", + axum::routing::get(org_handler::list_positions).post(org_handler::create_position), + ) + .route( + "/positions/{id}", + axum::routing::put(org_handler::update_position) + .delete(org_handler::delete_position), + ) + } +} + +impl Default for AuthModule { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl ErpModule for AuthModule { + fn name(&self) -> &str { + "auth" + } + + fn version(&self) -> &str { + env!("CARGO_PKG_VERSION") + } + + fn dependencies(&self) -> Vec<&str> { + // Auth is a foundational module with no business-module dependencies. + vec![] + } + + fn register_event_handlers(&self, _bus: &EventBus) { + // Auth 模块暂无跨模块事件订阅需求 + } + + async fn on_tenant_created( + &self, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + _event_bus: &EventBus, + ) -> AppResult<()> { + let password = std::env::var("ERP__SUPER_ADMIN_PASSWORD").map_err(|_| { + tracing::error!("环境变量 ERP__SUPER_ADMIN_PASSWORD 未设置,无法初始化租户认证"); + erp_core::error::AppError::Internal("ERP__SUPER_ADMIN_PASSWORD 未设置".to_string()) + })?; + crate::service::seed::seed_tenant_auth(db, tenant_id, &password) + .await + .map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; + tracing::info!(tenant_id = %tenant_id, "Tenant auth initialized"); + Ok(()) + } + + async fn on_tenant_deleted( + &self, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AppResult<()> { + use chrono::Utc; + use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; + + let now = Utc::now(); + + // 软删除该租户下所有用户 + let users = crate::entity::user::Entity::find() + .filter(crate::entity::user::Column::TenantId.eq(tenant_id)) + .filter(crate::entity::user::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; + + for user_model in users { + let current_version = user_model.version; + let active: crate::entity::user::ActiveModel = user_model.into(); + let mut to_update: crate::entity::user::ActiveModel = active; + to_update.deleted_at = Set(Some(now)); + to_update.updated_at = Set(now); + to_update.version = Set(current_version + 1); + let _ = to_update + .update(db) + .await + .map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; + } + + tracing::info!(tenant_id = %tenant_id, "Tenant users soft-deleted"); + Ok(()) + } + + fn permissions(&self) -> Vec { + vec![ + PermissionDescriptor { + code: "user.list".into(), + name: "查看用户列表".into(), + description: "查看用户列表".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "user.create".into(), + name: "创建用户".into(), + description: "创建新用户".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "user.read".into(), + name: "查看用户详情".into(), + description: "查看用户信息".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "user.update".into(), + name: "编辑用户".into(), + description: "编辑用户信息".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "user.delete".into(), + name: "删除用户".into(), + description: "软删除用户".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "user.reset-password".into(), + name: "重置用户密码".into(), + description: "管理员重置指定用户密码".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "role.list".into(), + name: "查看角色列表".into(), + description: "查看角色列表".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "role.create".into(), + name: "创建角色".into(), + description: "创建新角色".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "role.read".into(), + name: "查看角色详情".into(), + description: "查看角色信息".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "role.update".into(), + name: "编辑角色".into(), + description: "编辑角色".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "role.delete".into(), + name: "删除角色".into(), + description: "删除角色".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "permission.list".into(), + name: "查看权限".into(), + description: "查看权限列表".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "organization.list".into(), + name: "查看组织列表".into(), + description: "查看组织列表".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "organization.create".into(), + name: "创建组织".into(), + description: "创建组织".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "organization.update".into(), + name: "编辑组织".into(), + description: "编辑组织".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "organization.delete".into(), + name: "删除组织".into(), + description: "删除组织".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "department.list".into(), + name: "查看部门列表".into(), + description: "查看部门列表".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "department.create".into(), + name: "创建部门".into(), + description: "创建部门".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "department.update".into(), + name: "编辑部门".into(), + description: "编辑部门".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "department.delete".into(), + name: "删除部门".into(), + description: "删除部门".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "position.list".into(), + name: "查看岗位列表".into(), + description: "查看岗位列表".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "position.create".into(), + name: "创建岗位".into(), + description: "创建岗位".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "position.update".into(), + name: "编辑岗位".into(), + description: "编辑岗位".into(), + module: "auth".into(), + }, + PermissionDescriptor { + code: "position.delete".into(), + name: "删除岗位".into(), + description: "删除岗位".into(), + module: "auth".into(), + }, + ] + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/crates/erp-auth/src/service/auth_service.rs b/crates/erp-auth/src/service/auth_service.rs new file mode 100644 index 0000000..c14a4ca --- /dev/null +++ b/crates/erp-auth/src/service/auth_service.rs @@ -0,0 +1,414 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use uuid::Uuid; + +use crate::dto::{LoginResp, RoleResp, UserResp}; +use crate::entity::{role, user, user_credential, user_role}; +use crate::error::AuthError; +use crate::middleware::revoke_all_user_tokens as revoke_access_token_cache; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::events::EventBus; + +use crate::error::AuthResult; + +use super::password; +use super::token_service::TokenService; + +/// 请求来源信息,用于审计日志记录。 +pub struct RequestInfo { + pub ip: Option, + pub user_agent: Option, +} + +/// JWT configuration needed for token signing. +pub struct JwtConfig<'a> { + pub secret: &'a str, + pub access_ttl_secs: i64, + pub refresh_ttl_secs: i64, +} + +/// Authentication service handling login, token refresh, and logout. +pub struct AuthService; + +impl AuthService { + /// Authenticate a user and issue access + refresh tokens. + /// + /// Steps: + /// 1. Look up user by tenant + username (soft-delete aware) + /// 2. Verify user status is "active" + /// 3. Fetch the stored password credential + /// 4. Verify password hash + /// 5. Collect roles and permissions + /// 6. Sign JWT tokens + /// 7. Update last_login_at + /// 8. Publish login event + #[allow(clippy::too_many_arguments)] + pub async fn login( + tenant_id: Uuid, + username: &str, + password_plain: &str, + db: &sea_orm::DatabaseConnection, + jwt: &JwtConfig<'_>, + event_bus: &EventBus, + req_info: Option<&RequestInfo>, + client_type: Option<&str>, + ) -> AuthResult { + // 1. Find user by tenant_id + username + let user_model = match user::Entity::find() + .filter(user::Column::TenantId.eq(tenant_id)) + .filter(user::Column::Username.eq(username)) + .filter(user::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + { + Some(m) => m, + None => { + // 审计:用户不存在(登录失败) + audit_service::record( + AuditLog::new(tenant_id, None, "user.login_failed", "user").with_request_info( + req_info.as_ref().and_then(|r| r.ip.clone()), + req_info.as_ref().and_then(|r| r.user_agent.clone()), + ), + db, + ) + .await; + return Err(AuthError::InvalidCredentials); + } + }; + + // 2. Check user status + if user_model.status != "active" { + return Err(AuthError::UserDisabled(user_model.status.clone())); + } + + // 3. Find password credential + let cred = user_credential::Entity::find() + .filter(user_credential::Column::UserId.eq(user_model.id)) + .filter(user_credential::Column::CredentialType.eq("password")) + .filter(user_credential::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .ok_or(AuthError::InvalidCredentials)?; + + // 4. Verify password + let stored_hash = cred + .credential_data + .as_ref() + .and_then(|v| v.get("hash").and_then(|h| h.as_str())) + .ok_or(AuthError::InvalidCredentials)?; + + if !password::verify_password(password_plain, stored_hash)? { + // 审计:密码错误(登录失败) + audit_service::record( + AuditLog::new(tenant_id, Some(user_model.id), "user.login_failed", "user") + .with_request_info( + req_info.as_ref().and_then(|r| r.ip.clone()), + req_info.as_ref().and_then(|r| r.user_agent.clone()), + ), + db, + ) + .await; + return Err(AuthError::InvalidCredentials); + } + + // 5. Get roles and permissions + let roles: Vec = TokenService::get_user_roles(user_model.id, tenant_id, db).await?; + + // 纯患者角色不允许登录管理端(同时拥有医护角色则放行) + // 小程序端 (client_type=miniprogram) 允许患者登录 + let medical_roles = ["doctor", "nurse", "admin", "health_manager", "operator"]; + let is_pure_patient = + roles.iter().all(|r| r == "patient") && roles.iter().any(|r| r == "patient"); + let has_medical_role = roles.iter().any(|r| medical_roles.contains(&r.as_str())); + let is_miniprogram = client_type == Some("miniprogram"); + if is_pure_patient && !has_medical_role && !is_miniprogram { + return Err(AuthError::Forbidden("患者账号请使用小程序登录".to_string())); + } + + // 小程序端仅允许患者角色登录,医护角色请使用管理端 + let has_patient_role = roles.iter().any(|r| r == "patient"); + if is_miniprogram && !has_patient_role { + return Err(AuthError::Forbidden("医护账号请使用管理端登录".to_string())); + } + + let permissions = TokenService::get_user_permissions(user_model.id, tenant_id, db).await?; + + // 6. Sign tokens + let access_token = TokenService::sign_access_token( + user_model.id, + tenant_id, + roles.clone(), + permissions, + jwt.secret, + jwt.access_ttl_secs, + )?; + let (refresh_token, _) = TokenService::sign_refresh_token( + user_model.id, + tenant_id, + db, + jwt.secret, + jwt.refresh_ttl_secs, + ) + .await?; + + // 7. Update last_login_at + let mut user_active: user::ActiveModel = user_model.clone().into(); + user_active.last_login_at = Set(Some(Utc::now())); + user_active.updated_at = Set(Utc::now()); + user_active.version = Set(user_active.version.take().unwrap_or(0) + 1); + user_active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + // 8. Build response + let role_resps = Self::get_user_role_resps(user_model.id, tenant_id, db).await?; + let user_resp = UserResp { + id: user_model.id, + username: user_model.username.clone(), + email: user_model.email, + phone: user_model.phone, + display_name: user_model.display_name, + avatar_url: user_model.avatar_url, + status: user_model.status, + roles: role_resps, + version: user_model.version, + }; + + // 9. Publish event + event_bus.publish(erp_core::events::DomainEvent::new( + "user.login", + tenant_id, + erp_core::events::build_event_payload(serde_json::json!({ "user_id": user_model.id, "username": user_model.username })), + ), db).await; + + // 审计:登录成功 + audit_service::record( + AuditLog::new(tenant_id, Some(user_model.id), "user.login", "user") + .with_resource_id(user_model.id) + .with_request_info( + req_info.as_ref().and_then(|r| r.ip.clone()), + req_info.as_ref().and_then(|r| r.user_agent.clone()), + ), + db, + ) + .await; + + Ok(LoginResp { + access_token, + refresh_token, + expires_in: jwt.access_ttl_secs as u64, + user: user_resp, + }) + } + + /// Refresh the token pair: validate the old refresh token, revoke it, issue a new pair. + pub async fn refresh( + refresh_token_str: &str, + db: &sea_orm::DatabaseConnection, + jwt: &JwtConfig<'_>, + ) -> AuthResult { + // Atomically validate and revoke the old refresh token (prevents TOCTOU race) + let claims = + TokenService::validate_and_revoke_atomic(refresh_token_str, db, jwt.secret).await?; + + // Fetch fresh roles and permissions + let roles: Vec = TokenService::get_user_roles(claims.sub, claims.tid, db).await?; + let permissions = TokenService::get_user_permissions(claims.sub, claims.tid, db).await?; + + // Sign new token pair + let access_token = TokenService::sign_access_token( + claims.sub, + claims.tid, + roles.clone(), + permissions, + jwt.secret, + jwt.access_ttl_secs, + )?; + let (new_refresh_token, _) = TokenService::sign_refresh_token( + claims.sub, + claims.tid, + db, + jwt.secret, + jwt.refresh_ttl_secs, + ) + .await?; + + // Fetch user for the response + let user_model = user::Entity::find_by_id(claims.sub) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .ok_or(AuthError::TokenRevoked)?; + + // 验证用户属于 JWT 中声明的租户 + if user_model.tenant_id != claims.tid { + tracing::warn!( + user_id = %claims.sub, + jwt_tenant = %claims.tid, + actual_tenant = %user_model.tenant_id, + "Token tenant_id 与用户实际租户不匹配" + ); + return Err(AuthError::TokenRevoked); + } + + let role_resps = Self::get_user_role_resps(claims.sub, claims.tid, db).await?; + let user_resp = UserResp { + id: user_model.id, + username: user_model.username, + email: user_model.email, + phone: user_model.phone, + display_name: user_model.display_name, + avatar_url: user_model.avatar_url, + status: user_model.status, + roles: role_resps, + version: user_model.version, + }; + + Ok(LoginResp { + access_token, + refresh_token: new_refresh_token, + expires_in: jwt.access_ttl_secs as u64, + user: user_resp, + }) + } + + /// Revoke all refresh tokens for a user, effectively logging them out everywhere. + pub async fn logout( + user_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + req_info: Option<&RequestInfo>, + ) -> AuthResult<()> { + TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?; + + // 清除 access token 权限缓存,强制重新认证 + revoke_access_token_cache(user_id); + + // 审计:登出 + audit_service::record( + AuditLog::new(tenant_id, Some(user_id), "user.logout", "user") + .with_resource_id(user_id) + .with_request_info( + req_info.as_ref().and_then(|r| r.ip.clone()), + req_info.as_ref().and_then(|r| r.user_agent.clone()), + ), + db, + ) + .await; + + Ok(()) + } + + /// Change password for the authenticated user. + /// + /// Steps: + /// 1. Verify current password + /// 2. Hash the new password + /// 3. Update the credential record + /// 4. Revoke all existing refresh tokens (force re-login) + pub async fn change_password( + user_id: Uuid, + tenant_id: Uuid, + current_password: &str, + new_password: &str, + db: &sea_orm::DatabaseConnection, + req_info: Option<&RequestInfo>, + ) -> AuthResult<()> { + // 1. Find the user's password credential + let cred = user_credential::Entity::find() + .filter(user_credential::Column::UserId.eq(user_id)) + .filter(user_credential::Column::TenantId.eq(tenant_id)) + .filter(user_credential::Column::CredentialType.eq("password")) + .filter(user_credential::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .ok_or_else(|| AuthError::Validation("用户凭证不存在".to_string()))?; + + // 2. Verify current password + let stored_hash = cred + .credential_data + .as_ref() + .and_then(|v| v.get("hash").and_then(|h| h.as_str())) + .ok_or_else(|| AuthError::Validation("用户凭证异常".to_string()))?; + + if !password::verify_password(current_password, stored_hash)? { + return Err(AuthError::Validation("当前密码不正确".to_string())); + } + + // 3. Hash new password and update credential + let new_hash = password::hash_password(new_password)?; + let current_version = cred.version; + let mut cred_active: user_credential::ActiveModel = cred.into(); + cred_active.credential_data = Set(Some(serde_json::json!({ "hash": new_hash }))); + cred_active.updated_at = Set(Utc::now()); + cred_active.version = Set(current_version + 1); + cred_active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + // 4. Revoke all refresh tokens — force re-login on all devices + TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?; + + // 清除 access token 权限缓存,密码修改后所有已签发的 access token 强制失效 + revoke_access_token_cache(user_id); + + // 审计:密码修改 + audit_service::record( + AuditLog::new(tenant_id, Some(user_id), "user.change_password", "user") + .with_resource_id(user_id) + .with_request_info( + req_info.as_ref().and_then(|r| r.ip.clone()), + req_info.as_ref().and_then(|r| r.user_agent.clone()), + ), + db, + ) + .await; + + tracing::info!(user_id = %user_id, "Password changed successfully"); + Ok(()) + } + + /// Fetch role details for a user, returning RoleResp DTOs. + pub async fn get_user_role_resps( + user_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult> { + let user_roles = user_role::Entity::find() + .filter(user_role::Column::UserId.eq(user_id)) + .filter(user_role::Column::TenantId.eq(tenant_id)) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let role_ids: Vec = user_roles.iter().map(|ur| ur.role_id).collect(); + if role_ids.is_empty() { + return Ok(vec![]); + } + + let roles = role::Entity::find() + .filter(role::Column::Id.is_in(role_ids)) + .filter(role::Column::TenantId.eq(tenant_id)) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok(roles + .iter() + .map(|r| RoleResp { + id: r.id, + name: r.name.clone(), + code: r.code.clone(), + description: r.description.clone(), + is_system: r.is_system, + version: r.version, + }) + .collect()) + } +} diff --git a/crates/erp-auth/src/service/dept_service.rs b/crates/erp-auth/src/service/dept_service.rs new file mode 100644 index 0000000..5989894 --- /dev/null +++ b/crates/erp-auth/src/service/dept_service.rs @@ -0,0 +1,414 @@ +use std::collections::HashMap; + +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use uuid::Uuid; + +use crate::dto::{CreateDepartmentReq, DepartmentResp, UpdateDepartmentReq}; +use crate::entity::department; +use crate::entity::organization; +use crate::entity::position; +use crate::entity::user_department; +use crate::error::{AuthError, AuthResult}; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::events::EventBus; + +/// Department CRUD service -- create, read, update, soft-delete departments +/// within an organization, supporting tree-structured hierarchy. +pub struct DeptService; + +impl DeptService { + /// Fetch all departments for an organization as a nested tree. + /// + /// Root departments (parent_id = None) form the top level. + pub async fn list_tree( + org_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult> { + // Verify the organization exists + let _org = organization::Entity::find_by_id(org_id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?; + + let items = department::Entity::find() + .filter(department::Column::TenantId.eq(tenant_id)) + .filter(department::Column::OrgId.eq(org_id)) + .filter(department::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok(build_dept_tree(&items)) + } + + /// Create a new department under the specified organization. + /// + /// If `parent_id` is provided, computes `path` from the parent department. + /// Otherwise, path is computed from the organization root. + pub async fn create( + org_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &CreateDepartmentReq, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult { + // Verify the organization exists + let org = organization::Entity::find_by_id(org_id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?; + + // Check code uniqueness within tenant if code is provided + if let Some(ref code) = req.code { + let existing = department::Entity::find() + .filter(department::Column::TenantId.eq(tenant_id)) + .filter(department::Column::Code.eq(code.as_str())) + .filter(department::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("部门编码已存在".to_string())); + } + } + + // Check name uniqueness within the same organization + let name_exists = department::Entity::find() + .filter(department::Column::TenantId.eq(tenant_id)) + .filter(department::Column::OrgId.eq(org_id)) + .filter(department::Column::Name.eq(&req.name)) + .filter(department::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if name_exists.is_some() { + return Err(AuthError::Validation("部门名称已存在".to_string())); + } + + // Compute path from parent department or organization root + let path = if let Some(parent_id) = req.parent_id { + let parent = department::Entity::find_by_id(parent_id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|d| { + d.tenant_id == tenant_id && d.org_id == org_id && d.deleted_at.is_none() + }) + .ok_or_else(|| AuthError::Validation("父级部门不存在".to_string()))?; + + let parent_path = parent.path.clone().unwrap_or_default(); + Some(format!("{}{}/", parent_path, parent.id)) + } else { + // Root department under the organization + let org_path = org.path.clone().unwrap_or_default(); + Some(format!("{}{}/", org_path, org.id)) + }; + + let now = Utc::now(); + let id = Uuid::now_v7(); + let model = department::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + org_id: Set(org_id), + name: Set(req.name.clone()), + code: Set(req.code.clone()), + parent_id: Set(req.parent_id), + manager_id: Set(req.manager_id), + path: Set(path), + sort_order: Set(req.sort_order.unwrap_or(0)), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + model + .insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "department.created", + tenant_id, + serde_json::json!({ "dept_id": id, "org_id": org_id, "name": req.name }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "department.create", + "department", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(DepartmentResp { + id, + org_id, + name: req.name.clone(), + code: req.code.clone(), + parent_id: req.parent_id, + manager_id: req.manager_id, + path: None, + sort_order: req.sort_order.unwrap_or(0), + children: vec![], + version: 1, + }) + } + + /// Update editable department fields (name, code, manager_id, sort_order). + pub async fn update( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &UpdateDepartmentReq, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult { + let model = department::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?; + + // If code is being changed, check uniqueness + if let Some(new_code) = &req.code + && Some(new_code) != model.code.as_ref() + { + let existing = department::Entity::find() + .filter(department::Column::TenantId.eq(tenant_id)) + .filter(department::Column::Code.eq(new_code.as_str())) + .filter(department::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("部门编码已存在".to_string())); + } + } + + // If name is being changed, check uniqueness within the same org (exclude self) + if let Some(ref new_name) = req.name + && new_name != &model.name + { + let name_exists = department::Entity::find() + .filter(department::Column::TenantId.eq(tenant_id)) + .filter(department::Column::OrgId.eq(model.org_id)) + .filter(department::Column::Name.eq(new_name.as_str())) + .filter(department::Column::DeletedAt.is_null()) + .filter(department::Column::Id.ne(id)) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if name_exists.is_some() { + return Err(AuthError::Validation("部门名称已存在".to_string())); + } + } + + let next_ver = check_version(req.version, model.version) + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let mut active: department::ActiveModel = model.into(); + + if let Some(n) = &req.name { + active.name = Set(n.clone()); + } + if let Some(c) = &req.code { + active.code = Set(Some(c.clone())); + } + if let Some(mgr_id) = &req.manager_id { + active.manager_id = Set(Some(*mgr_id)); + } + if let Some(so) = &req.sort_order { + active.sort_order = Set(*so); + } + + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let updated = active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "department.update", + "department", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(DepartmentResp { + id: updated.id, + org_id: updated.org_id, + name: updated.name.clone(), + code: updated.code.clone(), + parent_id: updated.parent_id, + manager_id: updated.manager_id, + path: updated.path.clone(), + sort_order: updated.sort_order, + children: vec![], + version: updated.version, + }) + } + + /// Soft-delete a department by setting the `deleted_at` timestamp. + /// + /// Will not delete if child departments exist. + pub async fn delete( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult<()> { + let model = department::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?; + + // Check for child departments + let children = department::Entity::find() + .filter(department::Column::TenantId.eq(tenant_id)) + .filter(department::Column::ParentId.eq(id)) + .filter(department::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if children.is_some() { + return Err(AuthError::Validation( + "该部门下存在子部门,无法删除".to_string(), + )); + } + + // Check for positions under this department + let positions = position::Entity::find() + .filter(position::Column::TenantId.eq(tenant_id)) + .filter(position::Column::DeptId.eq(id)) + .filter(position::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if positions.is_some() { + return Err(AuthError::Validation( + "该部门下存在岗位,无法删除".to_string(), + )); + } + + // Check for users assigned to this department + let users = user_department::Entity::find() + .filter(user_department::Column::TenantId.eq(tenant_id)) + .filter(user_department::Column::DepartmentId.eq(id)) + .filter(user_department::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if users.is_some() { + return Err(AuthError::Validation( + "该部门下存在用户,无法删除".to_string(), + )); + } + + let current_version = model.version; + let mut active: department::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(current_version + 1); + active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "department.deleted", + tenant_id, + serde_json::json!({ "dept_id": id }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "department.delete", + "department", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(()) + } +} + +/// Build a nested tree of `DepartmentResp` from a flat list of models. +fn build_dept_tree(items: &[department::Model]) -> Vec { + let mut children_map: HashMap, Vec<&department::Model>> = HashMap::new(); + for item in items { + children_map.entry(item.parent_id).or_default().push(item); + } + + fn build_node( + item: &department::Model, + map: &HashMap, Vec<&department::Model>>, + ) -> DepartmentResp { + let children = map + .get(&Some(item.id)) + .map(|items| items.iter().map(|i| build_node(i, map)).collect()) + .unwrap_or_default(); + DepartmentResp { + id: item.id, + org_id: item.org_id, + name: item.name.clone(), + code: item.code.clone(), + parent_id: item.parent_id, + manager_id: item.manager_id, + path: item.path.clone(), + sort_order: item.sort_order, + children, + version: item.version, + } + } + + children_map + .get(&None) + .map(|root_items| { + root_items + .iter() + .map(|item| build_node(item, &children_map)) + .collect() + }) + .unwrap_or_default() +} diff --git a/crates/erp-auth/src/service/mod.rs b/crates/erp-auth/src/service/mod.rs new file mode 100644 index 0000000..94a1673 --- /dev/null +++ b/crates/erp-auth/src/service/mod.rs @@ -0,0 +1,11 @@ +pub mod auth_service; +pub mod dept_service; +pub mod org_service; +pub mod password; +pub mod permission_service; +pub mod position_service; +pub mod role_service; +pub mod seed; +pub mod token_service; +pub mod user_service; +pub mod wechat_service; diff --git a/crates/erp-auth/src/service/org_service.rs b/crates/erp-auth/src/service/org_service.rs new file mode 100644 index 0000000..d169101 --- /dev/null +++ b/crates/erp-auth/src/service/org_service.rs @@ -0,0 +1,494 @@ +use std::collections::HashMap; + +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use uuid::Uuid; + +use crate::dto::{CreateOrganizationReq, OrganizationResp, UpdateOrganizationReq}; +use crate::entity::department; +use crate::entity::organization; +use crate::error::{AuthError, AuthResult}; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::events::EventBus; + +/// Organization CRUD service -- create, read, update, soft-delete organizations +/// within a tenant, supporting tree-structured hierarchy with path and level. +pub struct OrgService; + +impl OrgService { + /// Fetch all organizations for a tenant as a flat list (not deleted). + pub async fn list_flat( + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult> { + let items = organization::Entity::find() + .filter(organization::Column::TenantId.eq(tenant_id)) + .filter(organization::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok(items) + } + + /// Fetch all organizations for a tenant as a nested tree. + /// + /// Root nodes have `parent_id = None`. Children are grouped by `parent_id`. + pub async fn get_tree( + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult> { + let items = Self::list_flat(tenant_id, db).await?; + Ok(build_org_tree(&items)) + } + + /// Create a new organization within the current tenant. + /// + /// If `parent_id` is provided, computes `path` from the parent's path and id, + /// and sets `level = parent.level + 1`. Otherwise, level defaults to 1. + pub async fn create( + tenant_id: Uuid, + operator_id: Uuid, + req: &CreateOrganizationReq, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult { + // Check code uniqueness within tenant if code is provided + if let Some(ref code) = req.code { + let existing = organization::Entity::find() + .filter(organization::Column::TenantId.eq(tenant_id)) + .filter(organization::Column::Code.eq(code.as_str())) + .filter(organization::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("组织编码已存在".to_string())); + } + } + + // Check name uniqueness within tenant + let name_exists = organization::Entity::find() + .filter(organization::Column::TenantId.eq(tenant_id)) + .filter(organization::Column::Name.eq(&req.name)) + .filter(organization::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if name_exists.is_some() { + return Err(AuthError::Validation("组织名称已存在".to_string())); + } + + let (path, level) = if let Some(parent_id) = req.parent_id { + let parent = organization::Entity::find_by_id(parent_id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("父级组织不存在".to_string()))?; + + let parent_path = parent.path.clone().unwrap_or_default(); + let computed_path = format!("{}{}/", parent_path, parent.id); + (Some(computed_path), parent.level + 1) + } else { + (None, 1) + }; + + let now = Utc::now(); + let id = Uuid::now_v7(); + let model = organization::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + name: Set(req.name.clone()), + code: Set(req.code.clone()), + parent_id: Set(req.parent_id), + path: Set(path), + level: Set(level), + sort_order: Set(req.sort_order.unwrap_or(0)), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + model + .insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "organization.created", + tenant_id, + serde_json::json!({ "org_id": id, "name": req.name }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "organization.create", + "organization", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(OrganizationResp { + id, + name: req.name.clone(), + code: req.code.clone(), + parent_id: req.parent_id, + path: None, + level, + sort_order: req.sort_order.unwrap_or(0), + children: vec![], + version: 1, + }) + } + + /// Update editable organization fields (name, code, sort_order). + pub async fn update( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &UpdateOrganizationReq, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult { + let model = organization::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?; + + // If code is being changed, check uniqueness + if let Some(ref new_code) = req.code + && Some(new_code) != model.code.as_ref() + { + let existing = organization::Entity::find() + .filter(organization::Column::TenantId.eq(tenant_id)) + .filter(organization::Column::Code.eq(new_code.as_str())) + .filter(organization::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("组织编码已存在".to_string())); + } + } + + // If name is being changed, check uniqueness (exclude self) + if let Some(ref new_name) = req.name + && new_name != &model.name + { + let name_exists = organization::Entity::find() + .filter(organization::Column::TenantId.eq(tenant_id)) + .filter(organization::Column::Name.eq(new_name.as_str())) + .filter(organization::Column::DeletedAt.is_null()) + .filter(organization::Column::Id.ne(id)) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if name_exists.is_some() { + return Err(AuthError::Validation("组织名称已存在".to_string())); + } + } + + let next_ver = check_version(req.version, model.version) + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let mut active: organization::ActiveModel = model.into(); + + if let Some(ref name) = req.name { + active.name = Set(name.clone()); + } + if let Some(ref code) = req.code { + active.code = Set(Some(code.clone())); + } + if let Some(sort_order) = req.sort_order { + active.sort_order = Set(sort_order); + } + + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let updated = active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "organization.update", + "organization", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(OrganizationResp { + id: updated.id, + name: updated.name.clone(), + code: updated.code.clone(), + parent_id: updated.parent_id, + path: updated.path.clone(), + level: updated.level, + sort_order: updated.sort_order, + children: vec![], + version: updated.version, + }) + } + + /// Soft-delete an organization by setting the `deleted_at` timestamp. + pub async fn delete( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult<()> { + let model = organization::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|o| o.tenant_id == tenant_id && o.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("组织不存在".to_string()))?; + + // Check for child organizations + let children = organization::Entity::find() + .filter(organization::Column::TenantId.eq(tenant_id)) + .filter(organization::Column::ParentId.eq(id)) + .filter(organization::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if children.is_some() { + return Err(AuthError::Validation( + "该组织下存在子组织,无法删除".to_string(), + )); + } + + // Check for departments under this organization + let depts = department::Entity::find() + .filter(department::Column::TenantId.eq(tenant_id)) + .filter(department::Column::OrgId.eq(id)) + .filter(department::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if depts.is_some() { + return Err(AuthError::Validation( + "该组织下存在部门,无法删除".to_string(), + )); + } + + let current_version = model.version; + let mut active: organization::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(current_version + 1); + active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "organization.deleted", + tenant_id, + serde_json::json!({ "org_id": id }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "organization.delete", + "organization", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(()) + } +} + +/// Build a nested tree of `OrganizationResp` from a flat list of models. +/// +/// Root nodes (parent_id = None) form the top level. Each node recursively +/// includes its children grouped by parent_id. +pub(crate) fn build_org_tree(items: &[organization::Model]) -> Vec { + let mut children_map: HashMap, Vec<&organization::Model>> = HashMap::new(); + for item in items { + children_map.entry(item.parent_id).or_default().push(item); + } + + fn build_node( + item: &organization::Model, + map: &HashMap, Vec<&organization::Model>>, + ) -> OrganizationResp { + let children = map + .get(&Some(item.id)) + .map(|items| items.iter().map(|i| build_node(i, map)).collect()) + .unwrap_or_default(); + OrganizationResp { + id: item.id, + name: item.name.clone(), + code: item.code.clone(), + parent_id: item.parent_id, + path: item.path.clone(), + level: item.level, + sort_order: item.sort_order, + children, + version: item.version, + } + } + + children_map + .get(&None) + .map(|root_items| { + root_items + .iter() + .map(|item| build_node(item, &children_map)) + .collect() + }) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + use uuid::Uuid; + + use crate::entity::organization; + + use super::*; + + fn make_org( + id: Uuid, + tenant_id: Uuid, + name: &str, + parent_id: Option, + level: i32, + version: i32, + ) -> organization::Model { + organization::Model { + id, + tenant_id, + name: name.to_string(), + code: None, + parent_id, + path: None, + level, + sort_order: 0, + created_at: Utc::now(), + updated_at: Utc::now(), + created_by: Uuid::now_v7(), + updated_by: Uuid::now_v7(), + deleted_at: None, + version, + } + } + + #[test] + fn build_org_tree_empty() { + let tree = build_org_tree(&[]); + assert!(tree.is_empty()); + } + + #[test] + fn build_org_tree_single_root() { + let tid = Uuid::now_v7(); + let root_id = Uuid::now_v7(); + let items = vec![make_org(root_id, tid, "总公司", None, 1, 1)]; + + let tree = build_org_tree(&items); + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].name, "总公司"); + assert!(tree[0].children.is_empty()); + } + + #[test] + fn build_org_tree_multiple_roots() { + let tid = Uuid::now_v7(); + let items = vec![ + make_org(Uuid::now_v7(), tid, "公司A", None, 1, 1), + make_org(Uuid::now_v7(), tid, "公司B", None, 1, 1), + ]; + + let tree = build_org_tree(&items); + assert_eq!(tree.len(), 2); + } + + #[test] + fn build_org_tree_nested_children() { + let tid = Uuid::now_v7(); + let root_id = Uuid::now_v7(); + let child1_id = Uuid::now_v7(); + let child2_id = Uuid::now_v7(); + let grandchild_id = Uuid::now_v7(); + + let items = vec![ + make_org(root_id, tid, "总公司", None, 1, 1), + make_org(child1_id, tid, "分公司A", Some(root_id), 2, 1), + make_org(child2_id, tid, "分公司B", Some(root_id), 2, 1), + make_org(grandchild_id, tid, "部门A1", Some(child1_id), 3, 1), + ]; + + let tree = build_org_tree(&items); + assert_eq!(tree.len(), 1); // one root + assert_eq!(tree[0].children.len(), 2); // two children + assert_eq!(tree[0].children[0].children.len(), 1); // one grandchild + assert_eq!(tree[0].children[0].children[0].name, "部门A1"); + } + + #[test] + fn build_org_tree_deep_nesting() { + let tid = Uuid::now_v7(); + let l1 = Uuid::now_v7(); + let l2 = Uuid::now_v7(); + let l3 = Uuid::now_v7(); + let l4 = Uuid::now_v7(); + + let items = vec![ + make_org(l1, tid, "L1", None, 1, 1), + make_org(l2, tid, "L2", Some(l1), 2, 1), + make_org(l3, tid, "L3", Some(l2), 3, 1), + make_org(l4, tid, "L4", Some(l3), 4, 1), + ]; + + let tree = build_org_tree(&items); + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].children[0].children[0].children[0].name, "L4"); + } + + #[test] + fn build_org_tree_preserves_version() { + let tid = Uuid::now_v7(); + let root_id = Uuid::now_v7(); + let items = vec![make_org(root_id, tid, "测试", None, 1, 5)]; + + let tree = build_org_tree(&items); + assert_eq!(tree[0].version, 5); + } +} diff --git a/crates/erp-auth/src/service/password.rs b/crates/erp-auth/src/service/password.rs new file mode 100644 index 0000000..76c0a74 --- /dev/null +++ b/crates/erp-auth/src/service/password.rs @@ -0,0 +1,56 @@ +use argon2::{ + Argon2, + password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng}, +}; + +use crate::error::{AuthError, AuthResult}; + +/// Hash a plaintext password using Argon2 with a random salt. +/// +/// Returns a PHC-format string suitable for database storage. +pub fn hash_password(plain: &str) -> AuthResult { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let hash = argon2 + .hash_password(plain.as_bytes(), &salt) + .map_err(|e| AuthError::HashError(e.to_string()))?; + Ok(hash.to_string()) +} + +/// Verify a plaintext password against a stored PHC-format hash. +/// +/// Returns `Ok(true)` if the password matches, `Ok(false)` if not. +pub fn verify_password(plain: &str, hash: &str) -> AuthResult { + let parsed = PasswordHash::new(hash).map_err(|e| AuthError::HashError(e.to_string()))?; + Ok(Argon2::default() + .verify_password(plain.as_bytes(), &parsed) + .is_ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_and_verify() { + let hash = hash_password("test123").unwrap(); + assert!( + verify_password("test123", &hash).unwrap(), + "Correct password should verify" + ); + assert!( + !verify_password("wrong", &hash).unwrap(), + "Wrong password should not verify" + ); + } + + #[test] + fn test_hash_is_unique() { + let hash1 = hash_password("same_password").unwrap(); + let hash2 = hash_password("same_password").unwrap(); + assert_ne!( + hash1, hash2, + "Two hashes of the same password should differ (different salts)" + ); + } +} diff --git a/crates/erp-auth/src/service/permission_service.rs b/crates/erp-auth/src/service/permission_service.rs new file mode 100644 index 0000000..3b9f6b2 --- /dev/null +++ b/crates/erp-auth/src/service/permission_service.rs @@ -0,0 +1,38 @@ +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use uuid::Uuid; + +use crate::dto::PermissionResp; +use crate::entity::permission; +use crate::error::AuthResult; + +/// Permission read-only service — list permissions within a tenant. +/// +/// Permissions are seeded by the system and not typically created via API. +pub struct PermissionService; + +impl PermissionService { + /// List all active permissions within a tenant. + pub async fn list( + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult> { + let perms = permission::Entity::find() + .filter(permission::Column::TenantId.eq(tenant_id)) + .filter(permission::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| crate::error::AuthError::Validation(e.to_string()))?; + + Ok(perms + .iter() + .map(|p| PermissionResp { + id: p.id, + code: p.code.clone(), + name: p.name.clone(), + resource: p.resource.clone(), + action: p.action.clone(), + description: p.description.clone(), + }) + .collect()) + } +} diff --git a/crates/erp-auth/src/service/position_service.rs b/crates/erp-auth/src/service/position_service.rs new file mode 100644 index 0000000..bfb0a3a --- /dev/null +++ b/crates/erp-auth/src/service/position_service.rs @@ -0,0 +1,259 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use uuid::Uuid; + +use crate::dto::{CreatePositionReq, PositionResp, UpdatePositionReq}; +use crate::entity::department; +use crate::entity::position; +use crate::error::{AuthError, AuthResult}; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::events::EventBus; + +/// Position CRUD service -- create, read, update, soft-delete positions +/// within a department. +pub struct PositionService; + +impl PositionService { + /// List all positions for a department within the given tenant. + pub async fn list( + dept_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult> { + // Verify the department exists + let _dept = department::Entity::find_by_id(dept_id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?; + + let items = position::Entity::find() + .filter(position::Column::TenantId.eq(tenant_id)) + .filter(position::Column::DeptId.eq(dept_id)) + .filter(position::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok(items + .iter() + .map(|p| PositionResp { + id: p.id, + dept_id: p.dept_id, + name: p.name.clone(), + code: p.code.clone(), + level: p.level, + sort_order: p.sort_order, + version: p.version, + }) + .collect()) + } + + /// Create a new position under the specified department. + pub async fn create( + dept_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &CreatePositionReq, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult { + // Verify the department exists + let _dept = department::Entity::find_by_id(dept_id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("部门不存在".to_string()))?; + + // Check code uniqueness within tenant if code is provided + if let Some(ref code) = req.code { + let existing = position::Entity::find() + .filter(position::Column::TenantId.eq(tenant_id)) + .filter(position::Column::Code.eq(code.as_str())) + .filter(position::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("岗位编码已存在".to_string())); + } + } + + let now = Utc::now(); + let id = Uuid::now_v7(); + let model = position::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + dept_id: Set(dept_id), + name: Set(req.name.clone()), + code: Set(req.code.clone()), + level: Set(req.level.unwrap_or(1)), + sort_order: Set(req.sort_order.unwrap_or(0)), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + model + .insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "position.created", + tenant_id, + serde_json::json!({ "position_id": id, "dept_id": dept_id, "name": req.name }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "position.create", "position") + .with_resource_id(id), + db, + ) + .await; + + Ok(PositionResp { + id, + dept_id, + name: req.name.clone(), + code: req.code.clone(), + level: req.level.unwrap_or(1), + sort_order: req.sort_order.unwrap_or(0), + version: 1, + }) + } + + /// Update editable position fields (name, code, level, sort_order). + pub async fn update( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &UpdatePositionReq, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult { + let model = position::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?; + + // If code is being changed, check uniqueness + if let Some(new_code) = &req.code + && Some(new_code) != model.code.as_ref() + { + let existing = position::Entity::find() + .filter(position::Column::TenantId.eq(tenant_id)) + .filter(position::Column::Code.eq(new_code.as_str())) + .filter(position::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("岗位编码已存在".to_string())); + } + } + + let next_ver = check_version(req.version, model.version) + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let mut active: position::ActiveModel = model.into(); + + if let Some(n) = &req.name { + active.name = Set(n.clone()); + } + if let Some(c) = &req.code { + active.code = Set(Some(c.clone())); + } + if let Some(l) = &req.level { + active.level = Set(*l); + } + if let Some(so) = &req.sort_order { + active.sort_order = Set(*so); + } + + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let updated = active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "position.update", "position") + .with_resource_id(id), + db, + ) + .await; + + Ok(PositionResp { + id: updated.id, + dept_id: updated.dept_id, + name: updated.name.clone(), + code: updated.code.clone(), + level: updated.level, + sort_order: updated.sort_order, + version: updated.version, + }) + } + + /// Soft-delete a position by setting the `deleted_at` timestamp. + pub async fn delete( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult<()> { + let model = position::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|p| p.tenant_id == tenant_id && p.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("岗位不存在".to_string()))?; + + let current_version = model.version; + let mut active: position::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(current_version + 1); + active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "position.deleted", + tenant_id, + serde_json::json!({ "position_id": id }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "position.delete", "position") + .with_resource_id(id), + db, + ) + .await; + + Ok(()) + } +} diff --git a/crates/erp-auth/src/service/role_service.rs b/crates/erp-auth/src/service/role_service.rs new file mode 100644 index 0000000..ec7378e --- /dev/null +++ b/crates/erp-auth/src/service/role_service.rs @@ -0,0 +1,370 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set}; +use uuid::Uuid; + +use crate::dto::{PermissionResp, RoleResp}; +use crate::entity::{permission, role, role_permission}; +use crate::error::AuthError; +use crate::error::AuthResult; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::events::EventBus; +use erp_core::types::Pagination; + +/// Role CRUD service — create, read, update, soft-delete roles within a tenant, +/// and manage role-permission assignments. +pub struct RoleService; + +impl RoleService { + /// List roles within a tenant with pagination. + /// + /// Returns `(roles, total_count)`. + pub async fn list( + tenant_id: Uuid, + pagination: &Pagination, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult<(Vec, u64)> { + let paginator = role::Entity::find() + .filter(role::Column::TenantId.eq(tenant_id)) + .filter(role::Column::DeletedAt.is_null()) + .paginate(db, pagination.limit()); + + let total = paginator + .num_items() + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let page_index = pagination.page.unwrap_or(1).saturating_sub(1); + let models = paginator + .fetch_page(page_index) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let resps: Vec = models + .iter() + .map(|m| RoleResp { + id: m.id, + name: m.name.clone(), + code: m.code.clone(), + description: m.description.clone(), + is_system: m.is_system, + version: m.version, + }) + .collect(); + + Ok((resps, total)) + } + + /// Fetch a single role by ID, scoped to the given tenant. + pub async fn get_by_id( + id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult { + let model = role::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?; + + Ok(RoleResp { + id: model.id, + name: model.name.clone(), + code: model.code.clone(), + description: model.description.clone(), + is_system: model.is_system, + version: model.version, + }) + } + + /// Create a new role within the current tenant. + /// + /// Validates code uniqueness, then inserts the record and publishes + /// a `role.created` domain event. + pub async fn create( + tenant_id: Uuid, + operator_id: Uuid, + name: &str, + code: &str, + description: &Option, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult { + // Check code uniqueness within tenant + let existing = role::Entity::find() + .filter(role::Column::TenantId.eq(tenant_id)) + .filter(role::Column::Code.eq(code)) + .filter(role::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("角色编码已存在".to_string())); + } + + let now = Utc::now(); + let id = Uuid::now_v7(); + let model = role::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + name: Set(name.to_string()), + code: Set(code.to_string()), + description: Set(description.clone()), + is_system: Set(false), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + model + .insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "role.created", + tenant_id, + serde_json::json!({ "role_id": id, "code": code }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "role.create", "role").with_resource_id(id), + db, + ) + .await; + + Ok(RoleResp { + id, + name: name.to_string(), + code: code.to_string(), + description: description.clone(), + is_system: false, + version: 1, + }) + } + + /// Update editable role fields (name and description). + /// + /// Code and is_system cannot be changed after creation. + pub async fn update( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + name: &Option, + description: &Option, + version: i32, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult { + let model = role::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?; + + let old_json = serde_json::to_value(&model).unwrap_or(serde_json::Value::Null); + + let next_ver = check_version(version, model.version) + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let mut active: role::ActiveModel = model.into(); + + if let Some(name) = name { + active.name = Set(name.clone()); + } + if let Some(desc) = description { + active.description = Set(Some(desc.clone())); + } + + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let updated = active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let new_json = serde_json::to_value(&updated).unwrap_or(serde_json::Value::Null); + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "role.update", "role") + .with_resource_id(id) + .with_changes(Some(old_json), Some(new_json)), + db, + ) + .await; + + Ok(RoleResp { + id: updated.id, + name: updated.name.clone(), + code: updated.code.clone(), + description: updated.description.clone(), + is_system: updated.is_system, + version: updated.version, + }) + } + + /// Soft-delete a role by setting the `deleted_at` timestamp. + /// + /// System roles cannot be deleted. + pub async fn delete( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult<()> { + let model = role::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?; + + if model.is_system { + return Err(AuthError::Validation("系统角色不可删除".to_string())); + } + + let current_version = model.version; + let mut active: role::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(current_version + 1); + active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "role.deleted", + tenant_id, + serde_json::json!({ "role_id": id }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "role.delete", "role").with_resource_id(id), + db, + ) + .await; + + Ok(()) + } + + /// Replace all permission assignments for a role. + /// + /// Soft-deletes existing assignments and creates new ones. + pub async fn assign_permissions( + role_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + permission_ids: &[Uuid], + db: &sea_orm::DatabaseConnection, + ) -> AuthResult<()> { + // Verify the role exists and belongs to this tenant + let _role = role::Entity::find_by_id(role_id) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none()) + .ok_or_else(|| AuthError::Validation("角色不存在".to_string()))?; + + // Soft-delete existing role_permission rows + let existing = role_permission::Entity::find() + .filter(role_permission::Column::RoleId.eq(role_id)) + .filter(role_permission::Column::TenantId.eq(tenant_id)) + .filter(role_permission::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let now = Utc::now(); + for rp in existing { + let mut active: role_permission::ActiveModel = rp.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(active.version.take().unwrap_or(0) + 1); + active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + } + + // Insert new role_permission rows + for perm_id in permission_ids { + let rp = role_permission::ActiveModel { + role_id: Set(role_id), + permission_id: Set(*perm_id), + tenant_id: Set(tenant_id), + data_scope: Set("all".to_string()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + rp.insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + } + Ok(()) + } + + /// Fetch all permissions assigned to a role. + /// + /// Resolves through the role_permission join table. + pub async fn get_role_permissions( + role_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult> { + let rp_rows = role_permission::Entity::find() + .filter(role_permission::Column::RoleId.eq(role_id)) + .filter(role_permission::Column::TenantId.eq(tenant_id)) + .filter(role_permission::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let perm_ids: Vec = rp_rows.iter().map(|rp| rp.permission_id).collect(); + if perm_ids.is_empty() { + return Ok(vec![]); + } + + let perms = permission::Entity::find() + .filter(permission::Column::Id.is_in(perm_ids)) + .filter(permission::Column::TenantId.eq(tenant_id)) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok(perms + .iter() + .map(|p| PermissionResp { + id: p.id, + code: p.code.clone(), + name: p.name.clone(), + resource: p.resource.clone(), + action: p.action.clone(), + description: p.description.clone(), + }) + .collect()) + } +} diff --git a/crates/erp-auth/src/service/seed.rs b/crates/erp-auth/src/service/seed.rs new file mode 100644 index 0000000..f9359ab --- /dev/null +++ b/crates/erp-auth/src/service/seed.rs @@ -0,0 +1,547 @@ +use sea_orm::{ActiveModelTrait, Set}; +use uuid::Uuid; + +use crate::entity::{permission, role, role_permission, user, user_credential, user_role}; +use crate::error::AuthError; + +use super::password; + +/// Permission definitions to seed for every new tenant. +/// Each tuple is: (code, name, resource, action, description) +/// +/// 编码使用点分隔 (`resource.action`),与 handler 中的 `require_permission` 调用保持一致。 +const DEFAULT_PERMISSIONS: &[(&str, &str, &str, &str, &str)] = &[ + // === Auth module === + ("user.list", "查看用户列表", "user", "list", "查看用户列表"), + ("user.create", "创建用户", "user", "create", "创建新用户"), + ("user.read", "查看用户详情", "user", "read", "查看用户信息"), + ("user.update", "编辑用户", "user", "update", "编辑用户信息"), + ("user.delete", "删除用户", "user", "delete", "软删除用户"), + ("role.list", "查看角色列表", "role", "list", "查看角色列表"), + ("role.create", "创建角色", "role", "create", "创建新角色"), + ("role.read", "查看角色详情", "role", "read", "查看角色信息"), + ("role.update", "编辑角色", "role", "update", "编辑角色"), + ("role.delete", "删除角色", "role", "delete", "删除角色"), + ( + "permission.list", + "查看权限", + "permission", + "list", + "查看权限列表", + ), + ( + "organization.list", + "查看组织列表", + "organization", + "list", + "查看组织列表", + ), + ( + "organization.create", + "创建组织", + "organization", + "create", + "创建组织", + ), + ( + "organization.update", + "编辑组织", + "organization", + "update", + "编辑组织", + ), + ( + "organization.delete", + "删除组织", + "organization", + "delete", + "删除组织", + ), + ( + "department.list", + "查看部门列表", + "department", + "list", + "查看部门列表", + ), + ( + "department.create", + "创建部门", + "department", + "create", + "创建部门", + ), + ( + "department.update", + "编辑部门", + "department", + "update", + "编辑部门", + ), + ( + "department.delete", + "删除部门", + "department", + "delete", + "删除部门", + ), + ( + "position.list", + "查看岗位列表", + "position", + "list", + "查看岗位列表", + ), + ( + "position.create", + "创建岗位", + "position", + "create", + "创建岗位", + ), + ( + "position.update", + "编辑岗位", + "position", + "update", + "编辑岗位", + ), + ( + "position.delete", + "删除岗位", + "position", + "delete", + "删除岗位", + ), + // === Config module === + ( + "dictionary.list", + "查看字典", + "dictionary", + "list", + "查看数据字典", + ), + ( + "dictionary.create", + "创建字典", + "dictionary", + "create", + "创建数据字典", + ), + ( + "dictionary.update", + "编辑字典", + "dictionary", + "update", + "编辑数据字典", + ), + ( + "dictionary.delete", + "删除字典", + "dictionary", + "delete", + "删除数据字典", + ), + ("menu.list", "查看菜单", "menu", "list", "查看菜单配置"), + ("menu.update", "编辑菜单", "menu", "update", "编辑菜单配置"), + ( + "setting.read", + "查看配置", + "setting", + "read", + "查看系统参数", + ), + ( + "setting.update", + "编辑配置", + "setting", + "update", + "编辑系统参数", + ), + ( + "setting.delete", + "删除配置", + "setting", + "delete", + "删除系统参数", + ), + ( + "numbering.list", + "查看编号规则", + "numbering", + "list", + "查看编号规则", + ), + ( + "numbering.create", + "创建编号规则", + "numbering", + "create", + "创建编号规则", + ), + ( + "numbering.update", + "编辑编号规则", + "numbering", + "update", + "编辑编号规则", + ), + ( + "numbering.delete", + "删除编号规则", + "numbering", + "delete", + "删除编号规则", + ), + ( + "numbering.generate", + "生成编号", + "numbering", + "generate", + "生成文档编号", + ), + ("theme.read", "查看主题", "theme", "read", "查看主题设置"), + ( + "theme.update", + "编辑主题", + "theme", + "update", + "编辑主题设置", + ), + ( + "language.list", + "查看语言", + "language", + "list", + "查看语言配置", + ), + ( + "language.update", + "编辑语言", + "language", + "update", + "编辑语言设置", + ), + // === Workflow module === + ( + "workflow.create", + "创建流程", + "workflow", + "create", + "创建流程定义", + ), + ( + "workflow.list", + "查看流程", + "workflow", + "list", + "查看流程列表", + ), + ( + "workflow.read", + "查看流程详情", + "workflow", + "read", + "查看流程定义详情", + ), + ( + "workflow.update", + "编辑流程", + "workflow", + "update", + "编辑流程定义", + ), + ( + "workflow.publish", + "发布流程", + "workflow", + "publish", + "发布流程定义", + ), + ( + "workflow.start", + "发起流程", + "workflow", + "start", + "发起流程实例", + ), + ( + "workflow.approve", + "审批任务", + "workflow", + "approve", + "审批流程任务", + ), + ( + "workflow.delegate", + "委派任务", + "workflow", + "delegate", + "委派流程任务", + ), + // === Message module === + ( + "message.list", + "查看消息", + "message", + "list", + "查看消息列表", + ), + ("message.send", "发送消息", "message", "send", "发送新消息"), + ( + "message.template.list", + "查看消息模板", + "message.template", + "list", + "查看消息模板列表", + ), + ( + "message.template.create", + "创建消息模板", + "message.template", + "create", + "创建消息模板", + ), + ( + "message.template.manage", + "管理消息模板", + "message.template", + "manage", + "编辑、删除消息模板", + ), + // === Plugin module === + ( + "plugin.admin", + "插件管理", + "plugin", + "admin", + "管理插件全生命周期", + ), + ("plugin.list", "查看插件", "plugin", "list", "查看插件列表"), + // === Server level === + ( + "tenant.manage", + "租户管理", + "tenant", + "manage", + "管理租户级设置(密钥轮换等)", + ), +]; + +/// Indices of read-only (list/read) permissions within DEFAULT_PERMISSIONS. +const READ_PERM_INDICES: &[usize] = &[ + 0, // user.list + 2, // user.read + 5, // role.list + 7, // role.read + 10, // permission.list + 11, // organization.list + 15, // department.list + 19, // position.list + 23, // dictionary.list + 28, // menu.list + 30, // setting.read + 32, // numbering.list + 37, // theme.read + 39, // language.list + 43, // workflow.list + 44, // workflow.read + 49, // message.list + 51, // message.template.list + 54, // plugin.list +]; + +/// Seed default auth data for a new tenant. +/// +/// Creates: +/// - 56 permissions covering auth/config/workflow/message/plugin modules +/// - An "admin" system role with all permissions +/// - A "viewer" system role with read-only permissions +/// - A super-admin user with the admin role and a password credential +pub async fn seed_tenant_auth( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + super_admin_password: &str, +) -> Result<(), AuthError> { + let now = chrono::Utc::now(); + let system_user_id = Uuid::nil(); + + // 1. Create permissions + let mut perm_ids: Vec = Vec::with_capacity(DEFAULT_PERMISSIONS.len()); + for (code, name, resource, action, desc) in DEFAULT_PERMISSIONS { + let perm_id = Uuid::now_v7(); + perm_ids.push(perm_id); + + let perm = permission::ActiveModel { + id: Set(perm_id), + tenant_id: Set(tenant_id), + code: Set(code.to_string()), + name: Set(name.to_string()), + resource: Set(resource.to_string()), + action: Set(action.to_string()), + description: Set(Some(desc.to_string())), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user_id), + updated_by: Set(system_user_id), + deleted_at: Set(None), + version: Set(1), + }; + perm.insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + } + + // 2. Create "admin" role with all permissions + let admin_role_id = Uuid::now_v7(); + let admin_role = role::ActiveModel { + id: Set(admin_role_id), + tenant_id: Set(tenant_id), + name: Set("管理员".to_string()), + code: Set("admin".to_string()), + description: Set(Some("系统管理员,拥有所有权限".to_string())), + is_system: Set(true), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user_id), + updated_by: Set(system_user_id), + deleted_at: Set(None), + version: Set(1), + }; + admin_role + .insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + // Assign all permissions to admin role + for perm_id in &perm_ids { + let rp = role_permission::ActiveModel { + role_id: Set(admin_role_id), + permission_id: Set(*perm_id), + tenant_id: Set(tenant_id), + data_scope: Set("all".to_string()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user_id), + updated_by: Set(system_user_id), + deleted_at: Set(None), + version: Set(1), + }; + rp.insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + } + + // 3. Create "viewer" role with read-only permissions + let viewer_role_id = Uuid::now_v7(); + let viewer_role = role::ActiveModel { + id: Set(viewer_role_id), + tenant_id: Set(tenant_id), + name: Set("查看者".to_string()), + code: Set("viewer".to_string()), + description: Set(Some("只读用户,可查看所有数据".to_string())), + is_system: Set(true), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user_id), + updated_by: Set(system_user_id), + deleted_at: Set(None), + version: Set(1), + }; + viewer_role + .insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + // Assign read permissions to viewer role + for idx in READ_PERM_INDICES { + if *idx < perm_ids.len() { + let rp = role_permission::ActiveModel { + role_id: Set(viewer_role_id), + permission_id: Set(perm_ids[*idx]), + tenant_id: Set(tenant_id), + data_scope: Set("all".to_string()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user_id), + updated_by: Set(system_user_id), + deleted_at: Set(None), + version: Set(1), + }; + rp.insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + } + } + + // 4. Create super admin user + let admin_user_id = Uuid::now_v7(); + let password_hash = password::hash_password(super_admin_password)?; + + let admin_user = user::ActiveModel { + id: Set(admin_user_id), + tenant_id: Set(tenant_id), + username: Set("admin".to_string()), + email: Set(None), + phone: Set(None), + display_name: Set(Some("系统管理员".to_string())), + avatar_url: Set(None), + status: Set("active".to_string()), + last_login_at: Set(None), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user_id), + updated_by: Set(system_user_id), + deleted_at: Set(None), + version: Set(1), + }; + admin_user + .insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + // Create password credential for admin user + let cred = user_credential::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + user_id: Set(admin_user_id), + credential_type: Set("password".to_string()), + credential_data: Set(Some(serde_json::json!({ "hash": password_hash }))), + verified: Set(true), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user_id), + updated_by: Set(system_user_id), + deleted_at: Set(None), + version: Set(1), + }; + cred.insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + // 5. Assign admin role to admin user + let user_role_assignment = user_role::ActiveModel { + user_id: Set(admin_user_id), + role_id: Set(admin_role_id), + tenant_id: Set(tenant_id), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user_id), + updated_by: Set(system_user_id), + deleted_at: Set(None), + version: Set(1), + }; + user_role_assignment + .insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + tracing::info!( + tenant_id = %tenant_id, + admin_user_id = %admin_user_id, + "Seeded tenant auth: admin user, 2 roles, {} permissions", + DEFAULT_PERMISSIONS.len() + ); + + Ok(()) +} diff --git a/crates/erp-auth/src/service/token_service.rs b/crates/erp-auth/src/service/token_service.rs new file mode 100644 index 0000000..f2e433d --- /dev/null +++ b/crates/erp-auth/src/service/token_service.rs @@ -0,0 +1,326 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::entity::{permission, role, role_permission, user_role, user_token}; +use crate::error::AuthError; + +use crate::error::AuthResult; + +/// JWT claims embedded in access and refresh tokens. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + /// Subject — the user ID + pub sub: Uuid, + /// Tenant ID + pub tid: Uuid, + /// Role codes assigned to this user + pub roles: Vec, + /// Permission codes granted to this user + pub permissions: Vec, + /// Expiry (unix timestamp) + pub exp: i64, + /// Issued at (unix timestamp) + pub iat: i64, + /// Token type: "access" or "refresh" + pub token_type: String, +} + +/// Stateless service for JWT token signing, validation, and revocation. +pub struct TokenService; + +impl TokenService { + /// Sign a short-lived access token containing roles and permissions. + pub fn sign_access_token( + user_id: Uuid, + tenant_id: Uuid, + roles: Vec, + permissions: Vec, + secret: &str, + ttl_secs: i64, + ) -> AuthResult { + let now = Utc::now(); + let claims = Claims { + sub: user_id, + tid: tenant_id, + roles, + permissions, + exp: now.timestamp() + ttl_secs, + iat: now.timestamp(), + token_type: "access".to_string(), + }; + let header = jsonwebtoken::Header::default(); + let encoded = jsonwebtoken::encode( + &header, + &claims, + &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()), + )?; + Ok(encoded) + } + + /// Sign a long-lived refresh token and persist its SHA-256 hash in the database. + /// + /// Returns the raw token string (sent to client) and the database row ID. + pub async fn sign_refresh_token( + user_id: Uuid, + tenant_id: Uuid, + db: &DatabaseConnection, + secret: &str, + ttl_secs: i64, + ) -> AuthResult<(String, Uuid)> { + let now = Utc::now(); + let token_id = Uuid::now_v7(); + + let claims = Claims { + sub: user_id, + tid: tenant_id, + roles: vec![], + permissions: vec![], + exp: now.timestamp() + ttl_secs, + iat: now.timestamp(), + token_type: "refresh".to_string(), + }; + let raw_token = jsonwebtoken::encode( + &jsonwebtoken::Header::default(), + &claims, + &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()), + )?; + + // Store the SHA-256 hash — the raw token is never persisted. + let hash = sha256_hex(&raw_token); + + let token_model = user_token::ActiveModel { + id: Set(token_id), + tenant_id: Set(tenant_id), + user_id: Set(user_id), + token_hash: Set(hash), + token_type: Set("refresh".to_string()), + expires_at: Set(now + chrono::Duration::seconds(ttl_secs)), + revoked_at: Set(None), + device_info: Set(None), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(user_id), + updated_by: Set(user_id), + deleted_at: Set(None), + version: Set(1), + }; + token_model + .insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok((raw_token, token_id)) + } + + /// Validate a refresh token against the database. + /// + /// Returns the database row ID and decoded claims. + pub async fn validate_refresh_token( + token: &str, + db: &DatabaseConnection, + secret: &str, + ) -> AuthResult<(Uuid, Claims)> { + let claims = Self::decode_token(token, secret)?; + if claims.token_type != "refresh" { + return Err(AuthError::Validation("不是 refresh token".to_string())); + } + + let hash = sha256_hex(token); + let token_row = user_token::Entity::find() + .filter(user_token::Column::TokenHash.eq(hash)) + .filter(user_token::Column::TenantId.eq(claims.tid)) + .filter(user_token::Column::RevokedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .ok_or(AuthError::TokenRevoked)?; + + Ok((token_row.id, claims)) + } + + /// Decode and validate any JWT token, returning the claims. + pub fn decode_token(token: &str, secret: &str) -> AuthResult { + let data = jsonwebtoken::decode::( + token, + &jsonwebtoken::DecodingKey::from_secret(secret.as_bytes()), + &jsonwebtoken::Validation::default(), + )?; + Ok(data.claims) + } + + /// Revoke a specific refresh token by database ID. + /// Verifies that the token belongs to the specified user for security. + pub async fn revoke_token( + token_id: Uuid, + user_id: Uuid, + db: &DatabaseConnection, + ) -> AuthResult<()> { + let token_row = user_token::Entity::find_by_id(token_id) + .filter(user_token::Column::UserId.eq(user_id)) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .ok_or(AuthError::TokenRevoked)?; + + let mut active: user_token::ActiveModel = token_row.into(); + active.revoked_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.version = Set(active.version.take().unwrap_or(0) + 1); + active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + Ok(()) + } + + /// Atomically validate and revoke a refresh token by hash. + /// This prevents TOCTOU race conditions during concurrent refresh requests. + /// Returns the decoded claims on success, or TokenRevoked if already consumed. + pub async fn validate_and_revoke_atomic( + token: &str, + db: &DatabaseConnection, + secret: &str, + ) -> AuthResult { + let claims = Self::decode_token(token, secret)?; + if claims.token_type != "refresh" { + return Err(AuthError::Validation("不是 refresh token".to_string())); + } + + let hash = sha256_hex(token); + let now = Utc::now(); + let result = user_token::Entity::update_many() + .col_expr( + user_token::Column::RevokedAt, + sea_orm::sea_query::Expr::value(Some(now.naive_utc())), + ) + .col_expr( + user_token::Column::UpdatedAt, + sea_orm::sea_query::Expr::value(now.naive_utc()), + ) + .col_expr( + user_token::Column::Version, + sea_orm::sea_query::Expr::col(user_token::Column::Version).add(1), + ) + .filter(user_token::Column::TokenHash.eq(&hash)) + .filter(user_token::Column::UserId.eq(claims.sub)) + .filter(user_token::Column::TenantId.eq(claims.tid)) + .filter(user_token::Column::RevokedAt.is_null()) + .exec(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + if result.rows_affected == 0 { + return Err(AuthError::TokenRevoked); + } + + Ok(claims) + } + + /// Revoke all non-revoked refresh tokens for a given user within a tenant. + pub async fn revoke_all_user_tokens( + user_id: Uuid, + tenant_id: Uuid, + db: &DatabaseConnection, + ) -> AuthResult<()> { + let tokens = user_token::Entity::find() + .filter(user_token::Column::UserId.eq(user_id)) + .filter(user_token::Column::TenantId.eq(tenant_id)) + .filter(user_token::Column::RevokedAt.is_null()) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let now = Utc::now(); + for token in tokens { + let mut active: user_token::ActiveModel = token.into(); + active.revoked_at = Set(Some(now)); + active.updated_at = Set(now); + active.version = Set(active.version.take().unwrap_or(0) + 1); + active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + } + Ok(()) + } + + /// Look up a user's permission codes through user_roles -> role_permissions -> permissions. + pub async fn get_user_permissions( + user_id: Uuid, + tenant_id: Uuid, + db: &DatabaseConnection, + ) -> AuthResult> { + let user_role_rows = user_role::Entity::find() + .filter(user_role::Column::UserId.eq(user_id)) + .filter(user_role::Column::TenantId.eq(tenant_id)) + .filter(user_role::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let role_ids: Vec = user_role_rows.iter().map(|ur| ur.role_id).collect(); + if role_ids.is_empty() { + return Ok(vec![]); + } + + let role_perm_rows = role_permission::Entity::find() + .filter(role_permission::Column::RoleId.is_in(role_ids)) + .filter(role_permission::Column::TenantId.eq(tenant_id)) + .filter(role_permission::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let perm_ids: Vec = role_perm_rows.iter().map(|rp| rp.permission_id).collect(); + if perm_ids.is_empty() { + return Ok(vec![]); + } + + let perms = permission::Entity::find() + .filter(permission::Column::Id.is_in(perm_ids)) + .filter(permission::Column::TenantId.eq(tenant_id)) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok(perms.iter().map(|p| p.code.clone()).collect()) + } + + /// Look up a user's role codes through user_roles -> roles. + pub async fn get_user_roles( + user_id: Uuid, + tenant_id: Uuid, + db: &DatabaseConnection, + ) -> AuthResult> { + let user_role_rows = user_role::Entity::find() + .filter(user_role::Column::UserId.eq(user_id)) + .filter(user_role::Column::TenantId.eq(tenant_id)) + .filter(user_role::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let role_ids: Vec = user_role_rows.iter().map(|ur| ur.role_id).collect(); + if role_ids.is_empty() { + return Ok(vec![]); + } + + let roles = role::Entity::find() + .filter(role::Column::Id.is_in(role_ids)) + .filter(role::Column::TenantId.eq(tenant_id)) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok(roles.iter().map(|r| r.code.clone()).collect()) + } +} + +/// Compute a SHA-256 hex digest of the input string. +fn sha256_hex(input: &str) -> String { + let hash = Sha256::digest(input.as_bytes()); + format!("{:x}", hash) +} diff --git a/crates/erp-auth/src/service/user_service.rs b/crates/erp-auth/src/service/user_service.rs new file mode 100644 index 0000000..ba62978 --- /dev/null +++ b/crates/erp-auth/src/service/user_service.rs @@ -0,0 +1,629 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set}; +use std::collections::HashMap; +use uuid::Uuid; + +use crate::dto::{CreateUserReq, RoleResp, UpdateUserReq, UserResp}; +use crate::entity::{role, user, user_credential, user_role}; +use crate::error::AuthError; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::events::EventBus; +use erp_core::types::Pagination; + +use crate::error::AuthResult; + +use super::password; + +/// User CRUD service — create, read, update, soft-delete users within a tenant. +pub struct UserService; + +impl UserService { + /// Create a new user with a password credential. + /// + /// Validates username uniqueness within the tenant, hashes the password, + /// and publishes a `user.created` event. + pub async fn create( + tenant_id: Uuid, + operator_id: Uuid, + req: &CreateUserReq, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult { + // Check username uniqueness within tenant + let existing = user::Entity::find() + .filter(user::Column::TenantId.eq(tenant_id)) + .filter(user::Column::Username.eq(&req.username)) + .filter(user::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(AuthError::Validation("用户名已存在".to_string())); + } + + let now = Utc::now(); + let user_id = Uuid::now_v7(); + + // Insert user record + let user_model = user::ActiveModel { + id: Set(user_id), + tenant_id: Set(tenant_id), + username: Set(req.username.clone()), + email: Set(req.email.clone()), + phone: Set(req.phone.clone()), + display_name: Set(req.display_name.clone()), + avatar_url: Set(None), + status: Set("active".to_string()), + last_login_at: Set(None), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + user_model + .insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + // Insert password credential + let hash = password::hash_password(&req.password)?; + let cred = user_credential::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + user_id: Set(user_id), + credential_type: Set("password".to_string()), + credential_data: Set(Some(serde_json::json!({ "hash": hash }))), + verified: Set(true), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + cred.insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + // Publish domain event + event_bus + .publish( + erp_core::events::DomainEvent::new( + "user.created", + tenant_id, + serde_json::json!({ "user_id": user_id, "username": req.username }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "user.create", "user") + .with_resource_id(user_id), + db, + ) + .await; + + Ok(UserResp { + id: user_id, + username: req.username.clone(), + email: req.email.clone(), + phone: req.phone.clone(), + display_name: req.display_name.clone(), + avatar_url: None, + status: "active".to_string(), + roles: vec![], + version: 1, + }) + } + + /// Fetch a single user by ID, scoped to the given tenant. + pub async fn get_by_id( + id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult { + let user_model = user::Entity::find() + .filter(user::Column::Id.eq(id)) + .filter(user::Column::TenantId.eq(tenant_id)) + .filter(user::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?; + + let roles = Self::fetch_user_role_resps(id, tenant_id, db).await?; + Ok(model_to_resp(&user_model, roles)) + } + + /// List users within a tenant with pagination and optional search. + /// + /// Returns `(users, total_count)`. When `search` is provided, filters + /// by username using case-insensitive substring match. + pub async fn list( + tenant_id: Uuid, + pagination: &Pagination, + search: Option<&str>, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult<(Vec, u64)> { + let mut query = user::Entity::find() + .filter(user::Column::TenantId.eq(tenant_id)) + .filter(user::Column::DeletedAt.is_null()); + + if let Some(term) = search + && !term.is_empty() + { + use sea_orm::sea_query::Expr; + query = query.filter(Expr::col(user::Column::Username).like(format!("%{}%", term))); + } + + let paginator = query.paginate(db, pagination.limit()); + + let total = paginator + .num_items() + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let page_index = pagination.page.unwrap_or(1).saturating_sub(1); + let models = paginator + .fetch_page(page_index) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let mut resps = Vec::with_capacity(models.len()); + // 批量查询所有用户的角色(N+1 → 3 固定查询) + let user_ids: Vec = models.iter().map(|m| m.id).collect(); + let role_map = Self::fetch_batch_user_role_resps(&user_ids, tenant_id, db).await; + + for m in models { + let roles = role_map.get(&m.id).cloned().unwrap_or_default(); + resps.push(model_to_resp(&m, roles)); + } + + Ok((resps, total)) + } + + /// Update editable user fields. + /// + /// Supports updating email, phone, display_name, and status. + /// Status must be one of: "active", "disabled", "locked". + pub async fn update( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &UpdateUserReq, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult { + let user_model = user::Entity::find() + .filter(user::Column::Id.eq(id)) + .filter(user::Column::TenantId.eq(tenant_id)) + .filter(user::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?; + + let old_json = serde_json::to_value(&user_model).unwrap_or(serde_json::Value::Null); + + let next_ver = check_version(req.version, user_model.version) + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let mut active: user::ActiveModel = user_model.into(); + + if let Some(email) = &req.email { + active.email = Set(Some(email.clone())); + } + if let Some(phone) = &req.phone { + active.phone = Set(Some(phone.clone())); + } + if let Some(display_name) = &req.display_name { + active.display_name = Set(Some(display_name.clone())); + } + if let Some(status) = &req.status { + if !["active", "disabled", "locked"].contains(&status.as_str()) { + return Err(AuthError::Validation("无效的状态值".to_string())); + } + active.status = Set(status.clone()); + } + + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + let updated = active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let new_json = serde_json::to_value(&updated).unwrap_or(serde_json::Value::Null); + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "user.update", "user") + .with_resource_id(id) + .with_changes(Some(old_json), Some(new_json)), + db, + ) + .await; + + let roles = Self::fetch_user_role_resps(id, tenant_id, db).await?; + Ok(model_to_resp(&updated, roles)) + } + + /// Soft-delete a user by setting the `deleted_at` timestamp. + pub async fn delete( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AuthResult<()> { + let user_model = user::Entity::find() + .filter(user::Column::Id.eq(id)) + .filter(user::Column::TenantId.eq(tenant_id)) + .filter(user::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?; + + let current_version = user_model.version; + let mut active: user::ActiveModel = user_model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(current_version + 1); + active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "user.deleted", + tenant_id, + serde_json::json!({ "user_id": id }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "user.delete", "user").with_resource_id(id), + db, + ) + .await; + + Ok(()) + } + + /// Replace all role assignments for a user within a tenant. + pub async fn assign_roles( + user_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + role_ids: &[Uuid], + db: &sea_orm::DatabaseConnection, + ) -> AuthResult> { + // 验证用户存在 + let _user = user::Entity::find() + .filter(user::Column::Id.eq(user_id)) + .filter(user::Column::TenantId.eq(tenant_id)) + .filter(user::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?; + + // 验证所有角色存在且属于当前租户 + if !role_ids.is_empty() { + let found = role::Entity::find() + .filter(role::Column::Id.is_in(role_ids.iter().copied())) + .filter(role::Column::TenantId.eq(tenant_id)) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + if found.len() != role_ids.len() { + return Err(AuthError::Validation( + "部分角色不存在或不属于当前租户".to_string(), + )); + } + } + + // 删除旧的角色分配 + user_role::Entity::delete_many() + .filter(user_role::Column::UserId.eq(user_id)) + .filter(user_role::Column::TenantId.eq(tenant_id)) + .exec(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + // 创建新的角色分配 + let now = chrono::Utc::now(); + for &role_id in role_ids { + let assignment = user_role::ActiveModel { + user_id: Set(user_id), + role_id: Set(role_id), + tenant_id: Set(tenant_id), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + assignment + .insert(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + } + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "user.assign_roles", "user") + .with_resource_id(user_id), + db, + ) + .await; + + Self::fetch_user_role_resps(user_id, tenant_id, db).await + } + + /// 批量查询多用户的角色,返回 user_id → RoleResp 映射。 + /// + /// 使用 3 次固定查询替代 N+1:用户角色关联 → 角色 → 分组组装。 + async fn fetch_batch_user_role_resps( + user_ids: &[Uuid], + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> HashMap> { + if user_ids.is_empty() { + return HashMap::new(); + } + + // 1. 批量查询 user_role 关联 + let user_roles: Vec = user_role::Entity::find() + .filter(user_role::Column::UserId.is_in(user_ids.iter().copied())) + .filter(user_role::Column::TenantId.eq(tenant_id)) + .all(db) + .await + .unwrap_or_default(); + + let role_ids: Vec = user_roles.iter().map(|ur| ur.role_id).collect(); + + // 2. 批量查询角色 + let roles: Vec = if role_ids.is_empty() { + vec![] + } else { + role::Entity::find() + .filter(role::Column::Id.is_in(role_ids.iter().copied())) + .filter(role::Column::TenantId.eq(tenant_id)) + .filter(role::Column::DeletedAt.is_null()) + .all(db) + .await + .unwrap_or_default() + }; + + let role_map: HashMap = roles.iter().map(|r| (r.id, r)).collect(); + + // 3. 按 user_id 分组 + let mut result: HashMap> = HashMap::new(); + for ur in &user_roles { + let resp = role_map + .get(&ur.role_id) + .map(|r| RoleResp { + id: r.id, + name: r.name.clone(), + code: r.code.clone(), + description: r.description.clone(), + is_system: r.is_system, + version: r.version, + }) + .unwrap_or_else(|| RoleResp { + id: ur.role_id, + name: "Unknown".into(), + code: "unknown".into(), + description: None, + is_system: false, + version: 0, + }); + result.entry(ur.user_id).or_default().push(resp); + } + result + } + + /// 管理员重置指定用户密码。 + pub async fn reset_password( + user_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + new_password: &str, + version: i32, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult<()> { + // 1. 验证用户存在且属于当前租户 + let user_model = user::Entity::find() + .filter(user::Column::Id.eq(user_id)) + .filter(user::Column::TenantId.eq(tenant_id)) + .filter(user::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?; + + let _next_version = check_version(version, user_model.version) + .map_err(|_| AuthError::Validation("版本冲突,请刷新后重试".to_string()))?; + + // 2. 查找密码凭证 + let cred = user_credential::Entity::find() + .filter(user_credential::Column::UserId.eq(user_id)) + .filter(user_credential::Column::TenantId.eq(tenant_id)) + .filter(user_credential::Column::CredentialType.eq("password")) + .filter(user_credential::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))? + .ok_or_else(|| AuthError::Validation("用户凭证不存在".to_string()))?; + + // 3. 哈希新密码并更新凭证 + let new_hash = password::hash_password(new_password)?; + let cred_version = cred.version; + let mut cred_active: user_credential::ActiveModel = cred.into(); + cred_active.credential_data = Set(Some(serde_json::json!({ "hash": new_hash }))); + cred_active.updated_at = Set(Utc::now()); + cred_active.updated_by = Set(operator_id); + cred_active.version = Set(cred_version + 1); + cred_active + .update(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + // 4. 吊销所有 refresh token + super::token_service::TokenService::revoke_all_user_tokens(user_id, tenant_id, db).await?; + + // 5. 审计日志 + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "user.reset_password", "user") + .with_resource_id(user_id), + db, + ) + .await; + + tracing::info!(user_id = %user_id, operator_id = %operator_id, "Password reset by admin"); + Ok(()) + } + + /// Fetch role details for a single user, returning RoleResp DTOs. + async fn fetch_user_role_resps( + user_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AuthResult> { + let user_roles = user_role::Entity::find() + .filter(user_role::Column::UserId.eq(user_id)) + .filter(user_role::Column::TenantId.eq(tenant_id)) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + let role_ids: Vec = user_roles.iter().map(|ur| ur.role_id).collect(); + if role_ids.is_empty() { + return Ok(vec![]); + } + + let roles = role::Entity::find() + .filter(role::Column::Id.is_in(role_ids)) + .filter(role::Column::TenantId.eq(tenant_id)) + .all(db) + .await + .map_err(|e| AuthError::Validation(e.to_string()))?; + + Ok(roles + .iter() + .map(|r| RoleResp { + id: r.id, + name: r.name.clone(), + code: r.code.clone(), + description: r.description.clone(), + is_system: r.is_system, + version: r.version, + }) + .collect()) + } +} + +/// Convert a SeaORM user Model and its role DTOs into a UserResp. +pub(crate) fn model_to_resp(m: &user::Model, roles: Vec) -> UserResp { + UserResp { + id: m.id, + username: m.username.clone(), + email: m.email.clone(), + phone: m.phone.clone(), + display_name: m.display_name.clone(), + avatar_url: m.avatar_url.clone(), + status: m.status.clone(), + roles, + version: m.version, + } +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + use uuid::Uuid; + + use crate::dto::RoleResp; + use crate::entity::user; + + use super::*; + + fn make_user_model( + id: Uuid, + tenant_id: Uuid, + username: &str, + status: &str, + version: i32, + ) -> user::Model { + user::Model { + id, + tenant_id, + username: username.to_string(), + email: None, + phone: None, + display_name: None, + avatar_url: None, + status: status.to_string(), + last_login_at: None, + created_at: Utc::now(), + updated_at: Utc::now(), + created_by: Uuid::now_v7(), + updated_by: Uuid::now_v7(), + deleted_at: None, + version, + } + } + + #[test] + fn model_to_resp_maps_basic_fields() { + let id = Uuid::now_v7(); + let tid = Uuid::now_v7(); + let m = make_user_model(id, tid, "alice", "active", 1); + let resp = model_to_resp(&m, vec![]); + assert_eq!(resp.id, id); + assert_eq!(resp.username, "alice"); + assert_eq!(resp.status, "active"); + assert_eq!(resp.version, 1); + assert!(resp.roles.is_empty()); + } + + #[test] + fn model_to_resp_includes_roles() { + let id = Uuid::now_v7(); + let tid = Uuid::now_v7(); + let m = make_user_model(id, tid, "bob", "active", 2); + let roles = vec![ + RoleResp { + id: Uuid::now_v7(), + name: "管理员".to_string(), + code: "admin".to_string(), + description: None, + is_system: true, + version: 1, + }, + RoleResp { + id: Uuid::now_v7(), + name: "用户".to_string(), + code: "user".to_string(), + description: None, + is_system: false, + version: 1, + }, + ]; + let resp = model_to_resp(&m, roles); + assert_eq!(resp.roles.len(), 2); + assert_eq!(resp.roles[0].code, "admin"); + assert_eq!(resp.version, 2); + } +} diff --git a/crates/erp-auth/src/service/wechat_service.rs b/crates/erp-auth/src/service/wechat_service.rs new file mode 100644 index 0000000..ec3323d --- /dev/null +++ b/crates/erp-auth/src/service/wechat_service.rs @@ -0,0 +1,575 @@ +use aes::cipher::{BlockDecryptMut, KeyIvInit, block_padding::Pkcs7}; +use base64::Engine; +use cbc::Decryptor; +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use serde::Deserialize; +use std::collections::HashMap; +use std::sync::LazyLock; +use std::time::Instant; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::auth_state::AuthState; +use crate::dto::{LoginResp, UserResp, WechatLoginResp}; +use crate::entity::wechat_user; +use crate::error::{AuthError, AuthResult}; +use crate::service::auth_service::JwtConfig; +use crate::service::token_service::TokenService; +use erp_core::sanitize::sanitize_string; + +type Aes128CbcDec = Decryptor; + +/// 内存降级缓存(Redis 不可用时使用) +struct SessionEntry { + session_key: String, + created_at: Instant, +} + +static MEMORY_FALLBACK: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +const SESSION_TTL_SECS: u64 = 300; + +/// Redis key 前缀 +const REDIS_KEY_PREFIX: &str = "wechat:session:"; + +#[derive(Debug, Deserialize)] +struct WechatSessionResp { + openid: Option, + session_key: Option, + #[allow(dead_code)] + unionid: Option, + errcode: Option, + errmsg: Option, +} + +pub struct WechatService; + +impl WechatService { + pub async fn login( + state: &AuthState, + tenant_id: Uuid, + code: &str, + ) -> AuthResult { + tracing::info!( + appid = %state.wechat_appid, + code = %code, + "fetch_session 开始" + ); + let session = fetch_session( + &state.wechat_appid, + &state.wechat_secret, + code, + state.wechat_dev_mode, + ) + .await?; + + let openid = session + .openid + .clone() + .ok_or_else(|| AuthError::Validation("微信登录失败:未获取到 openid".to_string()))?; + + // 缓存 session_key(Redis 优先,内存降级) + if let Some(sk) = &session.session_key + && let Err(e) = Self::store_session_key_redis(&state.redis, &openid, sk).await + { + tracing::warn!(openid = %openid, error = %e, "Redis session_key 存储失败,降级内存"); + let mut cache = MEMORY_FALLBACK.lock().await; + cache.insert( + openid.clone(), + SessionEntry { + session_key: sk.clone(), + created_at: Instant::now(), + }, + ); + } + + let existing = wechat_user::Entity::find() + .filter(wechat_user::Column::Openid.eq(&openid)) + .filter(wechat_user::Column::TenantId.eq(tenant_id)) + .filter(wechat_user::Column::DeletedAt.is_null()) + .one(&state.db) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + + if let Some(wu) = existing { + let token = build_login_resp( + &state.db, + wu.user_id, + tenant_id, + &JwtConfig { + secret: &state.jwt_secret, + access_ttl_secs: state.access_ttl_secs, + refresh_ttl_secs: state.refresh_ttl_secs, + }, + ) + .await?; + Ok(WechatLoginResp { + bound: true, + openid, + token: Some(token), + }) + } else { + Ok(WechatLoginResp { + bound: false, + openid, + token: None, + }) + } + } + + pub async fn bind_phone( + state: &AuthState, + tenant_id: Uuid, + openid: &str, + encrypted_data: &str, + iv: &str, + ) -> AuthResult { + // Dev 模式:mock session_key 无法解密真实微信加密数据,直接使用 mock 手机号 + let phone = if state.wechat_dev_mode { + let hash = openid + .bytes() + .fold(0u32, |acc, b| acc.wrapping_mul(31).wrapping_add(b as u32)); + let suffix = hash % 10000; + tracing::warn!(%openid, mock_phone = format!("1380000{suffix:04}"), "开发模式:跳过手机号解密,使用 mock 手机号"); + format!("1380000{suffix:04}") + } else { + let session_key = Self::get_session_key(&state.redis, openid).await?; + decrypt_phone_number(&session_key, encrypted_data, iv)? + }; + + let existing = wechat_user::Entity::find() + .filter(wechat_user::Column::Openid.eq(openid)) + .filter(wechat_user::Column::TenantId.eq(tenant_id)) + .filter(wechat_user::Column::DeletedAt.is_null()) + .one(&state.db) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + + if existing.is_some() { + return Err(AuthError::Validation("该微信已绑定账号".to_string())); + } + + let user_id = + Self::find_or_create_user_by_phone(&state.db, tenant_id, &phone, &state.crypto).await?; + + let now = Utc::now(); + let wu = wechat_user::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + openid: Set(openid.to_string()), + union_id: Set(None), + user_id: Set(user_id), + phone: Set(Some(phone.clone())), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(Some(user_id)), + updated_by: Set(Some(user_id)), + deleted_at: Set(None), + version: Set(1), + }; + wu.insert(&state.db) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + + build_login_resp( + &state.db, + user_id, + tenant_id, + &JwtConfig { + secret: &state.jwt_secret, + access_ttl_secs: state.access_ttl_secs, + refresh_ttl_secs: state.refresh_ttl_secs, + }, + ) + .await + } + + async fn find_or_create_user_by_phone( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + phone: &str, + crypto: &erp_core::crypto::PiiCrypto, + ) -> AuthResult { + use crate::entity::user; + + let existing = user::Entity::find() + .filter(user::Column::Phone.eq(phone)) + .filter(user::Column::TenantId.eq(tenant_id)) + .filter(user::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + + if let Some(u) = existing { + return Ok(u.id); + } + + let now = Utc::now(); + let user_id = Uuid::now_v7(); + let suffix = &phone[phone.len().saturating_sub(4)..]; + + let new_user = user::ActiveModel { + id: Set(user_id), + tenant_id: Set(tenant_id), + username: Set(format!("wx_{}", suffix)), + display_name: Set(Some(sanitize_string(&format!("微信用户{}", suffix)))), + phone: Set(Some(phone.to_string())), + email: Set(None), + avatar_url: Set(None), + status: Set("active".to_string()), + last_login_at: Set(None), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(user_id), + updated_by: Set(user_id), + deleted_at: Set(None), + version: Set(1), + }; + new_user + .insert(db) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + + // 自动分配 patient 角色 + Self::assign_patient_role(db, tenant_id, user_id).await?; + + // 自动创建或关联 patient 记录 + Self::ensure_patient_record(db, tenant_id, user_id, phone, crypto).await?; + + Ok(user_id) + } + + async fn assign_patient_role( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + user_id: Uuid, + ) -> AuthResult<()> { + use crate::entity::role; + use crate::entity::user_role; + + let patient_role = role::Entity::find() + .filter(role::Column::Code.eq("patient")) + .filter(role::Column::TenantId.eq(tenant_id)) + .filter(role::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + + if let Some(r) = patient_role { + let now = Utc::now(); + let ur = user_role::ActiveModel { + user_id: Set(user_id), + role_id: Set(r.id), + tenant_id: Set(tenant_id), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(user_id), + updated_by: Set(user_id), + deleted_at: Set(None), + version: Set(1), + }; + ur.insert(db) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + tracing::info!(%user_id, role_id = %r.id, "已为新用户分配 patient 角色"); + } else { + tracing::warn!(%tenant_id, "patient 角色不存在,跳过角色分配"); + } + + Ok(()) + } + + /// 自动创建或关联 patient 记录。 + /// + /// 1. 如果已有 user_id 关联的 patient → 跳过 + /// 2. 如果手机号盲索引匹配到未绑定的已有患者 → 合并(关联 user_id) + /// 3. 否则 → 创建新的 patient 记录 + async fn ensure_patient_record( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + user_id: Uuid, + phone: &str, + crypto: &erp_core::crypto::PiiCrypto, + ) -> AuthResult<()> { + use sea_orm::{ConnectionTrait, Statement}; + + // 使用 raw SQL 避免跨 crate 依赖 erp-health 的 entity + let result: Option = db + .query_one(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "SELECT id FROM patient WHERE user_id = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1", + [user_id.into(), tenant_id.into()], + )) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + + if result.is_some() { + tracing::debug!(%user_id, "patient 记录已存在,跳过创建"); + return Ok(()); + } + + // 智能合并:用手机号盲索引查找未绑定的已有患者(管理员/护士建档) + let phone_hash = erp_core::crypto::hmac_hash(crypto.hmac_key(), phone); + let blind_match: Option = db + .query_one(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + r#"SELECT bi.entity_id AS patient_id + FROM blind_index bi + JOIN patient p ON p.id = bi.entity_id AND p.tenant_id = $2 AND p.deleted_at IS NULL + WHERE bi.entity_type = 'patient' + AND bi.field_name = 'emergency_contact_phone' + AND bi.blind_hash = $1 + AND bi.tenant_id = $2 + AND p.user_id IS NULL + LIMIT 1"#, + [phone_hash.as_str().into(), tenant_id.into()], + )) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + + if let Some(row) = blind_match { + let patient_id: Uuid = row + .try_get("", "patient_id") + .map_err(|e| AuthError::DbError(format!("blind_index parse: {}", e)))?; + db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "UPDATE patient SET user_id = $1, updated_at = NOW(), updated_by = $1 WHERE id = $2 AND user_id IS NULL", + [user_id.into(), patient_id.into()], + )) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + tracing::info!(%user_id, %patient_id, "手机号盲索引合并 patient"); + return Ok(()); + } + + let suffix = &phone[phone.len().saturating_sub(4)..]; + let patient_id = Uuid::now_v7(); + let now = Utc::now(); + + db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + r#"INSERT INTO patient (id, tenant_id, user_id, name, gender, status, verification_status, source, created_at, updated_at, created_by, updated_by, deleted_at, version) + VALUES ($1, $2, $3, $4, NULL, 'active', 'pending', 'wechat_miniprogram', $5, $5, $3, $3, NULL, 1) + ON CONFLICT DO NOTHING"#, + [ + patient_id.into(), + tenant_id.into(), + user_id.into(), + sanitize_string(&format!("微信用户{}", suffix)).into(), + now.into(), + ], + )) + .await + .map_err(|e| AuthError::DbError(e.to_string()))?; + + tracing::info!(%user_id, %patient_id, "已自动创建 patient 记录"); + Ok(()) + } + + async fn store_session_key_redis( + redis: &Option, + openid: &str, + session_key: &str, + ) -> AuthResult<()> { + let client = redis + .as_ref() + .ok_or_else(|| AuthError::DbError("Redis 未配置".into()))?; + let mut conn = client + .get_multiplexed_async_connection() + .await + .map_err(|e| AuthError::DbError(format!("Redis 连接失败: {e}")))?; + let key = format!("{}{}", REDIS_KEY_PREFIX, openid); + redis::cmd("SET") + .arg(&key) + .arg(session_key) + .arg("EX") + .arg(SESSION_TTL_SECS) + .query_async::(&mut conn) + .await + .map_err(|e| AuthError::DbError(format!("Redis SET 失败: {e}")))?; + Ok(()) + } + + async fn get_session_key(redis: &Option, openid: &str) -> AuthResult { + // 1. 尝试 Redis + if let Some(client) = redis + && let Ok(mut conn) = client.get_multiplexed_async_connection().await + { + let key = format!("{}{}", REDIS_KEY_PREFIX, openid); + let result: Option = redis::cmd("GETDEL") + .arg(&key) + .query_async::>(&mut conn) + .await + .unwrap_or(None); + if let Some(sk) = result { + return Ok(sk); + } + } + + // 2. 降级到内存 + let mut cache = MEMORY_FALLBACK.lock().await; + if let Some(entry) = cache.get(openid) { + if entry.created_at.elapsed().as_secs() < SESSION_TTL_SECS { + let sk = entry.session_key.clone(); + cache.remove(openid); + return Ok(sk); + } + cache.remove(openid); + } + + Err(AuthError::Validation( + "未找到 session_key,请重新登录".to_string(), + )) + } +} + +/// AES-128-CBC 解密微信手机号 +fn decrypt_phone_number(session_key: &str, encrypted_data: &str, iv: &str) -> AuthResult { + let engine = base64::engine::general_purpose::STANDARD; + + let key_bytes = engine + .decode(session_key) + .map_err(|e| AuthError::Validation(format!("session_key base64 解码失败: {}", e)))?; + let iv_bytes = engine + .decode(iv) + .map_err(|e| AuthError::Validation(format!("iv base64 解码失败: {}", e)))?; + let ciphertext = engine + .decode(encrypted_data) + .map_err(|e| AuthError::Validation(format!("encrypted_data base64 解码失败: {}", e)))?; + + if key_bytes.len() != 16 { + return Err(AuthError::Validation("session_key 长度不正确".to_string())); + } + if iv_bytes.len() != 16 { + return Err(AuthError::Validation("iv 长度不正确".to_string())); + } + + let decryptor = Aes128CbcDec::new_from_slices(&key_bytes, &iv_bytes) + .map_err(|e| AuthError::Validation(format!("AES 初始化失败: {}", e)))?; + + let mut buf = ciphertext; + let decrypted = decryptor + .decrypt_padded_mut::(&mut buf) + .map_err(|e| AuthError::Validation(format!("AES 解密失败: {}", e)))?; + + let plaintext = String::from_utf8(decrypted.to_vec()) + .map_err(|_| AuthError::Validation("解密结果非 UTF-8".to_string()))?; + + // 微信返回的 JSON 包含 watermark 等字段,提取 phone_number + let info: serde_json::Value = serde_json::from_str(&plaintext) + .map_err(|e| AuthError::Validation(format!("解密结果 JSON 解析失败: {}", e)))?; + + info.get("phoneNumber") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| AuthError::Validation("解密结果中无 phoneNumber".to_string())) +} + +async fn build_login_resp( + db: &sea_orm::DatabaseConnection, + user_id: Uuid, + tenant_id: Uuid, + jwt: &JwtConfig<'_>, +) -> AuthResult { + use crate::entity::user; + use crate::service::auth_service::AuthService; + + let user_model = user::Entity::find_by_id(user_id) + .one(db) + .await + .map_err(|e| AuthError::DbError(e.to_string()))? + .ok_or_else(|| AuthError::Validation("用户不存在".to_string()))?; + + let roles = TokenService::get_user_roles(user_id, tenant_id, db).await?; + let permissions = TokenService::get_user_permissions(user_id, tenant_id, db).await?; + + let access_token = TokenService::sign_access_token( + user_id, + tenant_id, + roles.clone(), + permissions, + jwt.secret, + jwt.access_ttl_secs, + )?; + let (refresh_token, _) = + TokenService::sign_refresh_token(user_id, tenant_id, db, jwt.secret, jwt.refresh_ttl_secs) + .await?; + + let role_resps = AuthService::get_user_role_resps(user_id, tenant_id, db).await?; + + Ok(LoginResp { + access_token, + refresh_token, + expires_in: jwt.access_ttl_secs as u64, + user: UserResp { + id: user_model.id, + username: user_model.username, + email: user_model.email, + phone: user_model.phone, + display_name: user_model.display_name, + avatar_url: user_model.avatar_url, + status: user_model.status, + roles: role_resps, + version: user_model.version, + }, + }) +} + +async fn fetch_session( + appid: &str, + secret: &str, + code: &str, + dev_mode: bool, +) -> AuthResult { + // 开发模式降级:跳过 jscode2session,为 DevTools 模拟器生成确定性 mock openid + if dev_mode { + let mock_openid = format!("dev_mock_{}", &code[..8.min(code.len())]); + tracing::warn!(%mock_openid, "开发模式:使用 mock openid(跳过 jscode2session)"); + return Ok(WechatSessionResp { + openid: Some(mock_openid), + session_key: Some("dev_mock_session_key".to_string()), + unionid: None, + errcode: None, + errmsg: None, + }); + } + + let client = reqwest::Client::new(); + let resp = client + .get("https://api.weixin.qq.com/sns/jscode2session") + .query(&[ + ("appid", appid), + ("secret", secret), + ("js_code", code), + ("grant_type", "authorization_code"), + ]) + .send() + .await + .map_err(|e| AuthError::Validation(format!("微信 API 请求失败: {}", e)))?; + + let session: WechatSessionResp = resp + .json() + .await + .map_err(|e| AuthError::Validation(format!("微信 API 响应解析失败: {}", e)))?; + + if let Some(errcode) = session.errcode + && errcode != 0 + { + let msg = session.errmsg.clone().unwrap_or_default(); + tracing::error!(errcode, errmsg = %msg, "微信 jscode2session 返回错误"); + return Err(AuthError::Validation(format!( + "微信登录失败 ({}): {}", + errcode, msg + ))); + } + + tracing::info!( + has_openid = session.openid.is_some(), + has_session_key = session.session_key.is_some(), + "微信 jscode2session 成功" + ); + + Ok(session) +} diff --git a/crates/erp-config/Cargo.toml b/crates/erp-config/Cargo.toml new file mode 100644 index 0000000..aa3c722 --- /dev/null +++ b/crates/erp-config/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "erp-config" +version.workspace = true +edition.workspace = true + +[dependencies] +erp-core.workspace = true +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +axum.workspace = true +sea-orm.workspace = true +tracing.workspace = true +anyhow.workspace = true +thiserror.workspace = true +validator.workspace = true +utoipa.workspace = true +async-trait.workspace = true diff --git a/crates/erp-config/src/config_state.rs b/crates/erp-config/src/config_state.rs new file mode 100644 index 0000000..14d99e7 --- /dev/null +++ b/crates/erp-config/src/config_state.rs @@ -0,0 +1,11 @@ +use erp_core::events::EventBus; +use sea_orm::DatabaseConnection; + +/// Config-specific state extracted from the server's AppState via `FromRef`. +/// +/// Contains the database connection and event bus needed by config handlers. +#[derive(Clone)] +pub struct ConfigState { + pub db: DatabaseConnection, + pub event_bus: EventBus, +} diff --git a/crates/erp-config/src/dto.rs b/crates/erp-config/src/dto.rs new file mode 100644 index 0000000..9164435 --- /dev/null +++ b/crates/erp-config/src/dto.rs @@ -0,0 +1,693 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; +use validator::Validate; + +// --- Dictionary DTOs --- + +#[derive(Debug, Serialize, ToSchema)] +pub struct DictionaryItemResp { + pub id: Uuid, + pub dictionary_id: Uuid, + pub label: String, + pub value: String, + pub sort_order: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + pub version: i32, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct DictionaryResp { + pub id: Uuid, + pub name: String, + pub code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub items: Vec, + pub version: i32, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct CreateDictionaryReq { + #[validate(length(min = 1, max = 100, message = "字典名称不能为空"))] + pub name: String, + #[validate(length(min = 1, max = 50, message = "字典编码不能为空"))] + pub code: String, + pub description: Option, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct UpdateDictionaryReq { + #[validate(length(min = 1, max = 100, message = "字典名称不能为空且不超过100字符"))] + pub name: Option, + pub description: Option, + pub version: i32, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct CreateDictionaryItemReq { + #[validate(length(min = 1, max = 100, message = "标签不能为空"))] + pub label: String, + #[validate(length(min = 1, max = 100, message = "值不能为空"))] + pub value: String, + pub sort_order: Option, + pub color: Option, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct UpdateDictionaryItemReq { + #[validate(length(min = 1, max = 100, message = "标签不能为空且不超过100字符"))] + pub label: Option, + #[validate(length(min = 1, max = 100, message = "值不能为空且不超过100字符"))] + pub value: Option, + pub sort_order: Option, + pub color: Option, + pub version: i32, +} + +// --- Menu DTOs --- + +#[derive(Debug, Serialize, ToSchema, Clone)] +pub struct MenuResp { + pub id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + pub sort_order: i32, + pub visible: bool, + pub menu_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission: Option, + pub children: Vec, + pub version: i32, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct CreateMenuReq { + pub parent_id: Option, + #[validate(length(min = 1, max = 100, message = "菜单标题不能为空"))] + pub title: String, + pub path: Option, + pub icon: Option, + pub sort_order: Option, + pub visible: Option, + #[validate(length(min = 1, message = "菜单类型不能为空"))] + pub menu_type: Option, + pub permission: Option, + pub role_ids: Option>, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct UpdateMenuReq { + #[validate(length(min = 1, max = 100, message = "菜单标题不能为空且不超过100字符"))] + pub title: Option, + pub path: Option, + pub icon: Option, + pub sort_order: Option, + pub visible: Option, + pub permission: Option, + pub role_ids: Option>, + pub version: i32, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct BatchSaveMenusReq { + #[validate(length(min = 1, message = "菜单列表不能为空"), nested)] + pub menus: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Validate, ToSchema)] +pub struct MenuItemReq { + pub id: Option, + pub parent_id: Option, + #[validate(length(min = 1, max = 100, message = "菜单标题不能为空"))] + pub title: String, + pub path: Option, + pub icon: Option, + pub sort_order: Option, + pub visible: Option, + pub menu_type: Option, + pub permission: Option, + pub role_ids: Option>, + /// 乐观锁版本号。更新已有菜单时必填。 + pub version: Option, +} + +// --- Setting DTOs --- + +#[derive(Debug, Serialize, ToSchema)] +pub struct SettingResp { + pub id: Uuid, + pub scope: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope_id: Option, + pub setting_key: String, + pub setting_value: serde_json::Value, + pub version: i32, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct UpdateSettingReq { + pub setting_value: serde_json::Value, + /// 乐观锁版本号。更新已有设置时必填,创建新设置时忽略。 + pub version: Option, +} + +/// 内部参数结构体,用于减少 SettingService::set 的参数数量。 +pub struct SetSettingParams { + pub key: String, + pub scope: String, + pub scope_id: Option, + pub value: serde_json::Value, + /// 乐观锁版本号。更新已有设置时用于校验。 + pub version: Option, +} + +// --- Numbering Rule DTOs --- + +#[derive(Debug, Serialize, ToSchema)] +pub struct NumberingRuleResp { + pub id: Uuid, + pub name: String, + pub code: String, + pub prefix: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub date_format: Option, + pub seq_length: i32, + pub seq_start: i32, + pub seq_current: i64, + pub separator: String, + pub reset_cycle: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_reset_date: Option, + pub version: i32, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct CreateNumberingRuleReq { + #[validate(length(min = 1, max = 100, message = "规则名称不能为空"))] + pub name: String, + #[validate(length(min = 1, max = 50, message = "规则编码不能为空"))] + pub code: String, + pub prefix: Option, + pub date_format: Option, + pub seq_length: Option, + pub seq_start: Option, + pub separator: Option, + pub reset_cycle: Option, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct UpdateNumberingRuleReq { + #[validate(length(min = 1, max = 100, message = "规则名称不能为空且不超过100字符"))] + pub name: Option, + pub prefix: Option, + pub date_format: Option, + pub seq_length: Option, + pub separator: Option, + pub reset_cycle: Option, + pub version: i32, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct GenerateNumberResp { + pub number: String, +} + +// --- Theme DTOs (stored via settings) --- + +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] +pub struct ThemeResp { + #[serde(skip_serializing_if = "Option::is_none")] + pub primary_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub logo_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sidebar_style: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub brand_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub brand_slogan: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub brand_features: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub brand_copyright: Option, +} + +/// 品牌信息公开响应(不含内部配置) +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] +pub struct PublicBrandResp { + pub brand_name: String, + pub brand_slogan: String, + pub brand_features: String, + pub brand_copyright: String, +} + +// --- Language DTOs (stored via settings) --- + +#[derive(Debug, Serialize, ToSchema)] +pub struct LanguageResp { + pub code: String, + pub name: String, + pub is_active: bool, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct UpdateLanguageReq { + pub is_active: bool, + #[validate(length(min = 1, max = 100, message = "语言名称不能为空且不超过100字符"))] + pub name: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use validator::Validate; + + // ---- CreateDictionaryReq 验证 ---- + + #[test] + fn create_dictionary_req_valid() { + let req = CreateDictionaryReq { + name: "状态字典".to_string(), + code: "status".to_string(), + description: Some("通用状态".to_string()), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_dictionary_req_empty_name_fails() { + let req = CreateDictionaryReq { + name: "".to_string(), + code: "status".to_string(), + description: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dictionary_req_empty_code_fails() { + let req = CreateDictionaryReq { + name: "状态字典".to_string(), + code: "".to_string(), + description: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dictionary_req_name_too_long_fails() { + let req = CreateDictionaryReq { + name: "x".repeat(101), + code: "status".to_string(), + description: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dictionary_req_code_too_long_fails() { + let req = CreateDictionaryReq { + name: "状态字典".to_string(), + code: "x".repeat(51), + description: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dictionary_req_max_boundary_ok() { + let req = CreateDictionaryReq { + name: "x".repeat(100), + code: "x".repeat(50), + description: None, + }; + assert!(req.validate().is_ok()); + } + + // ---- CreateDictionaryItemReq 验证 ---- + + #[test] + fn create_dictionary_item_req_valid() { + let req = CreateDictionaryItemReq { + label: "启用".to_string(), + value: "active".to_string(), + sort_order: Some(1), + color: Some("#00FF00".to_string()), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_dictionary_item_req_empty_label_fails() { + let req = CreateDictionaryItemReq { + label: "".to_string(), + value: "active".to_string(), + sort_order: None, + color: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dictionary_item_req_empty_value_fails() { + let req = CreateDictionaryItemReq { + label: "启用".to_string(), + value: "".to_string(), + sort_order: None, + color: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dictionary_item_req_label_too_long_fails() { + let req = CreateDictionaryItemReq { + label: "x".repeat(101), + value: "active".to_string(), + sort_order: None, + color: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dictionary_item_req_value_too_long_fails() { + let req = CreateDictionaryItemReq { + label: "启用".to_string(), + value: "x".repeat(101), + sort_order: None, + color: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_dictionary_item_req_min_boundary_ok() { + let req = CreateDictionaryItemReq { + label: "x".to_string(), + value: "x".to_string(), + sort_order: None, + color: None, + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_dictionary_item_req_max_boundary_ok() { + let req = CreateDictionaryItemReq { + label: "x".repeat(100), + value: "x".repeat(100), + sort_order: Some(99), + color: Some("#FFFFFF".to_string()), + }; + assert!(req.validate().is_ok()); + } + + // ---- CreateMenuReq 验证 ---- + + #[test] + fn create_menu_req_valid() { + let req = CreateMenuReq { + parent_id: None, + title: "系统设置".to_string(), + path: Some("/settings".to_string()), + icon: Some("SettingOutlined".to_string()), + sort_order: Some(1), + visible: Some(true), + menu_type: Some("menu".to_string()), + permission: None, + role_ids: None, + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_menu_req_empty_title_fails() { + let req = CreateMenuReq { + parent_id: None, + title: "".to_string(), + path: None, + icon: None, + sort_order: None, + visible: None, + menu_type: None, + permission: None, + role_ids: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_menu_req_title_too_long_fails() { + let req = CreateMenuReq { + parent_id: None, + title: "x".repeat(101), + path: None, + icon: None, + sort_order: None, + visible: None, + menu_type: None, + permission: None, + role_ids: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_menu_req_title_max_boundary_ok() { + let req = CreateMenuReq { + parent_id: None, + title: "x".repeat(100), + path: None, + icon: None, + sort_order: None, + visible: None, + menu_type: None, + permission: None, + role_ids: None, + }; + assert!(req.validate().is_ok()); + } + + // ---- BatchSaveMenusReq 验证 ---- + + #[test] + fn batch_save_menus_req_valid() { + let req = BatchSaveMenusReq { + menus: vec![MenuItemReq { + id: None, + parent_id: None, + title: "首页".to_string(), + path: Some("/home".to_string()), + icon: None, + sort_order: Some(0), + visible: Some(true), + menu_type: Some("menu".to_string()), + permission: None, + role_ids: None, + version: None, + }], + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn batch_save_menus_req_empty_list_fails() { + let req = BatchSaveMenusReq { menus: vec![] }; + assert!(req.validate().is_err()); + } + + #[test] + fn batch_save_menus_req_item_empty_title_fails() { + let req = BatchSaveMenusReq { + menus: vec![MenuItemReq { + id: None, + parent_id: None, + title: "".to_string(), + path: None, + icon: None, + sort_order: None, + visible: None, + menu_type: None, + permission: None, + role_ids: None, + version: None, + }], + }; + assert!(req.validate().is_err()); + } + + #[test] + fn batch_save_menus_req_item_title_too_long_fails() { + let req = BatchSaveMenusReq { + menus: vec![MenuItemReq { + id: None, + parent_id: None, + title: "x".repeat(101), + path: None, + icon: None, + sort_order: None, + visible: None, + menu_type: None, + permission: None, + role_ids: None, + version: None, + }], + }; + assert!(req.validate().is_err()); + } + + #[test] + fn batch_save_menus_req_multiple_items_ok() { + let req = BatchSaveMenusReq { + menus: vec![ + MenuItemReq { + id: None, + parent_id: None, + title: "菜单A".to_string(), + path: Some("/a".to_string()), + icon: None, + sort_order: Some(0), + visible: Some(true), + menu_type: Some("menu".to_string()), + permission: None, + role_ids: None, + version: None, + }, + MenuItemReq { + id: None, + parent_id: None, + title: "菜单B".to_string(), + path: Some("/b".to_string()), + icon: None, + sort_order: Some(1), + visible: Some(true), + menu_type: Some("menu".to_string()), + permission: None, + role_ids: None, + version: Some(1), + }, + ], + }; + assert!(req.validate().is_ok()); + } + + // ---- CreateNumberingRuleReq 验证 ---- + + #[test] + fn create_numbering_rule_req_valid() { + let req = CreateNumberingRuleReq { + name: "订单编号".to_string(), + code: "ORDER".to_string(), + prefix: Some("ORD".to_string()), + date_format: Some("%Y%m%d".to_string()), + seq_length: Some(4), + seq_start: Some(1), + separator: Some("-".to_string()), + reset_cycle: Some("daily".to_string()), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn create_numbering_rule_req_empty_name_fails() { + let req = CreateNumberingRuleReq { + name: "".to_string(), + code: "ORDER".to_string(), + prefix: None, + date_format: None, + seq_length: None, + seq_start: None, + separator: None, + reset_cycle: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_numbering_rule_req_empty_code_fails() { + let req = CreateNumberingRuleReq { + name: "订单编号".to_string(), + code: "".to_string(), + prefix: None, + date_format: None, + seq_length: None, + seq_start: None, + separator: None, + reset_cycle: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_numbering_rule_req_name_too_long_fails() { + let req = CreateNumberingRuleReq { + name: "x".repeat(101), + code: "ORDER".to_string(), + prefix: None, + date_format: None, + seq_length: None, + seq_start: None, + separator: None, + reset_cycle: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_numbering_rule_req_code_too_long_fails() { + let req = CreateNumberingRuleReq { + name: "订单编号".to_string(), + code: "x".repeat(51), + prefix: None, + date_format: None, + seq_length: None, + seq_start: None, + separator: None, + reset_cycle: None, + }; + assert!(req.validate().is_err()); + } + + #[test] + fn create_numbering_rule_req_max_boundary_ok() { + let req = CreateNumberingRuleReq { + name: "x".repeat(100), + code: "x".repeat(50), + prefix: None, + date_format: None, + seq_length: None, + seq_start: None, + separator: None, + reset_cycle: None, + }; + assert!(req.validate().is_ok()); + } + + // ---- UpdateSettingReq 验证 ---- + + #[test] + fn update_setting_req_valid() { + let req = UpdateSettingReq { + setting_value: serde_json::json!({"key": "value"}), + version: Some(1), + }; + assert!(req.validate().is_ok()); + } + + #[test] + fn update_setting_req_without_version_ok() { + let req = UpdateSettingReq { + setting_value: serde_json::json!("hello"), + version: None, + }; + assert!(req.validate().is_ok()); + } +} diff --git a/crates/erp-config/src/entity/dictionary.rs b/crates/erp-config/src/entity/dictionary.rs new file mode 100644 index 0000000..a257447 --- /dev/null +++ b/crates/erp-config/src/entity/dictionary.rs @@ -0,0 +1,35 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "dictionaries")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub name: String, + pub code: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::dictionary_item::Entity")] + DictionaryItem, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::DictionaryItem.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-config/src/entity/dictionary_item.rs b/crates/erp-config/src/entity/dictionary_item.rs new file mode 100644 index 0000000..0d39a7b --- /dev/null +++ b/crates/erp-config/src/entity/dictionary_item.rs @@ -0,0 +1,42 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "dictionary_items")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub dictionary_id: Uuid, + pub label: String, + pub value: String, + pub sort_order: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub color: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::dictionary::Entity", + from = "Column::DictionaryId", + to = "super::dictionary::Column::Id", + on_delete = "Cascade" + )] + Dictionary, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Dictionary.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-config/src/entity/menu.rs b/crates/erp-config/src/entity/menu.rs new file mode 100644 index 0000000..4b35d1b --- /dev/null +++ b/crates/erp-config/src/entity/menu.rs @@ -0,0 +1,43 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "menus")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub icon: Option, + pub sort_order: i32, + pub visible: bool, + pub menu_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub permission: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::menu_role::Entity")] + MenuRole, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MenuRole.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-config/src/entity/menu_role.rs b/crates/erp-config/src/entity/menu_role.rs new file mode 100644 index 0000000..f50368a --- /dev/null +++ b/crates/erp-config/src/entity/menu_role.rs @@ -0,0 +1,38 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "menu_roles")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub menu_id: Uuid, + pub role_id: Uuid, + pub tenant_id: Uuid, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::menu::Entity", + from = "Column::MenuId", + to = "super::menu::Column::Id", + on_delete = "Cascade" + )] + Menu, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Menu.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-config/src/entity/mod.rs b/crates/erp-config/src/entity/mod.rs new file mode 100644 index 0000000..909af4a --- /dev/null +++ b/crates/erp-config/src/entity/mod.rs @@ -0,0 +1,6 @@ +pub mod dictionary; +pub mod dictionary_item; +pub mod menu; +pub mod menu_role; +pub mod numbering_rule; +pub mod setting; diff --git a/crates/erp-config/src/entity/numbering_rule.rs b/crates/erp-config/src/entity/numbering_rule.rs new file mode 100644 index 0000000..a12da51 --- /dev/null +++ b/crates/erp-config/src/entity/numbering_rule.rs @@ -0,0 +1,34 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "numbering_rules")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub name: String, + pub code: String, + pub prefix: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub date_format: Option, + pub seq_length: i32, + pub seq_start: i32, + pub seq_current: i64, + pub separator: String, + pub reset_cycle: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_reset_date: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-config/src/entity/setting.rs b/crates/erp-config/src/entity/setting.rs new file mode 100644 index 0000000..d9f486c --- /dev/null +++ b/crates/erp-config/src/entity/setting.rs @@ -0,0 +1,27 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "settings")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub scope: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope_id: Option, + pub setting_key: String, + pub setting_value: serde_json::Value, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-config/src/error.rs b/crates/erp-config/src/error.rs new file mode 100644 index 0000000..01c7db2 --- /dev/null +++ b/crates/erp-config/src/error.rs @@ -0,0 +1,143 @@ +use erp_core::error::AppError; + +/// Config module error types. +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("验证失败: {0}")] + Validation(String), + + #[error("资源未找到: {0}")] + NotFound(String), + + #[error("键已存在: {0}")] + DuplicateKey(String), + + #[error("编号序列耗尽: {0}")] + NumberingExhausted(String), + + #[error("版本冲突: 数据已被其他操作修改,请刷新后重试")] + VersionMismatch, +} + +impl From> for ConfigError { + fn from(err: sea_orm::TransactionError) -> Self { + match err { + sea_orm::TransactionError::Connection(err) => ConfigError::Validation(err.to_string()), + sea_orm::TransactionError::Transaction(inner) => inner, + } + } +} + +impl From for ConfigError { + fn from(err: sea_orm::DbErr) -> Self { + ConfigError::Validation(err.to_string()) + } +} + +impl From for AppError { + fn from(err: ConfigError) -> Self { + match err { + ConfigError::Validation(s) => AppError::Validation(s), + ConfigError::NotFound(s) => AppError::NotFound(s), + ConfigError::DuplicateKey(s) => AppError::Conflict(s), + ConfigError::NumberingExhausted(s) => AppError::Internal(s), + ConfigError::VersionMismatch => AppError::VersionMismatch, + } + } +} + +pub type ConfigResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + use erp_core::error::AppError; + + #[test] + fn config_error_validation_maps_to_app_validation() { + let app: AppError = ConfigError::Validation("字段不能为空".to_string()).into(); + match app { + AppError::Validation(msg) => assert_eq!(msg, "字段不能为空"), + other => panic!("期望 Validation,实际得到 {:?}", other), + } + } + + #[test] + fn config_error_not_found_maps_to_app_not_found() { + let app: AppError = ConfigError::NotFound("字典不存在".to_string()).into(); + match app { + AppError::NotFound(msg) => assert_eq!(msg, "字典不存在"), + other => panic!("期望 NotFound,实际得到 {:?}", other), + } + } + + #[test] + fn config_error_duplicate_key_maps_to_app_conflict() { + let app: AppError = ConfigError::DuplicateKey("编码已存在".to_string()).into(); + match app { + AppError::Conflict(msg) => assert_eq!(msg, "编码已存在"), + other => panic!("期望 Conflict,实际得到 {:?}", other), + } + } + + #[test] + fn config_error_numbering_exhausted_maps_to_app_internal() { + let app: AppError = ConfigError::NumberingExhausted("序列已耗尽".to_string()).into(); + match app { + AppError::Internal(msg) => assert!(msg.contains("序列已耗尽")), + other => panic!("期望 Internal,实际得到 {:?}", other), + } + } + + #[test] + fn config_error_version_mismatch_maps_to_app_version_mismatch() { + let app: AppError = ConfigError::VersionMismatch.into(); + match app { + AppError::VersionMismatch => {} + other => panic!("期望 VersionMismatch,实际得到 {:?}", other), + } + } + + #[test] + fn config_error_display_messages() { + // 验证各变体的 Display 输出包含中文描述 + assert!( + ConfigError::Validation("test".into()) + .to_string() + .contains("验证失败") + ); + assert!( + ConfigError::NotFound("test".into()) + .to_string() + .contains("资源未找到") + ); + assert!( + ConfigError::DuplicateKey("test".into()) + .to_string() + .contains("键已存在") + ); + assert!( + ConfigError::NumberingExhausted("test".into()) + .to_string() + .contains("编号序列耗尽") + ); + assert!( + ConfigError::VersionMismatch + .to_string() + .contains("版本冲突") + ); + } + + #[test] + fn transaction_error_connection_maps_to_validation() { + // TransactionError::Connection 应该转换为 ConfigError::Validation + let config_err: ConfigError = sea_orm::TransactionError::Connection(sea_orm::DbErr::Conn( + sea_orm::RuntimeErr::Internal("连接失败".to_string()), + )) + .into(); + match config_err { + ConfigError::Validation(msg) => assert!(msg.contains("连接失败")), + other => panic!("期望 Validation,实际得到 {:?}", other), + } + } +} diff --git a/crates/erp-config/src/handler/dictionary_handler.rs b/crates/erp-config/src/handler/dictionary_handler.rs new file mode 100644 index 0000000..f1891d7 --- /dev/null +++ b/crates/erp-config/src/handler/dictionary_handler.rs @@ -0,0 +1,360 @@ +use axum::Extension; +use axum::extract::{FromRef, Path, Query, State}; +use axum::response::Json; +use validator::Validate; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext}; +use uuid::Uuid; + +use crate::config_state::ConfigState; +use crate::dto::{ + CreateDictionaryItemReq, CreateDictionaryReq, DictionaryItemResp, DictionaryResp, + UpdateDictionaryItemReq, UpdateDictionaryReq, +}; +use crate::service::dictionary_service::DictionaryService; + +#[utoipa::path( + get, + path = "/api/v1/dictionaries", + params(Pagination), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "字典管理" +)] +/// GET /api/v1/dictionaries +/// +/// 分页查询当前租户下的字典列表。 +/// 每个字典包含其关联的字典项。 +/// 需要 `dictionary.list` 权限。 +pub async fn list_dictionaries( + State(state): State, + Extension(ctx): Extension, + Query(pagination): Query, +) -> Result>>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "dictionary.list")?; + + let (dictionaries, total) = + DictionaryService::list(ctx.tenant_id, &pagination, &state.db).await?; + + let page = pagination.page.unwrap_or(1); + let page_size = pagination.limit(); + let total_pages = total.div_ceil(page_size); + + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: dictionaries, + total, + page, + page_size, + total_pages, + }))) +} + +#[utoipa::path( + post, + path = "/api/v1/dictionaries", + request_body = CreateDictionaryReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "字典管理" +)] +/// POST /api/v1/dictionaries +/// +/// 在当前租户下创建新字典。 +/// 字典编码在租户内必须唯一。 +/// 需要 `dictionary.create` 权限。 +pub async fn create_dictionary( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "dictionary.create")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let dictionary = DictionaryService::create( + ctx.tenant_id, + ctx.user_id, + &req.name, + &req.code, + &req.description, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(dictionary))) +} + +#[utoipa::path( + put, + path = "/api/v1/dictionaries/{id}", + params(("id" = Uuid, Path, description = "字典ID")), + request_body = UpdateDictionaryReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "字典管理" +)] +/// PUT /api/v1/dictionaries/:id +/// +/// 更新字典的可编辑字段(名称、描述)。 +/// 编码创建后不可更改。 +/// 需要 `dictionary.update` 权限。 +pub async fn update_dictionary( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "dictionary.update")?; + + let dictionary = + DictionaryService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?; + + Ok(Json(ApiResponse::ok(dictionary))) +} + +#[utoipa::path( + delete, + path = "/api/v1/dictionaries/{id}", + params(("id" = Uuid, Path, description = "字典ID")), + request_body = DeleteVersionReq, + responses( + (status = 200, description = "成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "字典管理" +)] +/// DELETE /api/v1/dictionaries/:id +/// +/// 软删除字典,设置 deleted_at 时间戳。 +/// 需要请求体包含 version 字段用于乐观锁校验。 +/// 需要 `dictionary.delete` 权限。 +pub async fn delete_dictionary( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "dictionary.delete")?; + + DictionaryService::delete( + id, + ctx.tenant_id, + ctx.user_id, + req.version, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("字典已删除".to_string()), + })) +} + +#[utoipa::path( + get, + path = "/api/v1/dictionaries/items-by-code", + params(("code" = String, Query, description = "字典编码")), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "字典管理" +)] +/// GET /api/v1/dictionaries/items-by-code?code=xxx +/// +/// 根据字典编码查询所有字典项。 +/// 用于前端下拉框和枚举值查找。 +/// 需要 `dictionary.list` 权限。 +pub async fn list_items_by_code( + State(state): State, + Extension(ctx): Extension, + Query(query): Query, +) -> Result>>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "dictionary.list")?; + + let items = + DictionaryService::list_items_by_code(&query.code, ctx.tenant_id, &state.db).await?; + + Ok(Json(ApiResponse::ok(items))) +} + +#[utoipa::path( + post, + path = "/api/v1/dictionaries/{dict_id}/items", + params(("dict_id" = Uuid, Path, description = "字典ID")), + request_body = CreateDictionaryItemReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "字典管理" +)] +/// POST /api/v1/dictionaries/:dict_id/items +/// +/// 向指定字典添加新的字典项。 +/// 字典项的 value 在同一字典内必须唯一。 +/// 需要 `dictionary.create` 权限。 +pub async fn create_item( + State(state): State, + Extension(ctx): Extension, + Path(dict_id): Path, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "dictionary.create")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let item = + DictionaryService::add_item(dict_id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?; + + Ok(Json(ApiResponse::ok(item))) +} + +#[utoipa::path( + put, + path = "/api/v1/dictionaries/{dict_id}/items/{item_id}", + params( + ("dict_id" = Uuid, Path, description = "字典ID"), + ("item_id" = Uuid, Path, description = "字典项ID"), + ), + request_body = UpdateDictionaryItemReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "字典管理" +)] +/// PUT /api/v1/dictionaries/:dict_id/items/:item_id +/// +/// 更新字典项的可编辑字段(label、value、sort_order、color)。 +/// 需要 `dictionary.update` 权限。 +pub async fn update_item( + State(state): State, + Extension(ctx): Extension, + Path((dict_id, item_id)): Path<(Uuid, Uuid)>, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "dictionary.update")?; + + // 验证 item_id 属于 dict_id + let item = DictionaryService::update_item(item_id, ctx.tenant_id, ctx.user_id, &req, &state.db) + .await?; + + // 确保 item 属于指定的 dictionary + if item.dictionary_id != dict_id { + return Err(AppError::Validation("字典项不属于指定的字典".to_string())); + } + + Ok(Json(ApiResponse::ok(item))) +} + +#[utoipa::path( + delete, + path = "/api/v1/dictionaries/{dict_id}/items/{item_id}", + params( + ("dict_id" = Uuid, Path, description = "字典ID"), + ("item_id" = Uuid, Path, description = "字典项ID"), + ), + request_body = DeleteVersionReq, + responses( + (status = 200, description = "成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "字典管理" +)] +/// DELETE /api/v1/dictionaries/:dict_id/items/:item_id +/// +/// 软删除字典项,设置 deleted_at 时间戳。 +/// 需要请求体包含 version 字段用于乐观锁校验。 +/// 需要 `dictionary.delete` 权限。 +pub async fn delete_item( + State(state): State, + Extension(ctx): Extension, + Path((_dict_id, item_id)): Path<(Uuid, Uuid)>, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "dictionary.delete")?; + + DictionaryService::delete_item(item_id, ctx.tenant_id, ctx.user_id, req.version, &state.db) + .await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("字典项已删除".to_string()), + })) +} + +/// 按编码查询字典项的查询参数。 +#[derive(Debug, serde::Deserialize)] +pub struct ItemsByCodeQuery { + pub code: String, +} + +/// 删除操作的乐观锁版本号。 +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct DeleteVersionReq { + pub version: i32, +} diff --git a/crates/erp-config/src/handler/language_handler.rs b/crates/erp-config/src/handler/language_handler.rs new file mode 100644 index 0000000..109ff51 --- /dev/null +++ b/crates/erp-config/src/handler/language_handler.rs @@ -0,0 +1,142 @@ +use axum::Extension; +use axum::extract::{FromRef, Json, Path, State}; +use axum::response::Json as JsonResponse; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, Pagination, TenantContext}; + +use crate::config_state::ConfigState; +use crate::dto::{LanguageResp, SetSettingParams, UpdateLanguageReq}; +use crate::service::setting_service::SettingService; + +#[utoipa::path( + get, + path = "/api/v1/languages", + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "语言管理" +)] +/// GET /api/v1/languages +/// +/// 获取当前租户的语言配置列表。 +/// 查询 scope 为 "platform" 的设置,过滤 key 以 "language." 开头的记录。 +/// 需要 `language.list` 权限。 +pub async fn list_languages( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "language.list")?; + + let pagination = Pagination { + page: Some(1), + page_size: Some(100), + }; + + let (settings, _total) = + SettingService::list_by_scope("platform", &None, ctx.tenant_id, &pagination, &state.db) + .await?; + + let languages: Vec = settings + .into_iter() + .filter(|s| s.setting_key.starts_with("language.")) + .filter_map(|s| { + let code = s.setting_key.strip_prefix("language.")?.to_string(); + let name = s + .setting_value + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(&code) + .to_string(); + let is_active = s + .setting_value + .get("is_active") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + Some(LanguageResp { + code, + name, + is_active, + }) + }) + .collect(); + + Ok(JsonResponse(ApiResponse::ok(languages))) +} + +#[utoipa::path( + put, + path = "/api/v1/languages/{code}", + params(("code" = String, Path, description = "语言编码")), + request_body = UpdateLanguageReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "语言管理" +)] +/// PUT /api/v1/languages/:code +/// +/// 更新指定语言配置的激活状态。 +/// 语言配置存储在 settings 表中,key 为 "language.{code}",scope 为 "platform"。 +/// 需要 `language.update` 权限。 +pub async fn update_language( + State(state): State, + Extension(ctx): Extension, + Path(code): Path, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "language.update")?; + + let key = format!("language.{}", code); + let mut value = serde_json::json!({"is_active": req.is_active}); + if let Some(ref name) = req.name { + value["name"] = serde_json::Value::String(name.clone()); + } + + SettingService::set( + SetSettingParams { + key: key.clone(), + scope: "platform".to_string(), + scope_id: None, + value, + version: None, + }, + ctx.tenant_id, + ctx.user_id, + &state.db, + &state.event_bus, + ) + .await?; + + // 从返回的 SettingResp 中读取实际值 + let updated = SettingService::get(&key, "platform", &None, ctx.tenant_id, &state.db).await?; + + // 尝试从 value 中提取 name,否则用 code 作为默认名称 + let name = updated + .setting_value + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or(&code) + .to_string(); + + Ok(JsonResponse(ApiResponse::ok(LanguageResp { + code, + name, + is_active: req.is_active, + }))) +} diff --git a/crates/erp-config/src/handler/menu_handler.rs b/crates/erp-config/src/handler/menu_handler.rs new file mode 100644 index 0000000..3fd2bbf --- /dev/null +++ b/crates/erp-config/src/handler/menu_handler.rs @@ -0,0 +1,263 @@ +use axum::Extension; +use axum::extract::{FromRef, Json, Path, State}; +use axum::response::Json as JsonResponse; +use validator::Validate; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; +use uuid::Uuid; + +use crate::config_state::ConfigState; +use crate::dto::{BatchSaveMenusReq, CreateMenuReq, MenuResp, UpdateMenuReq}; +use crate::service::menu_service::MenuService; + +#[utoipa::path( + get, + path = "/api/v1/config/menus", + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "菜单管理" +)] +/// GET /api/v1/config/menus +/// +/// 获取当前租户下当前用户角色可见的菜单树。 +pub async fn get_menus( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "menu.list")?; + + let menus = MenuService::get_menu_tree(ctx.tenant_id, &ctx.roles, &state.db).await?; + + Ok(JsonResponse(ApiResponse::ok(menus))) +} + +#[utoipa::path( + post, + path = "/api/v1/config/menus", + request_body = CreateMenuReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "菜单管理" +)] +/// POST /api/v1/config/menus +/// +/// 创建单个菜单项。 +pub async fn create_menu( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "menu.update")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let resp = MenuService::create( + ctx.tenant_id, + ctx.user_id, + &req, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(JsonResponse(ApiResponse::ok(resp))) +} + +#[utoipa::path( + put, + path = "/api/v1/config/menus/{id}", + params(("id" = Uuid, Path, description = "菜单ID")), + request_body = UpdateMenuReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "菜单管理" +)] +/// PUT /api/v1/config/menus/{id} +/// +/// 更新单个菜单项。 +pub async fn update_menu( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "menu.update")?; + + let resp = MenuService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?; + Ok(JsonResponse(ApiResponse::ok(resp))) +} + +#[utoipa::path( + delete, + path = "/api/v1/config/menus/{id}", + params(("id" = Uuid, Path, description = "菜单ID")), + request_body = DeleteMenuVersionReq, + responses( + (status = 200, description = "成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "菜单管理" +)] +/// DELETE /api/v1/config/menus/{id} +/// +/// 软删除单个菜单项。需要请求体包含 version 字段用于乐观锁校验。 +pub async fn delete_menu( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "menu.update")?; + + MenuService::delete( + id, + ctx.tenant_id, + ctx.user_id, + req.version, + &state.db, + &state.event_bus, + ) + .await?; + Ok(JsonResponse(ApiResponse::ok(()))) +} + +#[utoipa::path( + put, + path = "/api/v1/config/menus/batch", + request_body = BatchSaveMenusReq, + responses( + (status = 200, description = "成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "菜单管理" +)] +/// PUT /api/v1/config/menus/batch +/// +/// 批量保存菜单列表。 +pub async fn batch_save_menus( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "menu.update")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + for item in &req.menus { + match item.id { + Some(id) => { + let version = item.version.unwrap_or(0); + let update_req = UpdateMenuReq { + title: Some(item.title.clone()), + path: item.path.clone(), + icon: item.icon.clone(), + sort_order: item.sort_order, + visible: item.visible, + permission: item.permission.clone(), + role_ids: item.role_ids.clone(), + version, + }; + MenuService::update(id, ctx.tenant_id, ctx.user_id, &update_req, &state.db).await?; + } + None => { + let create_req = CreateMenuReq { + parent_id: item.parent_id, + title: item.title.clone(), + path: item.path.clone(), + icon: item.icon.clone(), + sort_order: item.sort_order, + visible: item.visible, + menu_type: item.menu_type.clone(), + permission: item.permission.clone(), + role_ids: item.role_ids.clone(), + }; + MenuService::create( + ctx.tenant_id, + ctx.user_id, + &create_req, + &state.db, + &state.event_bus, + ) + .await?; + } + } + } + + Ok(JsonResponse(ApiResponse { + success: true, + data: None, + message: Some("菜单批量保存成功".to_string()), + })) +} + +#[utoipa::path( + get, + path = "/api/v1/menus/user", + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + ), + security(("bearer_auth" = [])), + tag = "菜单管理" +)] +/// GET /api/v1/menus/user +/// +/// 获取当前用户可见的菜单树(无需 menu.list 权限,仅需登录)。 +pub async fn get_user_menus( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let menus = MenuService::get_menu_tree(ctx.tenant_id, &ctx.roles, &state.db).await?; + + Ok(JsonResponse(ApiResponse::ok(menus))) +} + +/// 删除菜单的乐观锁版本号请求体。 +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct DeleteMenuVersionReq { + pub version: i32, +} diff --git a/crates/erp-config/src/handler/mod.rs b/crates/erp-config/src/handler/mod.rs new file mode 100644 index 0000000..c5b0180 --- /dev/null +++ b/crates/erp-config/src/handler/mod.rs @@ -0,0 +1,6 @@ +pub mod dictionary_handler; +pub mod language_handler; +pub mod menu_handler; +pub mod numbering_handler; +pub mod setting_handler; +pub mod theme_handler; diff --git a/crates/erp-config/src/handler/numbering_handler.rs b/crates/erp-config/src/handler/numbering_handler.rs new file mode 100644 index 0000000..bb7af14 --- /dev/null +++ b/crates/erp-config/src/handler/numbering_handler.rs @@ -0,0 +1,220 @@ +use axum::Extension; +use axum::extract::{FromRef, Path, Query, State}; +use axum::response::Json; +use validator::Validate; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext}; +use uuid::Uuid; + +use crate::config_state::ConfigState; +use crate::dto::{ + CreateNumberingRuleReq, GenerateNumberResp, NumberingRuleResp, UpdateNumberingRuleReq, +}; +use crate::service::numbering_service::NumberingService; + +#[utoipa::path( + get, + path = "/api/v1/numbering-rules", + params(Pagination), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "编号规则" +)] +/// GET /api/v1/numbering-rules +/// +/// 分页查询当前租户下的编号规则列表。 +/// 需要 `numbering.list` 权限。 +pub async fn list_numbering_rules( + State(state): State, + Extension(ctx): Extension, + Query(pagination): Query, +) -> Result>>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "numbering.list")?; + + let (rules, total) = NumberingService::list(ctx.tenant_id, &pagination, &state.db).await?; + + let page = pagination.page.unwrap_or(1); + let page_size = pagination.limit(); + let total_pages = total.div_ceil(page_size); + + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: rules, + total, + page, + page_size, + total_pages, + }))) +} + +#[utoipa::path( + post, + path = "/api/v1/numbering-rules", + request_body = CreateNumberingRuleReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "编号规则" +)] +/// POST /api/v1/numbering-rules +/// +/// 创建新的编号规则。 +/// 规则编码在租户内必须唯一。 +/// 需要 `numbering.create` 权限。 +pub async fn create_numbering_rule( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "numbering.create")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let rule = NumberingService::create( + ctx.tenant_id, + ctx.user_id, + &req, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(rule))) +} + +#[utoipa::path( + put, + path = "/api/v1/numbering-rules/{id}", + params(("id" = Uuid, Path, description = "编号规则ID")), + request_body = UpdateNumberingRuleReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "编号规则" +)] +/// PUT /api/v1/numbering-rules/:id +/// +/// 更新编号规则的可编辑字段。 +/// 需要 `numbering.update` 权限。 +pub async fn update_numbering_rule( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "numbering.update")?; + + let rule = NumberingService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?; + + Ok(Json(ApiResponse::ok(rule))) +} + +#[utoipa::path( + post, + path = "/api/v1/numbering-rules/{id}/generate", + params(("id" = Uuid, Path, description = "编号规则ID")), + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "编号规则" +)] +/// POST /api/v1/numbering-rules/:id/generate +/// +/// 根据编号规则生成新的编号。 +/// 使用 PostgreSQL advisory lock 保证并发安全。 +/// 需要 `numbering.generate` 权限。 +pub async fn generate_number( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "numbering.generate")?; + + let result = NumberingService::generate_number(id, ctx.tenant_id, &state.db).await?; + + Ok(Json(ApiResponse::ok(result))) +} + +#[utoipa::path( + delete, + path = "/api/v1/numbering-rules/{id}", + params(("id" = Uuid, Path, description = "编号规则ID")), + request_body = DeleteNumberingVersionReq, + responses( + (status = 200, description = "成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "编号规则" +)] +/// DELETE /api/v1/numbering-rules/:id +/// +/// 软删除编号规则,设置 deleted_at 时间戳。 +/// 需要请求体包含 version 字段用于乐观锁校验。 +/// 需要 `numbering.delete` 权限。 +pub async fn delete_numbering_rule( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "numbering.delete")?; + + NumberingService::delete( + id, + ctx.tenant_id, + ctx.user_id, + req.version, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("编号规则已删除".to_string()), + })) +} + +/// 删除编号规则的乐观锁版本号请求体。 +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct DeleteNumberingVersionReq { + pub version: i32, +} diff --git a/crates/erp-config/src/handler/setting_handler.rs b/crates/erp-config/src/handler/setting_handler.rs new file mode 100644 index 0000000..3cccfe6 --- /dev/null +++ b/crates/erp-config/src/handler/setting_handler.rs @@ -0,0 +1,169 @@ +use axum::Extension; +use axum::extract::{FromRef, Path, Query, State}; +use axum::response::Json; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; +use uuid::Uuid; + +use crate::config_state::ConfigState; +use crate::dto::{SetSettingParams, SettingResp, UpdateSettingReq}; +use crate::service::setting_service::SettingService; + +#[utoipa::path( + get, + path = "/api/v1/settings/{key}", + params( + ("key" = String, Path, description = "设置键名"), + ("scope" = Option, Query, description = "作用域"), + ("scope_id" = Option, Query, description = "作用域ID"), + ), + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "系统设置" +)] +/// GET /api/v1/settings/:key?scope=tenant&scope_id=xxx +/// +/// 获取设置值,支持分层回退查找。 +/// 解析顺序:精确匹配 -> 按作用域层级向上回退。 +/// 需要 `setting.read` 权限。 +pub async fn get_setting( + State(state): State, + Extension(ctx): Extension, + Path(key): Path, + Query(query): Query, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "setting.read")?; + + let scope = query.scope.unwrap_or_else(|| "tenant".to_string()); + + let setting = + SettingService::get(&key, &scope, &query.scope_id, ctx.tenant_id, &state.db).await?; + + Ok(Json(ApiResponse::ok(setting))) +} + +#[utoipa::path( + put, + path = "/api/v1/settings/{key}", + params(("key" = String, Path, description = "设置键名")), + request_body = UpdateSettingReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "系统设置" +)] +/// PUT /api/v1/settings/:key +/// +/// 创建或更新设置值。 +/// 如果相同 (scope, scope_id, key) 的记录存在则更新,否则插入。 +/// 需要 `setting.update` 权限。 +pub async fn update_setting( + State(state): State, + Extension(ctx): Extension, + Path(key): Path, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "setting.update")?; + + let setting = SettingService::set( + SetSettingParams { + key, + scope: "tenant".to_string(), + scope_id: None, + value: req.setting_value, + version: req.version, + }, + ctx.tenant_id, + ctx.user_id, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(setting))) +} + +/// 设置查询参数。 +#[derive(Debug, serde::Deserialize)] +pub struct SettingQuery { + pub scope: Option, + pub scope_id: Option, +} + +#[utoipa::path( + delete, + path = "/api/v1/settings/{key}", + params( + ("key" = String, Path, description = "设置键名"), + ("scope" = Option, Query, description = "作用域"), + ("scope_id" = Option, Query, description = "作用域ID"), + ), + request_body = DeleteSettingVersionReq, + responses( + (status = 200, description = "成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "系统设置" +)] +/// DELETE /api/v1/settings/:key +/// +/// 软删除设置值,设置 deleted_at 时间戳。 +/// 需要请求体包含 version 字段用于乐观锁校验。 +/// 需要 `setting.delete` 权限。 +pub async fn delete_setting( + State(state): State, + Extension(ctx): Extension, + Path(key): Path, + Query(query): Query, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "setting.delete")?; + + let scope = query.scope.unwrap_or_else(|| "tenant".to_string()); + + SettingService::delete( + &key, + &scope, + &query.scope_id, + ctx.tenant_id, + ctx.user_id, + req.version, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse { + success: true, + data: None, + message: Some("设置已删除".to_string()), + })) +} + +/// 删除设置的乐观锁版本号请求体。 +#[derive(Debug, serde::Deserialize, utoipa::ToSchema)] +pub struct DeleteSettingVersionReq { + pub version: i32, +} diff --git a/crates/erp-config/src/handler/theme_handler.rs b/crates/erp-config/src/handler/theme_handler.rs new file mode 100644 index 0000000..6d842d1 --- /dev/null +++ b/crates/erp-config/src/handler/theme_handler.rs @@ -0,0 +1,176 @@ +use axum::Extension; +use axum::extract::{FromRef, Json, State}; +use axum::response::Json as JsonResponse; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; + +use crate::config_state::ConfigState; +use crate::dto::{PublicBrandResp, SetSettingParams, ThemeResp}; +use crate::error::ConfigError; +use crate::service::setting_service::SettingService; + +/// 默认主题配置。 +fn default_theme() -> ThemeResp { + ThemeResp { + primary_color: None, + logo_url: None, + sidebar_style: None, + brand_name: Some("HMS 健康管理平台".into()), + brand_slogan: Some("新一代健康管理平台".into()), + brand_features: Some("患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()), + brand_copyright: Some("HMS 健康管理平台 · ©汕头市智界科技有限公司".into()), + } +} + +#[utoipa::path( + get, + path = "/api/v1/themes", + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "主题设置" +)] +/// GET /api/v1/theme +/// +/// 获取当前租户的主题配置。 +/// 主题配置存储在 settings 表中,key 为 "theme",scope 为 "tenant"。 +/// 当没有任何主题配置时,返回默认主题(所有字段为 null)。 +/// 需要 `theme.read` 权限。 +pub async fn get_theme( + State(state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "theme.read")?; + + let theme = match SettingService::get("theme", "tenant", &None, ctx.tenant_id, &state.db).await + { + Ok(setting) => serde_json::from_value(setting.setting_value) + .map_err(|e| AppError::Validation(format!("主题配置解析失败: {e}")))?, + Err(ConfigError::NotFound(_)) => default_theme(), + Err(e) => return Err(e.into()), + }; + + Ok(JsonResponse(ApiResponse::ok(theme))) +} + +#[utoipa::path( + put, + path = "/api/v1/themes", + request_body = ThemeResp, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "主题设置" +)] +/// PUT /api/v1/theme +/// +/// 更新当前租户的主题配置。 +/// 将主题配置序列化为 JSON 存储到 settings 表。 +/// 需要 `theme.update` 权限。 +pub async fn update_theme( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + ConfigState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "theme.update")?; + + let value = serde_json::to_value(&req) + .map_err(|e| AppError::Validation(format!("主题配置序列化失败: {e}")))?; + + SettingService::set( + SetSettingParams { + key: "theme".to_string(), + scope: "tenant".to_string(), + scope_id: None, + value, + version: None, + }, + ctx.tenant_id, + ctx.user_id, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(JsonResponse(ApiResponse::ok(req))) +} + +#[utoipa::path( + get, + path = "/api/v1/public/brand", + responses( + (status = 200, description = "成功", body = ApiResponse), + ), + tag = "主题设置" +)] +/// GET /api/v1/public/brand +/// +/// 获取公开品牌信息(无需认证)。 +pub async fn get_public_brand() -> JsonResponse> { + let defaults = default_theme(); + JsonResponse(ApiResponse::ok(PublicBrandResp { + brand_name: defaults + .brand_name + .unwrap_or_else(|| "HMS 健康管理平台".into()), + brand_slogan: defaults + .brand_slogan + .unwrap_or_else(|| "新一代健康管理平台".into()), + brand_features: defaults + .brand_features + .unwrap_or_else(|| "患者管理 · 健康监测 · 随访管理 · AI 智能分析".into()), + brand_copyright: defaults + .brand_copyright + .unwrap_or_else(|| "HMS 健康管理平台 · ©汕头市智界科技有限公司".into()), + })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_theme_has_brand_defaults() { + let theme = default_theme(); + assert!(theme.primary_color.is_none()); + assert!(theme.logo_url.is_none()); + assert!(theme.sidebar_style.is_none()); + assert_eq!(theme.brand_name, Some("HMS 健康管理平台".to_string())); + assert_eq!(theme.brand_slogan, Some("新一代健康管理平台".to_string())); + assert!(theme.brand_features.is_some()); + assert!(theme.brand_copyright.is_some()); + } + + #[test] + fn theme_resp_serde_roundtrip() { + let theme = ThemeResp { + primary_color: Some("#1890ff".to_string()), + logo_url: None, + sidebar_style: Some("dark".to_string()), + brand_name: Some("测试平台".to_string()), + brand_slogan: None, + brand_features: None, + brand_copyright: None, + }; + let json = serde_json::to_string(&theme).unwrap(); + let back: ThemeResp = serde_json::from_str(&json).unwrap(); + assert_eq!(back.primary_color, Some("#1890ff".to_string())); + assert_eq!(back.brand_name, Some("测试平台".to_string())); + assert!(back.brand_slogan.is_none()); + } +} diff --git a/crates/erp-config/src/lib.rs b/crates/erp-config/src/lib.rs new file mode 100644 index 0000000..1e84c86 --- /dev/null +++ b/crates/erp-config/src/lib.rs @@ -0,0 +1,10 @@ +pub mod config_state; +pub mod dto; +pub mod entity; +pub mod error; +pub mod handler; +pub mod module; +pub mod service; + +pub use config_state::ConfigState; +pub use module::ConfigModule; diff --git a/crates/erp-config/src/module.rs b/crates/erp-config/src/module.rs new file mode 100644 index 0000000..d5d8f7f --- /dev/null +++ b/crates/erp-config/src/module.rs @@ -0,0 +1,267 @@ +use axum::Router; +use axum::routing::{get, post, put}; +use uuid::Uuid; + +use erp_core::error::AppResult; +use erp_core::events::EventBus; +use erp_core::module::{ErpModule, PermissionDescriptor}; + +use crate::handler::{ + dictionary_handler, language_handler, menu_handler, numbering_handler, setting_handler, + theme_handler, +}; + +/// Config module implementing the `ErpModule` trait. +/// +/// Manages system configuration: dictionaries, menus, settings, +/// numbering rules, languages, and themes. +pub struct ConfigModule; + +impl ConfigModule { + pub fn new() -> Self { + Self + } + + /// Build protected (authenticated) routes for the config module. + pub fn protected_routes() -> Router + where + crate::config_state::ConfigState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + Router::new() + // Dictionary routes + .route( + "/config/dictionaries", + get(dictionary_handler::list_dictionaries) + .post(dictionary_handler::create_dictionary), + ) + .route( + "/config/dictionaries/{id}", + put(dictionary_handler::update_dictionary) + .delete(dictionary_handler::delete_dictionary), + ) + .route( + "/config/dictionaries/items", + get(dictionary_handler::list_items_by_code), + ) + .route( + "/config/dictionaries/{dict_id}/items", + post(dictionary_handler::create_item), + ) + .route( + "/config/dictionaries/{dict_id}/items/{item_id}", + put(dictionary_handler::update_item).delete(dictionary_handler::delete_item), + ) + // Menu routes + .route( + "/config/menus", + get(menu_handler::get_menus) + .post(menu_handler::create_menu) + .put(menu_handler::batch_save_menus), + ) + .route( + "/config/menus/{id}", + put(menu_handler::update_menu).delete(menu_handler::delete_menu), + ) + // User menu tree (no special permission required) + .route("/menus/user", get(menu_handler::get_user_menus)) + // Setting routes + .route( + "/config/settings/{key}", + get(setting_handler::get_setting) + .put(setting_handler::update_setting) + .delete(setting_handler::delete_setting), + ) + // Numbering rule routes + .route( + "/config/numbering-rules", + get(numbering_handler::list_numbering_rules) + .post(numbering_handler::create_numbering_rule), + ) + .route( + "/config/numbering-rules/{id}", + put(numbering_handler::update_numbering_rule) + .delete(numbering_handler::delete_numbering_rule), + ) + .route( + "/config/numbering-rules/{id}/generate", + post(numbering_handler::generate_number), + ) + // Theme routes + .route( + "/config/themes", + get(theme_handler::get_theme).put(theme_handler::update_theme), + ) + // Language routes + .route("/config/languages", get(language_handler::list_languages)) + .route( + "/config/languages/{code}", + put(language_handler::update_language), + ) + } + + /// Build public (unauthenticated) routes for the config module. + pub fn public_routes() -> Router + where + S: Clone + Send + Sync + 'static, + { + Router::new().route("/public/brand", get(theme_handler::get_public_brand)) + } +} + +impl Default for ConfigModule { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl ErpModule for ConfigModule { + fn name(&self) -> &str { + "config" + } + + fn version(&self) -> &str { + env!("CARGO_PKG_VERSION") + } + + fn dependencies(&self) -> Vec<&str> { + vec!["auth"] + } + + fn register_event_handlers(&self, _bus: &EventBus) {} + + async fn on_tenant_created( + &self, + _tenant_id: Uuid, + _db: &sea_orm::DatabaseConnection, + _event_bus: &EventBus, + ) -> AppResult<()> { + Ok(()) + } + + async fn on_tenant_deleted( + &self, + _tenant_id: Uuid, + _db: &sea_orm::DatabaseConnection, + ) -> AppResult<()> { + Ok(()) + } + + fn permissions(&self) -> Vec { + vec![ + PermissionDescriptor { + code: "dictionary.list".into(), + name: "查看字典".into(), + description: "查看数据字典".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "dictionary.create".into(), + name: "创建字典".into(), + description: "创建数据字典".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "dictionary.update".into(), + name: "编辑字典".into(), + description: "编辑数据字典".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "dictionary.delete".into(), + name: "删除字典".into(), + description: "删除数据字典".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "menu.list".into(), + name: "查看菜单".into(), + description: "查看菜单配置".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "menu.update".into(), + name: "编辑菜单".into(), + description: "编辑菜单配置".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "setting.read".into(), + name: "查看配置".into(), + description: "查看系统参数".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "setting.update".into(), + name: "编辑配置".into(), + description: "编辑系统参数".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "setting.delete".into(), + name: "删除配置".into(), + description: "删除系统参数".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "numbering.list".into(), + name: "查看编号规则".into(), + description: "查看编号规则".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "numbering.create".into(), + name: "创建编号规则".into(), + description: "创建编号规则".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "numbering.update".into(), + name: "编辑编号规则".into(), + description: "编辑编号规则".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "numbering.delete".into(), + name: "删除编号规则".into(), + description: "删除编号规则".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "numbering.generate".into(), + name: "生成编号".into(), + description: "生成文档编号".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "theme.read".into(), + name: "查看主题".into(), + description: "查看主题设置".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "theme.update".into(), + name: "编辑主题".into(), + description: "编辑主题设置".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "language.list".into(), + name: "查看语言".into(), + description: "查看语言配置".into(), + module: "config".into(), + }, + PermissionDescriptor { + code: "language.update".into(), + name: "编辑语言".into(), + description: "编辑语言设置".into(), + module: "config".into(), + }, + ] + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/crates/erp-config/src/service/dictionary_service.rs b/crates/erp-config/src/service/dictionary_service.rs new file mode 100644 index 0000000..a99ddeb --- /dev/null +++ b/crates/erp-config/src/service/dictionary_service.rs @@ -0,0 +1,628 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set}; +use uuid::Uuid; + +use crate::dto::{DictionaryItemResp, DictionaryResp}; +use crate::entity::{dictionary, dictionary_item}; +use crate::error::{ConfigError, ConfigResult}; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::events::EventBus; +use erp_core::types::Pagination; + +/// Dictionary CRUD service — manage dictionaries and their items within a tenant. +/// +/// Dictionaries provide enumerated value sets (e.g. status codes, categories) +/// that can be referenced throughout the system by their unique `code`. +pub struct DictionaryService; + +impl DictionaryService { + /// List dictionaries within a tenant with pagination. + /// + /// Each dictionary includes its associated items. + /// Returns `(dictionaries, total_count)`. + pub async fn list( + tenant_id: Uuid, + pagination: &Pagination, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult<(Vec, u64)> { + let paginator = dictionary::Entity::find() + .filter(dictionary::Column::TenantId.eq(tenant_id)) + .filter(dictionary::Column::DeletedAt.is_null()) + .paginate(db, pagination.limit()); + + let total = paginator + .num_items() + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + let page_index = pagination.page.unwrap_or(1).saturating_sub(1); + let models = paginator + .fetch_page(page_index) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + let mut resps = Vec::with_capacity(models.len()); + for m in &models { + let items = Self::fetch_items(m.id, tenant_id, db).await?; + resps.push(dict_model_to_resp(m, items)); + } + + Ok((resps, total)) + } + + /// Fetch a single dictionary by ID, scoped to the given tenant. + /// + /// Includes all associated items. + pub async fn get_by_id( + id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult { + let model = dictionary::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))? + .filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none()) + .ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?; + + let items = Self::fetch_items(model.id, tenant_id, db).await?; + + Ok(dict_model_to_resp(&model, items)) + } + + /// Create a new dictionary within the current tenant. + /// + /// Validates code uniqueness, then inserts the record and publishes + /// a `dictionary.created` domain event. + pub async fn create( + tenant_id: Uuid, + operator_id: Uuid, + name: &str, + code: &str, + description: &Option, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> ConfigResult { + // Check code uniqueness within tenant + let existing = dictionary::Entity::find() + .filter(dictionary::Column::TenantId.eq(tenant_id)) + .filter(dictionary::Column::Code.eq(code)) + .filter(dictionary::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(ConfigError::Validation("字典编码已存在".to_string())); + } + + let now = Utc::now(); + let id = Uuid::now_v7(); + let model = dictionary::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + name: Set(name.to_string()), + code: Set(code.to_string()), + description: Set(description.clone()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + model + .insert(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "dictionary.created", + tenant_id, + serde_json::json!({ "dictionary_id": id, "code": code }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "dictionary.create", + "dictionary", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(DictionaryResp { + id, + name: name.to_string(), + code: code.to_string(), + description: description.clone(), + items: vec![], + version: 1, + }) + } + + /// Update editable dictionary fields (name and description). + /// + /// Code cannot be changed after creation. + /// Performs optimistic locking via version check. + pub async fn update( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &crate::dto::UpdateDictionaryReq, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult { + let model = dictionary::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))? + .filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none()) + .ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?; + + let next_version = + check_version(req.version, model.version).map_err(|_| ConfigError::VersionMismatch)?; + + let mut active: dictionary::ActiveModel = model.into(); + + if let Some(n) = &req.name { + active.name = Set(n.clone()); + } + if let Some(d) = &req.description { + active.description = Set(Some(d.clone())); + } + + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_version); + + let updated = active + .update(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + let items = Self::fetch_items(updated.id, tenant_id, db).await?; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "dictionary.update", + "dictionary", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(dict_model_to_resp(&updated, items)) + } + + /// Soft-delete a dictionary by setting the `deleted_at` timestamp. + /// Performs optimistic locking via version check. + pub async fn delete( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + version: i32, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> ConfigResult<()> { + let model = dictionary::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))? + .filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none()) + .ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?; + + let next_version = + check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?; + + let mut active: dictionary::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_version); + active + .update(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "dictionary.deleted", + tenant_id, + serde_json::json!({ "dictionary_id": id }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "dictionary.delete", + "dictionary", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(()) + } + + /// Add a new item to a dictionary. + /// + /// Validates that the item `value` is unique within the dictionary. + pub async fn add_item( + dictionary_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &crate::dto::CreateDictionaryItemReq, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult { + // Verify the dictionary exists and belongs to this tenant + let _dict = dictionary::Entity::find_by_id(dictionary_id) + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))? + .filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none()) + .ok_or_else(|| ConfigError::NotFound("字典不存在".to_string()))?; + + // Check value uniqueness within dictionary + let existing = dictionary_item::Entity::find() + .filter(dictionary_item::Column::DictionaryId.eq(dictionary_id)) + .filter(dictionary_item::Column::TenantId.eq(tenant_id)) + .filter(dictionary_item::Column::Value.eq(&req.value)) + .filter(dictionary_item::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(ConfigError::Validation("字典项值已存在".to_string())); + } + + let now = Utc::now(); + let id = Uuid::now_v7(); + let sort_order = req.sort_order.unwrap_or(0); + let model = dictionary_item::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + dictionary_id: Set(dictionary_id), + label: Set(req.label.clone()), + value: Set(req.value.clone()), + sort_order: Set(sort_order), + color: Set(req.color.clone()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + model + .insert(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "dictionary_item.create", + "dictionary_item", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(DictionaryItemResp { + id, + dictionary_id, + label: req.label.clone(), + value: req.value.clone(), + sort_order, + color: req.color.clone(), + version: 1, + }) + } + + /// Update editable dictionary item fields (label, value, sort_order, color). + /// Performs optimistic locking via version check. + pub async fn update_item( + item_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &crate::dto::UpdateDictionaryItemReq, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult { + let model = dictionary_item::Entity::find_by_id(item_id) + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))? + .filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none()) + .ok_or_else(|| ConfigError::NotFound("字典项不存在".to_string()))?; + + let next_version = + check_version(req.version, model.version).map_err(|_| ConfigError::VersionMismatch)?; + + let mut active: dictionary_item::ActiveModel = model.into(); + + if let Some(l) = &req.label { + active.label = Set(l.clone()); + } + if let Some(v) = &req.value { + active.value = Set(v.clone()); + } + if let Some(s) = req.sort_order { + active.sort_order = Set(s); + } + if let Some(c) = &req.color { + active.color = Set(Some(c.clone())); + } + + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_version); + + let updated = active + .update(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "dictionary_item.update", + "dictionary_item", + ) + .with_resource_id(item_id), + db, + ) + .await; + + Ok(item_model_to_resp(&updated)) + } + + /// Soft-delete a dictionary item by setting the `deleted_at` timestamp. + /// Performs optimistic locking via version check. + pub async fn delete_item( + item_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + version: i32, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult<()> { + let model = dictionary_item::Entity::find_by_id(item_id) + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))? + .filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none()) + .ok_or_else(|| ConfigError::NotFound("字典项不存在".to_string()))?; + + let next_version = + check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?; + + let mut active: dictionary_item::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_version); + active + .update(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "dictionary_item.delete", + "dictionary_item", + ) + .with_resource_id(item_id), + db, + ) + .await; + + Ok(()) + } + + /// Look up a dictionary by its `code` and return all items. + /// + /// Useful for frontend dropdowns and enum-like lookups. + pub async fn list_items_by_code( + code: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult> { + let dict = dictionary::Entity::find() + .filter(dictionary::Column::TenantId.eq(tenant_id)) + .filter(dictionary::Column::Code.eq(code)) + .filter(dictionary::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))? + .ok_or_else(|| ConfigError::NotFound(format!("字典编码 '{}' 不存在", code)))?; + + Self::fetch_items(dict.id, tenant_id, db).await + } + + // ---- 内部辅助方法 ---- + + /// Fetch all non-deleted items for a given dictionary. + async fn fetch_items( + dictionary_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult> { + let items = dictionary_item::Entity::find() + .filter(dictionary_item::Column::DictionaryId.eq(dictionary_id)) + .filter(dictionary_item::Column::TenantId.eq(tenant_id)) + .filter(dictionary_item::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + Ok(items.iter().map(item_model_to_resp).collect()) + } +} + +/// Free function wrapping the private helper so the mapping logic is reusable +/// in both async methods and synchronous unit tests without a database. +fn item_model_to_resp(m: &dictionary_item::Model) -> DictionaryItemResp { + DictionaryItemResp { + id: m.id, + dictionary_id: m.dictionary_id, + label: m.label.clone(), + value: m.value.clone(), + sort_order: m.sort_order, + color: m.color.clone(), + version: m.version, + } +} + +/// Free function for dictionary model -> response DTO mapping. +fn dict_model_to_resp(m: &dictionary::Model, items: Vec) -> DictionaryResp { + DictionaryResp { + id: m.id, + name: m.name.clone(), + code: m.code.clone(), + description: m.description.clone(), + items, + version: m.version, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + use uuid::Uuid; + + fn sample_dict_model() -> dictionary::Model { + dictionary::Model { + id: Uuid::now_v7(), + tenant_id: Uuid::now_v7(), + name: "测试字典".to_string(), + code: "test_dict".to_string(), + description: Some("描述".to_string()), + created_at: Utc::now(), + updated_at: Utc::now(), + created_by: Uuid::now_v7(), + updated_by: Uuid::now_v7(), + deleted_at: None, + version: 1, + } + } + + fn sample_item_model() -> dictionary_item::Model { + dictionary_item::Model { + id: Uuid::now_v7(), + tenant_id: Uuid::now_v7(), + dictionary_id: Uuid::now_v7(), + label: "选项A".to_string(), + value: "option_a".to_string(), + sort_order: 1, + color: Some("#FF0000".to_string()), + created_at: Utc::now(), + updated_at: Utc::now(), + created_by: Uuid::now_v7(), + updated_by: Uuid::now_v7(), + deleted_at: None, + version: 1, + } + } + + // ---- dict_model_to_resp ---- + + #[test] + fn dict_model_to_resp_with_items() { + let m = sample_dict_model(); + let item = item_model_to_resp(&sample_item_model()); + let resp = dict_model_to_resp(&m, vec![item]); + + assert_eq!(resp.id, m.id); + assert_eq!(resp.name, "测试字典"); + assert_eq!(resp.code, "test_dict"); + assert_eq!(resp.description, Some("描述".to_string())); + assert_eq!(resp.version, 1); + assert_eq!(resp.items.len(), 1); + assert_eq!(resp.items[0].label, "选项A"); + } + + #[test] + fn dict_model_to_resp_without_description() { + let mut m = sample_dict_model(); + m.description = None; + let resp = dict_model_to_resp(&m, vec![]); + + assert_eq!(resp.description, None); + assert!(resp.items.is_empty()); + } + + #[test] + fn dict_model_to_resp_preserves_version() { + let mut m = sample_dict_model(); + m.version = 42; + let resp = dict_model_to_resp(&m, vec![]); + + assert_eq!(resp.version, 42); + } + + // ---- item_model_to_resp ---- + + #[test] + fn item_model_to_resp_all_fields() { + let m = sample_item_model(); + let resp = item_model_to_resp(&m); + + assert_eq!(resp.id, m.id); + assert_eq!(resp.dictionary_id, m.dictionary_id); + assert_eq!(resp.label, "选项A"); + assert_eq!(resp.value, "option_a"); + assert_eq!(resp.sort_order, 1); + assert_eq!(resp.color, Some("#FF0000".to_string())); + assert_eq!(resp.version, 1); + } + + #[test] + fn item_model_to_resp_without_color() { + let mut m = sample_item_model(); + m.color = None; + let resp = item_model_to_resp(&m); + + assert_eq!(resp.color, None); + } + + #[test] + fn item_model_to_resp_default_sort_order() { + let mut m = sample_item_model(); + m.sort_order = 0; + let resp = item_model_to_resp(&m); + + assert_eq!(resp.sort_order, 0); + } + + #[test] + fn item_model_to_resp_preserves_version() { + let mut m = sample_item_model(); + m.version = 7; + let resp = item_model_to_resp(&m); + + assert_eq!(resp.version, 7); + } +} diff --git a/crates/erp-config/src/service/menu_service.rs b/crates/erp-config/src/service/menu_service.rs new file mode 100644 index 0000000..18933c1 --- /dev/null +++ b/crates/erp-config/src/service/menu_service.rs @@ -0,0 +1,600 @@ +use std::collections::HashMap; + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, QueryFilter, QueryOrder, Set, +}; +use uuid::Uuid; + +use crate::dto::{CreateMenuReq, MenuResp}; +use crate::entity::{menu, menu_role}; +use crate::error::{ConfigError, ConfigResult}; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::events::EventBus; + +/// 菜单 CRUD 服务 -- 创建、查询(树形/平铺)、更新、软删除菜单, +/// 以及管理菜单-角色关联。 +pub struct MenuService; + +impl MenuService { + /// 通过角色 code 列表查找对应的角色 ID 列表。 + async fn resolve_role_ids( + tenant_id: Uuid, + role_codes: &[String], + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult> { + if role_codes.is_empty() { + return Ok(vec![]); + } + let codes_csv: String = role_codes + .iter() + .map(|c| format!("'{}'", c.replace('\'', "''"))) + .collect::>() + .join(","); + let sql = format!( + "SELECT id FROM roles WHERE tenant_id = '{}' AND code IN ({}) AND deleted_at IS NULL", + tenant_id, codes_csv + ); + let stmt = sea_orm::Statement::from_string(sea_orm::DatabaseBackend::Postgres, sql); + let rows = db.query_all(stmt).await?; + + Ok(rows + .into_iter() + .filter_map(|row| { + let id: Uuid = row.try_get_by_index(0).ok()?; + Some(id) + }) + .collect()) + } + + pub async fn get_menu_tree( + tenant_id: Uuid, + role_codes: &[String], + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult> { + // 0. admin 角色直接返回全部菜单,跳过 menu_roles 过滤 + if role_codes.iter().any(|c| c == "admin") { + let all_menus = menu::Entity::find() + .filter(menu::Column::TenantId.eq(tenant_id)) + .filter(menu::Column::DeletedAt.is_null()) + .order_by_asc(menu::Column::SortOrder) + .all(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + let mut children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); + for m in &all_menus { + children_map.entry(m.parent_id).or_default().push(m); + } + let roots = children_map.get(&None).cloned().unwrap_or_default(); + return Ok(Self::build_tree(&roots, &children_map)); + } + + // 1. 将角色 code 转换为 UUID + let role_ids = Self::resolve_role_ids(tenant_id, role_codes, db).await?; + + // 2. 查询租户下所有未删除的菜单,按 sort_order 排序 + let all_menus = menu::Entity::find() + .filter(menu::Column::TenantId.eq(tenant_id)) + .filter(menu::Column::DeletedAt.is_null()) + .order_by_asc(menu::Column::SortOrder) + .all(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + // 3. 通过 menu_roles 表过滤 + let visible_menu_ids: Option> = if !role_ids.is_empty() { + let mr_rows = menu_role::Entity::find() + .filter(menu_role::Column::TenantId.eq(tenant_id)) + .filter(menu_role::Column::RoleId.is_in(role_ids.iter().copied())) + .filter(menu_role::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + let ids: Vec = mr_rows.iter().map(|mr| mr.menu_id).collect(); + if ids.is_empty() { + Some(vec![]) // 无菜单关联 = 不显示 + } else { + Some(ids) + } + } else { + Some(vec![]) // 无角色 = 不显示任何菜单 + }; + + // 3. 按 parent_id 分组构建 HashMap + let filtered: Vec<&menu::Model> = match &visible_menu_ids { + Some(ids) => all_menus.iter().filter(|m| ids.contains(&m.id)).collect(), + None => all_menus.iter().collect(), + }; + + let mut children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); + for m in &filtered { + children_map.entry(m.parent_id).or_default().push(*m); + } + + // 4. 递归构建树形结构(从 parent_id == None 的根节点开始) + let roots = children_map.get(&None).cloned().unwrap_or_default(); + let tree = Self::build_tree(&roots, &children_map); + + Ok(tree) + } + + /// 获取当前租户下所有菜单的平铺列表(无角色过滤)。 + pub async fn get_flat_list( + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult> { + let menus = menu::Entity::find() + .filter(menu::Column::TenantId.eq(tenant_id)) + .filter(menu::Column::DeletedAt.is_null()) + .order_by_asc(menu::Column::SortOrder) + .all(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + Ok(menus + .iter() + .map(|m| MenuResp { + id: m.id, + parent_id: m.parent_id, + title: m.title.clone(), + path: m.path.clone(), + icon: m.icon.clone(), + sort_order: m.sort_order, + visible: m.visible, + menu_type: m.menu_type.clone(), + permission: m.permission.clone(), + children: vec![], + version: m.version, + }) + .collect()) + } + + /// 创建菜单并可选地关联角色。 + pub async fn create( + tenant_id: Uuid, + operator_id: Uuid, + req: &CreateMenuReq, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> ConfigResult { + let now = Utc::now(); + let id = Uuid::now_v7(); + + let model = menu::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + parent_id: Set(req.parent_id), + title: Set(req.title.clone()), + path: Set(req.path.clone()), + icon: Set(req.icon.clone()), + sort_order: Set(req.sort_order.unwrap_or(0)), + visible: Set(req.visible.unwrap_or(true)), + menu_type: Set(req.menu_type.clone().unwrap_or_else(|| "menu".to_string())), + permission: Set(req.permission.clone()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + model + .insert(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + // 关联角色(如果提供了 role_ids) + if let Some(role_ids) = &req.role_ids + && !role_ids.is_empty() + { + Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?; + } + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "menu.created", + tenant_id, + serde_json::json!({ "menu_id": id, "title": req.title }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "menu.create", "menu").with_resource_id(id), + db, + ) + .await; + + Ok(MenuResp { + id, + parent_id: req.parent_id, + title: req.title.clone(), + path: req.path.clone(), + icon: req.icon.clone(), + sort_order: req.sort_order.unwrap_or(0), + visible: req.visible.unwrap_or(true), + menu_type: req.menu_type.clone().unwrap_or_else(|| "menu".to_string()), + permission: req.permission.clone(), + children: vec![], + version: 1, + }) + } + + /// 更新菜单字段,并可选地重新关联角色。 + /// 使用乐观锁校验版本。 + pub async fn update( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &crate::dto::UpdateMenuReq, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult { + let model = menu::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {id}")))?; + + let next_version = + check_version(req.version, model.version).map_err(|_| ConfigError::VersionMismatch)?; + + let mut active: menu::ActiveModel = model.into(); + + if let Some(title) = &req.title { + active.title = Set(title.clone()); + } + if let Some(path) = &req.path { + active.path = Set(Some(path.clone())); + } + if let Some(icon) = &req.icon { + active.icon = Set(Some(icon.clone())); + } + if let Some(sort_order) = req.sort_order { + active.sort_order = Set(sort_order); + } + if let Some(visible) = req.visible { + active.visible = Set(visible); + } + if let Some(permission) = &req.permission { + active.permission = Set(Some(permission.clone())); + } + + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_version); + + let updated = active + .update(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + // 如果提供了 role_ids,重新关联角色 + if let Some(role_ids) = &req.role_ids { + Self::assign_roles(id, role_ids, tenant_id, operator_id, db).await?; + } + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "menu.update", "menu").with_resource_id(id), + db, + ) + .await; + + Ok(MenuResp { + id: updated.id, + parent_id: updated.parent_id, + title: updated.title.clone(), + path: updated.path.clone(), + icon: updated.icon.clone(), + sort_order: updated.sort_order, + visible: updated.visible, + menu_type: updated.menu_type.clone(), + permission: updated.permission.clone(), + children: vec![], + version: updated.version, + }) + } + + /// 软删除菜单。使用乐观锁校验版本。 + pub async fn delete( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + version: i32, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> ConfigResult<()> { + let model = menu::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {id}")))?; + + let next_version = + check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?; + + let mut active: menu::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_version); + active + .update(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "menu.deleted", + tenant_id, + serde_json::json!({ "menu_id": id }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "menu.delete", "menu").with_resource_id(id), + db, + ) + .await; + + Ok(()) + } + + /// 替换菜单的角色关联。 + /// + /// 软删除现有关联行,然后插入新关联(参考 RoleService::assign_permissions 模式)。 + pub async fn assign_roles( + menu_id: Uuid, + role_ids: &[Uuid], + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult<()> { + // 验证菜单存在且属于当前租户 + let _menu = menu::Entity::find_by_id(menu_id) + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or_else(|| ConfigError::NotFound(format!("菜单不存在: {menu_id}")))?; + + // 软删除现有关联 + let existing = menu_role::Entity::find() + .filter(menu_role::Column::MenuId.eq(menu_id)) + .filter(menu_role::Column::TenantId.eq(tenant_id)) + .filter(menu_role::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + let now = Utc::now(); + for mr in existing { + let mut active: menu_role::ActiveModel = mr.into(); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active.version = Set(active.version.take().unwrap_or(0) + 1); + active + .update(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + } + + // 插入新关联 + for role_id in role_ids { + let mr = menu_role::ActiveModel { + id: Set(Uuid::now_v7()), + menu_id: Set(menu_id), + role_id: Set(*role_id), + tenant_id: Set(tenant_id), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + mr.insert(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + } + + Ok(()) + } + + /// 递归构建菜单树。 + fn build_tree<'a>( + nodes: &[&'a menu::Model], + children_map: &HashMap, Vec<&'a menu::Model>>, + ) -> Vec { + nodes + .iter() + .map(|m| { + let children = children_map.get(&Some(m.id)).cloned().unwrap_or_default(); + MenuResp { + id: m.id, + parent_id: m.parent_id, + title: m.title.clone(), + path: m.path.clone(), + icon: m.icon.clone(), + sort_order: m.sort_order, + visible: m.visible, + menu_type: m.menu_type.clone(), + permission: m.permission.clone(), + children: Self::build_tree(&children, children_map), + version: m.version, + } + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + /// 辅助:构造 menu::Model + fn make_menu(id: Uuid, parent_id: Option, title: &str, sort_order: i32) -> menu::Model { + let now = Utc::now(); + let tenant_id = Uuid::now_v7(); + menu::Model { + id, + tenant_id, + parent_id, + title: title.to_string(), + path: Some(format!("/{}", title.to_lowercase())), + icon: None, + sort_order, + visible: true, + menu_type: "menu".to_string(), + permission: None, + created_at: now, + updated_at: now, + created_by: tenant_id, + updated_by: tenant_id, + deleted_at: None, + version: 1, + } + } + + #[test] + fn build_tree_empty_input() { + let nodes: Vec<&menu::Model> = vec![]; + let children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); + let tree = MenuService::build_tree(&nodes, &children_map); + assert!(tree.is_empty()); + } + + #[test] + fn build_tree_single_root() { + let root_id = Uuid::now_v7(); + let root = make_menu(root_id, None, "首页", 0); + + let children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); + let roots: Vec<&menu::Model> = vec![&root]; + let tree = MenuService::build_tree(&roots, &children_map); + + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].id, root_id); + assert_eq!(tree[0].title, "首页"); + assert!(tree[0].children.is_empty()); + } + + #[test] + fn build_tree_two_levels() { + // 根节点 -> 子节点1, 子节点2 + let root_id = Uuid::now_v7(); + let child1_id = Uuid::now_v7(); + let child2_id = Uuid::now_v7(); + + let root = make_menu(root_id, None, "系统管理", 0); + let child1 = make_menu(child1_id, Some(root_id), "用户管理", 1); + let child2 = make_menu(child2_id, Some(root_id), "角色管理", 2); + + let mut children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); + children_map.insert(Some(root_id), vec![&child1, &child2]); + + let roots: Vec<&menu::Model> = vec![&root]; + let tree = MenuService::build_tree(&roots, &children_map); + + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].children.len(), 2); + assert_eq!(tree[0].children[0].title, "用户管理"); + assert_eq!(tree[0].children[1].title, "角色管理"); + } + + #[test] + fn build_tree_three_levels() { + // 根 -> 子 -> 孙 + let root_id = Uuid::now_v7(); + let child_id = Uuid::now_v7(); + let grandchild_id = Uuid::now_v7(); + + let root = make_menu(root_id, None, "系统管理", 0); + let child = make_menu(child_id, Some(root_id), "用户管理", 1); + let grandchild = make_menu(grandchild_id, Some(child_id), "用户详情", 0); + + let mut children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); + children_map.insert(Some(root_id), vec![&child]); + children_map.insert(Some(child_id), vec![&grandchild]); + + let roots: Vec<&menu::Model> = vec![&root]; + let tree = MenuService::build_tree(&roots, &children_map); + + assert_eq!(tree.len(), 1); + assert_eq!(tree[0].children.len(), 1); + assert_eq!(tree[0].children[0].children.len(), 1); + assert_eq!(tree[0].children[0].children[0].title, "用户详情"); + } + + #[test] + fn build_tree_multiple_roots() { + // 两个独立的根节点 + let root1_id = Uuid::now_v7(); + let root2_id = Uuid::now_v7(); + + let root1 = make_menu(root1_id, None, "首页", 0); + let root2 = make_menu(root2_id, None, "系统管理", 1); + + let children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); + let roots: Vec<&menu::Model> = vec![&root1, &root2]; + let tree = MenuService::build_tree(&roots, &children_map); + + assert_eq!(tree.len(), 2); + assert_eq!(tree[0].title, "首页"); + assert_eq!(tree[1].title, "系统管理"); + } + + #[test] + fn build_tree_preserves_model_fields() { + let root_id = Uuid::now_v7(); + let now = Utc::now(); + let tenant_id = Uuid::now_v7(); + + let root = menu::Model { + id: root_id, + tenant_id, + parent_id: None, + title: "设置".to_string(), + path: Some("/settings".to_string()), + icon: Some("SettingOutlined".to_string()), + sort_order: 5, + visible: false, + menu_type: "directory".to_string(), + permission: Some("settings:view".to_string()), + created_at: now, + updated_at: now, + created_by: tenant_id, + updated_by: tenant_id, + deleted_at: None, + version: 3, + }; + + let children_map: HashMap, Vec<&menu::Model>> = HashMap::new(); + let roots: Vec<&menu::Model> = vec![&root]; + let tree = MenuService::build_tree(&roots, &children_map); + + assert_eq!(tree.len(), 1); + let node = &tree[0]; + assert_eq!(node.id, root_id); + assert_eq!(node.title, "设置"); + assert_eq!(node.path, Some("/settings".to_string())); + assert_eq!(node.icon, Some("SettingOutlined".to_string())); + assert_eq!(node.sort_order, 5); + assert!(!node.visible); + assert_eq!(node.menu_type, "directory"); + assert_eq!(node.permission, Some("settings:view".to_string())); + assert_eq!(node.version, 3); + } +} diff --git a/crates/erp-config/src/service/mod.rs b/crates/erp-config/src/service/mod.rs new file mode 100644 index 0000000..928561d --- /dev/null +++ b/crates/erp-config/src/service/mod.rs @@ -0,0 +1,4 @@ +pub mod dictionary_service; +pub mod menu_service; +pub mod numbering_service; +pub mod setting_service; diff --git a/crates/erp-config/src/service/numbering_service.rs b/crates/erp-config/src/service/numbering_service.rs new file mode 100644 index 0000000..24498a5 --- /dev/null +++ b/crates/erp-config/src/service/numbering_service.rs @@ -0,0 +1,747 @@ +use chrono::{Datelike, NaiveDate, Utc}; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseBackend, EntityTrait, PaginatorTrait, + QueryFilter, Set, Statement, TransactionTrait, +}; +use uuid::Uuid; + +use crate::dto::{CreateNumberingRuleReq, GenerateNumberResp, NumberingRuleResp}; +use crate::entity::numbering_rule; +use crate::error::{ConfigError, ConfigResult}; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::events::EventBus; +use erp_core::types::Pagination; + +/// 格式化编号字符串。 +/// +/// 拼接规则: +/// 1. 以 `prefix` 开头 +/// 2. 若 `prefix` 非空,追加 `separator` +/// 3. 若 `date_part` 为 `Some` 且非空,追加 `date_part` + `separator` +/// 4. 追加零填充的 `seq_current`(填充到 `seq_length` 位,最少 1 位) +pub(crate) fn format_number( + prefix: &str, + separator: &str, + date_part: Option<&str>, + seq_current: i64, + seq_length: i32, +) -> String { + let mut result = String::with_capacity(32); + result.push_str(prefix); + + if !prefix.is_empty() { + result.push_str(separator); + } + + if let Some(dp) = date_part + && !dp.is_empty() + { + result.push_str(dp); + result.push_str(separator); + } + + let width = (seq_length.max(1)) as usize; + let seq_padded = format!("{:0>width$}", seq_current, width = width); + result.push_str(&seq_padded); + + result +} + +/// 编号规则 CRUD 服务 -- 创建、查询、更新、软删除编号规则, +/// 以及线程安全地生成编号序列。 +pub struct NumberingService; + +impl NumberingService { + /// 分页查询编号规则列表。 + pub async fn list( + tenant_id: Uuid, + pagination: &Pagination, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult<(Vec, u64)> { + let paginator = numbering_rule::Entity::find() + .filter(numbering_rule::Column::TenantId.eq(tenant_id)) + .filter(numbering_rule::Column::DeletedAt.is_null()) + .paginate(db, pagination.limit()); + + let total = paginator + .num_items() + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + let page_index = pagination.page.unwrap_or(1).saturating_sub(1); + let models = paginator + .fetch_page(page_index) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + let resps: Vec = models.iter().map(Self::model_to_resp).collect(); + + Ok((resps, total)) + } + + /// 创建编号规则。 + /// + /// 检查 code 在租户内唯一后插入。 + pub async fn create( + tenant_id: Uuid, + operator_id: Uuid, + req: &CreateNumberingRuleReq, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> ConfigResult { + // 检查 code 唯一性 + let existing = numbering_rule::Entity::find() + .filter(numbering_rule::Column::TenantId.eq(tenant_id)) + .filter(numbering_rule::Column::Code.eq(&req.code)) + .filter(numbering_rule::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + if existing.is_some() { + return Err(ConfigError::DuplicateKey(format!( + "编号规则编码已存在: {}", + req.code + ))); + } + + let now = Utc::now(); + let id = Uuid::now_v7(); + let seq_start = req.seq_start.unwrap_or(1); + + let model = numbering_rule::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + name: Set(req.name.clone()), + code: Set(req.code.clone()), + prefix: Set(req.prefix.clone().unwrap_or_default()), + date_format: Set(req.date_format.clone()), + seq_length: Set(req.seq_length.unwrap_or(4)), + seq_start: Set(seq_start), + seq_current: Set(seq_start as i64), + separator: Set(req.separator.clone().unwrap_or_else(|| "-".to_string())), + reset_cycle: Set(req + .reset_cycle + .clone() + .unwrap_or_else(|| "never".to_string())), + last_reset_date: Set(Some(Utc::now().date_naive())), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + model + .insert(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "numbering_rule.created", + tenant_id, + serde_json::json!({ "rule_id": id, "code": req.code }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "numbering_rule.create", + "numbering_rule", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(NumberingRuleResp { + id, + name: req.name.clone(), + code: req.code.clone(), + prefix: req.prefix.clone().unwrap_or_default(), + date_format: req.date_format.clone(), + seq_length: req.seq_length.unwrap_or(4), + seq_start, + seq_current: seq_start as i64, + separator: req.separator.clone().unwrap_or_else(|| "-".to_string()), + reset_cycle: req + .reset_cycle + .clone() + .unwrap_or_else(|| "never".to_string()), + last_reset_date: Some(Utc::now().date_naive().to_string()), + version: 1, + }) + } + + /// 更新编号规则的可编辑字段。使用乐观锁校验版本。 + pub async fn update( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &crate::dto::UpdateNumberingRuleReq, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult { + let model = numbering_rule::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))? + .filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none()) + .ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {id}")))?; + + let next_version = + check_version(req.version, model.version).map_err(|_| ConfigError::VersionMismatch)?; + + let mut active: numbering_rule::ActiveModel = model.into(); + + if let Some(name) = &req.name { + active.name = Set(name.clone()); + } + if let Some(prefix) = &req.prefix { + active.prefix = Set(prefix.clone()); + } + if let Some(date_format) = &req.date_format { + active.date_format = Set(Some(date_format.clone())); + } + if let Some(seq_length) = req.seq_length { + active.seq_length = Set(seq_length); + } + if let Some(separator) = &req.separator { + active.separator = Set(separator.clone()); + } + if let Some(reset_cycle) = &req.reset_cycle { + active.reset_cycle = Set(reset_cycle.clone()); + } + + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_version); + + let updated = active + .update(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "numbering_rule.update", + "numbering_rule", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(Self::model_to_resp(&updated)) + } + + /// 软删除编号规则。使用乐观锁校验版本。 + pub async fn delete( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + version: i32, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> ConfigResult<()> { + let model = numbering_rule::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))? + .filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none()) + .ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {id}")))?; + + let next_version = + check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?; + + let mut active: numbering_rule::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_version); + active + .update(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "numbering_rule.deleted", + tenant_id, + serde_json::json!({ "rule_id": id }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "numbering_rule.delete", + "numbering_rule", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(()) + } + + /// 线程安全地生成编号。 + /// + /// 使用 PostgreSQL advisory lock 保证并发安全: + /// 1. 在事务内获取 pg_advisory_xact_lock + /// 2. 在同一事务内读取规则、检查重置周期、递增序列、更新数据库 + /// 3. 拼接编号字符串返回 + pub async fn generate_number( + rule_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult { + // 先读取规则获取 code(用于 advisory lock) + let rule = numbering_rule::Entity::find_by_id(rule_id) + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))? + .filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none()) + .ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {rule_id}")))?; + + let rule_code = rule.code.clone(); + let tenant_id_str = tenant_id.to_string(); + + // 在同一个事务内获取 advisory lock 并执行编号生成 + // pg_advisory_xact_lock 是事务级别的,锁会在事务结束时自动释放 + let number = db + .transaction(|txn| { + let rule_code = rule_code.clone(); + let tenant_id_str = tenant_id_str.clone(); + Box::pin(async move { + // 在事务内获取 advisory lock + txn.execute(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT pg_advisory_xact_lock(abs(hashtext($1)), abs(hashtext($2))::int)", + [rule_code.into(), tenant_id_str.into()], + )) + .await + .map_err(|e| ConfigError::Validation(format!("获取编号锁失败: {e}")))?; + + // 在同一个事务内执行编号生成 + Self::generate_number_in_txn(rule_id, tenant_id, txn).await + }) + }) + .await?; + + Ok(GenerateNumberResp { number }) + } + + /// 事务内执行编号生成逻辑。 + /// + /// 检查重置周期,必要时重置序列,然后递增并拼接编号。 + async fn generate_number_in_txn( + rule_id: Uuid, + tenant_id: Uuid, + txn: &C, + ) -> ConfigResult + where + C: ConnectionTrait, + { + let rule = numbering_rule::Entity::find_by_id(rule_id) + .one(txn) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))? + .filter(|r| r.tenant_id == tenant_id && r.deleted_at.is_none()) + .ok_or_else(|| ConfigError::NotFound(format!("编号规则不存在: {rule_id}")))?; + + let today = Utc::now().date_naive(); + let mut seq_current = rule.seq_current; + + // 检查是否需要重置序列 + seq_current = Self::maybe_reset_sequence( + seq_current, + rule.seq_start as i64, + &rule.reset_cycle, + rule.last_reset_date, + today, + ); + + // 递增序列 + let next_seq = seq_current + 1; + + // 检查序列是否超出 seq_length 能表示的最大值 + let max_val = 10i64.pow(rule.seq_length as u32) - 1; + if next_seq > max_val { + return Err(ConfigError::NumberingExhausted(format!( + "编号序列已耗尽,当前序列号 {next_seq} 超出长度 {} 的最大值", + rule.seq_length + ))); + } + + // 更新数据库中的 seq_current 和 last_reset_date + let mut active: numbering_rule::ActiveModel = rule.clone().into(); + active.seq_current = Set(next_seq); + active.last_reset_date = Set(Some(today)); + active.updated_at = Set(Utc::now()); + active.version = Set(active.version.take().unwrap_or(0) + 1); + active + .update(txn) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + // 拼接编号字符串: {prefix}{separator}{date_part}{separator}{seq_padded} + let date_part = rule + .date_format + .as_ref() + .map(|fmt| Utc::now().format(fmt).to_string()); + + let number = format_number( + &rule.prefix, + &rule.separator, + date_part.as_deref(), + seq_current, + rule.seq_length, + ); + + Ok(number) + } + + /// 根据重置周期判断是否需要重置序列号。 + /// + /// 如果需要重置,返回 `seq_start`;否则返回原值。 + fn maybe_reset_sequence( + seq_current: i64, + seq_start: i64, + reset_cycle: &str, + last_reset_date: Option, + today: NaiveDate, + ) -> i64 { + let last_reset = match last_reset_date { + Some(d) => d, + None => return seq_start, // 从未重置过,使用 seq_start + }; + + match reset_cycle { + "daily" => { + if last_reset != today { + seq_start + } else { + seq_current + } + } + "monthly" => { + if last_reset.month() != today.month() || last_reset.year() != today.year() { + seq_start + } else { + seq_current + } + } + "yearly" => { + if last_reset.year() != today.year() { + seq_start + } else { + seq_current + } + } + _ => seq_current, // "never" 或其他值不重置 + } + } + + /// 将数据库模型转换为响应 DTO。 + fn model_to_resp(m: &numbering_rule::Model) -> NumberingRuleResp { + NumberingRuleResp { + id: m.id, + name: m.name.clone(), + code: m.code.clone(), + prefix: m.prefix.clone(), + date_format: m.date_format.clone(), + seq_length: m.seq_length, + seq_start: m.seq_start, + seq_current: m.seq_current, + separator: m.separator.clone(), + reset_cycle: m.reset_cycle.clone(), + last_reset_date: m.last_reset_date.map(|d| d.to_string()), + version: m.version, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + /// 辅助:构造 NaiveDate + fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() + } + + // ---- maybe_reset_sequence 测试 ---- + + #[test] + fn reset_never_keeps_current() { + // "never" 周期:永远不重置,保持 seq_current + let result = NumberingService::maybe_reset_sequence( + 100, + 1, + "never", + Some(date(2025, 1, 1)), + date(2026, 4, 15), + ); + assert_eq!(result, 100); + } + + #[test] + fn reset_unknown_cycle_keeps_current() { + // 未知周期值等同于不重置 + let result = NumberingService::maybe_reset_sequence( + 50, + 1, + "weekly", + Some(date(2025, 1, 1)), + date(2026, 4, 15), + ); + assert_eq!(result, 50); + } + + #[test] + fn reset_daily_same_day_keeps_current() { + // 同一天内不重置 + let today = date(2026, 4, 15); + let result = NumberingService::maybe_reset_sequence(42, 1, "daily", Some(today), today); + assert_eq!(result, 42); + } + + #[test] + fn reset_daily_different_day_resets() { + // 不同天重置为 seq_start + let result = NumberingService::maybe_reset_sequence( + 42, + 1, + "daily", + Some(date(2026, 4, 14)), + date(2026, 4, 15), + ); + assert_eq!(result, 1); + } + + #[test] + fn reset_daily_resets_with_custom_start() { + // 重置时使用自定义 seq_start + let result = NumberingService::maybe_reset_sequence( + 99, + 10, + "daily", + Some(date(2026, 4, 10)), + date(2026, 4, 15), + ); + assert_eq!(result, 10); + } + + #[test] + fn reset_monthly_same_month_keeps_current() { + // 同月不重置 + let result = NumberingService::maybe_reset_sequence( + 30, + 1, + "monthly", + Some(date(2026, 4, 1)), + date(2026, 4, 15), + ); + assert_eq!(result, 30); + } + + #[test] + fn reset_monthly_different_month_resets() { + // 不同月份重置 + let result = NumberingService::maybe_reset_sequence( + 30, + 1, + "monthly", + Some(date(2026, 3, 31)), + date(2026, 4, 1), + ); + assert_eq!(result, 1); + } + + #[test] + fn reset_monthly_same_month_different_year_resets() { + // 不同年份但相同月份数字,仍然重置 + let result = NumberingService::maybe_reset_sequence( + 20, + 5, + "monthly", + Some(date(2025, 4, 15)), + date(2026, 4, 15), + ); + assert_eq!(result, 5); + } + + #[test] + fn reset_yearly_same_year_keeps_current() { + // 同年不重置 + let result = NumberingService::maybe_reset_sequence( + 50, + 1, + "yearly", + Some(date(2026, 1, 1)), + date(2026, 12, 31), + ); + assert_eq!(result, 50); + } + + #[test] + fn reset_yearly_different_year_resets() { + // 不同年份重置 + let result = NumberingService::maybe_reset_sequence( + 50, + 1, + "yearly", + Some(date(2025, 12, 31)), + date(2026, 1, 1), + ); + assert_eq!(result, 1); + } + + #[test] + fn reset_no_last_reset_date_returns_seq_start() { + // 从未重置过,使用 seq_start + let result = + NumberingService::maybe_reset_sequence(999, 1, "daily", None, date(2026, 4, 15)); + assert_eq!(result, 1); + } + + #[test] + fn reset_no_last_reset_date_uses_custom_start() { + // 从未重置过,使用自定义 seq_start + let result = + NumberingService::maybe_reset_sequence(999, 42, "monthly", None, date(2026, 4, 15)); + assert_eq!(result, 42); + } + + // ---- model_to_resp 测试 ---- + + #[test] + fn model_to_resp_maps_fields_correctly() { + let id = Uuid::now_v7(); + let tenant_id = Uuid::now_v7(); + let now = Utc::now(); + let today = now.date_naive(); + + let model = numbering_rule::Model { + id, + tenant_id, + name: "订单编号".to_string(), + code: "ORDER".to_string(), + prefix: "ORD".to_string(), + date_format: Some("%Y%m%d".to_string()), + seq_length: 6, + seq_start: 1, + seq_current: 42, + separator: "-".to_string(), + reset_cycle: "daily".to_string(), + last_reset_date: Some(today), + created_at: now, + updated_at: now, + created_by: tenant_id, + updated_by: tenant_id, + deleted_at: None, + version: 3, + }; + + let resp = NumberingService::model_to_resp(&model); + + assert_eq!(resp.id, id); + assert_eq!(resp.name, "订单编号"); + assert_eq!(resp.code, "ORDER"); + assert_eq!(resp.prefix, "ORD"); + assert_eq!(resp.date_format, Some("%Y%m%d".to_string())); + assert_eq!(resp.seq_length, 6); + assert_eq!(resp.seq_start, 1); + assert_eq!(resp.seq_current, 42); + assert_eq!(resp.separator, "-"); + assert_eq!(resp.reset_cycle, "daily"); + assert_eq!(resp.last_reset_date, Some(today.to_string())); + assert_eq!(resp.version, 3); + } + + #[test] + fn model_to_resp_none_fields() { + let id = Uuid::now_v7(); + let tenant_id = Uuid::now_v7(); + let now = Utc::now(); + + let model = numbering_rule::Model { + id, + tenant_id, + name: "简单编号".to_string(), + code: "SIMPLE".to_string(), + prefix: "".to_string(), + date_format: None, + seq_length: 4, + seq_start: 1, + seq_current: 1, + separator: "-".to_string(), + reset_cycle: "never".to_string(), + last_reset_date: None, + created_at: now, + updated_at: now, + created_by: tenant_id, + updated_by: tenant_id, + deleted_at: None, + version: 1, + }; + + let resp = NumberingService::model_to_resp(&model); + + assert_eq!(resp.date_format, None); + assert_eq!(resp.last_reset_date, None); + assert_eq!(resp.prefix, ""); + } + + // ---- format_number 测试 ---- + + #[test] + fn format_basic_prefix_no_date() { + // 基础:前缀 + 序列号 + let result = format_number("ORD", "/", None, 1, 5); + assert_eq!(result, "ORD/00001"); + } + + #[test] + fn format_with_date_part() { + // 前缀 + 日期 + 序列号 + let result = format_number("INV", "-", Some("20260430"), 42, 4); + assert_eq!(result, "INV-20260430-0042"); + } + + #[test] + fn format_no_prefix() { + // 无前缀,直接输出序列号 + let result = format_number("", "/", None, 7, 3); + assert_eq!(result, "007"); + } + + #[test] + fn format_no_prefix_no_date() { + // 无前缀无日期,仅序列号 + let result = format_number("", "-", None, 99, 6); + assert_eq!(result, "000099"); + } + + #[test] + fn format_seq_length_zero_pads_to_one() { + // seq_length=0 时仍至少填充 1 位 + let result = format_number("", "", None, 5, 0); + assert_eq!(result, "5"); + } +} diff --git a/crates/erp-config/src/service/setting_service.rs b/crates/erp-config/src/service/setting_service.rs new file mode 100644 index 0000000..1b11dd3 --- /dev/null +++ b/crates/erp-config/src/service/setting_service.rs @@ -0,0 +1,447 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set}; +use uuid::Uuid; + +use crate::dto::SettingResp; +use crate::entity::setting; +use crate::error::{ConfigError, ConfigResult}; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::events::EventBus; +use erp_core::types::Pagination; + +/// Setting scope hierarchy constants. +const SCOPE_PLATFORM: &str = "platform"; +const SCOPE_TENANT: &str = "tenant"; +const SCOPE_ORG: &str = "org"; +const SCOPE_USER: &str = "user"; + +/// Setting CRUD service — manage hierarchical configuration values. +/// +/// Settings support a 4-level inheritance hierarchy: +/// `user -> org -> tenant -> platform` +/// +/// When reading a setting, if the exact scope+scope_id match is not found, +/// the service walks up the hierarchy to find the nearest ancestor value. +pub struct SettingService; + +impl SettingService { + /// Get a setting value with hierarchical fallback. + /// + /// Resolution order: + /// 1. Exact match at (scope, scope_id) + /// 2. Walk up the hierarchy based on scope: + /// - `user` -> org -> tenant -> platform + /// - `org` -> tenant -> platform + /// - `tenant` -> platform + /// - `platform` -> NotFound + pub async fn get( + key: &str, + scope: &str, + scope_id: &Option, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult { + // 1. Try exact match + if let Some(resp) = Self::find_exact(key, scope, scope_id, tenant_id, db).await? { + return Ok(resp); + } + + // 2. Walk up the hierarchy based on scope + let fallback_chain = Self::fallback_chain(scope, scope_id, tenant_id)?; + + for (fb_scope, fb_scope_id) in fallback_chain { + if let Some(resp) = + Self::find_exact(key, &fb_scope, &fb_scope_id, tenant_id, db).await? + { + return Ok(resp); + } + } + + Err(ConfigError::NotFound(format!( + "设置 '{}' 在 '{}' 作用域下不存在", + key, scope + ))) + } + + /// Set a setting value. Creates or updates. + /// + /// If a record with the same (scope, scope_id, key) exists and is not + /// soft-deleted, it will be updated. Otherwise a new record is inserted. + pub async fn set( + params: crate::dto::SetSettingParams, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> ConfigResult { + // Look for an existing non-deleted record + let mut query = setting::Entity::find() + .filter(setting::Column::TenantId.eq(tenant_id)) + .filter(setting::Column::Scope.eq(¶ms.scope)) + .filter(setting::Column::SettingKey.eq(¶ms.key)) + .filter(setting::Column::DeletedAt.is_null()); + + query = match params.scope_id { + Some(id) => query.filter(setting::Column::ScopeId.eq(id)), + None => query.filter(setting::Column::ScopeId.is_null()), + }; + + let existing = query + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + if let Some(model) = existing { + // Update existing record — 乐观锁校验 + let next_version = match params.version { + Some(v) => { + check_version(v, model.version).map_err(|_| ConfigError::VersionMismatch)? + } + None => model.version + 1, + }; + + let mut active: setting::ActiveModel = model.into(); + active.setting_value = Set(params.value.clone()); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_version); + + let updated = active + .update(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "setting.updated", + tenant_id, + serde_json::json!({ + "setting_id": updated.id, + "key": params.key, + "scope": params.scope, + }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "setting.upsert", "setting") + .with_resource_id(updated.id), + db, + ) + .await; + + Ok(Self::model_to_resp(&updated)) + } else { + // Insert new record + let now = Utc::now(); + let id = Uuid::now_v7(); + let model = setting::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + scope: Set(params.scope.clone()), + scope_id: Set(params.scope_id), + setting_key: Set(params.key.clone()), + setting_value: Set(params.value), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + let inserted = model + .insert(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "setting.created", + tenant_id, + serde_json::json!({ + "setting_id": id, + "key": params.key, + "scope": params.scope, + }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "setting.upsert", "setting") + .with_resource_id(id), + db, + ) + .await; + + Ok(Self::model_to_resp(&inserted)) + } + } + + /// List all settings for a specific scope and scope_id, with pagination. + pub async fn list_by_scope( + scope: &str, + scope_id: &Option, + tenant_id: Uuid, + pagination: &Pagination, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult<(Vec, u64)> { + let mut query = setting::Entity::find() + .filter(setting::Column::TenantId.eq(tenant_id)) + .filter(setting::Column::Scope.eq(scope)) + .filter(setting::Column::DeletedAt.is_null()); + + query = match scope_id { + Some(id) => query.filter(setting::Column::ScopeId.eq(*id)), + None => query.filter(setting::Column::ScopeId.is_null()), + }; + + let paginator = query.paginate(db, pagination.limit()); + + let total = paginator + .num_items() + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + let page_index = pagination.page.unwrap_or(1).saturating_sub(1); + let models = paginator + .fetch_page(page_index) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + let resps: Vec = models.iter().map(Self::model_to_resp).collect(); + + Ok((resps, total)) + } + + /// Soft-delete a setting by setting the `deleted_at` timestamp. + /// Performs optimistic locking via version check. + pub async fn delete( + key: &str, + scope: &str, + scope_id: &Option, + tenant_id: Uuid, + operator_id: Uuid, + version: i32, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult<()> { + let mut query = setting::Entity::find() + .filter(setting::Column::TenantId.eq(tenant_id)) + .filter(setting::Column::Scope.eq(scope)) + .filter(setting::Column::SettingKey.eq(key)) + .filter(setting::Column::DeletedAt.is_null()); + + query = match scope_id { + Some(id) => query.filter(setting::Column::ScopeId.eq(*id)), + None => query.filter(setting::Column::ScopeId.is_null()), + }; + + let model = query + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))? + .ok_or_else(|| { + ConfigError::NotFound(format!("设置 '{}' 在 '{}' 作用域下不存在", key, scope)) + })?; + + let next_version = + check_version(version, model.version).map_err(|_| ConfigError::VersionMismatch)?; + + let setting_id = model.id; + let mut active: setting::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_version); + active + .update(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "setting.delete", "setting") + .with_resource_id(setting_id), + db, + ) + .await; + + Ok(()) + } + + // ---- 内部辅助方法 ---- + + /// Find an exact setting match by key, scope, and scope_id. + async fn find_exact( + key: &str, + scope: &str, + scope_id: &Option, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> ConfigResult> { + let mut query = setting::Entity::find() + .filter(setting::Column::TenantId.eq(tenant_id)) + .filter(setting::Column::Scope.eq(scope)) + .filter(setting::Column::SettingKey.eq(key)) + .filter(setting::Column::DeletedAt.is_null()); + + // SQL 中 `= NULL` 永远返回 false,必须用 IS NULL 匹配 NULL 值 + query = match scope_id { + Some(id) => query.filter(setting::Column::ScopeId.eq(*id)), + None => query.filter(setting::Column::ScopeId.is_null()), + }; + + let model = query + .one(db) + .await + .map_err(|e| ConfigError::Validation(e.to_string()))?; + + Ok(model.as_ref().map(Self::model_to_resp)) + } + + /// Build the fallback chain for hierarchical lookup. + /// + /// Returns a list of (scope, scope_id) tuples to try in order. + pub(crate) fn fallback_chain( + scope: &str, + _scope_id: &Option, + tenant_id: Uuid, + ) -> ConfigResult)>> { + match scope { + SCOPE_USER => { + // user -> org -> tenant -> platform + // Note: We cannot resolve the actual org_id from user scope here + // without a dependency on auth module. The caller should handle + // org-level resolution externally if needed. We skip org fallback + // and go directly to tenant. + Ok(vec![ + (SCOPE_TENANT.to_string(), Some(tenant_id)), + (SCOPE_PLATFORM.to_string(), None), + ]) + } + SCOPE_ORG => Ok(vec![ + (SCOPE_TENANT.to_string(), Some(tenant_id)), + (SCOPE_PLATFORM.to_string(), None), + ]), + SCOPE_TENANT => Ok(vec![(SCOPE_PLATFORM.to_string(), None)]), + SCOPE_PLATFORM => Ok(vec![]), + _ => Err(ConfigError::Validation(format!( + "不支持的作用域类型: '{}'", + scope + ))), + } + } + + /// Convert a SeaORM model to a response DTO. + pub(crate) fn model_to_resp(model: &setting::Model) -> SettingResp { + SettingResp { + id: model.id, + scope: model.scope.clone(), + scope_id: model.scope_id, + setting_key: model.setting_key.clone(), + setting_value: model.setting_value.clone(), + version: model.version, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tid() -> Uuid { + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap() + } + + // ---- fallback_chain ---- + + #[test] + fn fallback_user_scope_returns_tenant_then_platform() { + let chain = SettingService::fallback_chain("user", &None, tid()).unwrap(); + assert_eq!(chain.len(), 2); + assert_eq!(chain[0], ("tenant".to_string(), Some(tid()))); + assert_eq!(chain[1], ("platform".to_string(), None)); + } + + #[test] + fn fallback_org_scope_returns_tenant_then_platform() { + let chain = SettingService::fallback_chain("org", &None, tid()).unwrap(); + assert_eq!(chain.len(), 2); + assert_eq!(chain[0], ("tenant".to_string(), Some(tid()))); + assert_eq!(chain[1], ("platform".to_string(), None)); + } + + #[test] + fn fallback_tenant_scope_returns_platform() { + let chain = SettingService::fallback_chain("tenant", &None, tid()).unwrap(); + assert_eq!(chain.len(), 1); + assert_eq!(chain[0], ("platform".to_string(), None)); + } + + #[test] + fn fallback_platform_scope_returns_empty() { + let chain = SettingService::fallback_chain("platform", &None, tid()).unwrap(); + assert!(chain.is_empty()); + } + + #[test] + fn fallback_invalid_scope_returns_error() { + let result = SettingService::fallback_chain("invalid", &None, tid()); + assert!(result.is_err()); + match result.unwrap_err() { + ConfigError::Validation(msg) => assert!(msg.contains("不支持的作用域")), + other => panic!("期望 Validation,得到 {:?}", other), + } + } + + // ---- model_to_resp ---- + + #[test] + fn model_to_resp_maps_all_fields() { + let m = setting::Model { + id: Uuid::parse_str("00000000-0000-0000-0000-000000000010").unwrap(), + tenant_id: tid(), + scope: "tenant".to_string(), + scope_id: Some(tid()), + setting_key: "theme.primary_color".to_string(), + setting_value: serde_json::json!("#1890ff"), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + created_by: tid(), + updated_by: tid(), + deleted_at: None, + version: 3, + }; + let resp = SettingService::model_to_resp(&m); + assert_eq!(resp.scope, "tenant"); + assert_eq!(resp.setting_key, "theme.primary_color"); + assert_eq!(resp.setting_value, serde_json::json!("#1890ff")); + assert_eq!(resp.version, 3); + } + + #[test] + fn model_to_resp_null_scope_id() { + let m = setting::Model { + id: Uuid::parse_str("00000000-0000-0000-0000-000000000010").unwrap(), + tenant_id: tid(), + scope: "platform".to_string(), + scope_id: None, + setting_key: "language.default".to_string(), + setting_value: serde_json::json!("zh-CN"), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + created_by: tid(), + updated_by: tid(), + deleted_at: None, + version: 1, + }; + let resp = SettingService::model_to_resp(&m); + assert_eq!(resp.scope_id, None); + } +} diff --git a/crates/erp-core/Cargo.toml b/crates/erp-core/Cargo.toml new file mode 100644 index 0000000..293a369 --- /dev/null +++ b/crates/erp-core/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "erp-core" +version.workspace = true +edition.workspace = true + +[dependencies] +tokio.workspace = true +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +thiserror.workspace = true +anyhow.workspace = true +tracing.workspace = true +axum.workspace = true +sea-orm.workspace = true +async-trait.workspace = true +utoipa.workspace = true +aes-gcm = "0.10" +hmac = "0.12" +sha2 = "0.10" +base64 = "0.22" +hex = "0.4" +rand = "0.8" +dashmap = "6" +ammonia.workspace = true diff --git a/crates/erp-core/src/aggregate.rs b/crates/erp-core/src/aggregate.rs new file mode 100644 index 0000000..26e4479 --- /dev/null +++ b/crates/erp-core/src/aggregate.rs @@ -0,0 +1,38 @@ +//! 聚合查询容错工具 +//! +//! 仪表盘等聚合统计端点通常包含多个独立子查询。 +//! 单个子查询失败不应导致整个接口 500。 +//! `safe_aggregate` 让每个子查询独立容错,失败时返回默认值并记录警告日志。 + +use std::future::Future; + +/// 执行一个子查询,失败时返回 `T::default()` 并记录警告日志。 +/// +/// # 使用场景 +/// +/// 仪表盘统计 API 聚合多个指标(患者数/咨询数/随访数等), +/// 任一子查询失败不应阻塞其他指标返回。 +/// +/// # 示例 +/// +/// ```rust,ignore +/// let patients = safe_aggregate( +/// stats_service::get_patient_statistics(&state, tenant_id), +/// "患者统计", +/// ).await; +/// ``` +pub async fn safe_aggregate( + fut: impl Future>, + label: &str, +) -> T { + match fut.await { + Ok(v) => { + tracing::debug!("聚合子查询 [{label}] 成功"); + v + } + Err(e) => { + tracing::warn!("聚合子查询 [{label}] 失败,使用默认值: {e}"); + T::default() + } + } +} diff --git a/crates/erp-core/src/audit.rs b/crates/erp-core/src/audit.rs new file mode 100644 index 0000000..68a6275 --- /dev/null +++ b/crates/erp-core/src/audit.rs @@ -0,0 +1,67 @@ +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// 审计日志记录。 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuditLog { + pub id: Uuid, + pub tenant_id: Uuid, + pub user_id: Option, + pub action: String, + pub resource_type: String, + pub resource_id: Option, + pub old_value: Option, + pub new_value: Option, + pub ip_address: Option, + pub user_agent: Option, + pub created_at: chrono::DateTime, +} + +impl AuditLog { + /// 创建一条审计日志记录。 + pub fn new( + tenant_id: Uuid, + user_id: Option, + action: impl Into, + resource_type: impl Into, + ) -> Self { + Self { + id: Uuid::now_v7(), + tenant_id, + user_id, + action: action.into(), + resource_type: resource_type.into(), + resource_id: None, + old_value: None, + new_value: None, + ip_address: None, + user_agent: None, + created_at: Utc::now(), + } + } + + /// 设置资源 ID。 + pub fn with_resource_id(mut self, id: Uuid) -> Self { + self.resource_id = Some(id); + self + } + + /// 设置变更前后的值。 + pub fn with_changes( + mut self, + old: Option, + new: Option, + ) -> Self { + self.old_value = old; + self.new_value = new; + self + } + + /// 设置请求来源信息。 + pub fn with_request_info(mut self, ip: Option, user_agent: Option) -> Self { + self.ip_address = ip; + self.user_agent = user_agent; + self + } +} diff --git a/crates/erp-core/src/audit_service.rs b/crates/erp-core/src/audit_service.rs new file mode 100644 index 0000000..ea147e2 --- /dev/null +++ b/crates/erp-core/src/audit_service.rs @@ -0,0 +1,285 @@ +use crate::audit::AuditLog; +use crate::entity::audit_log; +use crate::request_info::RequestInfo; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tracing; +use uuid::Uuid; + +/// 审计日志中需要脱敏的 PII 字段名(小写匹配) +const PII_FIELDS: &[&str] = &[ + "id_number", + "phone", + "emergency_contact_phone", + "emergency_contact_name", + "allergy_history", + "medical_history_summary", + "name", + "content", +]; + +/// 审计日志中需要脱敏的 resource_type 前缀 +const PII_RESOURCE_TYPES: &[&str] = &[ + "patient", + "consultation", + "follow_up", + "family_member", + "doctor_profile", +]; + +/// 对 JSON Value 中的 PII 字段进行脱敏 +fn sanitize_audit_value( + value: &Option, + resource_type: &str, +) -> Option { + let needs_sanitization = PII_RESOURCE_TYPES + .iter() + .any(|prefix| resource_type.starts_with(prefix)); + + if !needs_sanitization { + return value.clone(); + } + + value.as_ref().map(sanitize_json_value) +} + +fn sanitize_json_value(v: &serde_json::Value) -> serde_json::Value { + match v { + serde_json::Value::Object(map) => { + let sanitized: serde_json::Map = map + .into_iter() + .map(|(k, v)| { + let key_lower = k.to_lowercase(); + if PII_FIELDS.iter().any(|f| key_lower.contains(f)) { + (k.clone(), serde_json::Value::String("***".to_string())) + } else { + (k.clone(), sanitize_json_value(v)) + } + }) + .collect(); + serde_json::Value::Object(sanitized) + } + serde_json::Value::Array(arr) => { + serde_json::Value::Array(arr.iter().map(sanitize_json_value).collect()) + } + other => other.clone(), + } +} + +/// 持久化审计日志到 audit_logs 表。 +/// +/// 使用 fire-and-forget 模式:失败仅记录 warning 日志,不影响业务操作。 +/// +/// 自动从 task_local 读取当前请求的 IP 和 User-Agent, +/// 如果 AuditLog 中已有 ip_address/user_agent 则不覆盖。 +/// +/// 哈希链:查询同租户最新一条记录的 record_hash 作为 prev_hash, +/// 计算 SHA256(id + action + resource_type + resource_id + created_at + prev_hash) 作为 record_hash。 +pub async fn record(mut log: AuditLog, db: &sea_orm::DatabaseConnection) { + // 自动填充请求来源信息(仅当调用方未显式设置时) + if let Some(info) = RequestInfo::try_current() { + if log.ip_address.is_none() { + log.ip_address = info.ip_address; + } + if log.user_agent.is_none() { + log.user_agent = info.user_agent; + } + } + + // 查询同租户最新一条记录的 record_hash 作为 prev_hash + let prev_hash = audit_log::Entity::find() + .filter(audit_log::Column::TenantId.eq(log.tenant_id)) + .filter(audit_log::Column::RecordHash.is_not_null()) + .order_by_desc(audit_log::Column::CreatedAt) + .one(db) + .await + .ok() + .flatten() + .and_then(|m| m.record_hash); + + // 计算当前记录的 record_hash + let record_hash = compute_record_hash(&log, prev_hash.as_deref()); + + // 脱敏处理:对 patient/consultation/follow_up 等资源类型的变更值中 PII 字段进行 mask + let sanitized_old = sanitize_audit_value(&log.old_value, &log.resource_type); + let sanitized_new = sanitize_audit_value(&log.new_value, &log.resource_type); + + // 保存日志字段用于错误日志(model 构建会 move String 字段) + let err_tenant_id = log.tenant_id; + let err_action = log.action.clone(); + let err_resource_type = log.resource_type.clone(); + let err_resource_id = log.resource_id; + + let model = audit_log::ActiveModel { + id: Set(log.id), + tenant_id: Set(log.tenant_id), + user_id: Set(log.user_id), + action: Set(log.action), + resource_type: Set(log.resource_type), + resource_id: Set(log.resource_id), + old_value: Set(sanitized_old), + new_value: Set(sanitized_new), + ip_address: Set(log.ip_address), + user_agent: Set(log.user_agent), + created_at: Set(log.created_at), + prev_hash: Set(prev_hash), + record_hash: Set(Some(record_hash)), + }; + + if let Err(e) = model.insert(db).await { + tracing::error!( + error = %e, + tenant_id = ?err_tenant_id, + action = %err_action, + resource_type = %err_resource_type, + resource_id = ?err_resource_id, + "审计日志写入失败 — 数据完整性风险" + ); + } +} + +/// 计算 record_hash: SHA256(id + action + resource_type + resource_id + created_at + prev_hash) +fn compute_record_hash(log: &AuditLog, prev_hash: Option<&str>) -> String { + let mut hasher = Sha256::new(); + hasher.update(log.id.to_string().as_bytes()); + hasher.update(log.action.as_bytes()); + hasher.update(log.resource_type.as_bytes()); + hasher.update( + log.resource_id + .map(|id| id.to_string()) + .unwrap_or_default() + .as_bytes(), + ); + hasher.update(log.created_at.to_rfc3339().as_bytes()); + hasher.update(prev_hash.unwrap_or("").as_bytes()); + format!("{:x}", hasher.finalize()) +} + +/// 验证审计日志哈希链完整性。 +/// +/// 检查指定租户的所有含 record_hash 的日志记录, +/// 验证每条记录的 prev_hash 是否等于前一条的 record_hash, +/// 以及 record_hash 是否可以重新计算验证。 +/// +/// 返回 (总记录数, 断链数)。 +pub async fn verify_hash_chain( + db: &sea_orm::DatabaseConnection, + tenant_id: uuid::Uuid, +) -> Result<(usize, usize), sea_orm::DbErr> { + use sea_orm::QueryOrder; + + let records = audit_log::Entity::find() + .filter(audit_log::Column::TenantId.eq(tenant_id)) + .filter(audit_log::Column::RecordHash.is_not_null()) + .order_by_asc(audit_log::Column::CreatedAt) + .all(db) + .await?; + + let total = records.len(); + let mut broken = 0; + let mut prev: Option = None; + + for record in &records { + // 验证 prev_hash 指向正确 + if prev.as_deref() != record.prev_hash.as_deref() { + broken += 1; + } + + // 验证 record_hash 可重算 + let log = AuditLog { + id: record.id, + tenant_id: record.tenant_id, + user_id: record.user_id, + action: record.action.clone(), + resource_type: record.resource_type.clone(), + resource_id: record.resource_id, + old_value: record.old_value.clone(), + new_value: record.new_value.clone(), + ip_address: record.ip_address.clone(), + user_agent: record.user_agent.clone(), + created_at: record.created_at, + }; + let expected = compute_record_hash(&log, record.prev_hash.as_deref()); + if Some(expected.as_str()) != record.record_hash.as_deref() { + broken += 1; + } + + prev = record.record_hash.clone(); + } + + Ok((total, broken)) +} + +/// 哈希链验证结果 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChainVerificationResult { + pub total: usize, + pub passed: usize, + pub failed: usize, + pub failed_ids: Vec, +} + +/// 验证最近 N 条审计记录的哈希链完整性。 +pub async fn verify_recent_chain( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + limit: u64, +) -> Result { + let records = audit_log::Entity::find() + .filter(audit_log::Column::TenantId.eq(tenant_id)) + .filter(audit_log::Column::RecordHash.is_not_null()) + .order_by_desc(audit_log::Column::CreatedAt) + .limit(limit) + .all(db) + .await + .map_err(|e| format!("查询审计日志失败: {}", e))?; + + let mut records = records; + records.sort_by(|a, b| a.created_at.cmp(&b.created_at)); + + let total = records.len(); + let mut passed = 0; + let mut failed_ids = Vec::new(); + let mut prev: Option = None; + + for record in &records { + let mut record_broken = false; + if prev.as_deref() != record.prev_hash.as_deref() { + record_broken = true; + } + let log = AuditLog { + id: record.id, + tenant_id: record.tenant_id, + user_id: record.user_id, + action: record.action.clone(), + resource_type: record.resource_type.clone(), + resource_id: record.resource_id, + old_value: record.old_value.clone(), + new_value: record.new_value.clone(), + ip_address: record.ip_address.clone(), + user_agent: record.user_agent.clone(), + created_at: record.created_at, + }; + let expected = compute_record_hash(&log, record.prev_hash.as_deref()); + if Some(expected.as_str()) != record.record_hash.as_deref() { + record_broken = true; + } + if record_broken { + failed_ids.push(record.id); + } else { + passed += 1; + } + prev = record.record_hash.clone(); + } + + let failed = total - passed; + Ok(ChainVerificationResult { + total, + passed, + failed, + failed_ids, + }) +} diff --git a/crates/erp-core/src/crypto/engine.rs b/crates/erp-core/src/crypto/engine.rs new file mode 100644 index 0000000..c7f3e62 --- /dev/null +++ b/crates/erp-core/src/crypto/engine.rs @@ -0,0 +1,48 @@ +use aes_gcm::aead::Aead; +use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use rand::RngCore; + +const CIPHER_VERSION: u8 = 0x01; + +/// AES-256-GCM 加密。输出格式: Base64(0x01 || nonce[12] || ciphertext + tag) +pub fn encrypt(key: &[u8; 32], plaintext: &str) -> Result { + let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?; + let mut nonce_bytes = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from_slice(&nonce_bytes); + let ciphertext = cipher + .encrypt(nonce, plaintext.as_bytes()) + .map_err(|e| e.to_string())?; + let mut combined = vec![CIPHER_VERSION]; + combined.extend_from_slice(&nonce_bytes); + combined.extend_from_slice(&ciphertext); + Ok(BASE64.encode(&combined)) +} + +/// AES-256-GCM 解密。支持 v1 格式: Base64(0x01 || nonce[12] || ciphertext + tag) +/// 兼容旧格式: Base64(nonce[12] || ciphertext + tag) +pub fn decrypt(key: &[u8; 32], encoded: &str) -> Result { + let bytes = BASE64.decode(encoded).map_err(|e| e.to_string())?; + if bytes.len() < 13 { + return Err("ciphertext too short".into()); + } + + let (nonce_bytes, ciphertext) = if bytes[0] == CIPHER_VERSION { + // v1: version(1) + nonce(12) + ciphertext + if bytes.len() < 14 { + return Err("v1 ciphertext too short".into()); + } + (&bytes[1..13], &bytes[13..]) + } else { + // 旧格式: nonce(12) + ciphertext(向后兼容) + (&bytes[0..12], &bytes[12..]) + }; + + let cipher = Aes256Gcm::new_from_slice(key).map_err(|e| e.to_string())?; + let nonce = Nonce::from_slice(nonce_bytes); + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|e| e.to_string())?; + String::from_utf8(plaintext).map_err(|e| e.to_string()) +} diff --git a/crates/erp-core/src/crypto/hmac_index.rs b/crates/erp-core/src/crypto/hmac_index.rs new file mode 100644 index 0000000..ba11766 --- /dev/null +++ b/crates/erp-core/src/crypto/hmac_index.rs @@ -0,0 +1,24 @@ +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +/// HMAC-SHA256 搜索索引。使用 KEK 派生的独立子密钥,与加密密钥分离。 +pub fn hmac_hash(key: &[u8; 32], value: &str) -> String { + let hmac_key = derive_hmac_key(key); + let mut mac = HmacSha256::new_from_slice(&hmac_key).expect("HMAC key length is valid"); + mac.update(value.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +/// 从 KEK 派生独立的 HMAC 子密钥,避免密钥复用 +fn derive_hmac_key(kek: &[u8; 32]) -> [u8; 32] { + use sha2::Digest; + let derived = ::new() + .chain_update(b"pii-hmac-index-v1") + .chain_update(kek) + .finalize(); + let mut key = [0u8; 32]; + key.copy_from_slice(&derived); + key +} diff --git a/crates/erp-core/src/crypto/key_manager.rs b/crates/erp-core/src/crypto/key_manager.rs new file mode 100644 index 0000000..c56d756 --- /dev/null +++ b/crates/erp-core/src/crypto/key_manager.rs @@ -0,0 +1,225 @@ +use std::time::Instant; + +use dashmap::DashMap; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; + +use super::engine; + +/// DEK 缓存条目 — Drop 时清零密钥材料 +#[derive(Clone)] +struct CachedDek { + dek: [u8; 32], + version: u32, + loaded_at: Instant, +} + +impl Drop for CachedDek { + fn drop(&mut self) { + self.dek.fill(0); + } +} + +/// DEK 缓存管理 — 每租户独立 DEK,LRU + TTL +#[derive(Clone)] +pub struct DekManager { + cache: DashMap, + ttl_secs: u64, + max_entries: usize, +} + +impl DekManager { + pub fn new(ttl_secs: u64, max_entries: usize) -> Self { + Self { + cache: DashMap::new(), + ttl_secs, + max_entries, + } + } + + /// 获取或创建租户的 DEK + pub fn get_or_create_dek( + &self, + tenant_id: Uuid, + encrypted_dek: Option<&str>, + kek: &[u8; 32], + ) -> AppResult<([u8; 32], u32)> { + // 检查缓存 + if let Some(entry) = self.cache.get(&tenant_id) + && entry.loaded_at.elapsed().as_secs() < self.ttl_secs + { + return Ok((entry.dek, entry.version)); + } + + // 从加密 DEK 解密 + if let Some(enc_dek) = encrypted_dek { + let dek_hex = engine::decrypt(kek, enc_dek).map_err(AppError::Internal)?; + let dek_bytes = hex::decode(&dek_hex).map_err(|e| AppError::Internal(e.to_string()))?; + if dek_bytes.len() != 32 { + return Err(AppError::Internal("DEK must be 32 bytes".into())); + } + let mut dek = [0u8; 32]; + dek.copy_from_slice(&dek_bytes); + + // 缓存(版本从外部传入时无法确定,使用默认值 1) + self.evict_if_full(); + self.cache.insert( + tenant_id, + CachedDek { + dek, + version: 1, + loaded_at: Instant::now(), + }, + ); + return Ok((dek, 1)); + } + + // 无现有 DEK → 生成新的 + let dek = Self::generate_dek(); + self.evict_if_full(); + self.cache.insert( + tenant_id, + CachedDek { + dek, + version: 1, + loaded_at: Instant::now(), + }, + ); + Ok((dek, 1)) + } + + /// 使用 KEK 加密 DEK 以便存储 + pub fn encrypt_dek_for_storage(dek: &[u8; 32], kek: &[u8; 32]) -> AppResult { + let dek_hex = hex::encode(dek); + engine::encrypt(kek, &dek_hex).map_err(AppError::Internal) + } + + /// 生成新 DEK 并用 KEK 加密,返回 (新 DEK, 加密后的 DEK) + pub fn generate_new_dek(kek: &[u8; 32]) -> AppResult<([u8; 32], String)> { + let dek = Self::generate_dek(); + let encrypted = Self::encrypt_dek_for_storage(&dek, kek)?; + Ok((dek, encrypted)) + } + + /// 使缓存失效(轮换后调用) + pub fn invalidate(&self, tenant_id: Uuid) { + self.cache.remove(&tenant_id); + } + + fn generate_dek() -> [u8; 32] { + use rand::RngCore; + let mut dek = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut dek); + dek + } + + fn evict_if_full(&self) { + if self.cache.len() >= self.max_entries { + let to_remove: Vec = self + .cache + .iter() + .filter(|e| e.loaded_at.elapsed().as_secs() > self.ttl_secs / 2) + .map(|e| *e.key()) + .take(self.max_entries / 2) + .collect(); + for id in to_remove { + self.cache.remove(&id); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::crypto::PiiCrypto; + + fn test_kek() -> [u8; 32] { + *PiiCrypto::dev_default().kek() + } + + fn test_uuid(i: u8) -> Uuid { + let s = format!("00000000-0000-0000-0000-0000000000{:02x}", i); + Uuid::parse_str(&s).unwrap() + } + + #[test] + fn generate_new_dek_returns_32_bytes() { + let (dek, _enc) = DekManager::generate_new_dek(&test_kek()).unwrap(); + assert_eq!(dek.len(), 32); + } + + #[test] + fn generate_new_dek_produces_unique_keys() { + let (dek1, _) = DekManager::generate_new_dek(&test_kek()).unwrap(); + let (dek2, _) = DekManager::generate_new_dek(&test_kek()).unwrap(); + assert_ne!(dek1, dek2); + } + + #[test] + fn encrypt_dek_roundtrip() { + let kek = test_kek(); + let (original_dek, encrypted) = DekManager::generate_new_dek(&kek).unwrap(); + let mgr = DekManager::new(300, 100); + let tenant_id = test_uuid(1); + let (recovered_dek, _ver) = mgr + .get_or_create_dek(tenant_id, Some(&encrypted), &kek) + .unwrap(); + assert_eq!(original_dek, recovered_dek); + } + + #[test] + fn get_or_create_generates_when_none() { + let mgr = DekManager::new(300, 100); + let tenant_id = test_uuid(2); + let (dek1, ver1) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap(); + assert_eq!(ver1, 1); + let (dek2, ver2) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap(); + assert_eq!(dek1, dek2); + assert_eq!(ver2, 1); + } + + #[test] + fn invalidate_removes_cached_dek() { + let mgr = DekManager::new(300, 100); + let tenant_id = test_uuid(3); + let (dek1, _) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap(); + mgr.invalidate(tenant_id); + let (dek2, _) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap(); + assert_ne!(dek1, dek2); + } + + #[test] + fn decrypt_with_wrong_kek_fails() { + let kek1 = test_kek(); + let kek2 = [0xffu8; 32]; + let (_, encrypted) = DekManager::generate_new_dek(&kek1).unwrap(); + let mgr = DekManager::new(300, 100); + let tenant_id = test_uuid(4); + assert!( + mgr.get_or_create_dek(tenant_id, Some(&encrypted), &kek2) + .is_err() + ); + } + + #[test] + fn expired_entry_not_returned() { + let mgr = DekManager::new(0, 100); + let tenant_id = test_uuid(5); + let (dek1, _) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap(); + let (dek2, _) = mgr.get_or_create_dek(tenant_id, None, &test_kek()).unwrap(); + assert_ne!(dek1, dek2); + } + + #[test] + fn max_entries_eviction() { + let mgr = DekManager::new(300, 3); + for i in 0..5u8 { + let _ = mgr + .get_or_create_dek(test_uuid(i), None, &test_kek()) + .unwrap(); + } + assert!(mgr.cache.len() <= 6); + } +} diff --git a/crates/erp-core/src/crypto/masking.rs b/crates/erp-core/src/crypto/masking.rs new file mode 100644 index 0000000..3754520 --- /dev/null +++ b/crates/erp-core/src/crypto/masking.rs @@ -0,0 +1,113 @@ +/// 身份证号脱敏: 保留前 3 位和后 4 位,中间用 **** 替代 +pub fn mask_id_number(s: &str) -> String { + let chars: Vec = s.chars().collect(); + if chars.len() >= 7 { + let head: String = chars[..3].iter().collect(); + let tail: String = chars[chars.len() - 4..].iter().collect(); + format!("{}****{}", head, tail) + } else { + "****".to_string() + } +} + +/// 手机号脱敏: 保留前 3 位和后 4 位,中间用 **** 替代 +pub fn mask_phone(s: Option<&str>) -> Option { + s.map(|p| { + let chars: Vec = p.chars().collect(); + if chars.len() >= 7 { + let head: String = chars[..3].iter().collect(); + let tail: String = chars[chars.len() - 4..].iter().collect(); + format!("{}****{}", head, tail) + } else { + "****".to_string() + } + }) +} + +/// 执业证号脱敏: 保留前 2 位和后 2 位,中间用 **** 替代 +pub fn mask_license_number(s: &str) -> String { + let chars: Vec = s.chars().collect(); + if chars.len() >= 5 { + let head: String = chars[..2].iter().collect(); + let tail: String = chars[chars.len() - 2..].iter().collect(); + format!("{}****{}", head, tail) + } else { + "****".to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mask_id_18_digits() { + assert_eq!("110****1234", mask_id_number("110101199001011234")); + } + + #[test] + fn mask_id_short() { + assert_eq!("****", mask_id_number("123456")); + } + + #[test] + fn mask_id_empty() { + assert_eq!("****", mask_id_number("")); + } + + #[test] + fn mask_phone_normal() { + assert_eq!( + Some("138****5678".to_string()), + mask_phone(Some("13812345678")) + ); + } + + #[test] + fn mask_phone_none() { + assert_eq!(None, mask_phone(None)); + } + + #[test] + fn mask_phone_short() { + assert_eq!(Some("****".to_string()), mask_phone(Some("123"))); + } + + #[test] + fn mask_phone_exactly_7() { + assert_eq!(Some("123****4567".to_string()), mask_phone(Some("1234567"))); + } + + #[test] + fn mask_id_exactly_7() { + assert_eq!("123****4567", mask_id_number("1234567")); + } + + #[test] + fn mask_id_unicode_safe() { + assert_eq!("你好世****cdef", mask_id_number("你好世界abcdef")); + } + + #[test] + fn mask_phone_unicode_safe() { + assert_eq!( + Some("你好世****cdef".to_string()), + mask_phone(Some("你好世界abcdef")) + ); + } + + #[test] + fn mask_license_normal() { + assert_eq!("YL****23", mask_license_number("YL-2024-00123")); + } + + #[test] + fn mask_license_short() { + assert_eq!("****", mask_license_number("AB")); + } + + #[test] + fn mask_license_empty() { + assert_eq!("****", mask_license_number("")); + } +} diff --git a/crates/erp-core/src/crypto/mod.rs b/crates/erp-core/src/crypto/mod.rs new file mode 100644 index 0000000..ca507db --- /dev/null +++ b/crates/erp-core/src/crypto/mod.rs @@ -0,0 +1,234 @@ +pub mod engine; +pub mod hmac_index; +pub mod key_manager; +pub mod masking; + +pub use engine::{decrypt, encrypt}; +pub use hmac_index::hmac_hash; +pub use key_manager::DekManager; +pub use masking::{mask_id_number, mask_license_number, mask_phone}; + +use crate::error::{AppError, AppResult}; + +/// PII 加密服务 — 封装 KEK 和 DEK 管理 +#[derive(Clone)] +pub struct PiiCrypto { + kek: [u8; 32], + hmac_key: [u8; 32], + pub(crate) dek_manager: DekManager, +} + +impl PiiCrypto { + /// 从 hex 编码的 KEK 创建。KEK 为 64 字符 hex(32 字节)。 + pub fn from_kek_hex(kek_hex: &str) -> AppResult { + let bytes = hex::decode(kek_hex) + .map_err(|e| AppError::Internal(format!("KEK hex decode failed: {}", e)))?; + if bytes.len() != 32 { + return Err(AppError::Internal( + "KEK must be 32 bytes (64 hex chars)".into(), + )); + } + let mut kek = [0u8; 32]; + kek.copy_from_slice(&bytes); + Ok(Self::from_kek(kek)) + } + + /// Dev fallback: 从固定字符串派生确定性 KEK。仅用于开发。 + pub fn dev_default() -> Self { + use sha2::Digest; + let kek = ::digest(b"erp-pii-kek-dev-key-DO-NOT-USE-IN-PROD"); + let mut key = [0u8; 32]; + key.copy_from_slice(&kek); + Self::from_kek(key) + } + + fn from_kek(kek: [u8; 32]) -> Self { + use sha2::Digest; + let hmac_key = ::new() + .chain_update(b"pii-hmac-index-v1") + .chain_update(kek) + .finalize(); + let mut hk = [0u8; 32]; + hk.copy_from_slice(&hmac_key); + Self { + kek, + hmac_key: hk, + dek_manager: DekManager::new(300, 100), + } + } + + pub fn kek(&self) -> &[u8; 32] { + &self.kek + } + + /// HMAC 搜索索引使用的独立子密钥 + pub fn hmac_key(&self) -> &[u8; 32] { + &self.hmac_key + } + + /// 使指定租户的 DEK 缓存失效 + pub fn invalidate_dek(&self, tenant_id: uuid::Uuid) { + self.dek_manager.invalidate(tenant_id); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_crypto() -> PiiCrypto { + PiiCrypto::dev_default() + } + + #[test] + fn from_kek_hex_roundtrip() { + let kek_hex = "00".repeat(32); + let crypto = PiiCrypto::from_kek_hex(&kek_hex).unwrap(); + assert_eq!(crypto.kek(), &[0u8; 32]); + } + + #[test] + fn from_kek_hex_invalid() { + assert!(PiiCrypto::from_kek_hex("not-hex").is_err()); + } + + #[test] + fn from_kek_hex_wrong_length() { + assert!(PiiCrypto::from_kek_hex("ab").is_err()); + } + + #[test] + fn encrypt_decrypt_roundtrip() { + let crypto = test_crypto(); + let plaintext = "13812345678"; + let encrypted = encrypt(crypto.kek(), plaintext).unwrap(); + let decrypted = decrypt(crypto.kek(), &encrypted).unwrap(); + assert_eq!(plaintext, decrypted); + } + + #[test] + fn encrypt_produces_different_ciphertexts() { + let crypto = test_crypto(); + let e1 = encrypt(crypto.kek(), "test").unwrap(); + let e2 = encrypt(crypto.kek(), "test").unwrap(); + assert_ne!(e1, e2); + } + + #[test] + fn decrypt_wrong_key_fails() { + let crypto1 = PiiCrypto::dev_default(); + let other_key_hex = "ff".repeat(32); + let crypto2 = PiiCrypto::from_kek_hex(&other_key_hex).unwrap(); + let encrypted = encrypt(crypto1.kek(), "test").unwrap(); + assert!(decrypt(crypto2.kek(), &encrypted).is_err()); + } + + #[test] + fn hmac_hash_deterministic() { + let crypto = test_crypto(); + let h1 = hmac_hash(crypto.hmac_key(), "13812345678"); + let h2 = hmac_hash(crypto.hmac_key(), "13812345678"); + assert_eq!(h1, h2); + } + + #[test] + fn hmac_hash_different_inputs() { + let crypto = test_crypto(); + let h1 = hmac_hash(crypto.hmac_key(), "111"); + let h2 = hmac_hash(crypto.hmac_key(), "222"); + assert_ne!(h1, h2); + } + + #[test] + fn hmac_key_differs_from_kek() { + let crypto = test_crypto(); + assert_ne!(crypto.kek(), crypto.hmac_key(), "HMAC 密钥应与 KEK 不同"); + } + + #[test] + fn encrypt_empty_string() { + let crypto = test_crypto(); + let encrypted = encrypt(crypto.kek(), "").unwrap(); + let decrypted = decrypt(crypto.kek(), &encrypted).unwrap(); + assert_eq!("", decrypted); + } + + #[test] + fn decrypt_too_short_fails() { + use base64::Engine; + let short = base64::engine::general_purpose::STANDARD.encode(b"short"); + assert!(decrypt(&[0u8; 32], &short).is_err()); + } + + #[test] + fn encrypt_unicode() { + let crypto = test_crypto(); + let plaintext = "患者过敏史:青霉素、磺胺类药物"; + let encrypted = encrypt(crypto.kek(), plaintext).unwrap(); + let decrypted = decrypt(crypto.kek(), &encrypted).unwrap(); + assert_eq!(plaintext, decrypted); + } + + #[test] + fn ciphertext_has_version_prefix() { + let crypto = test_crypto(); + let encrypted = encrypt(crypto.kek(), "test").unwrap(); + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(&encrypted) + .unwrap(); + assert_eq!(bytes[0], 0x01, "密文首字节应为版本号 0x01"); + } + + // ── 性能基准 ── + + #[test] + fn bench_encrypt_1000() { + let crypto = test_crypto(); + let kek = crypto.kek(); + let plaintext = "13812345678"; + let start = std::time::Instant::now(); + for _ in 0..1000 { + let _ = encrypt(kek, plaintext).unwrap(); + } + let elapsed = start.elapsed(); + let avg_us = elapsed.as_micros() / 1000; + assert!(avg_us < 50, "encrypt 平均耗时应 < 50μs, 实际: {}μs", avg_us); + eprintln!("encrypt 1000 次: {:?} (avg {}μs)", elapsed, avg_us); + } + + #[test] + fn bench_decrypt_1000() { + let crypto = test_crypto(); + let kek = crypto.kek(); + let ciphertext = encrypt(kek, "13812345678").unwrap(); + let start = std::time::Instant::now(); + for _ in 0..1000 { + let _ = decrypt(kek, &ciphertext).unwrap(); + } + let elapsed = start.elapsed(); + let avg_us = elapsed.as_micros() / 1000; + assert!(avg_us < 50, "decrypt 平均耗时应 < 50μs, 实际: {}μs", avg_us); + eprintln!("decrypt 1000 次: {:?} (avg {}μs)", elapsed, avg_us); + } + + #[test] + fn bench_batch_decrypt_50() { + let crypto = test_crypto(); + let kek = crypto.kek(); + let ciphertexts: Vec = (0..50) + .map(|i| encrypt(kek, &format!("数据{}", i)).unwrap()) + .collect(); + let start = std::time::Instant::now(); + for ct in &ciphertexts { + let _ = decrypt(kek, ct).unwrap(); + } + let elapsed = start.elapsed(); + assert!( + elapsed.as_millis() < 10, + "批量解密 50 条应 < 10ms, 实际: {}ms", + elapsed.as_millis() + ); + eprintln!("batch decrypt 50 条: {:?}", elapsed); + } +} diff --git a/crates/erp-core/src/entity/audit_log.rs b/crates/erp-core/src/entity/audit_log.rs new file mode 100644 index 0000000..a828a0e --- /dev/null +++ b/crates/erp-core/src/entity/audit_log.rs @@ -0,0 +1,29 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// 审计日志实体 — 映射 audit_logs 表。 +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "audit_logs")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub user_id: Option, + pub action: String, + pub resource_type: String, + pub resource_id: Option, + pub old_value: Option, + pub new_value: Option, + pub ip_address: Option, + pub user_agent: Option, + pub created_at: DateTimeUtc, + /// 哈希链 — 前一条记录的 record_hash + pub prev_hash: Option, + /// 当前记录的哈希 SHA256(id + action + resource_type + resource_id + created_at + prev_hash) + pub record_hash: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-core/src/entity/dead_letter_event.rs b/crates/erp-core/src/entity/dead_letter_event.rs new file mode 100644 index 0000000..e8ec2f9 --- /dev/null +++ b/crates/erp-core/src/entity/dead_letter_event.rs @@ -0,0 +1,27 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "dead_letter_events")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, + pub original_event_id: Uuid, + pub event_type: String, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub payload: Option, + pub consumer_id: String, + pub attempts: i32, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub last_error: Option, + pub created_at: DateTimeUtc, + #[sea_orm(skip_serializing_if = "Option::is_none")] + pub resolved_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-core/src/entity/domain_event.rs b/crates/erp-core/src/entity/domain_event.rs new file mode 100644 index 0000000..0d72db5 --- /dev/null +++ b/crates/erp-core/src/entity/domain_event.rs @@ -0,0 +1,24 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// 领域事件实体 — 映射 domain_events 表。 +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "domain_events")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub event_type: String, + pub payload: Option, + pub correlation_id: Option, + pub status: String, + pub attempts: i32, + pub last_error: Option, + pub created_at: DateTimeUtc, + pub published_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-core/src/entity/mod.rs b/crates/erp-core/src/entity/mod.rs new file mode 100644 index 0000000..34dd114 --- /dev/null +++ b/crates/erp-core/src/entity/mod.rs @@ -0,0 +1,4 @@ +pub mod audit_log; +pub mod dead_letter_event; +pub mod domain_event; +pub mod processed_event; diff --git a/crates/erp-core/src/entity/processed_event.rs b/crates/erp-core/src/entity/processed_event.rs new file mode 100644 index 0000000..b03d124 --- /dev/null +++ b/crates/erp-core/src/entity/processed_event.rs @@ -0,0 +1,18 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +/// 已处理事件记录 — 幂等性去重表。 +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "processed_events")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub event_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub consumer_id: String, + pub processed_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-core/src/error.rs b/crates/erp-core/src/error.rs new file mode 100644 index 0000000..7c6baa6 --- /dev/null +++ b/crates/erp-core/src/error.rs @@ -0,0 +1,188 @@ +use axum::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; + +/// 统一错误响应格式 +#[derive(Debug, Serialize)] +pub struct ErrorResponse { + pub error: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub details: Option, +} + +/// 平台级错误类型 +#[derive(Debug, thiserror::Error)] +pub enum AppError { + #[error("资源未找到: {0}")] + NotFound(String), + + #[error("验证失败: {0}")] + Validation(String), + + #[error("未授权")] + Unauthorized, + + #[error("禁止访问: {0}")] + Forbidden(String), + + #[error("冲突: {0}")] + Conflict(String), + + #[error("版本冲突: 数据已被其他操作修改,请刷新后重试")] + VersionMismatch, + + #[error("请求过于频繁,请稍后重试")] + TooManyRequests, + + #[error("内部错误: {0}")] + Internal(String), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match &self { + AppError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), + AppError::Validation(_) => (StatusCode::BAD_REQUEST, self.to_string()), + AppError::Unauthorized => (StatusCode::UNAUTHORIZED, "未授权".to_string()), + AppError::Forbidden(_) => (StatusCode::FORBIDDEN, self.to_string()), + AppError::Conflict(_) => (StatusCode::CONFLICT, self.to_string()), + AppError::VersionMismatch => (StatusCode::CONFLICT, self.to_string()), + AppError::TooManyRequests => (StatusCode::TOO_MANY_REQUESTS, self.to_string()), + AppError::Internal(msg) => { + tracing::error!("Internal error: {}", msg); + (StatusCode::INTERNAL_SERVER_ERROR, "内部错误".to_string()) + } + }; + + let body = ErrorResponse { + error: status.canonical_reason().unwrap_or("Error").to_string(), + message, + details: None, + }; + + (status, Json(body)).into_response() + } +} + +impl From for AppError { + fn from(err: anyhow::Error) -> Self { + AppError::Internal(err.to_string()) + } +} + +impl From for AppError { + fn from(err: sea_orm::DbErr) -> Self { + match err { + sea_orm::DbErr::RecordNotFound(msg) => AppError::NotFound(msg), + sea_orm::DbErr::Query(sea_orm::RuntimeErr::SqlxError(e)) + if e.to_string().contains("duplicate key") => + { + AppError::Conflict("记录已存在".to_string()) + } + _ => AppError::Internal(err.to_string()), + } + } +} + +pub type AppResult = Result; + +/// 检查乐观锁版本是否匹配。 +/// +/// 返回下一个版本号(actual + 1),或 VersionMismatch 错误。 +pub fn check_version(expected: i32, actual: i32) -> AppResult { + if expected == actual { + Ok(actual + 1) + } else { + Err(AppError::VersionMismatch) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_version_ok() { + assert_eq!(check_version(1, 1).unwrap(), 2); + assert_eq!(check_version(5, 5).unwrap(), 6); + } + + #[test] + fn check_version_mismatch() { + let result = check_version(1, 2); + assert!(result.is_err()); + match result.unwrap_err() { + AppError::VersionMismatch => {} + other => panic!("Expected VersionMismatch, got {:?}", other), + } + } + + #[test] + fn db_err_record_not_found_maps_to_not_found() { + let err = sea_orm::DbErr::RecordNotFound("test".to_string()); + let app_err: AppError = err.into(); + match app_err { + AppError::NotFound(msg) => assert_eq!(msg, "test"), + other => panic!("Expected NotFound, got {:?}", other), + } + } + + #[test] + fn db_err_generic_maps_to_internal() { + let db_err = sea_orm::DbErr::Custom("some error".to_string()); + let app_err: AppError = db_err.into(); + match app_err { + AppError::Internal(msg) => assert!(msg.contains("some error")), + other => panic!("Expected Internal, got {:?}", other), + } + } + + #[test] + fn app_error_into_response_status_codes() { + // NotFound -> 404 + let resp = AppError::NotFound("test".to_string()).into_response(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + + // Validation -> 400 + let resp = AppError::Validation("bad input".to_string()).into_response(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // Unauthorized -> 401 + let resp = AppError::Unauthorized.into_response(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + + // Forbidden -> 403 + let resp = AppError::Forbidden("no access".to_string()).into_response(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + + // VersionMismatch -> 409 + let resp = AppError::VersionMismatch.into_response(); + assert_eq!(resp.status(), StatusCode::CONFLICT); + + // TooManyRequests -> 429 + let resp = AppError::TooManyRequests.into_response(); + assert_eq!(resp.status(), StatusCode::TOO_MANY_REQUESTS); + + // Internal -> 500 + let resp = AppError::Internal("oops".to_string()).into_response(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn app_error_internal_hides_details_from_response() { + // Internal errors should map to 500 with a generic message + let resp = AppError::Internal("sensitive db error detail".to_string()).into_response(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn anyhow_error_maps_to_internal() { + let err: AppError = anyhow::anyhow!("something went wrong").into(); + match err { + AppError::Internal(msg) => assert_eq!(msg, "something went wrong"), + other => panic!("Expected Internal, got {:?}", other), + } + } +} diff --git a/crates/erp-core/src/events.rs b/crates/erp-core/src/events.rs new file mode 100644 index 0000000..2c49afb --- /dev/null +++ b/crates/erp-core/src/events.rs @@ -0,0 +1,458 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, Set, +}; +use serde::{Deserialize, Serialize}; +use tokio::sync::{broadcast, mpsc}; +use tracing::{error, info}; +use uuid::Uuid; + +use crate::entity::dead_letter_event; +use crate::entity::domain_event; + +/// 已知的 PII 字段列表 -- 在事件 payload 中自动脱敏 +const PII_FIELDS: &[&str] = &[ + "phone", + "id_number", + "emergency_contact_phone", + "emergency_contact_name", + "medical_history_summary", + "allergy_history", + "content", +]; + +/// 递归脱敏 payload 中的 PII 字段(原地修改)。 +fn sanitize_payload(payload: &mut serde_json::Value) { + if let Some(obj) = payload.as_object_mut() { + for field in PII_FIELDS { + if let Some(val) = obj.get_mut(*field) + && val.is_string() + { + *val = serde_json::Value::String("[REDACTED]".to_string()); + } + } + for val in obj.values_mut() { + if val.is_object() { + sanitize_payload(val); + } + } + } +} + +/// 领域事件 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DomainEvent { + pub id: Uuid, + pub event_type: String, + pub tenant_id: Uuid, + pub payload: serde_json::Value, + pub timestamp: chrono::DateTime, + pub correlation_id: Uuid, +} + +impl DomainEvent { + pub fn new(event_type: impl Into, tenant_id: Uuid, payload: serde_json::Value) -> Self { + Self { + id: Uuid::now_v7(), + event_type: event_type.into(), + tenant_id, + payload, + timestamp: Utc::now(), + correlation_id: Uuid::now_v7(), + } + } +} + +/// 当前事件 payload schema 版本 +pub const EVENT_SCHEMA_VERSION: &str = "v1"; + +/// 构造统一信封格式的事件 payload。 +/// +/// 自动注入 `schema_version` 和 `occurred_at`,业务数据通过 `data` 传入。 +/// 用法:`build_event_payload(serde_json::json!({ "patient_id": ..., }))` +pub fn build_event_payload(data: serde_json::Value) -> serde_json::Value { + let mut envelope = serde_json::json!({ + "schema_version": EVENT_SCHEMA_VERSION, + "occurred_at": Utc::now().to_rfc3339(), + }); + if let serde_json::Value::Object(ref mut map) = envelope + && let serde_json::Value::Object(data_map) = data + { + for (k, v) in data_map { + map.insert(k, v); + } + } + envelope +} + +/// 检查事件是否已被指定消费者处理。 +/// +/// 查询 `processed_events` 表判断 event_id + consumer_id 是否已存在。 +pub async fn is_event_processed( + db: &sea_orm::DatabaseConnection, + event_id: Uuid, + consumer_id: &str, +) -> Result { + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + let count = crate::entity::processed_event::Entity::find() + .filter(crate::entity::processed_event::Column::EventId.eq(event_id)) + .filter(crate::entity::processed_event::Column::ConsumerId.eq(consumer_id)) + .count(db) + .await?; + Ok(count > 0) +} + +/// 标记事件已被指定消费者处理。 +/// +/// 插入 `processed_events` 记录,重复插入会因主键冲突被安全忽略。 +pub async fn mark_event_processed( + db: &sea_orm::DatabaseConnection, + event_id: Uuid, + consumer_id: &str, +) -> Result<(), sea_orm::DbErr> { + use sea_orm::ActiveModelTrait; + use sea_orm::Set; + + let model = crate::entity::processed_event::ActiveModel { + event_id: Set(event_id), + consumer_id: Set(consumer_id.to_string()), + processed_at: Set(Utc::now()), + }; + // INSERT ... ON CONFLICT DO NOTHING(主键冲突时安全忽略) + match model.insert(db).await { + Ok(_) => Ok(()), + Err(e) => { + // 唯一约束冲突 = 已处理,不是错误 + if e.to_string().contains("duplicate") || e.to_string().contains("violates unique") { + Ok(()) + } else { + Err(e) + } + } + } +} + +/// 消费事件 — 带幂等检查和 dead-letter 兜底。 +/// +/// 如果事件已被处理(幂等),返回 `ConsumeResult::AlreadyProcessed`。 +/// 如果处理成功,标记为已处理并返回 `ConsumeResult::Success`。 +/// 如果处理失败,将事件转入 dead_letter_events 表并返回 `ConsumeResult::DeadLettered`。 +pub async fn consume_with_retry( + db: &sea_orm::DatabaseConnection, + event: &DomainEvent, + consumer_id: &str, + handler: F, +) -> ConsumeResult +where + F: FnOnce(&DomainEvent) -> Fut, + Fut: std::future::Future>, +{ + if is_event_processed(db, event.id, consumer_id) + .await + .unwrap_or(false) + { + return ConsumeResult::AlreadyProcessed; + } + + match handler(event).await { + Ok(()) => { + if let Err(e) = mark_event_processed(db, event.id, consumer_id).await { + tracing::warn!( + event_id = %event.id, + consumer_id = consumer_id, + error = %e, + "标记事件已处理失败(非致命)" + ); + } + ConsumeResult::Success + } + Err(err) => { + tracing::error!( + event_id = %event.id, + event_type = %event.event_type, + consumer_id = consumer_id, + error = %err, + "事件消费失败,转入 dead-letter" + ); + if let Err(e) = insert_dead_letter(db, event, consumer_id, &err).await { + tracing::error!( + event_id = %event.id, + error = %e, + "Dead-letter 写入失败" + ); + } + ConsumeResult::DeadLettered(err) + } + } +} + +/// 消费结果 +#[derive(Debug)] +pub enum ConsumeResult { + Success, + AlreadyProcessed, + DeadLettered(String), +} + +/// 将失败事件写入 dead_letter_events 表 +pub async fn insert_dead_letter( + db: &sea_orm::DatabaseConnection, + event: &DomainEvent, + consumer_id: &str, + error_msg: &str, +) -> Result<(), sea_orm::DbErr> { + let model = dead_letter_event::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(Some(event.tenant_id)), + original_event_id: Set(event.id), + event_type: Set(event.event_type.clone()), + payload: Set(Some(event.payload.clone())), + consumer_id: Set(consumer_id.to_string()), + attempts: Set(1), + last_error: Set(Some(error_msg.to_string())), + created_at: Set(Utc::now()), + resolved_at: Set(None), + }; + model.insert(db).await?; + Ok(()) +} + +/// 过滤事件接收器 — 只接收匹配 `event_type_prefix` 的事件 +pub struct FilteredEventReceiver { + receiver: mpsc::Receiver, +} + +impl FilteredEventReceiver { + /// 接收下一个匹配的事件 + pub async fn recv(&mut self) -> Option { + self.receiver.recv().await + } +} + +/// 订阅句柄 — 用于取消过滤订阅 +pub struct SubscriptionHandle { + cancel_tx: mpsc::Sender<()>, + join_handle: tokio::task::JoinHandle<()>, +} + +impl SubscriptionHandle { + /// 取消订阅并等待后台任务结束 + pub async fn cancel(self) { + let _ = self.cancel_tx.send(()).await; + let _ = self.join_handle.await; + } +} + +/// 进程内事件总线 +#[derive(Clone)] +pub struct EventBus { + sender: broadcast::Sender, +} + +impl EventBus { + pub fn new(capacity: usize) -> Self { + let (sender, _) = broadcast::channel(capacity); + Self { sender } + } + + /// 发布事件:先持久化到 domain_events 表(pending 状态),再内存广播, + /// 最后更新为 published 并 NOTIFY outbox relay。 + /// + /// 两阶段提交保证:即使广播后服务崩溃,事件仍为 pending 状态, + /// 重启后 outbox relay 会重新广播。 + pub async fn publish(&self, mut event: DomainEvent, db: &sea_orm::DatabaseConnection) { + // 0. 脱敏 payload 中的 PII 字段 + sanitize_payload(&mut event.payload); + + // 1. 持久化为 pending 状态 + let event_id = event.id; + let model = domain_event::ActiveModel { + id: Set(event.id), + tenant_id: Set(event.tenant_id), + event_type: Set(event.event_type.clone()), + payload: Set(Some(event.payload.clone())), + correlation_id: Set(Some(event.correlation_id)), + status: Set("pending".to_string()), + attempts: Set(0), + last_error: Set(None), + created_at: Set(event.timestamp), + published_at: Set(None), + }; + + let saved = match model.insert(db).await { + Ok(m) => m, + Err(e) => { + tracing::warn!(event_id = %event_id, error = %e, "领域事件持久化失败"); + // 持久化失败仍然广播(best-effort) + self.broadcast(event); + return; + } + }; + + // 2. 内存广播 + self.broadcast(event); + + // 3. 更新为 published + let mut active: domain_event::ActiveModel = saved.into(); + active.status = Set("published".to_string()); + active.published_at = Set(Some(Utc::now())); + if let Err(e) = active.update(db).await { + tracing::warn!(event_id = %event_id, error = %e, "领域事件状态更新为 published 失败"); + } + + // 4. NOTIFY outbox relay(通知 outbox relay 有新事件到达) + let notify_sql = sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + format!("NOTIFY outbox_channel, '{}'", event_id), + ); + if let Err(e) = db.execute(notify_sql).await { + tracing::debug!(event_id = %event_id, error = %e, "NOTIFY outbox_channel 失败(非致命)"); + } + } + + /// 仅内存广播(不持久化,用于内部测试等场景)。 + pub fn broadcast(&self, event: DomainEvent) { + info!(event_type = %event.event_type, event_id = %event.id, "Event broadcast"); + if let Err(e) = self.sender.send(event) { + error!("Failed to broadcast event: {}", e); + } + } + + /// 订阅所有事件,返回接收端 + pub fn subscribe(&self) -> broadcast::Receiver { + self.sender.subscribe() + } + + /// 按事件类型前缀过滤订阅。 + /// + /// 为每次调用 spawn 一个 Tokio task 从 broadcast channel 读取, + /// 只转发匹配 `event_type_prefix` 的事件到 mpsc channel(capacity 256)。 + pub fn subscribe_filtered( + &self, + event_type_prefix: String, + ) -> (FilteredEventReceiver, SubscriptionHandle) { + let mut broadcast_rx = self.sender.subscribe(); + let (mpsc_tx, mpsc_rx) = mpsc::channel(256); + let (cancel_tx, mut cancel_rx) = mpsc::channel::<()>(1); + + let prefix = event_type_prefix.clone(); + let join_handle = tokio::spawn(async move { + loop { + tokio::select! { + biased; + _ = cancel_rx.recv() => { + tracing::info!(prefix = %prefix, "Filtered subscription cancelled"); + break; + } + event = broadcast_rx.recv() => { + match event { + Ok(event) => { + if event.event_type.starts_with(&prefix) + && mpsc_tx.send(event).await.is_err() + { + break; + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!(prefix = %prefix, lagged = n, "Filtered subscriber lagged"); + } + Err(broadcast::error::RecvError::Closed) => { + break; + } + } + } + } + } + }); + + tracing::info!(prefix = %event_type_prefix, "Filtered subscription created"); + + ( + FilteredEventReceiver { receiver: mpsc_rx }, + SubscriptionHandle { + cancel_tx, + join_handle, + }, + ) + } +} + +/// 重试 dead_letter_events 中未解决的失败事件(指数退避)。 +pub async fn retry_dead_letters( + db: &sea_orm::DatabaseConnection, + bus: &EventBus, + max_attempts: i32, +) -> Result { + // 1. 查询所有未解决且未超过最大重试次数的 dead-letter + let pending = dead_letter_event::Entity::find() + .filter(dead_letter_event::Column::ResolvedAt.is_null()) + .filter(dead_letter_event::Column::Attempts.lt(max_attempts)) + .all(db) + .await + .map_err(|e| format!("查询 dead_letter_events 失败: {}", e))?; + + let retried = pending.len() as u64; + + for dl in &pending { + let event = DomainEvent { + id: dl.original_event_id, + event_type: dl.event_type.clone(), + tenant_id: dl.tenant_id.unwrap_or(Uuid::nil()), + payload: dl.payload.clone().unwrap_or(serde_json::Value::Null), + timestamp: dl.created_at, + correlation_id: Uuid::now_v7(), + }; + bus.broadcast(event); + + let mut active: dead_letter_event::ActiveModel = dl.clone().into(); + let new_attempts = dl.attempts + 1; + active.attempts = Set(new_attempts); + active.last_error = Set(Some(format!( + "第 {} 次自动重试({})", + new_attempts, + Utc::now().to_rfc3339() + ))); + if let Err(e) = active.update(db).await { + tracing::warn!( + dead_letter_id = %dl.id, + error = %e, + "更新 dead_letter_events attempts 失败" + ); + } + } + + // 2. 标记超过最大重试次数的记录为永久失败 + let exhausted = dead_letter_event::Entity::find() + .filter(dead_letter_event::Column::ResolvedAt.is_null()) + .filter(dead_letter_event::Column::Attempts.gte(max_attempts)) + .all(db) + .await + .map_err(|e| format!("查询超限 dead_letter_events 失败: {}", e))?; + + for dl in &exhausted { + let mut active: dead_letter_event::ActiveModel = dl.clone().into(); + active.resolved_at = Set(Some(Utc::now())); + active.last_error = Set(Some(format!( + "已达最大重试次数 {},标记为永久失败", + max_attempts + ))); + if let Err(e) = active.update(db).await { + tracing::warn!( + dead_letter_id = %dl.id, + error = %e, + "标记 dead_letter_event 为永久失败时更新失败" + ); + } + } + + if retried > 0 || !exhausted.is_empty() { + tracing::info!( + retried = retried, + permanently_failed = exhausted.len(), + "Dead-letter 自动重试完成" + ); + } + + Ok(retried) +} diff --git a/crates/erp-core/src/lib.rs b/crates/erp-core/src/lib.rs new file mode 100644 index 0000000..8cf174b --- /dev/null +++ b/crates/erp-core/src/lib.rs @@ -0,0 +1,19 @@ +pub mod aggregate; +pub mod audit; +pub mod audit_service; +pub mod crypto; +pub mod entity; +pub mod error; +pub mod events; +pub mod module; +pub mod rbac; +pub mod request_info; +pub mod sanitize; +pub mod sea_orm_ext; +pub mod types; + +#[cfg(test)] +pub mod test_helpers; + +// 便捷导出 +pub use module::{ModuleContext, ModuleType, PermissionDescriptor}; diff --git a/crates/erp-core/src/module.rs b/crates/erp-core/src/module.rs new file mode 100644 index 0000000..15e9241 --- /dev/null +++ b/crates/erp-core/src/module.rs @@ -0,0 +1,357 @@ +use std::any::Any; +use std::collections::HashMap; +use std::sync::Arc; + +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; +use crate::events::EventBus; + +/// 权限描述符,用于模块声明自己需要的权限。 +/// +/// 各业务模块通过 `ErpModule::permissions()` 返回此列表, +/// 由 erp-server 在启动时统一注册到权限表。 +#[derive(Clone, Debug)] +pub struct PermissionDescriptor { + /// 权限编码,全局唯一,格式建议 `{模块}.{动作}` 如 `plugin.admin` + pub code: String, + /// 权限显示名称 + pub name: String, + /// 权限描述 + pub description: String, + /// 所属模块名称 + pub module: String, +} + +/// 模块类型 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ModuleType { + /// 内置模块(编译时链接) + Builtin, + /// 插件模块(运行时加载) + Plugin, +} + +/// 模块启动上下文 — 在 on_startup 时提供给模块 +pub struct ModuleContext { + pub db: sea_orm::DatabaseConnection, + pub event_bus: EventBus, +} + +/// 模块注册接口 +/// 所有业务模块(Auth, Workflow, Message, Config, 行业模块)都实现此 trait +#[async_trait::async_trait] +pub trait ErpModule: Send + Sync { + /// 模块名称(唯一标识) + fn name(&self) -> &str; + + /// 模块唯一 ID(默认等于 name) + fn id(&self) -> &str { + self.name() + } + + /// 模块版本 + fn version(&self) -> &str { + env!("CARGO_PKG_VERSION") + } + + /// 模块类型 + fn module_type(&self) -> ModuleType { + ModuleType::Builtin + } + + /// 依赖的其他模块名称 + fn dependencies(&self) -> Vec<&str> { + vec![] + } + + /// 注册事件处理器 + fn register_event_handlers(&self, _bus: &EventBus) {} + + /// 模块启动钩子 — 服务启动时调用 + async fn on_startup(&self, _ctx: &ModuleContext) -> AppResult<()> { + Ok(()) + } + + /// 模块关闭钩子 — 服务关闭时调用 + async fn on_shutdown(&self) -> AppResult<()> { + Ok(()) + } + + /// 健康检查 + async fn health_check(&self) -> AppResult { + Ok(serde_json::json!({"status": "healthy"})) + } + + /// 租户创建时的初始化钩子。 + /// + /// 用于为新建租户创建默认角色、管理员用户等初始数据。 + async fn on_tenant_created( + &self, + _tenant_id: Uuid, + _db: &sea_orm::DatabaseConnection, + _event_bus: &EventBus, + ) -> AppResult<()> { + Ok(()) + } + + /// 租户删除时的清理钩子。 + /// + /// 用于软删除该租户下的所有关联数据。 + async fn on_tenant_deleted( + &self, + _tenant_id: Uuid, + _db: &sea_orm::DatabaseConnection, + ) -> AppResult<()> { + Ok(()) + } + + /// 返回此模块需要注册的权限列表。 + /// + /// 默认返回空列表,有权限需求的模块(如 plugin)可覆写此方法。 + fn permissions(&self) -> Vec { + vec![] + } + + /// Downcast support: return `self` as `&dyn Any` for concrete type access. + /// + /// This allows the server crate to retrieve module-specific methods + /// (e.g. `AuthModule::public_routes()`) that are not part of the trait. + fn as_any(&self) -> &dyn Any; +} + +/// 模块注册器 — 用 Arc 包装使其可 Clone(用于 Axum State) +#[derive(Clone, Default)] +pub struct ModuleRegistry { + modules: Arc>>, +} + +impl ModuleRegistry { + pub fn new() -> Self { + Self { + modules: Arc::new(vec![]), + } + } + + pub fn register(mut self, module: impl ErpModule + 'static) -> Self { + tracing::info!( + module = module.name(), + id = module.id(), + version = module.version(), + module_type = ?module.module_type(), + "Module registered" + ); + let mut modules = (*self.modules).clone(); + modules.push(Arc::new(module)); + self.modules = Arc::new(modules); + self + } + + pub fn register_handlers(&self, bus: &EventBus) { + for module in self.modules.iter() { + module.register_event_handlers(bus); + } + } + + pub fn modules(&self) -> &[Arc] { + &self.modules + } + + /// 按名称获取模块 + pub fn get_module(&self, name: &str) -> Option> { + self.modules.iter().find(|m| m.name() == name).cloned() + } + + /// 按拓扑排序返回模块(依赖在前,被依赖在后) + /// + /// 使用 Kahn 算法,环检测返回 Validation 错误。 + pub fn sorted_modules(&self) -> AppResult>> { + let modules = &*self.modules; + let n = modules.len(); + if n == 0 { + return Ok(vec![]); + } + + // 构建名称到索引的映射 + let name_to_idx: HashMap<&str, usize> = modules + .iter() + .enumerate() + .map(|(i, m)| (m.name(), i)) + .collect(); + + // 构建邻接表和入度 + let mut adjacency: Vec> = vec![vec![]; n]; + let mut in_degree: Vec = vec![0; n]; + + for (idx, module) in modules.iter().enumerate() { + for dep in module.dependencies() { + if let Some(&dep_idx) = name_to_idx.get(dep) { + adjacency[dep_idx].push(idx); + in_degree[idx] += 1; + } + // 依赖未注册的模块不阻断(可能是可选依赖) + } + } + + // Kahn 算法 + let mut queue: Vec = (0..n).filter(|&i| in_degree[i] == 0).collect(); + let mut sorted_indices = Vec::with_capacity(n); + + while let Some(idx) = queue.pop() { + sorted_indices.push(idx); + for &next in &adjacency[idx] { + in_degree[next] -= 1; + if in_degree[next] == 0 { + queue.push(next); + } + } + } + + if sorted_indices.len() != n { + let cycle_modules: Vec<&str> = (0..n) + .filter(|i| !sorted_indices.contains(i)) + .filter_map(|i| modules.get(i).map(|m| m.name())) + .collect(); + return Err(AppError::Validation(format!( + "模块依赖存在循环: {}", + cycle_modules.join(", ") + ))); + } + + Ok(sorted_indices + .into_iter() + .map(|i| modules[i].clone()) + .collect()) + } + + /// 按拓扑顺序启动所有模块 + pub async fn startup_all(&self, ctx: &ModuleContext) -> AppResult<()> { + let sorted = self.sorted_modules()?; + for module in sorted { + tracing::info!(module = module.name(), "Starting module"); + module.on_startup(ctx).await?; + tracing::info!(module = module.name(), "Module started"); + } + Ok(()) + } + + /// 按拓扑逆序关闭所有模块 + pub async fn shutdown_all(&self) -> AppResult<()> { + let sorted = self.sorted_modules()?; + for module in sorted.into_iter().rev() { + tracing::info!(module = module.name(), "Shutting down module"); + if let Err(e) = module.on_shutdown().await { + tracing::error!(module = module.name(), error = %e, "Module shutdown failed"); + } + } + Ok(()) + } + + /// 对所有模块执行健康检查 + pub async fn health_check_all(&self) -> Vec<(String, AppResult)> { + let mut results = Vec::with_capacity(self.modules.len()); + for module in self.modules.iter() { + let result = module.health_check().await; + results.push((module.name().to_string(), result)); + } + results + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct TestModule { + name: &'static str, + deps: Vec<&'static str>, + } + + #[async_trait::async_trait] + impl ErpModule for TestModule { + fn name(&self) -> &str { + self.name + } + fn dependencies(&self) -> Vec<&str> { + self.deps.clone() + } + fn as_any(&self) -> &dyn Any { + self + } + } + + #[test] + fn sorted_modules_empty() { + let registry = ModuleRegistry::new(); + let sorted = registry.sorted_modules().unwrap(); + assert!(sorted.is_empty()); + } + + #[test] + fn sorted_modules_no_deps() { + let registry = ModuleRegistry::new() + .register(TestModule { + name: "a", + deps: vec![], + }) + .register(TestModule { + name: "b", + deps: vec![], + }); + let sorted = registry.sorted_modules().unwrap(); + assert_eq!(sorted.len(), 2); + } + + #[test] + fn sorted_modules_with_deps() { + let registry = ModuleRegistry::new() + .register(TestModule { + name: "auth", + deps: vec![], + }) + .register(TestModule { + name: "plugin", + deps: vec!["auth", "config"], + }) + .register(TestModule { + name: "config", + deps: vec!["auth"], + }); + let sorted = registry.sorted_modules().unwrap(); + let names: Vec<&str> = sorted.iter().map(|m| m.name()).collect(); + let auth_pos = names.iter().position(|&n| n == "auth").unwrap(); + let config_pos = names.iter().position(|&n| n == "config").unwrap(); + let plugin_pos = names.iter().position(|&n| n == "plugin").unwrap(); + assert!(auth_pos < config_pos); + assert!(config_pos < plugin_pos); + } + + #[test] + fn sorted_modules_circular_dep() { + let registry = ModuleRegistry::new() + .register(TestModule { + name: "a", + deps: vec!["b"], + }) + .register(TestModule { + name: "b", + deps: vec!["a"], + }); + let result = registry.sorted_modules(); + assert!(result.is_err()); + match result.err().unwrap() { + AppError::Validation(msg) => assert!(msg.contains("循环")), + other => panic!("Expected Validation, got {:?}", other), + } + } + + #[test] + fn get_module_found() { + let registry = ModuleRegistry::new().register(TestModule { + name: "auth", + deps: vec![], + }); + assert!(registry.get_module("auth").is_some()); + assert!(registry.get_module("unknown").is_none()); + } +} diff --git a/crates/erp-core/src/rbac.rs b/crates/erp-core/src/rbac.rs new file mode 100644 index 0000000..e3b588e --- /dev/null +++ b/crates/erp-core/src/rbac.rs @@ -0,0 +1,102 @@ +use crate::error::AppError; +use crate::types::{DataScope, TenantContext}; + +/// Check whether the `TenantContext` includes the specified permission code. +/// +/// Returns `Ok(())` if the permission is present, or `AppError::Forbidden` otherwise. +pub fn require_permission(ctx: &TenantContext, permission: &str) -> Result<(), AppError> { + if ctx.permissions.iter().any(|p| p == permission) { + Ok(()) + } else { + Err(AppError::Forbidden("权限不足".to_string())) + } +} + +/// Check whether the `TenantContext` includes at least one of the specified permission codes. +/// +/// Useful when multiple permissions can grant access to the same resource. +pub fn require_any_permission(ctx: &TenantContext, permissions: &[&str]) -> Result<(), AppError> { + let has_any = permissions + .iter() + .any(|p| ctx.permissions.iter().any(|up| up == *p)); + + if has_any { + Ok(()) + } else { + Err(AppError::Forbidden("权限不足".to_string())) + } +} + +/// Check whether the `TenantContext` includes the specified role code. +/// +/// Returns `Ok(())` if the role is present, or `AppError::Forbidden` otherwise. +pub fn require_role(ctx: &TenantContext, role: &str) -> Result<(), AppError> { + if ctx.roles.iter().any(|r| r == role) { + Ok(()) + } else { + Err(AppError::Forbidden("权限不足".to_string())) + } +} + +/// 获取指定权限的数据范围。默认 All(向后兼容)。 +/// +/// Service 层根据返回值追加对应的查询过滤条件。 +pub fn get_data_scope(ctx: &TenantContext, permission: &str) -> DataScope { + ctx.permission_data_scopes + .get(permission) + .cloned() + .unwrap_or(DataScope::All) +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + fn test_ctx(roles: Vec<&str>, permissions: Vec<&str>) -> TenantContext { + TenantContext { + tenant_id: Uuid::now_v7(), + user_id: Uuid::now_v7(), + roles: roles.into_iter().map(String::from).collect(), + permissions: permissions.into_iter().map(String::from).collect(), + department_ids: vec![], + permission_data_scopes: std::collections::HashMap::new(), + } + } + + #[test] + fn require_permission_succeeds_when_present() { + let ctx = test_ctx(vec![], vec!["user.read", "user.write"]); + assert!(require_permission(&ctx, "user.read").is_ok()); + } + + #[test] + fn require_permission_fails_when_missing() { + let ctx = test_ctx(vec![], vec!["user.read"]); + assert!(require_permission(&ctx, "user.delete").is_err()); + } + + #[test] + fn require_any_permission_succeeds_with_match() { + let ctx = test_ctx(vec![], vec!["user.read"]); + assert!(require_any_permission(&ctx, &["user.delete", "user.read"]).is_ok()); + } + + #[test] + fn require_any_permission_fails_with_no_match() { + let ctx = test_ctx(vec![], vec!["user.read"]); + assert!(require_any_permission(&ctx, &["user.delete", "user.admin"]).is_err()); + } + + #[test] + fn require_role_succeeds_when_present() { + let ctx = test_ctx(vec!["admin", "user"], vec![]); + assert!(require_role(&ctx, "admin").is_ok()); + } + + #[test] + fn require_role_fails_when_missing() { + let ctx = test_ctx(vec!["user"], vec![]); + assert!(require_role(&ctx, "admin").is_err()); + } +} diff --git a/crates/erp-core/src/request_info.rs b/crates/erp-core/src/request_info.rs new file mode 100644 index 0000000..d6549de --- /dev/null +++ b/crates/erp-core/src/request_info.rs @@ -0,0 +1,54 @@ +/// 请求来源信息(IP 地址 + User-Agent)。 +/// +/// 通过 `tokio::task_local!` 在请求生命周期内传递, +/// JWT 中间件设置,审计服务自动读取。 +#[derive(Debug, Clone, Default)] +pub struct RequestInfo { + pub ip_address: Option, + pub user_agent: Option, +} + +tokio::task_local! { + /// 当前请求的来源信息。 + /// + /// 在 JWT 中间件中通过 `REQUEST_INFO.scope(info, future)` 设置, + /// 在 `audit_service::record()` 中自动读取。 + pub static REQUEST_INFO: RequestInfo; +} + +impl RequestInfo { + /// 从 HTTP 请求头中提取 IP 地址和 User-Agent。 + /// + /// IP 优先级:X-Forwarded-For > X-Real-IP > 直接连接(不记录)。 + pub fn from_headers(headers: &axum::http::HeaderMap) -> Self { + let ip_address = headers + .get("X-Forwarded-For") + .and_then(|v| v.to_str().ok()) + .map(|s| { + // X-Forwarded-For 可能包含多个 IP,取第一个(客户端真实 IP) + s.split(',').next().unwrap_or(s).trim().to_string() + }) + .or_else(|| { + headers + .get("X-Real-IP") + .and_then(|v| v.to_str().ok()) + .map(|s| s.trim().to_string()) + }); + + let user_agent = headers + .get("user-agent") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + + Self { + ip_address, + user_agent, + } + } + + /// 尝试从 task_local 中读取当前请求信息。 + /// 如果不在请求上下文中(如后台任务),返回 None。 + pub fn try_current() -> Option { + REQUEST_INFO.try_with(|info| info.clone()).ok() + } +} diff --git a/crates/erp-core/src/sanitize.rs b/crates/erp-core/src/sanitize.rs new file mode 100644 index 0000000..8053178 --- /dev/null +++ b/crates/erp-core/src/sanitize.rs @@ -0,0 +1,218 @@ +/// HTML/Script 内容清理工具。 +/// +/// 基于 ammonia(html5ever)剥离所有 HTML 标签,防止存储型 XSS。 +/// 覆盖场景:用户名、显示名、邮箱、电话等字符串字段。 +/// +/// 剥离字符串中的所有 HTML 标签,返回纯文本。 +/// +/// 使用 ammonia 构建 DOM 树,然后用 tendril 收集文本节点。 +/// 比手写字符级解析器更安全,能正确处理所有 HTML 边界情况。 +pub fn strip_html_tags(input: &str) -> String { + // 使用 ammonia 清理(保留在 span 中的纯文本),然后剥离 span 标签 + let doc = ammonia::Builder::new() + .tags(std::collections::HashSet::new()) + .clean(input) + .to_string(); + + // ammonia 的 clean() 结果可能包含 HTML 实体(如 <),需要解码 + // 但由于所有标签已被禁止,结果是纯文本(可能有实体转义) + // 使用二次清理:将结果作为纯文本处理 + decode_entities(&doc).trim().to_string() +} + +/// 简单解码常见 HTML 实体。 +fn decode_entities(input: &str) -> String { + input + .replace("<", "<") + .replace(">", ">") + .replace("&", "&") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("/", "/") + .replace(" ", " ") +} + +/// 对 Option 类型的字段进行清理。 +pub fn sanitize_option(input: Option) -> Option { + input.map(|s| strip_html_tags(&s)).filter(|s| !s.is_empty()) +} + +/// 对 String 类型的必填字段进行清理。 +pub fn sanitize_string(input: &str) -> String { + strip_html_tags(input) +} + +/// 对富文本 HTML 进行安全清理,保留安全的 HTML 标签和内联样式,去除危险元素。 +/// 适用于文章内容等需要保留 HTML 排版的场景。 +pub fn sanitize_rich_html(input: &str) -> String { + use std::collections::{HashMap, HashSet}; + + let tag_attrs: HashMap<&str, HashSet<&str>> = [ + ("div", HashSet::from(["style", "data-w-e-type"])), + ("span", HashSet::from(["style"])), + ("p", HashSet::from(["style"])), + ( + "img", + HashSet::from(["src", "alt", "style", "width", "height"]), + ), + ("a", HashSet::from(["href", "target"])), + ("td", HashSet::from(["style", "colspan", "rowspan"])), + ("th", HashSet::from(["style", "colspan", "rowspan"])), + ("blockquote", HashSet::from(["style"])), + ] + .into_iter() + .collect(); + + ammonia::Builder::new() + .tags( + [ + "p", + "br", + "span", + "div", + "strong", + "b", + "em", + "i", + "u", + "s", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "ul", + "ol", + "li", + "blockquote", + "pre", + "code", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "img", + "a", + "hr", + ] + .into_iter() + .collect(), + ) + .tag_attributes(tag_attrs) + .generic_attributes(HashSet::from(["style"])) + .url_relative(ammonia::UrlRelative::PassThrough) + .clean(input) + .to_string() +} + +/// 对 Option 的富文本进行安全清理。 +pub fn sanitize_rich_html_option(input: Option) -> Option { + input + .map(|s| sanitize_rich_html(&s)) + .filter(|s| !s.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_script_tag() { + // script 内容在 HTML 规范中是 raw text,ammonia 正确地将其完全移除 + assert_eq!(strip_html_tags(""), ""); + } + + #[test] + fn strips_img_onerror() { + assert_eq!(strip_html_tags(""), ""); + } + + #[test] + fn strips_bold_tags() { + assert_eq!(strip_html_tags("Hello World"), "Hello World"); + } + + #[test] + fn no_tags_passthrough() { + assert_eq!(strip_html_tags("Normal text"), "Normal text"); + } + + #[test] + fn nested_tags() { + assert_eq!(strip_html_tags("

text

"), "text"); + } + + #[test] + fn sanitize_option_some() { + assert_eq!( + sanitize_option(Some("evil".to_string())), + Some("evil".to_string()) + ); + } + + #[test] + fn sanitize_option_none() { + assert_eq!(sanitize_option(None), None); + } + + #[test] + fn sanitize_option_becomes_empty() { + assert_eq!(sanitize_option(Some("".to_string())), None); + } + + #[test] + fn strips_nested_script_attack() { + let result = strip_html_tags("ipt>alert(1)ipt>"); + assert!(!result.contains("<"), "不应残留 HTML 标签"); + } + + #[test] + fn strips_unclosed_tag() { + let result = strip_html_tags("text Hello

Green box
Bold"#; + let result = sanitize_rich_html(html); + assert!(result.contains("

Hello

"), "should preserve

tags"); + assert!( + result.contains("Bold"), + "should preserve " + ); + assert!( + result.contains("background"), + "should preserve style attribute" + ); + } + + #[test] + fn rich_html_removes_script() { + let html = r#"

Hello

"#; + let result = sanitize_rich_html(html); + assert!(!result.contains("script"), "should remove script tags"); + assert!(result.contains("Hello")); + } + + #[test] + fn rich_html_preserves_styled_block() { + let html = r#"
Tip content
"#; + let result = sanitize_rich_html(html); + assert!( + result.contains("styled-block"), + "should preserve data-w-e-type" + ); + assert!(result.contains("Tip content")); + } +} diff --git a/crates/erp-core/src/sea_orm_ext.rs b/crates/erp-core/src/sea_orm_ext.rs new file mode 100644 index 0000000..8602769 --- /dev/null +++ b/crates/erp-core/src/sea_orm_ext.rs @@ -0,0 +1,17 @@ +use sea_orm::ActiveValue; + +/// 从 SeaORM ActiveValue 中安全提取 version 值。 +/// Set(v) / Unchanged(v) → 返回 v +/// NotSet → 返回 1(首次版本号) +/// 绝不 panic。 +pub fn safe_version(val: &ActiveValue) -> i32 { + match val { + ActiveValue::Set(v) | ActiveValue::Unchanged(v) => *v, + ActiveValue::NotSet => 1, + } +} + +/// 安全递增 version:基于当前值 +1,绝不 panic。 +pub fn bump_version(current: &ActiveValue) -> i32 { + safe_version(current) + 1 +} diff --git a/crates/erp-core/src/test_helpers.rs b/crates/erp-core/src/test_helpers.rs new file mode 100644 index 0000000..382fbdc --- /dev/null +++ b/crates/erp-core/src/test_helpers.rs @@ -0,0 +1,37 @@ +//! 测试基础设施 — 事务回滚模式解决并行化问题 +//! +//! 每个测试在独立事务中执行,测试结束自动回滚,无数据残留。 +//! 多个测试共享同一个数据库连接池,无连接竞争。 + +use sea_orm::{ + ConnectOptions, Database, DatabaseConnection, DatabaseTransaction, TransactionTrait, +}; +use std::sync::OnceLock; +use tokio::sync::OnceCell; + +static DB_POOL: OnceCell = OnceCell::const_new(); +static DB_URL: OnceLock = OnceLock::new(); + +fn db_url() -> String { + DB_URL + .get_or_init(|| { + std::env::var("TEST_DATABASE_URL") + .unwrap_or_else(|_| "postgres://erp:erp@localhost:5432/erp_test".into()) + }) + .clone() +} + +async fn db_pool() -> &'static DatabaseConnection { + DB_POOL + .get_or_init(|| async { + let opt = ConnectOptions::new(db_url()).max_connections(5).to_owned(); + Database::connect(opt).await.expect("测试数据库连接失败") + }) + .await +} + +/// 创建测试用事务。测试结束自动回滚,无数据残留。 +pub async fn test_txn() -> DatabaseTransaction { + let pool = db_pool().await; + pool.begin().await.expect("测试事务创建失败") +} diff --git a/crates/erp-core/src/types.rs b/crates/erp-core/src/types.rs new file mode 100644 index 0000000..5a2d0de --- /dev/null +++ b/crates/erp-core/src/types.rs @@ -0,0 +1,188 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use uuid::Uuid; + +/// 所有数据库实体的公共字段 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BaseFields { + pub id: Uuid, + pub tenant_id: Uuid, + pub created_at: DateTime, + pub updated_at: DateTime, + pub created_by: Uuid, + pub updated_by: Uuid, + pub deleted_at: Option>, + pub version: i32, +} + +/// 分页请求 +#[derive(Debug, Deserialize, utoipa::IntoParams)] +pub struct Pagination { + pub page: Option, + pub page_size: Option, +} + +impl Pagination { + pub fn offset(&self) -> u64 { + (self.page.unwrap_or(1).saturating_sub(1)) * self.limit() + } + + pub fn limit(&self) -> u64 { + self.page_size.unwrap_or(20).min(100) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pagination_defaults() { + let p = Pagination { + page: None, + page_size: None, + }; + assert_eq!(p.limit(), 20); + assert_eq!(p.offset(), 0); + } + + #[test] + fn pagination_custom_values() { + let p = Pagination { + page: Some(3), + page_size: Some(10), + }; + assert_eq!(p.limit(), 10); + assert_eq!(p.offset(), 20); // (3-1) * 10 + } + + #[test] + fn pagination_max_cap() { + let p = Pagination { + page: Some(1), + page_size: Some(200), + }; + assert_eq!(p.limit(), 100); // capped at 100 + } + + #[test] + fn pagination_page_zero_treated_as_first() { + // page 0 -> saturating_sub wraps to 0 -> offset = 0 + let p = Pagination { + page: Some(0), + page_size: Some(10), + }; + assert_eq!(p.offset(), 0); + } + + #[test] + fn pagination_page_one() { + let p = Pagination { + page: Some(1), + page_size: Some(50), + }; + assert_eq!(p.offset(), 0); + } + + #[test] + fn paginated_response_total_pages() { + let resp = PaginatedResponse { + data: vec![1, 2, 3], + total: 25, + page: 1, + page_size: 10, + total_pages: 3, + }; + assert_eq!(resp.data.len(), 3); + assert_eq!(resp.total, 25); + assert_eq!(resp.total_pages, 3); + } + + #[test] + fn api_response_ok() { + let resp = ApiResponse::ok(42); + assert!(resp.success); + assert_eq!(resp.data, Some(42)); + assert!(resp.message.is_none()); + } + + #[test] + fn tenant_context_fields() { + let ctx = TenantContext { + tenant_id: Uuid::now_v7(), + user_id: Uuid::now_v7(), + roles: vec!["admin".to_string()], + permissions: vec!["user.read".to_string()], + department_ids: vec![], + permission_data_scopes: HashMap::new(), + }; + assert_eq!(ctx.roles.len(), 1); + assert_eq!(ctx.permissions.len(), 1); + } +} + +/// 分页响应 +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct PaginatedResponse { + pub data: Vec, + pub total: u64, + pub page: u64, + pub page_size: u64, + pub total_pages: u64, +} + +/// API 统一响应 +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct ApiResponse { + pub success: bool, + pub data: Option, + pub message: Option, +} + +impl ApiResponse { + pub fn ok(data: T) -> Self { + Self { + success: true, + data: Some(data), + message: None, + } + } +} + +/// 行级数据权限范围 +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum DataScope { + /// 查看所有数据 + All, + /// 仅查看自己创建的数据 + SelfOnly, + /// 仅查看本部门数据 + Department, + /// 查看本部门及下属部门数据 + DepartmentTree, +} + +impl DataScope { + pub fn parse_scope(s: &str) -> Self { + match s { + "self" => Self::SelfOnly, + "department" => Self::Department, + "department_tree" => Self::DepartmentTree, + _ => Self::All, + } + } +} + +/// 租户上下文(中间件注入) +#[derive(Debug, Clone)] +pub struct TenantContext { + pub tenant_id: Uuid, + pub user_id: Uuid, + pub roles: Vec, + pub permissions: Vec, + /// 用户所属部门 ID 列表(行级数据权限使用) + pub department_ids: Vec, + /// 每个权限码对应的数据范围(从 role_permissions.data_scope 加载) + pub permission_data_scopes: HashMap, +} diff --git a/crates/erp-diary/Cargo.toml b/crates/erp-diary/Cargo.toml new file mode 100644 index 0000000..311adbc --- /dev/null +++ b/crates/erp-diary/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "erp-diary" +version.workspace = true +edition.workspace = true + +[dependencies] +erp-core.workspace = true +erp-auth.workspace = true +tokio.workspace = true +axum.workspace = true +sea-orm.workspace = true +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +tracing.workspace = true +thiserror.workspace = true +anyhow.workspace = true +utoipa.workspace = true +validator.workspace = true +async-trait.workspace = true diff --git a/crates/erp-diary/src/dto.rs b/crates/erp-diary/src/dto.rs new file mode 100644 index 0000000..2b49306 --- /dev/null +++ b/crates/erp-diary/src/dto.rs @@ -0,0 +1,125 @@ +// erp-diary 数据传输对象 (DTO) + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// 日记心情枚举 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum Mood { + Happy, + Calm, + Sad, + Angry, + Thinking, +} + +/// 天气枚举 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum Weather { + Sunny, + Cloudy, + Rainy, + Snowy, + Windy, +} + +/// 创建日记请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateJournalReq { + pub title: String, + pub date: chrono::NaiveDate, + pub mood: Mood, + pub weather: Weather, + pub tags: Vec, + pub is_private: bool, + pub class_id: Option, + pub assigned_topic_id: Option, +} + +/// 更新日记请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct UpdateJournalReq { + pub title: Option, + pub mood: Option, + pub weather: Option, + pub tags: Option>, + pub is_private: Option, + pub shared_to_class: Option, + pub version: i32, +} + +/// 日记响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct JournalResp { + pub id: uuid::Uuid, + pub author_id: uuid::Uuid, + pub class_id: Option, + pub title: String, + pub date: chrono::NaiveDate, + pub mood: Mood, + pub weather: Weather, + pub tags: Vec, + pub is_private: bool, + pub shared_to_class: bool, + pub version: i32, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +/// 创建班级请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateClassReq { + pub name: String, + pub school_name: Option, +} + +/// 加入班级请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct JoinClassReq { + pub class_code: String, +} + +/// 班级响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct ClassResp { + pub id: uuid::Uuid, + pub name: String, + pub school_name: Option, + pub teacher_id: uuid::Uuid, + pub class_code: String, + pub member_count: i32, + pub is_active: bool, +} + +/// 同步请求 +#[derive(Debug, Deserialize, ToSchema)] +pub struct SyncReq { + pub last_sync_time: Option>, + pub changes: Vec, +} + +/// 同步变更条目 +#[derive(Debug, Deserialize, ToSchema)] +pub enum SyncChange { + CreateJournal { data: serde_json::Value }, + UpdateJournal { id: uuid::Uuid, version: i32, data: serde_json::Value }, + DeleteJournal { id: uuid::Uuid, version: i32 }, +} + +/// 同步响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct SyncResp { + pub server_changes: Vec, + pub conflicts: Vec, + pub sync_time: chrono::DateTime, +} + +/// 冲突信息 +#[derive(Debug, Serialize, ToSchema)] +pub struct ConflictInfo { + pub journal_id: uuid::Uuid, + pub local_version: i32, + pub server_version: i32, +} diff --git a/crates/erp-diary/src/entity/mod.rs b/crates/erp-diary/src/entity/mod.rs new file mode 100644 index 0000000..d32fa21 --- /dev/null +++ b/crates/erp-diary/src/entity/mod.rs @@ -0,0 +1,2 @@ +// erp-diary SeaORM 实体占位 +// 后续 Phase B1 会定义完整的 ~15 个实体 diff --git a/crates/erp-diary/src/error.rs b/crates/erp-diary/src/error.rs new file mode 100644 index 0000000..c8b6314 --- /dev/null +++ b/crates/erp-diary/src/error.rs @@ -0,0 +1,75 @@ +// erp-diary 错误类型 + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; + +#[derive(Debug, thiserror::Error)] +pub enum DiaryError { + #[error("日记未找到: {0}")] + NotFound(String), + + #[error("版本冲突: 本地版本 {local}, 服务端版本 {server}")] + VersionConflict { local: i32, server: i32 }, + + #[error("班级码无效")] + InvalidClassCode, + + #[error("班级码已过期")] + ClassCodeExpired, + + #[error("班级码尝试次数过多,请 {lockout_minutes} 分钟后重试")] + ClassCodeLocked { lockout_minutes: u32 }, + + #[error("无权限执行此操作")] + Forbidden, + + #[error("内容安全检查未通过")] + ContentSafetyViolation, + + #[error("同步失败: {0}")] + SyncFailed(String), + + #[error("{0}")] + BadRequest(String), + + #[error("内部错误: {0}")] + Internal(String), +} + +#[derive(Serialize)] +struct ErrorBody { + error: String, + message: String, +} + +impl IntoResponse for DiaryError { + fn into_response(self) -> Response { + let (status, message) = match &self { + DiaryError::NotFound(_) => (StatusCode::NOT_FOUND, self.to_string()), + DiaryError::VersionConflict { .. } => (StatusCode::CONFLICT, self.to_string()), + DiaryError::InvalidClassCode | DiaryError::ClassCodeExpired => { + (StatusCode::BAD_REQUEST, self.to_string()) + } + DiaryError::ClassCodeLocked { .. } => (StatusCode::TOO_MANY_REQUESTS, self.to_string()), + DiaryError::Forbidden => (StatusCode::FORBIDDEN, self.to_string()), + DiaryError::ContentSafetyViolation => (StatusCode::BAD_REQUEST, self.to_string()), + DiaryError::SyncFailed(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), + DiaryError::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()), + DiaryError::Internal(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), + }; + + let body = ErrorBody { + error: format!("diary.{}", status.as_u16()), + message, + }; + + (status, axum::Json(body)).into_response() + } +} + +impl From for DiaryError { + fn from(err: sea_orm::DbErr) -> Self { + DiaryError::Internal(err.to_string()) + } +} diff --git a/crates/erp-diary/src/event.rs b/crates/erp-diary/src/event.rs new file mode 100644 index 0000000..2402df7 --- /dev/null +++ b/crates/erp-diary/src/event.rs @@ -0,0 +1,61 @@ +// erp-diary 事件定义 + +/// 日记模块领域事件 +pub enum DiaryEvent { + /// 日记创建 + JournalCreated { + journal_id: uuid::Uuid, + author_id: uuid::Uuid, + class_id: Option, + }, + /// 日记更新 + JournalUpdated { + journal_id: uuid::Uuid, + author_id: uuid::Uuid, + version: i32, + }, + /// 日记删除 + JournalDeleted { + journal_id: uuid::Uuid, + author_id: uuid::Uuid, + }, + /// 日记分享到班级 + JournalShared { + journal_id: uuid::Uuid, + author_id: uuid::Uuid, + class_id: uuid::Uuid, + }, + /// 班级创建 + ClassCreated { + class_id: uuid::Uuid, + teacher_id: uuid::Uuid, + }, + /// 学生加入班级 + StudentJoinedClass { + class_id: uuid::Uuid, + student_id: uuid::Uuid, + }, + /// 老师布置主题 + TopicAssigned { + topic_id: uuid::Uuid, + class_id: uuid::Uuid, + teacher_id: uuid::Uuid, + }, + /// 老师点评 + CommentCreated { + comment_id: uuid::Uuid, + journal_id: uuid::Uuid, + teacher_id: uuid::Uuid, + student_id: uuid::Uuid, + }, + /// 家长绑定孩子 + ParentBound { + parent_id: uuid::Uuid, + child_id: uuid::Uuid, + }, + /// 成就解锁 + AchievementUnlocked { + user_id: uuid::Uuid, + achievement_id: String, + }, +} diff --git a/crates/erp-diary/src/handler/mod.rs b/crates/erp-diary/src/handler/mod.rs new file mode 100644 index 0000000..50446a3 --- /dev/null +++ b/crates/erp-diary/src/handler/mod.rs @@ -0,0 +1,2 @@ +// erp-diary API 处理器占位 +// 后续 Phase B2-B7 会实现 ~10 个处理器 diff --git a/crates/erp-diary/src/lib.rs b/crates/erp-diary/src/lib.rs new file mode 100644 index 0000000..e2cf8ec --- /dev/null +++ b/crates/erp-diary/src/lib.rs @@ -0,0 +1,112 @@ +pub mod entity; +pub mod service; +pub mod handler; +pub mod dto; +pub mod error; +pub mod event; +pub mod state; + +pub use state::DiaryState; + +use erp_core::module::ErpModule; + +/// 暖记日记业务模块 +pub struct DiaryModule; + +impl ErpModule for DiaryModule { + fn name(&self) -> &str { + "diary" + } + + fn id(&self) -> &str { + "erp-diary" + } + + fn version(&self) -> &str { + env!("CARGO_PKG_VERSION") + } + + fn module_type(&self) -> erp_core::module::ModuleType { + erp_core::module::ModuleType::Builtin + } + + fn dependencies(&self) -> Vec<&str> { + vec!["erp-auth", "erp-core"] + } + + fn permissions(&self) -> Vec { + vec![ + erp_core::module::PermissionDescriptor { + code: "diary.journal.create".into(), + name: "创建日记".into(), + module: "diary".into(), + description: "允许创建日记条目".into(), + }, + erp_core::module::PermissionDescriptor { + code: "diary.journal.read".into(), + name: "查看日记".into(), + module: "diary".into(), + description: "允许查看日记条目".into(), + }, + erp_core::module::PermissionDescriptor { + code: "diary.journal.update".into(), + name: "编辑日记".into(), + module: "diary".into(), + description: "允许编辑日记条目".into(), + }, + erp_core::module::PermissionDescriptor { + code: "diary.journal.delete".into(), + name: "删除日记".into(), + module: "diary".into(), + description: "允许删除日记条目".into(), + }, + erp_core::module::PermissionDescriptor { + code: "diary.class.manage".into(), + name: "管理班级".into(), + module: "diary".into(), + description: "允许创建和管理班级".into(), + }, + erp_core::module::PermissionDescriptor { + code: "diary.topic.assign".into(), + name: "布置主题".into(), + module: "diary".into(), + description: "允许老师布置日记主题".into(), + }, + erp_core::module::PermissionDescriptor { + code: "diary.comment.write".into(), + name: "写评语".into(), + module: "diary".into(), + description: "允许老师点评日记".into(), + }, + erp_core::module::PermissionDescriptor { + code: "diary.parent.bind".into(), + name: "家长绑定".into(), + module: "diary".into(), + description: "允许家长绑定孩子账号".into(), + }, + ] + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +impl DiaryModule { + /// 公开路由(无需认证) + pub fn public_routes() -> axum::Router + where + S: Clone + Send + Sync + 'static, + { + axum::Router::new() + } + + /// 受保护路由(需要 JWT 认证) + pub fn protected_routes() -> axum::Router + where + crate::state::DiaryState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + axum::Router::new() + } +} diff --git a/crates/erp-diary/src/service/mod.rs b/crates/erp-diary/src/service/mod.rs new file mode 100644 index 0000000..f387554 --- /dev/null +++ b/crates/erp-diary/src/service/mod.rs @@ -0,0 +1,2 @@ +// erp-diary 业务服务占位 +// 后续 Phase B2-B6 会实现 ~12 个服务 diff --git a/crates/erp-diary/src/state.rs b/crates/erp-diary/src/state.rs new file mode 100644 index 0000000..112ec26 --- /dev/null +++ b/crates/erp-diary/src/state.rs @@ -0,0 +1,13 @@ +// erp-diary State 定义 + +use sea_orm::DatabaseConnection; +use erp_core::crypto::PiiCrypto; +use erp_core::events::EventBus; + +/// 暖记模块状态,通过 Axum State 提取 +#[derive(Clone)] +pub struct DiaryState { + pub db: DatabaseConnection, + pub event_bus: EventBus, + pub crypto: PiiCrypto, +} diff --git a/crates/erp-message/Cargo.toml b/crates/erp-message/Cargo.toml new file mode 100644 index 0000000..d535d6b --- /dev/null +++ b/crates/erp-message/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "erp-message" +version.workspace = true +edition.workspace = true + +[dependencies] +erp-core.workspace = true +tokio = { workspace = true, features = ["full"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +uuid = { workspace = true, features = ["v7", "serde"] } +chrono = { workspace = true, features = ["serde"] } +axum = { workspace = true } +sea-orm = { workspace = true, features = ["sqlx-postgres", "runtime-tokio-rustls", "with-uuid", "with-chrono", "with-json"] } +tracing = { workspace = true } +anyhow.workspace = true +thiserror.workspace = true +utoipa = { workspace = true, features = ["uuid", "chrono"] } +async-trait.workspace = true +validator.workspace = true +futures.workspace = true +tokio-stream.workspace = true +async-stream.workspace = true diff --git a/crates/erp-message/src/dto.rs b/crates/erp-message/src/dto.rs new file mode 100644 index 0000000..01595bb --- /dev/null +++ b/crates/erp-message/src/dto.rs @@ -0,0 +1,517 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; +use validator::Validate; + +// ============ 消息 DTO ============ + +/// 消息响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct MessageResp { + pub id: Uuid, + pub tenant_id: Uuid, + pub template_id: Option, + pub sender_id: Option, + pub sender_type: String, + pub recipient_id: Uuid, + pub recipient_type: String, + pub title: String, + pub body: String, + pub priority: String, + pub business_type: Option, + pub business_id: Option, + pub is_read: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub read_at: Option>, + pub is_archived: bool, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub sent_at: Option>, + pub created_at: DateTime, + pub updated_at: DateTime, + pub version: i32, +} + +/// 发送消息请求 +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct SendMessageReq { + #[validate(length(min = 1, max = 200, message = "标题不能为空且不超过200字符"))] + pub title: String, + #[validate(length(min = 1, message = "内容不能为空"))] + pub body: String, + pub recipient_id: Uuid, + #[serde(default = "default_recipient_type")] + #[validate(custom(function = "validate_recipient_type"))] + pub recipient_type: String, + #[serde(default = "default_priority")] + #[validate(custom(function = "validate_priority"))] + pub priority: String, + pub template_id: Option, + pub business_type: Option, + pub business_id: Option, +} + +fn validate_recipient_type(value: &str) -> Result<(), validator::ValidationError> { + match value { + "user" | "role" | "department" | "all" => Ok(()), + _ => Err(validator::ValidationError::new("invalid_recipient_type")), + } +} + +fn validate_priority(value: &str) -> Result<(), validator::ValidationError> { + match value { + "normal" | "important" | "urgent" => Ok(()), + _ => Err(validator::ValidationError::new("invalid_priority")), + } +} + +fn default_recipient_type() -> String { + "user".to_string() +} + +fn default_priority() -> String { + "normal".to_string() +} + +/// 消息列表查询参数 +#[derive(Debug, Deserialize, ToSchema, utoipa::IntoParams)] +pub struct MessageQuery { + pub page: Option, + pub page_size: Option, + pub is_read: Option, + pub priority: Option, + pub business_type: Option, + pub status: Option, +} + +impl MessageQuery { + /// 获取安全的分页大小(上限 100)。 + pub fn safe_page_size(&self) -> u64 { + self.page_size.unwrap_or(20).min(100) + } +} + +/// 未读消息计数响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct UnreadCountResp { + pub count: i64, +} + +// ============ 消息模板 DTO ============ + +/// 消息模板响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct MessageTemplateResp { + pub id: Uuid, + pub tenant_id: Uuid, + pub name: String, + pub code: String, + pub channel: String, + pub title_template: String, + pub body_template: String, + pub language: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub version: i32, +} + +/// 创建消息模板请求 +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct CreateTemplateReq { + #[validate(length(min = 1, max = 100, message = "名称不能为空且不超过100字符"))] + pub name: String, + #[validate(length(min = 1, max = 50, message = "编码不能为空且不超过50字符"))] + pub code: String, + #[serde(default = "default_channel")] + #[validate(custom(function = "validate_channel"))] + pub channel: String, + #[validate(length(min = 1, max = 200, message = "标题模板不能为空"))] + pub title_template: String, + #[validate(length(min = 1, message = "内容模板不能为空"))] + pub body_template: String, + #[serde(default = "default_language")] + pub language: String, +} + +fn default_channel() -> String { + "in_app".to_string() +} + +fn validate_channel(value: &str) -> Result<(), validator::ValidationError> { + match value { + "in_app" | "email" | "sms" | "wechat" => Ok(()), + _ => Err(validator::ValidationError::new("invalid_channel")), + } +} + +fn default_language() -> String { + "zh-CN".to_string() +} + +/// 更新消息模板请求 +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct UpdateTemplateReq { + #[validate(length(min = 1, max = 100, message = "名称不能为空且不超过100字符"))] + pub name: Option, + #[validate(length(min = 1, max = 200, message = "标题模板不能为空"))] + pub title_template: Option, + #[validate(length(min = 1, message = "内容模板不能为空"))] + pub body_template: Option, + #[validate(length(min = 1, max = 10, message = "语言代码无效"))] + pub language: Option, + #[validate(custom(function = "validate_channel"))] + pub channel: Option, + pub version: i32, +} + +// ============ 消息订阅偏好 DTO ============ + +/// 消息订阅偏好响应 +#[derive(Debug, Serialize, ToSchema)] +pub struct MessageSubscriptionResp { + pub id: Uuid, + pub tenant_id: Uuid, + pub user_id: Uuid, + pub notification_types: Option, + pub channel_preferences: Option, + pub dnd_enabled: bool, + pub dnd_start: Option, + pub dnd_end: Option, + pub created_at: DateTime, + pub updated_at: DateTime, + pub version: i32, +} + +/// 更新消息订阅偏好请求 +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct UpdateSubscriptionReq { + pub notification_types: Option, + pub channel_preferences: Option, + pub dnd_enabled: Option, + #[validate(length(max = 8, message = "免打扰开始时间格式无效"))] + pub dnd_start: Option, + #[validate(length(max = 8, message = "免打扰结束时间格式无效"))] + pub dnd_end: Option, + pub version: i32, +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + use validator::Validate; + + // ============ SendMessageReq 测试 ============ + + fn valid_send_message_req() -> SendMessageReq { + SendMessageReq { + title: "系统通知".to_string(), + body: "您有一条新消息".to_string(), + recipient_id: Uuid::now_v7(), + recipient_type: "user".to_string(), + priority: "normal".to_string(), + template_id: None, + business_type: None, + business_id: None, + } + } + + #[test] + fn send_message_req_valid() { + let req = valid_send_message_req(); + assert!(req.validate().is_ok()); + } + + #[test] + fn send_message_req_empty_title_fails() { + let mut req = valid_send_message_req(); + req.title = "".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn send_message_req_title_too_long_fails() { + let mut req = valid_send_message_req(); + req.title = "x".repeat(201); + assert!(req.validate().is_err()); + } + + #[test] + fn send_message_req_title_max_length_ok() { + let mut req = valid_send_message_req(); + req.title = "x".repeat(200); + assert!(req.validate().is_ok()); + } + + #[test] + fn send_message_req_empty_body_fails() { + let mut req = valid_send_message_req(); + req.body = "".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn send_message_req_valid_recipient_types() { + for rt in &["user", "role", "department", "all"] { + let mut req = valid_send_message_req(); + req.recipient_type = rt.to_string(); + assert!( + req.validate().is_ok(), + "recipient_type '{}' should be valid", + rt + ); + } + } + + #[test] + fn send_message_req_invalid_recipient_type_fails() { + let mut req = valid_send_message_req(); + req.recipient_type = "invalid".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn send_message_req_valid_priorities() { + for p in &["normal", "important", "urgent"] { + let mut req = valid_send_message_req(); + req.priority = p.to_string(); + assert!(req.validate().is_ok(), "priority '{}' should be valid", p); + } + } + + #[test] + fn send_message_req_invalid_priority_fails() { + let mut req = valid_send_message_req(); + req.priority = "critical".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn send_message_req_default_recipient_type_is_user() { + assert_eq!(default_recipient_type(), "user"); + } + + #[test] + fn send_message_req_default_priority_is_normal() { + assert_eq!(default_priority(), "normal"); + } + + // ============ MessageQuery 测试 ============ + + #[test] + fn message_query_safe_page_size_default() { + let query = MessageQuery { + page: None, + page_size: None, + is_read: None, + priority: None, + business_type: None, + status: None, + }; + assert_eq!(query.safe_page_size(), 20); + } + + #[test] + fn message_query_safe_page_size_custom() { + let query = MessageQuery { + page: None, + page_size: Some(50), + is_read: None, + priority: None, + business_type: None, + status: None, + }; + assert_eq!(query.safe_page_size(), 50); + } + + #[test] + fn message_query_safe_page_size_capped_at_100() { + let query = MessageQuery { + page: None, + page_size: Some(200), + is_read: None, + priority: None, + business_type: None, + status: None, + }; + assert_eq!(query.safe_page_size(), 100); + } + + #[test] + fn message_query_safe_page_size_exactly_100() { + let query = MessageQuery { + page: None, + page_size: Some(100), + is_read: None, + priority: None, + business_type: None, + status: None, + }; + assert_eq!(query.safe_page_size(), 100); + } + + // ============ CreateTemplateReq 测试 ============ + + fn valid_create_template_req() -> CreateTemplateReq { + CreateTemplateReq { + name: "欢迎模板".to_string(), + code: "WELCOME".to_string(), + channel: "in_app".to_string(), + title_template: "欢迎加入".to_string(), + body_template: "您好,{{name}},欢迎加入平台".to_string(), + language: "zh-CN".to_string(), + } + } + + #[test] + fn create_template_req_valid() { + let req = valid_create_template_req(); + assert!(req.validate().is_ok()); + } + + #[test] + fn create_template_req_empty_name_fails() { + let mut req = valid_create_template_req(); + req.name = "".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn create_template_req_name_too_long_fails() { + let mut req = valid_create_template_req(); + req.name = "x".repeat(101); + assert!(req.validate().is_err()); + } + + #[test] + fn create_template_req_name_max_length_ok() { + let mut req = valid_create_template_req(); + req.name = "x".repeat(100); + assert!(req.validate().is_ok()); + } + + #[test] + fn create_template_req_empty_code_fails() { + let mut req = valid_create_template_req(); + req.code = "".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn create_template_req_code_too_long_fails() { + let mut req = valid_create_template_req(); + req.code = "X".repeat(51); + assert!(req.validate().is_err()); + } + + #[test] + fn create_template_req_code_max_length_ok() { + let mut req = valid_create_template_req(); + req.code = "X".repeat(50); + assert!(req.validate().is_ok()); + } + + #[test] + fn create_template_req_valid_channels() { + for ch in &["in_app", "email", "sms", "wechat"] { + let mut req = valid_create_template_req(); + req.channel = ch.to_string(); + assert!(req.validate().is_ok(), "channel '{}' should be valid", ch); + } + } + + #[test] + fn create_template_req_invalid_channel_fails() { + let mut req = valid_create_template_req(); + req.channel = "telegram".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn create_template_req_empty_title_template_fails() { + let mut req = valid_create_template_req(); + req.title_template = "".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn create_template_req_title_template_too_long_fails() { + let mut req = valid_create_template_req(); + req.title_template = "x".repeat(201); + assert!(req.validate().is_err()); + } + + #[test] + fn create_template_req_empty_body_template_fails() { + let mut req = valid_create_template_req(); + req.body_template = "".to_string(); + assert!(req.validate().is_err()); + } + + #[test] + fn create_template_req_default_channel_is_in_app() { + assert_eq!(default_channel(), "in_app"); + } + + #[test] + fn create_template_req_default_language_is_zh_cn() { + assert_eq!(default_language(), "zh-CN"); + } + + // ============ 自定义验证函数测试 ============ + + #[test] + fn validate_recipient_type_valid() { + for rt in &["user", "role", "department", "all"] { + assert!( + validate_recipient_type(rt).is_ok(), + "'{}' should be a valid recipient type", + rt + ); + } + } + + #[test] + fn validate_recipient_type_invalid() { + assert!(validate_recipient_type("invalid").is_err()); + assert!(validate_recipient_type("").is_err()); + assert!(validate_recipient_type("USER").is_err()); + } + + #[test] + fn validate_priority_valid() { + for p in &["normal", "important", "urgent"] { + assert!( + validate_priority(p).is_ok(), + "'{}' should be a valid priority", + p + ); + } + } + + #[test] + fn validate_priority_invalid() { + assert!(validate_priority("critical").is_err()); + assert!(validate_priority("").is_err()); + assert!(validate_priority("NORMAL").is_err()); + } + + #[test] + fn validate_channel_valid() { + for ch in &["in_app", "email", "sms", "wechat"] { + assert!( + validate_channel(ch).is_ok(), + "'{}' should be a valid channel", + ch + ); + } + } + + #[test] + fn validate_channel_invalid() { + assert!(validate_channel("slack").is_err()); + assert!(validate_channel("").is_err()); + assert!(validate_channel("EMAIL").is_err()); + } +} diff --git a/crates/erp-message/src/entity/message.rs b/crates/erp-message/src/entity/message.rs new file mode 100644 index 0000000..d28b01b --- /dev/null +++ b/crates/erp-message/src/entity/message.rs @@ -0,0 +1,58 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "messages")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub template_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sender_id: Option, + pub sender_type: String, + pub recipient_id: Uuid, + pub recipient_type: String, + pub title: String, + pub body: String, + pub priority: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub business_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub business_id: Option, + pub is_read: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub read_at: Option, + pub is_archived: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub archived_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sent_at: Option, + pub status: String, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::message_template::Entity", + from = "Column::TemplateId", + to = "super::message_template::Column::Id" + )] + MessageTemplate, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MessageTemplate.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-message/src/entity/message_subscription.rs b/crates/erp-message/src/entity/message_subscription.rs new file mode 100644 index 0000000..56dbdce --- /dev/null +++ b/crates/erp-message/src/entity/message_subscription.rs @@ -0,0 +1,32 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "message_subscriptions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub user_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub notification_types: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel_preferences: Option, + pub dnd_enabled: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub dnd_start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dnd_end: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-message/src/entity/message_template.rs b/crates/erp-message/src/entity/message_template.rs new file mode 100644 index 0000000..98aa839 --- /dev/null +++ b/crates/erp-message/src/entity/message_template.rs @@ -0,0 +1,37 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "message_templates")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub name: String, + pub code: String, + pub channel: String, + pub title_template: String, + pub body_template: String, + pub language: String, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::message::Entity")] + Message, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Message.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-message/src/entity/mod.rs b/crates/erp-message/src/entity/mod.rs new file mode 100644 index 0000000..47b3f7b --- /dev/null +++ b/crates/erp-message/src/entity/mod.rs @@ -0,0 +1,3 @@ +pub mod message; +pub mod message_subscription; +pub mod message_template; diff --git a/crates/erp-message/src/error.rs b/crates/erp-message/src/error.rs new file mode 100644 index 0000000..e708a64 --- /dev/null +++ b/crates/erp-message/src/error.rs @@ -0,0 +1,144 @@ +use erp_core::error::AppError; + +/// 消息中心模块错误类型。 +#[derive(Debug, thiserror::Error)] +pub enum MessageError { + #[error("验证失败: {0}")] + Validation(String), + + #[error("未找到: {0}")] + NotFound(String), + + #[error("模板编码已存在: {0}")] + DuplicateTemplateCode(String), + + #[error("渲染失败: {0}")] + TemplateRenderError(String), + + #[error("版本冲突: 数据已被其他操作修改,请刷新后重试")] + VersionMismatch, +} + +impl From for AppError { + fn from(err: MessageError) -> Self { + match err { + MessageError::Validation(msg) => AppError::Validation(msg), + MessageError::NotFound(msg) => AppError::NotFound(msg), + MessageError::DuplicateTemplateCode(msg) => AppError::Conflict(msg), + MessageError::TemplateRenderError(msg) => AppError::Internal(msg), + MessageError::VersionMismatch => AppError::VersionMismatch, + } + } +} + +impl From> for MessageError { + fn from(err: sea_orm::TransactionError) -> Self { + match err { + sea_orm::TransactionError::Connection(db_err) => { + MessageError::Validation(db_err.to_string()) + } + sea_orm::TransactionError::Transaction(msg_err) => msg_err, + } + } +} + +pub type MessageResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + use erp_core::error::AppError; + + #[test] + fn validation_maps_to_app_validation() { + let app: AppError = MessageError::Validation("标题不能为空".to_string()).into(); + match app { + AppError::Validation(msg) => assert_eq!(msg, "标题不能为空"), + other => panic!("Expected AppError::Validation, got {:?}", other), + } + } + + #[test] + fn not_found_maps_to_app_not_found() { + let app: AppError = MessageError::NotFound("消息不存在".to_string()).into(); + match app { + AppError::NotFound(msg) => assert_eq!(msg, "消息不存在"), + other => panic!("Expected AppError::NotFound, got {:?}", other), + } + } + + #[test] + fn duplicate_template_code_maps_to_app_conflict() { + let app: AppError = MessageError::DuplicateTemplateCode("WELCOME".to_string()).into(); + match app { + AppError::Conflict(msg) => assert_eq!(msg, "WELCOME"), + other => panic!("Expected AppError::Conflict, got {:?}", other), + } + } + + #[test] + fn template_render_error_maps_to_app_internal() { + let app: AppError = MessageError::TemplateRenderError("变量缺失".to_string()).into(); + match app { + AppError::Internal(msg) => assert_eq!(msg, "变量缺失"), + other => panic!("Expected AppError::Internal, got {:?}", other), + } + } + + #[test] + fn version_mismatch_maps_to_app_version_mismatch() { + let app: AppError = MessageError::VersionMismatch.into(); + match app { + AppError::VersionMismatch => {} + other => panic!("Expected AppError::VersionMismatch, got {:?}", other), + } + } + + #[test] + fn error_display_format() { + assert_eq!( + MessageError::Validation("字段为空".to_string()).to_string(), + "验证失败: 字段为空" + ); + assert_eq!( + MessageError::NotFound("id=123".to_string()).to_string(), + "未找到: id=123" + ); + assert_eq!( + MessageError::DuplicateTemplateCode("CODE".to_string()).to_string(), + "模板编码已存在: CODE" + ); + assert_eq!( + MessageError::TemplateRenderError("解析失败".to_string()).to_string(), + "渲染失败: 解析失败" + ); + assert_eq!( + MessageError::VersionMismatch.to_string(), + "版本冲突: 数据已被其他操作修改,请刷新后重试" + ); + } + + #[test] + fn transaction_connection_error_maps_to_validation() { + let db_err = sea_orm::DbErr::Conn(sea_orm::RuntimeErr::Internal("连接超时".to_string())); + let tx_err: sea_orm::TransactionError = + sea_orm::TransactionError::Connection(db_err); + let msg_err: MessageError = tx_err.into(); + match msg_err { + MessageError::Validation(msg) => assert!(msg.contains("连接超时")), + other => panic!("期望 Validation,得到 {:?}", other), + } + } + + #[test] + fn transaction_inner_error_passthrough() { + let inner = MessageError::NotFound("模板不存在".to_string()); + let tx_err: sea_orm::TransactionError = + sea_orm::TransactionError::Transaction(inner); + let msg_err: MessageError = tx_err.into(); + match msg_err { + MessageError::NotFound(msg) => assert_eq!(msg, "模板不存在"), + other => panic!("期望 NotFound,得到 {:?}", other), + } + } +} diff --git a/crates/erp-message/src/handler/message_handler.rs b/crates/erp-message/src/handler/message_handler.rs new file mode 100644 index 0000000..c60e75e --- /dev/null +++ b/crates/erp-message/src/handler/message_handler.rs @@ -0,0 +1,194 @@ +use axum::Json; +use axum::extract::FromRef; +use axum::extract::{Extension, Path, Query, State}; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; +use validator::Validate; + +use crate::dto::{MessageQuery, MessageResp, SendMessageReq, UnreadCountResp}; +use crate::message_state::MessageState; +use crate::service::message_service::MessageService; + +#[utoipa::path( + get, + path = "/api/v1/messages", + params(MessageQuery), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "消息管理" +)] +/// 查询消息列表。 +pub async fn list_messages( + State(_state): State, + Extension(ctx): Extension, + Query(query): Query, +) -> Result>>, AppError> +where + MessageState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "message.list")?; + + let db = &_state.db; + let page = query.page.unwrap_or(1); + let page_size = query.page_size.unwrap_or(20); + + let (messages, total) = MessageService::list(ctx.tenant_id, ctx.user_id, &query, db).await?; + + let total_pages = total.div_ceil(page_size); + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: messages, + total, + page, + page_size, + total_pages, + }))) +} + +#[utoipa::path( + get, + path = "/api/v1/messages/unread-count", + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "消息管理" +)] +/// 获取未读消息数量。 +pub async fn unread_count( + State(_state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + MessageState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "message.list")?; + + let result = MessageService::unread_count(ctx.tenant_id, ctx.user_id, &_state.db).await?; + Ok(Json(ApiResponse::ok(result))) +} + +#[utoipa::path( + post, + path = "/api/v1/messages", + request_body = SendMessageReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "消息管理" +)] +/// 发送消息。 +pub async fn send_message( + State(_state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + MessageState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "message.send")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let resp = MessageService::send( + ctx.tenant_id, + ctx.user_id, + &req, + &_state.db, + &_state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + put, + path = "/api/v1/messages/{id}/read", + params(("id" = Uuid, Path, description = "消息ID")), + responses( + (status = 200, description = "成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "消息管理" +)] +/// 标记消息已读。 +pub async fn mark_read( + State(_state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + MessageState: FromRef, + S: Clone + Send + Sync + 'static, +{ + MessageService::mark_read(id, ctx.tenant_id, ctx.user_id, &_state.db).await?; + Ok(Json(ApiResponse::ok(()))) +} + +#[utoipa::path( + put, + path = "/api/v1/messages/read-all", + responses( + (status = 200, description = "成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "消息管理" +)] +/// 标记所有消息已读。 +pub async fn mark_all_read( + State(_state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + MessageState: FromRef, + S: Clone + Send + Sync + 'static, +{ + MessageService::mark_all_read(ctx.tenant_id, ctx.user_id, &_state.db).await?; + Ok(Json(ApiResponse::ok(()))) +} + +#[utoipa::path( + delete, + path = "/api/v1/messages/{id}", + params(("id" = Uuid, Path, description = "消息ID")), + responses( + (status = 200, description = "成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "消息管理" +)] +/// 删除消息。 +pub async fn delete_message( + State(_state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + MessageState: FromRef, + S: Clone + Send + Sync + 'static, +{ + MessageService::delete(id, ctx.tenant_id, ctx.user_id, &_state.db).await?; + Ok(Json(ApiResponse::ok(()))) +} diff --git a/crates/erp-message/src/handler/mod.rs b/crates/erp-message/src/handler/mod.rs new file mode 100644 index 0000000..2c0a2ed --- /dev/null +++ b/crates/erp-message/src/handler/mod.rs @@ -0,0 +1,4 @@ +pub mod message_handler; +pub mod sse_handler; +pub mod subscription_handler; +pub mod template_handler; diff --git a/crates/erp-message/src/handler/sse_handler.rs b/crates/erp-message/src/handler/sse_handler.rs new file mode 100644 index 0000000..6d0604d --- /dev/null +++ b/crates/erp-message/src/handler/sse_handler.rs @@ -0,0 +1,322 @@ +use std::cell::Cell; +use std::collections::HashSet; + +use axum::extract::{Extension, Query}; +use axum::http::{HeaderMap, HeaderValue, header}; +use axum::response::IntoResponse; +use axum::response::sse::{Event, KeepAlive, Sse}; +use futures::stream::Stream; +use sea_orm::ConnectionTrait; +use serde::Deserialize; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::types::TenantContext; + +/// 包装 SSE 响应,添加 Cache-Control: no-store 头 +pub struct NoCacheSse(Sse); + +impl IntoResponse for NoCacheSse +where + S: Stream> + Send + 'static, +{ + fn into_response(self) -> axum::response::Response { + let mut response = self.0.into_response(); + response.headers_mut().insert( + header::CACHE_CONTROL, + HeaderValue::from_static("no-store, no-cache, must-revalidate"), + ); + response + } +} + +use crate::message_state::MessageState; + +/// SSE 查询参数 +#[derive(Debug, Deserialize, Default)] +pub struct SseQuery { + /// 逗号分隔的患者 ID 列表,为空则订阅所有管床患者 + pub patient_ids: Option, +} + +/// SSE 消息推送端点。 +/// +/// 监听所有事件,按类型分发为不同 SSE event: +/// - `message.sent` → SSE event: `message` +/// - `alert.triggered` → SSE event: `alert` +/// - `device.readings.synced` → SSE event: `vital_update` +/// +/// 增强: +/// - Event ID(支持 Last-Event-ID 断点续传) +/// - 30s 心跳保活 +/// - 患者选择性订阅(?patient_ids=id1,id2) +pub async fn message_stream( + axum::extract::State(state): axum::extract::State, + Extension(ctx): Extension, + headers: HeaderMap, + Query(query): Query, +) -> Result>>, AppError> { + let user_id = ctx.user_id; + let tenant_id = ctx.tenant_id; + + let last_event_id: Option = headers + .get("Last-Event-ID") + .and_then(|v| v.to_str().ok()) + .and_then(|s| Uuid::parse_str(s).ok()); + + let subscribed_patient_ids: Option> = query.patient_ids.as_ref().map(|s| { + s.split(',') + .map(|id| id.trim().to_string()) + .filter(|id| !id.is_empty()) + .collect() + }); + + let (mut rx, _handle) = state.event_bus.subscribe_filtered(String::new()); + + let db = state.db.clone(); + let last_event_id_cell = Cell::new(last_event_id); + + let sse_stream = async_stream::stream! { + loop { + let result = tokio::time::timeout( + std::time::Duration::from_secs(30), + rx.recv(), + ).await; + + match result { + Ok(Some(event)) => { + if event.tenant_id != tenant_id { + continue; + } + + // Last-Event-ID 恢复:跳过已发送的事件 + if let Some(skip_until) = last_event_id_cell.take() + && event.id <= skip_until { + last_event_id_cell.set(Some(skip_until)); + continue; + } + + match event.event_type.as_str() { + "message.sent" => { + let is_recipient = event.payload.get("recipient_id") + .and_then(|v| v.as_str()) + .map(|s| s == user_id.to_string()) + .unwrap_or(false); + if !is_recipient { + continue; + } + let data = serde_json::to_string(&event.payload) + .unwrap_or_default(); + yield Ok(Event::default() + .event("message") + .id(event.id.to_string()) + .data(data)); + } + "alert.triggered" => { + let patient_id = event.payload.get("patient_id") + .and_then(|v| v.as_str()); + + // 患者订阅过滤 + if let (Some(pid_str), Some(subscribed)) = (patient_id, &subscribed_patient_ids) + && !subscribed.contains(pid_str) { + continue; + } + + if let Some(pid_str) = patient_id { + let pid = Uuid::parse_str(pid_str).ok(); + if let Some(pid) = pid { + let is_doctor = is_doctor_for_patient( + &db, tenant_id, user_id, pid, + ).await; + if !is_doctor { + continue; + } + } + } + let data = serde_json::to_string(&event.payload) + .unwrap_or_default(); + yield Ok(Event::default() + .event("alert") + .id(event.id.to_string()) + .data(data)); + } + "device.readings.synced" => { + let patient_id = event.payload.get("patient_id") + .and_then(|v| v.as_str()); + + // 患者订阅过滤 + if let (Some(pid_str), Some(subscribed)) = (patient_id, &subscribed_patient_ids) + && !subscribed.contains(pid_str) { + continue; + } + + if let Some(pid_str) = patient_id { + let pid = Uuid::parse_str(pid_str).ok(); + if let Some(pid) = pid { + let is_doctor = is_doctor_for_patient( + &db, tenant_id, user_id, pid, + ).await; + if !is_doctor { + continue; + } + } + } + let data = serde_json::to_string(&event.payload) + .unwrap_or_default(); + yield Ok(Event::default() + .event("vital_update") + .id(event.id.to_string()) + .data(data)); + } + _ => {} + } + } + Ok(None) => { + break; + } + Err(_) => { + // 超时 = 发送心跳 + yield Ok(Event::default().comment("ping")); + } + } + } + }; + + Ok(NoCacheSse( + Sse::new(sse_stream).keep_alive( + KeepAlive::new() + .interval(std::time::Duration::from_secs(30)) + .text("ping"), + ), + )) +} + +/// 检查 user_id 对应的医生是否是某患者的管床医生。 +async fn is_doctor_for_patient( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + user_id: Uuid, + patient_id: Uuid, +) -> bool { + let sql = sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + r#"SELECT COUNT(*) AS cnt FROM patient_doctor_relation + WHERE tenant_id = $1 AND doctor_id = $2 AND patient_id = $3 AND deleted_at IS NULL"#, + [tenant_id.into(), user_id.into(), patient_id.into()], + ); + match db.query_one(sql).await { + Ok(Some(row)) => { + let cnt: i64 = row.try_get::("", "cnt").unwrap_or(0); + cnt > 0 + } + _ => { + tracing::warn!( + user_id = %user_id, + patient_id = %patient_id, + "查询医患关系失败,跳过推送" + ); + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn patient_id_parsing_from_payload() { + let payload = serde_json::json!({ + "patient_id": "550e8400-e29b-41d4-a716-446655440000", + "severity": "critical", + "rule_name": "心率过高", + }); + let pid_str = payload.get("patient_id").and_then(|v| v.as_str()); + assert!(pid_str.is_some()); + let pid = Uuid::parse_str(pid_str.unwrap()).ok(); + assert!(pid.is_some()); + assert_eq!( + pid.unwrap(), + Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap() + ); + } + + #[test] + fn patient_id_missing_returns_none() { + let payload = serde_json::json!({ + "severity": "warning", + }); + let pid_str = payload.get("patient_id").and_then(|v| v.as_str()); + assert!(pid_str.is_none()); + } + + #[test] + fn patient_id_invalid_uuid_returns_none() { + let payload = serde_json::json!({ + "patient_id": "not-a-uuid", + }); + let pid_str = payload.get("patient_id").and_then(|v| v.as_str()); + assert!(pid_str.is_some()); + let pid = Uuid::parse_str(pid_str.unwrap()).ok(); + assert!(pid.is_none()); + } + + #[test] + fn sse_query_parses_patient_ids() { + let query = SseQuery { + patient_ids: Some("id1,id2,id3".into()), + }; + assert!(query.patient_ids.is_some()); + let ids = query.patient_ids.unwrap(); + assert_eq!(ids, "id1,id2,id3"); + } + + #[test] + fn sse_query_default_is_empty() { + let query = SseQuery::default(); + assert!(query.patient_ids.is_none()); + } + + #[test] + fn subscribed_patient_ids_parsing() { + let query = SseQuery { + patient_ids: Some("aaa,bbb,ccc".into()), + }; + let set: Option> = query.patient_ids.map(|s: String| { + s.split(',') + .map(|id: &str| id.trim().to_string()) + .filter(|id: &String| !id.is_empty()) + .collect() + }); + assert!(set.is_some()); + let set = set.unwrap(); + assert_eq!(set.len(), 3); + assert!(set.contains("aaa")); + assert!(set.contains("bbb")); + assert!(set.contains("ccc")); + } + + #[test] + fn last_event_id_parsing_from_headers() { + let event_id = Uuid::now_v7(); + let mut headers = HeaderMap::new(); + headers.insert("Last-Event-ID", event_id.to_string().parse().unwrap()); + + let parsed: Option = headers + .get("Last-Event-ID") + .and_then(|v| v.to_str().ok()) + .and_then(|s| Uuid::parse_str(s).ok()); + + assert_eq!(parsed, Some(event_id)); + } + + #[test] + fn last_event_id_missing_returns_none() { + let headers = HeaderMap::new(); + let parsed: Option = headers + .get("Last-Event-ID") + .and_then(|v| v.to_str().ok()) + .and_then(|s| Uuid::parse_str(s).ok()); + assert!(parsed.is_none()); + } +} diff --git a/crates/erp-message/src/handler/subscription_handler.rs b/crates/erp-message/src/handler/subscription_handler.rs new file mode 100644 index 0000000..73157b1 --- /dev/null +++ b/crates/erp-message/src/handler/subscription_handler.rs @@ -0,0 +1,60 @@ +use axum::Json; +use axum::extract::FromRef; +use axum::extract::{Extension, State}; + +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, TenantContext}; + +use crate::dto::UpdateSubscriptionReq; +use crate::message_state::MessageState; +use crate::service::subscription_service::SubscriptionService; + +#[utoipa::path( + get, + path = "/api/v1/message-subscriptions", + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + ), + security(("bearer_auth" = [])), + tag = "消息订阅" +)] +/// 获取当前用户的消息订阅偏好。 +pub async fn get_subscription( + State(_state): State, + Extension(ctx): Extension, +) -> Result>, AppError> +where + MessageState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let resp = SubscriptionService::get(ctx.tenant_id, ctx.user_id, &_state.db).await?; + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + put, + path = "/api/v1/message-subscriptions", + request_body = UpdateSubscriptionReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "消息订阅" +)] +/// 更新消息订阅偏好。 +pub async fn update_subscription( + State(_state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + MessageState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let resp = SubscriptionService::upsert(ctx.tenant_id, ctx.user_id, &req, &_state.db).await?; + + Ok(Json(ApiResponse::ok(resp))) +} diff --git a/crates/erp-message/src/handler/template_handler.rs b/crates/erp-message/src/handler/template_handler.rs new file mode 100644 index 0000000..233e31b --- /dev/null +++ b/crates/erp-message/src/handler/template_handler.rs @@ -0,0 +1,140 @@ +use axum::Json; +use axum::extract::FromRef; +use axum::extract::{Extension, Path, Query, State}; +use serde::Deserialize; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; +use validator::Validate; + +use crate::dto::{CreateTemplateReq, MessageTemplateResp, UpdateTemplateReq}; +use crate::message_state::MessageState; +use crate::service::template_service::TemplateService; + +#[derive(Debug, Deserialize, utoipa::IntoParams)] +pub struct TemplateQuery { + pub page: Option, + pub page_size: Option, +} + +#[utoipa::path( + get, + path = "/api/v1/message-templates", + params(TemplateQuery), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "消息模板" +)] +/// 查询消息模板列表。 +pub async fn list_templates( + State(_state): State, + Extension(ctx): Extension, + Query(query): Query, +) -> Result>>, AppError> +where + MessageState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "message.template.list")?; + + let page = query.page.unwrap_or(1).max(1); + let page_size = query.page_size.unwrap_or(20).max(1); + + let (templates, total) = + TemplateService::list(ctx.tenant_id, page, page_size, &_state.db).await?; + + let total_pages = total.div_ceil(page_size); + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: templates, + total, + page, + page_size, + total_pages, + }))) +} + +#[utoipa::path( + post, + path = "/api/v1/message-templates", + request_body = CreateTemplateReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "消息模板" +)] +/// 创建消息模板。 +pub async fn create_template( + State(_state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + MessageState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "message.template.create")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let resp = TemplateService::create(ctx.tenant_id, ctx.user_id, &req, &_state.db).await?; + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + put, + path = "/api/v1/message-templates/{id}", + request_body = UpdateTemplateReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "消息模板" +)] +/// 更新消息模板。 +pub async fn update_template( + State(_state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + MessageState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "message.template.manage")?; + + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let resp = TemplateService::update(id, ctx.tenant_id, ctx.user_id, &req, &_state.db).await?; + Ok(Json(ApiResponse::ok(resp))) +} + +/// 删除消息模板。 +#[allow(clippy::type_complexity)] +pub async fn delete_template( + State(_state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + MessageState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "message.template.manage")?; + + TemplateService::delete(id, ctx.tenant_id, ctx.user_id, &_state.db).await?; + Ok(Json(ApiResponse::ok(()))) +} diff --git a/crates/erp-message/src/lib.rs b/crates/erp-message/src/lib.rs new file mode 100644 index 0000000..db68d72 --- /dev/null +++ b/crates/erp-message/src/lib.rs @@ -0,0 +1,10 @@ +pub mod dto; +pub mod entity; +pub mod error; +pub mod handler; +pub mod message_state; +pub mod module; +pub mod service; + +pub use message_state::MessageState; +pub use module::MessageModule; diff --git a/crates/erp-message/src/message_state.rs b/crates/erp-message/src/message_state.rs new file mode 100644 index 0000000..914cf89 --- /dev/null +++ b/crates/erp-message/src/message_state.rs @@ -0,0 +1,9 @@ +use erp_core::events::EventBus; +use sea_orm::DatabaseConnection; + +/// 消息中心模块状态,通过 FromRef 从 AppState 提取。 +#[derive(Clone)] +pub struct MessageState { + pub db: DatabaseConnection, + pub event_bus: EventBus, +} diff --git a/crates/erp-message/src/module.rs b/crates/erp-message/src/module.rs new file mode 100644 index 0000000..ef16dcc --- /dev/null +++ b/crates/erp-message/src/module.rs @@ -0,0 +1,1283 @@ +use axum::Router; +use axum::routing::{delete, get, put}; +use sea_orm::{ColumnTrait, EntityTrait, FromQueryResult, QueryFilter}; +use std::sync::Arc; +use tokio::sync::Semaphore; +use uuid::Uuid; + +use erp_core::error::AppResult; +use erp_core::events::EventBus; +use erp_core::module::{ErpModule, PermissionDescriptor}; + +use crate::entity::message_subscription; +use crate::handler::{message_handler, sse_handler, subscription_handler, template_handler}; + +/// 消息中心模块,实现 ErpModule trait。 +pub struct MessageModule; + +impl MessageModule { + pub fn new() -> Self { + Self + } + + /// 构建需要认证的路由。 + pub fn protected_routes() -> Router + where + crate::message_state::MessageState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + Router::new() + // 消息路由 + .route( + "/messages", + get(message_handler::list_messages).post(message_handler::send_message), + ) + .route("/messages/unread-count", get(message_handler::unread_count)) + .route("/messages/{id}/read", put(message_handler::mark_read)) + .route("/messages/read-all", put(message_handler::mark_all_read)) + .route("/messages/{id}", delete(message_handler::delete_message)) + // SSE 实时推送 + .route("/messages/stream", get(sse_handler::message_stream)) + // 模板路由 + .route( + "/message-templates", + get(template_handler::list_templates).post(template_handler::create_template), + ) + .route( + "/message-templates/{id}", + put(template_handler::update_template).delete(template_handler::delete_template), + ) + // 订阅偏好路由 + .route( + "/message-subscriptions", + get(subscription_handler::get_subscription) + .put(subscription_handler::update_subscription), + ) + } + + /// 启动后台事件监听任务,将工作流事件转化为消息通知。 + /// + /// 使用 Semaphore 限制最大并发数为 8,防止事件突发时过度消耗资源。 + /// 在 main.rs 中调用,因为需要 db 连接。 + pub fn start_event_listener(db: sea_orm::DatabaseConnection, event_bus: EventBus) { + let mut rx = event_bus.subscribe(); + let semaphore = Arc::new(Semaphore::new(8)); + + tokio::spawn(async move { + loop { + match rx.recv().await { + Ok(event) => { + let db = db.clone(); + let event_bus = event_bus.clone(); + let permit = semaphore.clone(); + + // 先获取许可,再 spawn 任务 + tokio::spawn(async move { + let _permit = match permit.acquire().await { + Ok(p) => p, + Err(_) => { + tracing::warn!("信号量已关闭,跳过工作流事件处理"); + return; + } + }; + if let Err(e) = handle_workflow_event(&event, &db, &event_bus).await { + tracing::warn!( + event_type = %event.event_type, + error = %e, + "Failed to handle workflow event for messages" + ); + } + }); + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!(skipped = n, "Event listener lagged, skipping events"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + tracing::info!("Event bus closed, stopping message event listener"); + break; + } + } + } + }); + } +} + +impl Default for MessageModule { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl ErpModule for MessageModule { + fn name(&self) -> &str { + "message" + } + + fn version(&self) -> &str { + env!("CARGO_PKG_VERSION") + } + + fn dependencies(&self) -> Vec<&str> { + vec!["auth"] + } + + fn register_event_handlers(&self, _bus: &EventBus) {} + + async fn on_tenant_created( + &self, + _tenant_id: Uuid, + _db: &sea_orm::DatabaseConnection, + _event_bus: &EventBus, + ) -> AppResult<()> { + Ok(()) + } + + async fn on_tenant_deleted( + &self, + _tenant_id: Uuid, + _db: &sea_orm::DatabaseConnection, + ) -> AppResult<()> { + Ok(()) + } + + fn permissions(&self) -> Vec { + vec![ + PermissionDescriptor { + code: "message.list".into(), + name: "查看消息".into(), + description: "查看消息列表".into(), + module: "message".into(), + }, + PermissionDescriptor { + code: "message.send".into(), + name: "发送消息".into(), + description: "发送新消息".into(), + module: "message".into(), + }, + PermissionDescriptor { + code: "message.template.list".into(), + name: "查看消息模板".into(), + description: "查看消息模板列表".into(), + module: "message".into(), + }, + PermissionDescriptor { + code: "message.template.create".into(), + name: "创建消息模板".into(), + description: "创建消息模板".into(), + module: "message".into(), + }, + PermissionDescriptor { + code: "message.template.manage".into(), + name: "管理消息模板".into(), + description: "编辑、删除消息模板".into(), + module: "message".into(), + }, + ] + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +/// 检查用户是否启用了 DND(免打扰)且当前时间在 DND 窗口内。 +/// 返回 true 表示应该跳过发送。 +async fn should_skip_for_dnd( + tenant_id: Uuid, + recipient_id: Uuid, + priority: &str, + db: &sea_orm::DatabaseConnection, +) -> bool { + // 紧急消息永远不跳过 + if priority == "urgent" { + return false; + } + let sub = match message_subscription::Entity::find() + .filter(message_subscription::Column::TenantId.eq(tenant_id)) + .filter(message_subscription::Column::UserId.eq(recipient_id)) + .filter(message_subscription::Column::DeletedAt.is_null()) + .one(db) + .await + { + Ok(Some(s)) => s, + _ => return false, + }; + if !sub.dnd_enabled { + return false; + } + let (start, end) = match (sub.dnd_start, sub.dnd_end) { + (Some(s), Some(e)) => (s, e), + _ => return false, + }; + let now = chrono::Local::now(); + let now_time = now.format("%H:%M").to_string(); + is_in_dnd_window(&now_time, &start, &end) +} + +/// 判断当前时间是否在 DND 窗口内。支持跨午夜窗口(如 22:00-06:00)。 +pub(crate) fn is_in_dnd_window(now_time: &str, start: &str, end: &str) -> bool { + if start <= end { + now_time >= start && now_time < end + } else { + now_time >= start || now_time < end + } +} + +/// 处理工作流事件,生成对应的消息通知。 +async fn handle_workflow_event( + event: &erp_core::events::DomainEvent, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, +) -> Result<(), String> { + match event.event_type.as_str() { + "process_instance.started" => { + let instance_id = event + .payload + .get("instance_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let starter_id = event.payload.get("started_by").and_then(|v| v.as_str()); + + if let Some(starter) = starter_id { + let recipient = match uuid::Uuid::parse_str(starter) { + Ok(id) => id, + Err(_) => return Ok(()), + }; + if should_skip_for_dnd(event.tenant_id, recipient, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + recipient, + "流程已启动".to_string(), + format!("您的流程实例 {} 已启动执行。", instance_id), + "normal", + Some("workflow_instance".to_string()), + uuid::Uuid::parse_str(instance_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + "task.completed" => { + let task_id = event + .payload + .get("task_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let starter_id = event.payload.get("started_by").and_then(|v| v.as_str()); + + if let Some(starter) = starter_id { + let recipient = match uuid::Uuid::parse_str(starter) { + Ok(id) => id, + Err(_) => return Ok(()), + }; + if should_skip_for_dnd(event.tenant_id, recipient, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + recipient, + "流程任务已完成".to_string(), + format!("流程任务 {} 已完成,请查看。", task_id), + "normal", + Some("workflow_task".to_string()), + uuid::Uuid::parse_str(task_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + // 预约事件通知 + "appointment.created" => { + let appointment_id = event + .payload + .get("appointment_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + + if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + "预约已创建".to_string(), + format!( + "您的新预约 {} 已创建,请等待确认。", + &appointment_id[..8.min(appointment_id.len())] + ), + "normal", + Some("appointment".to_string()), + uuid::Uuid::parse_str(appointment_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + "appointment.confirmed" => { + let appointment_id = event + .payload + .get("appointment_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let appointment_date = event + .payload + .get("appointment_date") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + + if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "important", db).await { + return Ok(()); + } + let date_info = if appointment_date.is_empty() { + String::new() + } else { + format!("({})", appointment_date) + }; + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + "预约已确认".to_string(), + format!("您的预约{}已确认,请按时就诊。", date_info), + "important", + Some("appointment".to_string()), + uuid::Uuid::parse_str(appointment_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + "appointment.cancelled" => { + let appointment_id = event + .payload + .get("appointment_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + + if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + "预约已取消".to_string(), + format!( + "您的预约 {} 已被取消。", + &appointment_id[..8.min(appointment_id.len())] + ), + "normal", + Some("appointment".to_string()), + uuid::Uuid::parse_str(appointment_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + "appointment.reminder" => { + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + let appointment_date = event + .payload + .get("appointment_date") + .and_then(|v| v.as_str()) + .unwrap_or("明天"); + let time_slot = event + .payload + .get("time_slot") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + "预约提醒".to_string(), + format!( + "您明天({})有一个预约,时间段:{},请准时就诊。", + appointment_date, time_slot + ), + "normal", + Some("appointment".to_string()), + event + .payload + .get("appointment_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + "health_data.critical_alert" => { + let patient_name = event + .payload + .get("patient_name") + .and_then(|v| v.as_str()) + .unwrap_or("未知患者"); + let alert = event.payload.get("alert"); + let indicator = alert + .and_then(|a| a.get("indicator")) + .and_then(|v| v.as_str()) + .unwrap_or("未知指标"); + let value = alert + .and_then(|a| a.get("value")) + .map(|v| v.to_string()) + .unwrap_or_else(|| "?".to_string()); + let direction = alert + .and_then(|a| a.get("direction")) + .and_then(|v| v.as_str()) + .unwrap_or("high"); + + let direction_text = match direction { + "low" => "偏低", + _ => "偏高", + }; + + // 通知责任医生(优先)— urgent 不跳过 DND + if let Some(doctor_uid) = event + .payload + .get("doctor_user_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()) + { + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + doctor_uid, + format!("危急值告警:患者 {}", patient_name), + format!( + "患者 {} 的{}{}(值:{}),请立即关注处理。", + patient_name, indicator, direction_text, value + ), + "urgent", + Some("critical_alert".to_string()), + Some(event.id), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + + // 同时通知操作人(录入者) + if let Some(operator_uid) = event + .payload + .get("operator_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()) + { + let is_doctor = event + .payload + .get("doctor_user_id") + .and_then(|v| v.as_str()) + .map(|s| s == operator_uid.to_string()) + .unwrap_or(false); + + if !is_doctor { + if should_skip_for_dnd(event.tenant_id, operator_uid, "important", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + operator_uid, + format!("危急值告警:患者 {}", patient_name), + format!( + "患者 {} 的{}{}(值:{})已触发危急值告警,已通知责任医生。", + patient_name, indicator, direction_text, value + ), + "important", + Some("critical_alert".to_string()), + Some(event.id), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + } + "follow_up.overdue" => { + let task_id = event + .payload + .get("task_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let assigned_to = event + .payload + .get("assigned_to") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + let planned_date = event + .payload + .get("planned_date") + .and_then(|v| v.as_str()) + .unwrap_or("未知日期"); + let patient_name = event + .payload + .get("patient_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if let Some(assignee) = assigned_to { + if should_skip_for_dnd(event.tenant_id, assignee, "important", db).await { + return Ok(()); + } + let patient_info = if patient_name.is_empty() { + String::new() + } else { + format!("(患者:{})", patient_name) + }; + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + assignee, + "随访任务逾期提醒".to_string(), + format!( + "您的随访任务{}(计划日期:{})已逾期,请尽快处理。", + patient_info, planned_date + ), + "important", + Some("follow_up".to_string()), + uuid::Uuid::parse_str(task_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + // 咨询新消息通知医生 + "consultation.new_message" => { + let doctor_id = event + .payload + .get("doctor_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + let patient_name = event + .payload + .get("patient_name") + .and_then(|v| v.as_str()) + .unwrap_or("患者"); + let session_id = event + .payload + .get("session_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if let Some(did) = doctor_id { + if should_skip_for_dnd(event.tenant_id, did, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + did, + format!("新咨询消息 — {}", patient_name), + format!("患者 {} 发来了一条咨询消息,请及时回复。", patient_name), + "normal", + Some("consultation".to_string()), + uuid::Uuid::parse_str(session_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + // 化验报告审核完成通知患者 + "lab_report.reviewed" => { + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + let report_type = event + .payload + .get("report_type") + .and_then(|v| v.as_str()) + .unwrap_or("化验报告"); + let report_id = event + .payload + .get("report_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + format!("{}已审核", report_type), + format!("您的{}已由医生审核完成,请查看医生注释。", report_type), + "normal", + Some("lab_report".to_string()), + uuid::Uuid::parse_str(report_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + // 咨询会话开启通知医生 + "consultation.opened" => { + let doctor_id = event + .payload + .get("doctor_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + let patient_name = event + .payload + .get("patient_name") + .and_then(|v| v.as_str()) + .unwrap_or("患者"); + let session_id = event + .payload + .get("session_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if let Some(did) = doctor_id { + if should_skip_for_dnd(event.tenant_id, did, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + did, + format!("新咨询会话 — {}", patient_name), + format!("患者 {} 发起了咨询会话,请及时响应。", patient_name), + "normal", + Some("consultation".to_string()), + uuid::Uuid::parse_str(session_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + // 咨询会话关闭通知患者 + "consultation.closed" => { + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + let session_id = event + .payload + .get("session_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + "咨询会话已结束".to_string(), + "您的咨询会话已由医生关闭,感谢使用。".to_string(), + "normal", + Some("consultation".to_string()), + uuid::Uuid::parse_str(session_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + // 随访任务创建通知被分配人 + "follow_up.created" => { + let assigned_to = event + .payload + .get("assigned_to") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + let patient_name = event + .payload + .get("patient_name") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let planned_date = event + .payload + .get("planned_date") + .and_then(|v| v.as_str()) + .unwrap_or("未知日期"); + let task_id = event + .payload + .get("task_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if let Some(assignee) = assigned_to { + if should_skip_for_dnd(event.tenant_id, assignee, "normal", db).await { + return Ok(()); + } + let patient_info = if patient_name.is_empty() { + String::new() + } else { + format!("(患者:{})", patient_name) + }; + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + assignee, + format!("新随访任务{}", patient_info), + format!( + "您被分配了一个随访任务{},计划日期:{}。", + patient_info, planned_date + ), + "normal", + Some("follow_up".to_string()), + uuid::Uuid::parse_str(task_id).ok(), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + // 随访完成通知患者 + "follow_up.completed" => { + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + + if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + "随访已完成".to_string(), + "您的随访任务已完成,感谢配合。".to_string(), + "normal", + Some("follow_up".to_string()), + None, + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + // 积分获得通知患者 + "points.earned" => { + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + let points = event + .payload + .get("points") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + let reason = event + .payload + .get("reason") + .and_then(|v| v.as_str()) + .unwrap_or("系统奖励"); + + if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "low", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + "积分到账".to_string(), + format!("您获得了 {} 积分({})。", points, reason), + "low", + Some("points".to_string()), + None, + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + // 积分兑换通知患者 + "points.exchanged" => { + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + let product_name = event + .payload + .get("product_name") + .and_then(|v| v.as_str()) + .unwrap_or("商品"); + + if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + "兑换成功".to_string(), + format!("您已成功兑换「{}」,请留意后续通知。", product_name), + "normal", + Some("points".to_string()), + None, + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + // 积分过期通知患者 + "points.expired" => { + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + let points = event + .payload + .get("points") + .and_then(|v| v.as_i64()) + .unwrap_or(0); + + if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "low", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + "积分过期提醒".to_string(), + format!("您有 {} 积分已过期。", points), + "low", + Some("points".to_string()), + None, + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + // 文章发布通知(暂记录日志,无直接用户推送) + "article.published" => { + tracing::info!( + article_id = %event.payload.get("article_id").and_then(|v| v.as_str()).unwrap_or(""), + "文章已发布" + ); + } + // 文章驳回通知作者 + "article.rejected" => { + let author_id = event + .payload + .get("author_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + let title = event + .payload + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("文章"); + + if let Some(aid) = author_id { + if should_skip_for_dnd(event.tenant_id, aid, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + aid, + format!("文章未通过审核 — {}", title), + format!("您的文章「{}」未通过审核,请修改后重新提交。", title), + "normal", + Some("article".to_string()), + None, + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + // 患者信息更新通知(审计日志用途) + "patient.updated" => { + tracing::info!( + patient_id = %event.payload.get("patient_id").and_then(|v| v.as_str()).unwrap_or(""), + "患者信息已更新" + ); + } + // AI 分析失败通知医生 + "ai.analysis.failed" => { + let doctor_id = event + .payload + .get("doctor_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + let error = event + .payload + .get("error") + .and_then(|v| v.as_str()) + .unwrap_or("未知错误"); + + if let Some(did) = doctor_id { + if should_skip_for_dnd(event.tenant_id, did, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + did, + "AI 分析失败".to_string(), + format!("AI 分析任务执行失败:{},请稍后重试。", error), + "normal", + Some("ai".to_string()), + None, + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + // 化验单上传记录日志 + "lab_report.uploaded" => { + tracing::info!( + patient_id = %event.payload.get("patient_id").and_then(|v| v.as_str()).unwrap_or(""), + "化验单已上传" + ); + } + // 日常监测记录日志 + "daily_monitoring.created" => { + tracing::info!( + patient_id = %event.payload.get("patient_id").and_then(|v| v.as_str()).unwrap_or(""), + "日常监测数据已记录" + ); + } + // 医生在线状态变更记录日志 + "doctor.online_status_changed" => { + tracing::info!( + doctor_id = %event.payload.get("doctor_id").and_then(|v| v.as_str()).unwrap_or(""), + status = %event.payload.get("status").and_then(|v| v.as_str()).unwrap_or(""), + "医生在线状态变更" + ); + } + // AI Copilot 洞察生成 → 通知主管医生 + "copilot.insight.created" => { + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + let severity = event + .payload + .get("severity") + .and_then(|v| v.as_str()) + .unwrap_or("warning"); + let title = event + .payload + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("AI 健康洞察"); + + if let Some(pid) = patient_id { + // 查询患者的责任医生(通过 follow_up_task 的 assigned_to) + #[derive(sea_orm::FromQueryResult)] + struct DoctorRow { + assigned_to: uuid::Uuid, + } + let doctor: Option = DoctorRow::find_by_statement( + sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "SELECT assigned_to FROM follow_up_task WHERE tenant_id = $1 AND patient_id = $2 AND assigned_to IS NOT NULL AND deleted_at IS NULL AND status IN ('pending', 'in_progress') ORDER BY created_at DESC LIMIT 1", + [event.tenant_id.into(), pid.into()], + ), + ) + .one(db) + .await + .unwrap_or(None); + + if let Some(doc) = doctor { + let priority = match severity { + "critical" => "urgent", + "warning" | "high" => "important", + _ => "normal", + }; + if should_skip_for_dnd(event.tenant_id, doc.assigned_to, priority, db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + doc.assigned_to, + format!("AI 健康洞察:{}", title), + format!( + "AI 系统检测到患者存在「{}」级别的健康风险,请及时关注。洞察内容:{}", + severity, title + ), + priority, + Some("ai_insight".to_string()), + Some(event.id), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + } + // 关怀计划激活 — 温暖通知患者 + "care_plan.activated" => { + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + + if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + "护理计划已启动".to_string(), + "您的护理计划已启动,我们将持续关注您的健康状况。如有任何疑问,请随时咨询您的护理团队。".to_string(), + "normal", + Some("care_plan".to_string()), + event.payload.get("plan_id").and_then(|v| v.as_str()).and_then(|s| uuid::Uuid::parse_str(s).ok()), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + // 关怀计划完成 — 温暖通知患者 + "care_plan.completed" => { + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + + if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "normal", db).await { + return Ok(()); + } + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + "护理计划已完成".to_string(), + "您的护理计划已完成,感谢您这段时间的配合!我们将继续关注您的健康。" + .to_string(), + "normal", + Some("care_plan".to_string()), + event + .payload + .get("plan_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + // 关怀行动执行 — 温暖通知患者(护理项完成、测量数据记录等) + "care.action.performed" => { + let patient_id = event + .payload + .get("patient_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()); + let action = event + .payload + .get("action") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if let Some(pid) = patient_id { + if should_skip_for_dnd(event.tenant_id, pid, "low", db).await { + return Ok(()); + } + + let (title, body) = match action { + "item_completed" => { + let item_title = event + .payload + .get("item_title") + .and_then(|v| v.as_str()) + .unwrap_or("护理项目"); + ( + "关怀已送达".to_string(), + format!("您的护理团队已完成「{}」,感谢您的配合。", item_title), + ) + } + "outcome_measured" => { + let metric = event + .payload + .get("metric") + .and_then(|v| v.as_str()) + .unwrap_or("健康指标"); + ( + "健康数据已更新".to_string(), + format!("您的{}数据已记录,护理团队正在持续关注。", metric), + ) + } + _ => ( + "关怀已送达".to_string(), + "您的护理团队正在关注您的健康状况。".to_string(), + ), + }; + + let _ = crate::service::message_service::MessageService::send_system( + event.tenant_id, + pid, + title, + body, + "low", + Some("care_action".to_string()), + event + .payload + .get("plan_id") + .and_then(|v| v.as_str()) + .and_then(|s| uuid::Uuid::parse_str(s).ok()), + db, + event_bus, + ) + .await + .map_err(|e| e.to_string())?; + } + } + _ => {} + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + // ---- DND 时间窗逻辑 ---- + + #[test] + fn dnd_normal_range_inside() { + // 09:00-17:00,当前 12:00 → 在窗口内 + assert!(is_in_dnd_window("12:00", "09:00", "17:00")); + } + + #[test] + fn dnd_normal_range_before() { + // 09:00-17:00,当前 08:00 → 不在窗口内 + assert!(!is_in_dnd_window("08:00", "09:00", "17:00")); + } + + #[test] + fn dnd_normal_range_after() { + // 09:00-17:00,当前 18:00 → 不在窗口内 + assert!(!is_in_dnd_window("18:00", "09:00", "17:00")); + } + + #[test] + fn dnd_normal_range_at_start() { + // 09:00-17:00,当前 09:00 → 在窗口内(>= start) + assert!(is_in_dnd_window("09:00", "09:00", "17:00")); + } + + #[test] + fn dnd_normal_range_at_end() { + // 09:00-17:00,当前 17:00 → 不在窗口内(< end 排除了 end 本身) + assert!(!is_in_dnd_window("17:00", "09:00", "17:00")); + } + + #[test] + fn dnd_cross_midnight_night_time() { + // 22:00-06:00,当前 23:30 → 在窗口内 + assert!(is_in_dnd_window("23:30", "22:00", "06:00")); + } + + #[test] + fn dnd_cross_midnight_early_morning() { + // 22:00-06:00,当前 03:00 → 在窗口内 + assert!(is_in_dnd_window("03:00", "22:00", "06:00")); + } + + #[test] + fn dnd_cross_midnight_daytime() { + // 22:00-06:00,当前 14:00 → 不在窗口内 + assert!(!is_in_dnd_window("14:00", "22:00", "06:00")); + } + + #[test] + fn dnd_cross_midnight_at_start() { + assert!(is_in_dnd_window("22:00", "22:00", "06:00")); + } + + #[test] + fn dnd_cross_midnight_at_end() { + assert!(!is_in_dnd_window("06:00", "22:00", "06:00")); + } + + #[test] + fn dnd_cross_midnight_just_before_end() { + assert!(is_in_dnd_window("05:59", "22:00", "06:00")); + } + + #[test] + fn dnd_same_start_end_always_in() { + // start == end 意味着 start <= end,所以 now >= start && now < end + // "00:00" >= "00:00" && "00:00" < "00:00" → false + assert!(!is_in_dnd_window("00:00", "12:00", "12:00")); + // "15:00" >= "12:00" && "15:00" < "12:00" → false + assert!(!is_in_dnd_window("15:00", "12:00", "12:00")); + } + + #[test] + fn dnd_single_minute_window() { + // 23:59-00:00(跨午夜 1 分钟) + assert!(is_in_dnd_window("23:59", "23:59", "00:00")); + assert!(!is_in_dnd_window("00:00", "23:59", "00:00")); + } +} diff --git a/crates/erp-message/src/service/message_service.rs b/crates/erp-message/src/service/message_service.rs new file mode 100644 index 0000000..d5211dd --- /dev/null +++ b/crates/erp-message/src/service/message_service.rs @@ -0,0 +1,570 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseBackend, EntityTrait, FromQueryResult, + PaginatorTrait, QueryFilter, Set, Statement, +}; +use uuid::Uuid; + +use crate::dto::{MessageQuery, MessageResp, SendMessageReq, UnreadCountResp}; +use crate::entity::message; +use crate::error::{MessageError, MessageResult}; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::events::EventBus; + +/// 原始 SQL 查询 user_id 的结果结构体。 +#[derive(Debug, FromQueryResult)] +struct UserIdRow { + user_id: Uuid, +} + +/// 消息服务。 +pub struct MessageService; + +impl MessageService { + /// 查询消息列表(分页)。 + pub async fn list( + tenant_id: Uuid, + recipient_id: Uuid, + query: &MessageQuery, + db: &sea_orm::DatabaseConnection, + ) -> MessageResult<(Vec, u64)> { + let page_size = query.safe_page_size(); + let mut q = message::Entity::find() + .filter(message::Column::TenantId.eq(tenant_id)) + .filter(message::Column::RecipientId.eq(recipient_id)) + .filter(message::Column::DeletedAt.is_null()); + + if let Some(is_read) = query.is_read { + q = q.filter(message::Column::IsRead.eq(is_read)); + } + if let Some(ref priority) = query.priority { + q = q.filter(message::Column::Priority.eq(priority.as_str())); + } + if let Some(ref business_type) = query.business_type { + q = q.filter(message::Column::BusinessType.eq(business_type.as_str())); + } + if let Some(ref status) = query.status { + q = q.filter(message::Column::Status.eq(status.as_str())); + } + + let paginator = q.paginate(db, page_size); + + let total = paginator + .num_items() + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + let page_index = query.page.unwrap_or(1).saturating_sub(1); + let models = paginator + .fetch_page(page_index) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + let resps = models.iter().map(Self::model_to_resp).collect(); + Ok((resps, total)) + } + + /// 获取未读消息数量。 + pub async fn unread_count( + tenant_id: Uuid, + recipient_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> MessageResult { + let count = message::Entity::find() + .filter(message::Column::TenantId.eq(tenant_id)) + .filter(message::Column::RecipientId.eq(recipient_id)) + .filter(message::Column::IsRead.eq(false)) + .filter(message::Column::DeletedAt.is_null()) + .count(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + Ok(UnreadCountResp { + count: count as i64, + }) + } + + /// 发送消息。 + /// + /// 根据 `recipient_type` 执行不同的投递策略: + /// - `"user"` — 单条消息,直接投递给 `recipient_id` 指定的用户。 + /// - `"role"` — 查询 `user_roles` 表,向该角色下的所有用户批量投递。 + /// - `"department"` — 查询 `user_departments` 表,向该部门下的所有用户批量投递。 + /// - `"all"` — 查询 `users` 表,向租户内所有活跃用户批量投递。 + pub async fn send( + tenant_id: Uuid, + sender_id: Uuid, + req: &SendMessageReq, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> MessageResult { + let now = Utc::now(); + + // Resolve target user IDs based on recipient type + let recipient_user_ids = match req.recipient_type.as_str() { + "user" => vec![req.recipient_id], + "role" => Self::resolve_user_ids_by_role(db, req.recipient_id, tenant_id).await?, + "department" => { + Self::resolve_user_ids_by_department(db, req.recipient_id, tenant_id).await? + } + "all" => Self::resolve_all_active_user_ids(db, tenant_id).await?, + other => { + return Err(MessageError::Validation(format!( + "不支持的收件人类型: {other}" + ))); + } + }; + + if recipient_user_ids.is_empty() { + return Err(MessageError::Validation( + "没有找到符合条件的收件人".to_string(), + )); + } + + // Build message models for all recipients + let models: Vec = recipient_user_ids + .iter() + .map(|uid| message::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + template_id: Set(req.template_id), + sender_id: Set(Some(sender_id)), + sender_type: Set("user".to_string()), + recipient_id: Set(*uid), + recipient_type: Set("user".to_string()), + title: Set(req.title.clone()), + body: Set(req.body.clone()), + priority: Set(req.priority.clone()), + business_type: Set(req.business_type.clone()), + business_id: Set(req.business_id), + is_read: Set(false), + read_at: Set(None), + is_archived: Set(false), + archived_at: Set(None), + sent_at: Set(Some(now)), + status: Set("sent".to_string()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(sender_id), + updated_by: Set(sender_id), + deleted_at: Set(None), + version: Set(1), + }) + .collect(); + + // Batch insert all messages + message::Entity::insert_many(models) + .exec(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + // Publish one event per batch (summary event) + event_bus + .publish( + erp_core::events::DomainEvent::new( + "message.sent", + tenant_id, + serde_json::json!({ + "recipient_type": req.recipient_type, + "recipient_count": recipient_user_ids.len(), + "title": req.title, + }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new(tenant_id, Some(sender_id), "message.send", "message").with_changes( + None, + Some(serde_json::json!({ + "recipient_type": req.recipient_type, + "recipient_count": recipient_user_ids.len(), + "title": req.title, + })), + ), + db, + ) + .await; + + // Construct a representative response (no row returned from batch insert) + Ok(MessageResp { + id: Uuid::nil(), + tenant_id, + template_id: req.template_id, + sender_id: Some(sender_id), + sender_type: "user".to_string(), + recipient_id: req.recipient_id, + recipient_type: req.recipient_type.clone(), + title: req.title.clone(), + body: req.body.clone(), + priority: req.priority.clone(), + business_type: req.business_type.clone(), + business_id: req.business_id, + is_read: false, + read_at: None, + is_archived: false, + status: "sent".to_string(), + sent_at: Some(now), + created_at: now, + updated_at: now, + version: 1, + }) + } + + /// 根据角色 ID 查询关联的用户 ID 列表(跨模块 raw SQL)。 + async fn resolve_user_ids_by_role( + db: &sea_orm::DatabaseConnection, + role_id: Uuid, + tenant_id: Uuid, + ) -> MessageResult> { + let rows = UserIdRow::find_by_statement(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT user_id FROM user_roles WHERE role_id = $1 AND tenant_id = $2 AND deleted_at IS NULL", + [role_id.into(), tenant_id.into()], + )) + .all(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + Ok(rows.into_iter().map(|r| r.user_id).collect()) + } + + /// 根据部门 ID 查询关联的用户 ID 列表(跨模块 raw SQL)。 + async fn resolve_user_ids_by_department( + db: &sea_orm::DatabaseConnection, + department_id: Uuid, + tenant_id: Uuid, + ) -> MessageResult> { + let rows = UserIdRow::find_by_statement(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT user_id FROM user_departments WHERE department_id = $1 AND tenant_id = $2 AND deleted_at IS NULL", + [department_id.into(), tenant_id.into()], + )) + .all(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + Ok(rows.into_iter().map(|r| r.user_id).collect()) + } + + /// 查询租户内所有活跃用户的 ID 列表(跨模块 raw SQL)。 + async fn resolve_all_active_user_ids( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + ) -> MessageResult> { + let rows = UserIdRow::find_by_statement(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT id AS user_id FROM users WHERE tenant_id = $1 AND deleted_at IS NULL AND status = 'active'", + [tenant_id.into()], + )) + .all(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + Ok(rows.into_iter().map(|r| r.user_id).collect()) + } + + /// 系统发送消息(由事件处理器调用)。 + /// + /// 幂等保证:当 `business_id` 存在时,若同 tenant + recipient + business_id 的消息已存在, + /// 直接返回已有消息,避免 outbox relay 重放导致重复通知。 + #[allow(clippy::too_many_arguments)] + pub async fn send_system( + tenant_id: Uuid, + recipient_id: Uuid, + title: String, + body: String, + priority: &str, + business_type: Option, + business_id: Option, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> MessageResult { + // 幂等检查:防止 outbox relay 重放导致重复消息 + if let Some(bid) = business_id { + let existing = message::Entity::find() + .filter(message::Column::TenantId.eq(tenant_id)) + .filter(message::Column::RecipientId.eq(recipient_id)) + .filter(message::Column::BusinessId.eq(bid)) + .filter(message::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + if let Some(m) = existing { + tracing::debug!( + message_id = %m.id, + business_id = %bid, + "消息已存在,跳过重复创建(幂等保护)" + ); + return Ok(Self::model_to_resp(&m)); + } + } + + let id = Uuid::now_v7(); + let now = Utc::now(); + let system_user = Uuid::nil(); + + let model = message::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + template_id: Set(None), + sender_id: Set(None), + sender_type: Set("system".to_string()), + recipient_id: Set(recipient_id), + recipient_type: Set("user".to_string()), + title: Set(title), + body: Set(body), + priority: Set(priority.to_string()), + business_type: Set(business_type), + business_id: Set(business_id), + is_read: Set(false), + read_at: Set(None), + is_archived: Set(false), + archived_at: Set(None), + sent_at: Set(Some(now)), + status: Set("sent".to_string()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user), + updated_by: Set(system_user), + deleted_at: Set(None), + version: Set(1), + }; + + let inserted = model + .insert(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "message.sent", + tenant_id, + serde_json::json!({ + "message_id": id, + "recipient_id": recipient_id, + }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(system_user), + "message.send_system", + "message", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(Self::model_to_resp(&inserted)) + } + + /// 标记消息已读。 + pub async fn mark_read( + id: Uuid, + tenant_id: Uuid, + user_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> MessageResult<()> { + let model = message::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or_else(|| MessageError::NotFound(format!("消息不存在: {id}")))?; + + if model.recipient_id != user_id { + return Err(MessageError::Validation( + "只能标记自己的消息为已读".to_string(), + )); + } + + if model.is_read { + return Ok(()); + } + + let current_version = model.version; + let mut active: message::ActiveModel = model.into(); + active.is_read = Set(true); + active.read_at = Set(Some(Utc::now())); + active.version = Set(current_version + 1); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(user_id); + active + .update(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + audit_service::record( + AuditLog::new(tenant_id, Some(user_id), "message.mark_read", "message") + .with_resource_id(id), + db, + ) + .await; + + Ok(()) + } + + /// 标记所有消息已读(批量 UPDATE,避免 N+1)。 + pub async fn mark_all_read( + tenant_id: Uuid, + user_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> MessageResult<()> { + let now = Utc::now(); + db.execute(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "UPDATE messages SET is_read = true, read_at = $1, updated_at = $2, updated_by = $3, version = version + 1 WHERE tenant_id = $4 AND recipient_id = $5 AND is_read = false AND deleted_at IS NULL", + [ + now.into(), + now.into(), + user_id.into(), + tenant_id.into(), + user_id.into(), + ], + )) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + audit_service::record( + AuditLog::new(tenant_id, Some(user_id), "message.mark_all_read", "message"), + db, + ) + .await; + + Ok(()) + } + + /// 删除消息(软删除)。 + pub async fn delete( + id: Uuid, + tenant_id: Uuid, + user_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> MessageResult<()> { + let model = message::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or_else(|| MessageError::NotFound(format!("消息不存在: {id}")))?; + + if model.recipient_id != user_id { + return Err(MessageError::Validation("只能删除自己的消息".to_string())); + } + + let current_version = model.version; + let mut active: message::ActiveModel = model.into(); + active.version = Set(current_version + 1); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(user_id); + active + .update(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + audit_service::record( + AuditLog::new(tenant_id, Some(user_id), "message.delete", "message") + .with_resource_id(id), + db, + ) + .await; + + Ok(()) + } + + pub(crate) fn model_to_resp(m: &message::Model) -> MessageResp { + MessageResp { + id: m.id, + tenant_id: m.tenant_id, + template_id: m.template_id, + sender_id: m.sender_id, + sender_type: m.sender_type.clone(), + recipient_id: m.recipient_id, + recipient_type: m.recipient_type.clone(), + title: m.title.clone(), + body: m.body.clone(), + priority: m.priority.clone(), + business_type: m.business_type.clone(), + business_id: m.business_id, + is_read: m.is_read, + read_at: m.read_at, + is_archived: m.is_archived, + status: m.status.clone(), + sent_at: m.sent_at, + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + fn sample_model() -> message::Model { + message::Model { + id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + tenant_id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(), + template_id: None, + sender_id: None, + sender_type: "system".to_string(), + recipient_id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(), + recipient_type: "user".to_string(), + title: "测试消息".to_string(), + body: "消息内容".to_string(), + priority: "normal".to_string(), + business_type: None, + business_id: None, + is_read: false, + read_at: None, + is_archived: false, + archived_at: None, + sent_at: None, + status: "sent".to_string(), + created_at: Utc::now(), + updated_at: Utc::now(), + created_by: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000004").unwrap(), + updated_by: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000004").unwrap(), + deleted_at: None, + version: 1, + } + } + + #[test] + fn model_to_resp_maps_all_fields() { + let m = sample_model(); + let resp = MessageService::model_to_resp(&m); + assert_eq!(resp.id, m.id); + assert_eq!(resp.tenant_id, m.tenant_id); + assert_eq!(resp.title, "测试消息"); + assert_eq!(resp.body, "消息内容"); + assert_eq!(resp.priority, "normal"); + assert_eq!(resp.is_read, false); + assert_eq!(resp.status, "sent"); + assert_eq!(resp.version, 1); + } + + #[test] + fn model_to_resp_preserves_optional_fields() { + let m = sample_model(); + let resp = MessageService::model_to_resp(&m); + assert_eq!(resp.template_id, None); + assert_eq!(resp.sender_id, None); + assert_eq!(resp.business_type, None); + assert_eq!(resp.read_at, None); + assert_eq!(resp.sent_at, None); + } +} diff --git a/crates/erp-message/src/service/mod.rs b/crates/erp-message/src/service/mod.rs new file mode 100644 index 0000000..64775db --- /dev/null +++ b/crates/erp-message/src/service/mod.rs @@ -0,0 +1,3 @@ +pub mod message_service; +pub mod subscription_service; +pub mod template_service; diff --git a/crates/erp-message/src/service/subscription_service.rs b/crates/erp-message/src/service/subscription_service.rs new file mode 100644 index 0000000..0990a92 --- /dev/null +++ b/crates/erp-message/src/service/subscription_service.rs @@ -0,0 +1,155 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use uuid::Uuid; + +use crate::dto::{MessageSubscriptionResp, UpdateSubscriptionReq}; +use crate::entity::message_subscription; +use crate::error::{MessageError, MessageResult}; +use erp_core::error::check_version; + +/// 消息订阅偏好服务。 +pub struct SubscriptionService; + +impl SubscriptionService { + /// 获取用户订阅偏好。 + pub async fn get( + tenant_id: Uuid, + user_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> MessageResult { + let model = message_subscription::Entity::find() + .filter(message_subscription::Column::TenantId.eq(tenant_id)) + .filter(message_subscription::Column::UserId.eq(user_id)) + .filter(message_subscription::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))? + .ok_or_else(|| MessageError::NotFound("订阅偏好不存在".to_string()))?; + + Ok(Self::model_to_resp(&model)) + } + + /// 创建或更新用户订阅偏好(upsert)。 + pub async fn upsert( + tenant_id: Uuid, + user_id: Uuid, + req: &UpdateSubscriptionReq, + db: &sea_orm::DatabaseConnection, + ) -> MessageResult { + let existing = message_subscription::Entity::find() + .filter(message_subscription::Column::TenantId.eq(tenant_id)) + .filter(message_subscription::Column::UserId.eq(user_id)) + .filter(message_subscription::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + let now = Utc::now(); + + if let Some(model) = existing { + let current_version = model.version; + let next_ver = check_version(req.version, current_version) + .map_err(|_| MessageError::VersionMismatch)?; + let mut active: message_subscription::ActiveModel = model.into(); + if let Some(types) = &req.notification_types { + active.notification_types = Set(Some(types.clone())); + } + if let Some(prefs) = &req.channel_preferences { + active.channel_preferences = Set(Some(prefs.clone())); + } + if let Some(dnd) = req.dnd_enabled { + active.dnd_enabled = Set(dnd); + } + if let Some(ref start) = req.dnd_start { + active.dnd_start = Set(Some(start.clone())); + } + if let Some(ref end) = req.dnd_end { + active.dnd_end = Set(Some(end.clone())); + } + active.updated_at = Set(now); + active.updated_by = Set(user_id); + active.version = Set(next_ver); + + let updated = active + .update(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + Ok(Self::model_to_resp(&updated)) + } else { + let id = Uuid::now_v7(); + + let model = message_subscription::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + user_id: Set(user_id), + notification_types: Set(req.notification_types.clone()), + channel_preferences: Set(req.channel_preferences.clone()), + dnd_enabled: Set(req.dnd_enabled.unwrap_or(false)), + dnd_start: Set(req.dnd_start.clone()), + dnd_end: Set(req.dnd_end.clone()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(user_id), + updated_by: Set(user_id), + deleted_at: Set(None), + version: Set(1), + }; + + let inserted = model + .insert(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + Ok(Self::model_to_resp(&inserted)) + } + } + + pub(crate) fn model_to_resp(m: &message_subscription::Model) -> MessageSubscriptionResp { + MessageSubscriptionResp { + id: m.id, + tenant_id: m.tenant_id, + user_id: m.user_id, + notification_types: m.notification_types.clone(), + channel_preferences: m.channel_preferences.clone(), + dnd_enabled: m.dnd_enabled, + dnd_start: m.dnd_start.clone(), + dnd_end: m.dnd_end.clone(), + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::Utc; + + #[test] + fn model_to_resp_maps_all_fields() { + let m = message_subscription::Model { + id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + tenant_id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(), + user_id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(), + notification_types: Some(serde_json::json!(["appointment"])), + channel_preferences: Some(serde_json::json!(["in_app"])), + dnd_enabled: true, + dnd_start: Some("22:00".to_string()), + dnd_end: Some("08:00".to_string()), + created_at: Utc::now(), + updated_at: Utc::now(), + created_by: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(), + updated_by: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(), + deleted_at: None, + version: 1, + }; + let resp = SubscriptionService::model_to_resp(&m); + assert_eq!(resp.user_id, m.user_id); + assert_eq!(resp.dnd_enabled, true); + assert_eq!(resp.dnd_start, Some("22:00".to_string())); + assert_eq!(resp.dnd_end, Some("08:00".to_string())); + assert_eq!(resp.version, 1); + } +} diff --git a/crates/erp-message/src/service/template_service.rs b/crates/erp-message/src/service/template_service.rs new file mode 100644 index 0000000..718372e --- /dev/null +++ b/crates/erp-message/src/service/template_service.rs @@ -0,0 +1,296 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set}; +use uuid::Uuid; + +use crate::dto::{CreateTemplateReq, MessageTemplateResp, UpdateTemplateReq}; +use crate::entity::message_template; +use crate::error::{MessageError, MessageResult}; + +/// 消息模板服务。 +pub struct TemplateService; + +impl TemplateService { + /// 查询模板列表。 + pub async fn list( + tenant_id: Uuid, + page: u64, + page_size: u64, + db: &sea_orm::DatabaseConnection, + ) -> MessageResult<(Vec, u64)> { + let paginator = message_template::Entity::find() + .filter(message_template::Column::TenantId.eq(tenant_id)) + .filter(message_template::Column::DeletedAt.is_null()) + .paginate(db, page_size); + + let total = paginator + .num_items() + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + let page_index = page.saturating_sub(1); + let models = paginator + .fetch_page(page_index) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + let resps = models.iter().map(Self::model_to_resp).collect(); + Ok((resps, total)) + } + + /// 创建消息模板。 + pub async fn create( + tenant_id: Uuid, + operator_id: Uuid, + req: &CreateTemplateReq, + db: &sea_orm::DatabaseConnection, + ) -> MessageResult { + // 检查编码唯一性 + let existing = message_template::Entity::find() + .filter(message_template::Column::TenantId.eq(tenant_id)) + .filter(message_template::Column::Code.eq(&req.code)) + .filter(message_template::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + if existing.is_some() { + return Err(MessageError::DuplicateTemplateCode(format!( + "模板编码已存在: {}", + req.code + ))); + } + + let id = Uuid::now_v7(); + let now = Utc::now(); + + let model = message_template::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + name: Set(req.name.clone()), + code: Set(req.code.clone()), + channel: Set(req.channel.clone()), + title_template: Set(req.title_template.clone()), + body_template: Set(req.body_template.clone()), + language: Set(req.language.clone()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version: Set(1), + }; + + let inserted = model + .insert(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + Ok(Self::model_to_resp(&inserted)) + } + + /// 更新消息模板。 + pub async fn update( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &UpdateTemplateReq, + db: &sea_orm::DatabaseConnection, + ) -> MessageResult { + let model = message_template::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or_else(|| MessageError::NotFound(format!("模板不存在: {id}")))?; + + let current_version = model.version; + let next_ver = erp_core::error::check_version(req.version, current_version) + .map_err(|_| MessageError::VersionMismatch)?; + + let mut active: message_template::ActiveModel = model.into(); + if let Some(name) = &req.name { + active.name = Set(name.clone()); + } + if let Some(title) = &req.title_template { + active.title_template = Set(title.clone()); + } + if let Some(body) = &req.body_template { + active.body_template = Set(body.clone()); + } + if let Some(lang) = &req.language { + active.language = Set(lang.clone()); + } + if let Some(channel) = &req.channel { + active.channel = Set(channel.clone()); + } + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(next_ver); + + let updated = active + .update(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + Ok(Self::model_to_resp(&updated)) + } + + /// 软删除消息模板。 + pub async fn delete( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> MessageResult<()> { + let model = message_template::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or_else(|| MessageError::NotFound(format!("模板不存在: {id}")))?; + + let current_version = model.version; + let mut active: message_template::ActiveModel = model.into(); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active.version = Set(current_version + 1); + + active + .update(db) + .await + .map_err(|e| MessageError::Validation(e.to_string()))?; + + Ok(()) + } + + /// 使用模板渲染消息内容。 + /// 支持 {{variable}} 格式的变量插值。 + pub fn render(template: &str, variables: &std::collections::HashMap) -> String { + let mut result = template.to_string(); + for (key, value) in variables { + result = result.replace(&format!("{{{{{}}}}}", key), value); + } + result + } + + pub(crate) fn model_to_resp(m: &message_template::Model) -> MessageTemplateResp { + MessageTemplateResp { + id: m.id, + tenant_id: m.tenant_id, + name: m.name.clone(), + code: m.code.clone(), + channel: m.channel.clone(), + title_template: m.title_template.clone(), + body_template: m.body_template.clone(), + language: m.language.clone(), + created_at: m.created_at, + updated_at: m.updated_at, + version: m.version, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_replaces_single_variable() { + let mut vars = std::collections::HashMap::new(); + vars.insert("name".to_string(), "张三".to_string()); + let result = TemplateService::render("您好,{{name}}", &vars); + assert_eq!(result, "您好,张三"); + } + + #[test] + fn render_replaces_multiple_variables() { + let mut vars = std::collections::HashMap::new(); + vars.insert("name".to_string(), "李四".to_string()); + vars.insert("code".to_string(), "ORD-001".to_string()); + let result = TemplateService::render("{{name}},您的订单 {{code}} 已发货", &vars); + assert_eq!(result, "李四,您的订单 ORD-001 已发货"); + } + + #[test] + fn render_no_variables_returns_original() { + let vars = std::collections::HashMap::new(); + let result = TemplateService::render("没有变量的模板", &vars); + assert_eq!(result, "没有变量的模板"); + } + + #[test] + fn render_missing_variable_leaves_placeholder() { + let vars = std::collections::HashMap::new(); + let result = TemplateService::render("您好,{{name}}", &vars); + assert_eq!(result, "您好,{{name}}"); + } + + #[test] + fn render_same_variable_multiple_times() { + let mut vars = std::collections::HashMap::new(); + vars.insert("user".to_string(), "王五".to_string()); + let result = TemplateService::render("{{user}} 你好,{{user}} 的订单已确认", &vars); + assert_eq!(result, "王五 你好,王五 的订单已确认"); + } + + #[test] + fn render_empty_template() { + let mut vars = std::collections::HashMap::new(); + vars.insert("name".to_string(), "test".to_string()); + let result = TemplateService::render("", &vars); + assert_eq!(result, ""); + } + + #[test] + fn render_empty_variable_value() { + let mut vars = std::collections::HashMap::new(); + vars.insert("name".to_string(), "".to_string()); + let result = TemplateService::render("您好,{{name}}!", &vars); + assert_eq!(result, "您好,!"); + } + + #[test] + fn render_adjacent_variables() { + let mut vars = std::collections::HashMap::new(); + vars.insert("a".to_string(), "1".to_string()); + vars.insert("b".to_string(), "2".to_string()); + let result = TemplateService::render("{{a}}{{b}}", &vars); + assert_eq!(result, "12"); + } + + #[test] + fn render_extra_variables_not_in_template_are_ignored() { + let mut vars = std::collections::HashMap::new(); + vars.insert("name".to_string(), "赵六".to_string()); + vars.insert("unused".to_string(), "ignore".to_string()); + let result = TemplateService::render("你好 {{name}}", &vars); + assert_eq!(result, "你好 赵六"); + } + + #[test] + fn model_to_resp_maps_all_fields() { + let m = message_template::Model { + id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + tenant_id: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(), + name: "欢迎消息".to_string(), + code: "WELCOME".to_string(), + channel: "in_app".to_string(), + title_template: "欢迎 {{name}}".to_string(), + body_template: "{{name}},欢迎使用".to_string(), + language: "zh-CN".to_string(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + created_by: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(), + updated_by: uuid::Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(), + deleted_at: None, + version: 2, + }; + let resp = TemplateService::model_to_resp(&m); + assert_eq!(resp.name, "欢迎消息"); + assert_eq!(resp.code, "WELCOME"); + assert_eq!(resp.channel, "in_app"); + assert_eq!(resp.language, "zh-CN"); + assert_eq!(resp.version, 2); + } +} diff --git a/crates/erp-plugin/Cargo.toml b/crates/erp-plugin/Cargo.toml new file mode 100644 index 0000000..eeb78d3 --- /dev/null +++ b/crates/erp-plugin/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "erp-plugin" +version = "0.1.0" +edition = "2024" +description = "ERP WASM 插件运行时 — 生产级 Host API" + +[dependencies] +wasmtime = "43" +wasmtime-wasi = "43" +erp-core = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sea-orm = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +tracing = { workspace = true } +thiserror = { workspace = true } +dashmap = "6" +toml = "0.8" +axum = { workspace = true } +utoipa = { workspace = true } +async-trait = { workspace = true } +sha2 = { workspace = true } +base64 = "0.22" +moka = { version = "0.12", features = ["sync"] } +regex = "1" +csv = { workspace = true } +rust_xlsxwriter = { workspace = true } +validator = { workspace = true } diff --git a/crates/erp-plugin/src/data_dto.rs b/crates/erp-plugin/src/data_dto.rs new file mode 100644 index 0000000..9fb70a4 --- /dev/null +++ b/crates/erp-plugin/src/data_dto.rs @@ -0,0 +1,331 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; + +/// 插件数据记录响应 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct PluginDataResp { + pub id: String, + pub data: serde_json::Value, + pub created_at: Option>, + pub updated_at: Option>, + pub version: Option, +} + +/// 创建插件数据请求 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct CreatePluginDataReq { + pub data: serde_json::Value, +} + +/// 更新插件数据请求(全量替换) +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct UpdatePluginDataReq { + pub data: serde_json::Value, + pub version: i32, +} + +/// 部分更新请求(PATCH — 只合并提供的字段) +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct PatchPluginDataReq { + pub data: serde_json::Value, + pub version: i32, +} + +/// 插件数据列表查询参数 +#[derive(Debug, Serialize, Deserialize, IntoParams)] +pub struct PluginDataListParams { + pub page: Option, + pub page_size: Option, + /// Base64 编码的游标(用于 Keyset 分页) + pub cursor: Option, + pub search: Option, + /// JSON 格式过滤: {"field":"value"} + pub filter: Option, + pub sort_by: Option, + /// "asc" or "desc" + pub sort_order: Option, +} + +/// 聚合查询响应项 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct AggregateItem { + /// 分组键(字段值) + pub key: String, + /// 计数 + pub count: i64, +} + +/// 多聚合查询响应项 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct AggregateMultiRow { + /// 分组键 + pub key: String, + /// 计数 + pub count: i64, + /// 聚合指标: {"sum_amount": 5000.0, "avg_price": 25.5} + #[serde(default)] + pub metrics: std::collections::HashMap, +} + +/// 聚合查询参数 +#[derive(Debug, Serialize, Deserialize, IntoParams)] +pub struct AggregateQueryParams { + /// 分组字段名 + pub group_by: String, + /// JSON 格式过滤: {"field":"value"} + pub filter: Option, +} + +/// 多聚合查询请求体 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct AggregateMultiReq { + /// 分组字段名 + pub group_by: String, + /// 聚合定义列表: [{"func": "sum", "field": "amount"}] + pub aggregations: Vec, + /// JSON 格式过滤 + pub filter: Option, +} + +/// 单个聚合定义 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct AggregateDefDto { + /// 聚合函数: count, sum, avg, min, max + pub func: String, + /// 字段名 + pub field: String, +} + +/// 统计查询参数 +#[derive(Debug, Serialize, Deserialize, IntoParams)] +pub struct CountQueryParams { + /// 搜索关键词 + pub search: Option, + /// JSON 格式过滤: {"field":"value"} + pub filter: Option, +} + +/// 批量操作请求 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct BatchActionReq { + /// 操作类型: "batch_delete" 或 "batch_update" + pub action: String, + /// 记录 ID 列表(上限 100) + pub ids: Vec, + /// batch_update 时的更新数据 + pub data: Option, +} + +/// 时间序列查询参数 +#[derive(Debug, Serialize, Deserialize, IntoParams)] +pub struct TimeseriesParams { + /// 时间字段名 + pub time_field: String, + /// 时间粒度: "day" / "week" / "month" + pub time_grain: String, + /// 开始日期 (ISO) + pub start: Option, + /// 结束日期 (ISO) + pub end: Option, +} + +/// 时间序列数据项 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct TimeseriesItem { + /// 时间周期 + pub period: String, + /// 计数 + pub count: i64, +} + +// ─── 跨插件引用 DTO ────────────────────────────────────────────────── + +/// 批量标签解析请求 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ResolveLabelsReq { + /// 字段名 → UUID 列表 + pub fields: std::collections::HashMap>, +} + +/// 批量标签解析响应 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ResolveLabelsResp { + /// 字段名 → { uuid: label } 映射 + pub labels: serde_json::Value, + /// 字段名 → 目标插件元信息 + pub meta: serde_json::Value, +} + +/// 公开实体信息(实体注册表查询响应) +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct PublicEntityResp { + pub manifest_id: String, + pub plugin_id: String, + pub entity_name: String, + pub display_name: String, +} + +// ─── 导入导出 DTO ────────────────────────────────────────────────── + +/// 数据导出参数 +#[derive(Debug, Serialize, Deserialize, IntoParams)] +pub struct ExportParams { + /// JSON 格式过滤: {"field":"value"} + pub filter: Option, + /// 搜索关键词 + pub search: Option, + /// 排序字段 + pub sort_by: Option, + /// "asc" or "desc" + pub sort_order: Option, + /// 导出格式: "json" (默认) | "csv" | "xlsx" + pub format: Option, +} + +/// 导出结果 — 根据格式返回不同内容 +pub enum ExportPayload { + Json(Vec), + Csv(Vec), + Xlsx(Vec), +} + +/// 数据导入请求 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ImportReq { + /// 导入数据行列表,每行是一个 JSON 对象 + pub rows: Vec, +} + +/// 数据导入结果 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ImportResult { + /// 成功导入行数 + pub success_count: usize, + /// 失败行数 + pub error_count: usize, + /// 每行错误详情: [{ row: 0, errors: ["字段 xxx 必填"] }] + #[serde(default)] + pub errors: Vec, +} + +/// 单行导入错误 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ImportRowError { + /// 行号(0-based) + pub row: usize, + /// 错误消息列表 + pub errors: Vec, +} + +// ─── 市场目录 DTO ────────────────────────────────────────────────── + +/// 市场条目列表查询参数 +#[derive(Debug, Serialize, Deserialize, IntoParams)] +pub struct MarketListParams { + pub page: Option, + pub page_size: Option, + pub category: Option, + pub search: Option, +} + +/// 市场条目响应(不含二进制数据) +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct MarketEntryResp { + pub id: String, + pub plugin_id: String, + pub name: String, + pub version: String, + pub description: Option, + pub author: Option, + pub category: Option, + pub tags: Option, + pub icon_url: Option, + pub screenshots: Option, + pub min_platform_version: Option, + pub status: String, + pub download_count: i32, + pub rating_avg: f64, + pub rating_count: i32, + pub changelog: Option, + pub created_at: Option>, + pub updated_at: Option>, +} + +/// 市场条目详情响应(含完整信息) +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct MarketEntryDetailResp { + #[serde(flatten)] + pub entry: MarketEntryResp, + /// 依赖提示(安装时检查 manifest.dependencies) + pub dependency_warnings: Vec, +} + +/// 提交评分/评论请求 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct SubmitReviewReq { + /// 评分 1-5 + pub rating: i32, + /// 评论内容 + pub review_text: Option, +} + +/// 评论响应 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct MarketReviewResp { + pub id: String, + pub user_id: String, + pub market_entry_id: String, + pub rating: i32, + pub review_text: Option, + pub created_at: Option>, +} + +// ─── 对账扫描 DTO ────────────────────────────────────────────────── + +/// 对账报告 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct ReconciliationReport { + /// 有效引用数 + pub valid_count: i64, + /// 悬空引用数 + pub dangling_count: i64, + /// 悬空引用详情 + pub details: Vec, +} + +/// 悬空引用详情 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct DanglingRef { + /// 实体名 + pub entity: String, + /// 字段名 + pub field: String, + /// 记录 ID + pub record_id: String, + /// 悬空的 UUID 值 + pub dangling_value: String, +} + +// ─── 自定义视图 DTO ────────────────────────────────────────────────── + +/// 用户视图配置请求 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct UserViewReq { + pub view_name: String, + pub view_config: serde_json::Value, + pub is_default: Option, +} + +/// 用户视图响应 +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct UserViewResp { + pub id: String, + pub plugin_id: String, + pub entity_name: String, + pub view_name: String, + pub view_config: serde_json::Value, + pub is_default: bool, + pub created_at: Option>, + pub updated_at: Option>, +} diff --git a/crates/erp-plugin/src/data_service.rs b/crates/erp-plugin/src/data_service.rs new file mode 100644 index 0000000..2ea3676 --- /dev/null +++ b/crates/erp-plugin/src/data_service.rs @@ -0,0 +1,1907 @@ +use sea_orm::{ColumnTrait, ConnectionTrait, EntityTrait, FromQueryResult, QueryFilter, Statement}; +use uuid::Uuid; + +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::{AppError, AppResult}; +use erp_core::events::EventBus; + +use crate::data_dto::{AggregateMultiRow, BatchActionReq, PluginDataResp}; +use crate::dynamic_table::{DynamicTableManager, sanitize_identifier}; +use crate::entity::plugin; +use crate::entity::plugin_entity; +use crate::error::PluginError; +use crate::manifest::PluginField; +use crate::state::EntityInfo; + +/// 根据 plugin 数据库 ID 查找 manifest 中匹配 entity 的触发事件 +async fn find_trigger_events( + plugin_db_id: Uuid, + entity_name: &str, + db: &sea_orm::DatabaseConnection, +) -> AppResult> { + let model = plugin::Entity::find_by_id(plugin_db_id) + .one(db) + .await? + .ok_or_else(|| AppError::NotFound(format!("插件 {} 不存在", plugin_db_id)))?; + + let manifest: crate::manifest::PluginManifest = serde_json::from_value(model.manifest_json) + .map_err(|e| PluginError::InvalidManifest(e.to_string()))?; + + let triggers = manifest + .trigger_events + .unwrap_or_default() + .into_iter() + .filter(|t| t.entity == entity_name) + .collect(); + Ok(triggers) +} + +/// 发布触发事件 +#[allow(clippy::too_many_arguments)] +async fn emit_trigger_events( + triggers: &[crate::manifest::PluginTriggerEvent], + action: &str, + entity_name: &str, + record_id: &str, + tenant_id: Uuid, + data: Option<&serde_json::Value>, + event_bus: &EventBus, + db: &sea_orm::DatabaseConnection, + manifest_id: &str, +) { + use crate::manifest::PluginTriggerOn; + for trigger in triggers { + let should_fire = match &trigger.on { + PluginTriggerOn::Create => action == "create", + PluginTriggerOn::Update => action == "update", + PluginTriggerOn::Delete => action == "delete", + PluginTriggerOn::CreateOrUpdate => action == "create" || action == "update", + }; + if should_fire { + let payload = serde_json::json!({ + "event": trigger.name, + "entity": entity_name, + "record_id": record_id, + "data": data, + "plugin_id": manifest_id, + "trigger_name": trigger.name, + "action": action, + }); + // 发布原始触发事件 + let event = + erp_core::events::DomainEvent::new(&trigger.name, tenant_id, payload.clone()); + event_bus.publish(event, db).await; + + // 同时发布 plugin.trigger.{manifest_id} 事件用于通知引擎 + let notify_event = erp_core::events::DomainEvent::new( + format!("plugin.trigger.{}.{}", manifest_id, trigger.name), + tenant_id, + payload, + ); + event_bus.publish(notify_event, db).await; + } + } +} + +/// 行级数据权限参数 — 传递到 service 层注入 SQL 条件 +pub struct DataScopeParams { + pub scope_level: String, + pub user_id: Uuid, + pub dept_member_ids: Vec, + pub owner_field: String, +} + +pub struct PluginDataService; + +impl PluginDataService { + /// 创建插件数据 + pub async fn create( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + operator_id: Uuid, + data: serde_json::Value, + db: &sea_orm::DatabaseConnection, + _event_bus: &EventBus, + ) -> AppResult { + let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?; + let fields = info.fields()?; + validate_data(&data, &fields)?; + validate_ref_entities( + &data, + &fields, + entity_name, + plugin_id, + tenant_id, + db, + true, + None, + ) + .await?; + + let (sql, values) = + DynamicTableManager::build_insert_sql(&info.table_name, tenant_id, operator_id, &data); + + #[derive(FromQueryResult)] + struct InsertResult { + id: Uuid, + data: serde_json::Value, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + version: i32, + } + + let result = InsertResult::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .one(db) + .await? + .ok_or_else(|| PluginError::DatabaseError("INSERT 未返回结果".to_string()))?; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "plugin.data.create", + entity_name, + ) + .with_resource_id(result.id), + db, + ) + .await; + + // 触发事件发布 + if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await + && let Ok(mid) = resolve_manifest_id(plugin_id, tenant_id, db).await + { + emit_trigger_events( + &triggers, + "create", + entity_name, + &result.id.to_string(), + tenant_id, + Some(&result.data), + _event_bus, + db, + &mid, + ) + .await; + } + + Ok(PluginDataResp { + id: result.id.to_string(), + data: result.data, + created_at: Some(result.created_at), + updated_at: Some(result.updated_at), + version: Some(result.version), + }) + } + + /// 列表查询(支持过滤/搜索/排序/Generated Column 路由/数据权限) + #[allow(clippy::too_many_arguments)] + pub async fn list( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + page: u64, + page_size: u64, + db: &sea_orm::DatabaseConnection, + filter: Option, + search: Option, + sort_by: Option, + sort_order: Option, + cache: &moka::sync::Cache, + scope: Option, + ) -> AppResult<(Vec, u64)> { + let info = resolve_entity_info_cached(plugin_id, entity_name, tenant_id, db, cache).await?; + + // 获取 searchable 字段列表 + let entity_fields = info.fields()?; + let search_tuple = { + let searchable: Vec<&str> = entity_fields + .iter() + .filter(|f| f.searchable == Some(true)) + .map(|f| f.name.as_str()) + .collect(); + match (searchable.is_empty(), &search) { + (false, Some(kw)) => Some((searchable.join(","), kw.clone())), + _ => None, + } + }; + + // 构建数据权限条件(count 查询只有 tenant_id 占 $1,scope 从 $2 开始) + let count_scope = build_scope_sql(&scope, &info.generated_fields, 2); + + // Count + let (count_sql, mut count_values) = + DynamicTableManager::build_count_sql(&info.table_name, tenant_id); + let count_sql = merge_scope_condition(count_sql, &count_scope); + count_values.extend(count_scope.1); + + #[derive(FromQueryResult)] + struct CountResult { + count: i64, + } + let total = CountResult::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + count_sql, + count_values, + )) + .one(db) + .await? + .map(|r| r.count as u64) + .unwrap_or(0); + + // Query — 使用 Generated Column 路由 + let offset = page.saturating_sub(1) * page_size; + let (sql, mut values) = DynamicTableManager::build_filtered_query_sql_ex( + &info.table_name, + tenant_id, + page_size, + offset, + filter, + search_tuple, + sort_by, + sort_order, + &info.generated_fields, + ) + .map_err(AppError::Validation)?; + + // 注入数据权限条件(scope 参数索引接在 values 之后) + let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1); + let sql = merge_scope_condition(sql, &scope_condition); + values.extend(scope_condition.1); + + #[derive(FromQueryResult)] + struct DataRow { + id: Uuid, + data: serde_json::Value, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + version: i32, + } + + let rows = DataRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .all(db) + .await?; + + let items = rows + .into_iter() + .map(|r| PluginDataResp { + id: r.id.to_string(), + data: r.data, + created_at: Some(r.created_at), + updated_at: Some(r.updated_at), + version: Some(r.version), + }) + .collect(); + + Ok((items, total)) + } + + /// 按 ID 获取 + pub async fn get_by_id( + plugin_id: Uuid, + entity_name: &str, + id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AppResult { + let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?; + let (sql, values) = + DynamicTableManager::build_get_by_id_sql(&info.table_name, id, tenant_id); + + #[derive(FromQueryResult)] + struct DataRow { + id: Uuid, + data: serde_json::Value, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + version: i32, + } + + let row = DataRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .one(db) + .await? + .ok_or_else(|| AppError::NotFound("记录不存在".to_string()))?; + + Ok(PluginDataResp { + id: row.id.to_string(), + data: row.data, + created_at: Some(row.created_at), + updated_at: Some(row.updated_at), + version: Some(row.version), + }) + } + + /// 更新 + #[allow(clippy::too_many_arguments)] + pub async fn update( + plugin_id: Uuid, + entity_name: &str, + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + data: serde_json::Value, + expected_version: i32, + db: &sea_orm::DatabaseConnection, + _event_bus: &EventBus, + ) -> AppResult { + let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?; + let fields = info.fields()?; + validate_data(&data, &fields)?; + validate_ref_entities( + &data, + &fields, + entity_name, + plugin_id, + tenant_id, + db, + false, + Some(id), + ) + .await?; + + // 循环引用检测 + for field in &fields { + if field.no_cycle == Some(true) && data.get(&field.name).is_some() { + check_no_cycle(id, field, &data, &info.table_name, tenant_id, db).await?; + } + } + + let (sql, values) = DynamicTableManager::build_update_sql( + &info.table_name, + id, + tenant_id, + operator_id, + &data, + expected_version, + ); + + #[derive(FromQueryResult)] + struct UpdateResult { + id: Uuid, + data: serde_json::Value, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + version: i32, + } + + let result = UpdateResult::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .one(db) + .await? + .ok_or(AppError::VersionMismatch)?; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "plugin.data.update", + entity_name, + ) + .with_resource_id(id), + db, + ) + .await; + + // 触发事件发布 + if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await + && let Ok(mid) = resolve_manifest_id(plugin_id, tenant_id, db).await + { + emit_trigger_events( + &triggers, + "update", + entity_name, + &result.id.to_string(), + tenant_id, + Some(&result.data), + _event_bus, + db, + &mid, + ) + .await; + } + + Ok(PluginDataResp { + id: result.id.to_string(), + data: result.data, + created_at: Some(result.created_at), + updated_at: Some(result.updated_at), + version: Some(result.version), + }) + } + + /// 部分更新(PATCH)— 只合并提供的字段 + #[allow(clippy::too_many_arguments)] + pub async fn partial_update( + plugin_id: Uuid, + entity_name: &str, + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + partial_data: serde_json::Value, + expected_version: i32, + db: &sea_orm::DatabaseConnection, + ) -> AppResult { + let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?; + let fields = info.fields()?; + + // 合并现有数据后校验,确保 partial update 也能触发 required/pattern/ref 校验 + let existing = Self::get_by_id(plugin_id, entity_name, id, tenant_id, db).await?; + let merged = { + let mut base = existing.data.as_object().cloned().unwrap_or_default(); + if let Some(patch) = partial_data.as_object() { + for (k, v) in patch { + base.insert(k.clone(), v.clone()); + } + } + serde_json::Value::Object(base) + }; + + validate_data(&merged, &fields)?; + validate_ref_entities( + &merged, + &fields, + entity_name, + plugin_id, + tenant_id, + db, + false, + Some(id), + ) + .await?; + + let (sql, values) = DynamicTableManager::build_patch_sql( + &info.table_name, + id, + tenant_id, + operator_id, + partial_data, + expected_version, + ); + + #[derive(FromQueryResult)] + struct PatchResult { + id: Uuid, + data: serde_json::Value, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + version: i32, + } + + let result = PatchResult::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .one(db) + .await? + .ok_or(AppError::VersionMismatch)?; + + Ok(PluginDataResp { + id: result.id.to_string(), + data: result.data, + created_at: Some(result.created_at), + updated_at: Some(result.updated_at), + version: Some(result.version), + }) + } + + /// 删除(软删除) + pub async fn delete( + plugin_id: Uuid, + entity_name: &str, + id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + _event_bus: &EventBus, + ) -> AppResult<()> { + let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?; + + // 解析 entity schema 获取 relations + let entity_def: crate::manifest::PluginEntity = + serde_json::from_value(info.schema_json.clone()) + .map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?; + + let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?; + + // 处理级联关系 + for relation in &entity_def.relations { + let rel_table = DynamicTableManager::table_name(&manifest_id, &relation.entity); + let fk = sanitize_identifier(&relation.foreign_key); + + match relation.on_delete { + crate::manifest::OnDeleteStrategy::Restrict => { + let check_sql = format!( + "SELECT 1 as chk FROM \"{}\" WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1", + rel_table, fk + ); + #[derive(FromQueryResult)] + #[allow(dead_code)] // FromQueryResult 映射需要 chk 字段,仅检查是否存在 + struct RefCheck { + chk: Option, + } + let has_ref = RefCheck::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + check_sql, + [id.to_string().into(), tenant_id.into()], + )) + .one(db) + .await?; + if has_ref.is_some() { + return Err(AppError::Validation(format!( + "存在关联的 {} 记录,无法删除", + relation.entity + ))); + } + } + crate::manifest::OnDeleteStrategy::Nullify => { + let nullify_sql = format!( + "UPDATE \"{}\" SET data = jsonb_set(data, '{{{}}}', 'null'), updated_at = NOW() WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL", + rel_table, fk, fk + ); + db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + nullify_sql, + [id.to_string().into(), tenant_id.into()], + )) + .await?; + } + crate::manifest::OnDeleteStrategy::Cascade => { + let cascade_sql = format!( + "UPDATE \"{}\" SET deleted_at = NOW(), updated_at = NOW() WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL", + rel_table, fk + ); + db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + cascade_sql, + [id.to_string().into(), tenant_id.into()], + )) + .await?; + } + } + } + + // 软删除主记录 + let (sql, values) = DynamicTableManager::build_delete_sql(&info.table_name, id, tenant_id); + + db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .await?; + + audit_service::record( + AuditLog::new(tenant_id, None, "plugin.data.delete", entity_name).with_resource_id(id), + db, + ) + .await; + + // 触发事件发布 + if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await + && let Ok(mid) = resolve_manifest_id(plugin_id, tenant_id, db).await + { + emit_trigger_events( + &triggers, + "delete", + entity_name, + &id.to_string(), + tenant_id, + None, + _event_bus, + db, + &mid, + ) + .await; + } + + Ok(()) + } + + /// 导出数据(支持 JSON/CSV/XLSX 格式) + #[allow(clippy::too_many_arguments)] + pub async fn export( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + filter: Option, + search: Option, + sort_by: Option, + sort_order: Option, + format: Option, + cache: &moka::sync::Cache, + scope: Option, + ) -> AppResult { + use crate::data_dto::ExportPayload; + + let info = resolve_entity_info_cached(plugin_id, entity_name, tenant_id, db, cache).await?; + + let entity_fields = info.fields()?; + let search_tuple = { + let searchable: Vec<&str> = entity_fields + .iter() + .filter(|f| f.searchable == Some(true)) + .map(|f| f.name.as_str()) + .collect(); + match (searchable.is_empty(), &search) { + (false, Some(kw)) => Some((searchable.join(","), kw.clone())), + _ => None, + } + }; + + let (sql, mut values) = DynamicTableManager::build_filtered_query_sql_ex( + &info.table_name, + tenant_id, + 10000, + 0, + filter, + search_tuple, + sort_by, + sort_order, + &info.generated_fields, + ) + .map_err(AppError::Validation)?; + + let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1); + let sql = merge_scope_condition(sql, &scope_condition); + values.extend(scope_condition.1); + + #[derive(FromQueryResult)] + struct DataRow { + data: serde_json::Value, + } + + let rows = DataRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .all(db) + .await?; + + let data: Vec = rows.into_iter().map(|r| r.data).collect(); + let fmt = format.as_deref().unwrap_or("json").to_lowercase(); + + match fmt.as_str() { + "csv" => Ok(ExportPayload::Csv(Self::to_csv(&data, &entity_fields)?)), + "xlsx" => Ok(ExportPayload::Xlsx(Self::to_xlsx(&data, &entity_fields)?)), + _ => Ok(ExportPayload::Json(data)), + } + } + + fn to_csv( + rows: &[serde_json::Value], + fields: &[crate::manifest::PluginField], + ) -> AppResult> { + let mut wtr = csv::Writer::from_writer(Vec::new()); + let headers: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect(); + wtr.write_record(&headers) + .map_err(|e| AppError::Internal(format!("CSV 写头失败: {}", e)))?; + + for row in rows { + let record: Vec = headers + .iter() + .map(|h| { + row.get(*h) + .map(|v| match v { + serde_json::Value::String(s) => s.clone(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Null => String::new(), + other => other.to_string(), + }) + .unwrap_or_default() + }) + .collect(); + wtr.write_record(&record) + .map_err(|e| AppError::Internal(format!("CSV 写行失败: {}", e)))?; + } + + wtr.into_inner() + .map_err(|e| AppError::Internal(format!("CSV 刷新失败: {}", e))) + } + + fn to_xlsx( + rows: &[serde_json::Value], + fields: &[crate::manifest::PluginField], + ) -> AppResult> { + use rust_xlsxwriter::*; + + let mut wb = Workbook::new(); + let ws = wb.add_worksheet(); + let header_fmt = Format::new() + .set_bold() + .set_background_color(Color::RGB(0x4F46E5)) + .set_font_color(Color::White); + + for (col, field) in fields.iter().enumerate() { + let label = field.display_name.as_deref().unwrap_or(&field.name); + ws.write_string_with_format(0, col as u16, label, &header_fmt) + .map_err(|e| AppError::Internal(format!("XLSX 写头失败: {}", e)))?; + } + + for (row_idx, row) in rows.iter().enumerate() { + for (col, field) in fields.iter().enumerate() { + let val = row.get(&field.name); + let row_num = (row_idx + 1) as u32; + match val { + Some(serde_json::Value::String(s)) => { + ws.write_string(row_num, col as u16, s).ok(); + } + Some(serde_json::Value::Number(n)) => { + if let Some(f) = n.as_f64() { + ws.write_number(row_num, col as u16, f).ok(); + } else { + ws.write_string(row_num, col as u16, n.to_string()).ok(); + } + } + Some(serde_json::Value::Bool(b)) => { + ws.write_string(row_num, col as u16, b.to_string()).ok(); + } + _ => {} + } + } + } + + let buf = wb + .save_to_buffer() + .map_err(|e| AppError::Internal(format!("XLSX 保存失败: {}", e)))?; + Ok(buf.to_vec()) + } + + /// 批量导入数据(逐行校验 + 逐行插入) + pub async fn import( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + operator_id: Uuid, + rows: Vec, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> AppResult { + use crate::data_dto::{ImportResult, ImportRowError}; + + if rows.len() > 1000 { + return Err(AppError::Validation("单次导入上限 1000 行".to_string())); + } + + let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?; + let fields = info.fields()?; + + let mut success_count = 0usize; + let mut row_errors: Vec = Vec::new(); + + for (i, row_data) in rows.iter().enumerate() { + if let Err(e) = validate_data(row_data, &fields) { + row_errors.push(ImportRowError { + row: i, + errors: vec![e.to_string()], + }); + continue; + } + if let Err(e) = validate_ref_entities( + row_data, + &fields, + entity_name, + plugin_id, + tenant_id, + db, + true, + None, + ) + .await + { + row_errors.push(ImportRowError { + row: i, + errors: vec![e.to_string()], + }); + continue; + } + + let (sql, values) = DynamicTableManager::build_insert_sql( + &info.table_name, + tenant_id, + operator_id, + row_data, + ); + + let result = db + .execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .await; + + match result { + Ok(_) => success_count += 1, + Err(e) => { + row_errors.push(ImportRowError { + row: i, + errors: vec![format!("写入失败: {}", e)], + }); + } + } + } + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "plugin.data.import", + entity_name, + ), + db, + ) + .await; + + if let Ok(triggers) = find_trigger_events(plugin_id, entity_name, db).await + && let Ok(mid) = resolve_manifest_id(plugin_id, tenant_id, db).await + { + emit_trigger_events( + &triggers, + "create", + entity_name, + &format!("batch_import:{}", success_count), + tenant_id, + None, + event_bus, + db, + &mid, + ) + .await; + } + + Ok(ImportResult { + success_count, + error_count: row_errors.len(), + errors: row_errors, + }) + } + + /// 批量操作 — batch_delete / batch_update + pub async fn batch( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + operator_id: Uuid, + req: BatchActionReq, + db: &sea_orm::DatabaseConnection, + ) -> AppResult { + if req.ids.is_empty() { + return Err(AppError::Validation("ids 不能为空".to_string())); + } + if req.ids.len() > 100 { + return Err(AppError::Validation("批量操作上限 100 条".to_string())); + } + + let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?; + let ids: Vec = req + .ids + .iter() + .map(|s| Uuid::parse_str(s)) + .collect::, _>>() + .map_err(|_| AppError::Validation("ids 中包含无效的 UUID".to_string()))?; + + let affected = match req.action.as_str() { + "batch_delete" => { + // 批量删除前先执行级联策略(逐条,复用 delete 的级联逻辑) + let entity_def: crate::manifest::PluginEntity = + serde_json::from_value(info.schema_json.clone()).map_err(|e| { + AppError::Internal(format!("解析 entity schema 失败: {}", e)) + })?; + let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?; + + for &del_id in &ids { + for relation in &entity_def.relations { + let rel_table = + DynamicTableManager::table_name(&manifest_id, &relation.entity); + let fk = sanitize_identifier(&relation.foreign_key); + match relation.on_delete { + crate::manifest::OnDeleteStrategy::Restrict => { + let check_sql = format!( + "SELECT 1 as chk FROM \"{}\" WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1", + rel_table, fk + ); + #[derive(FromQueryResult)] + #[allow(dead_code)] // FromQueryResult 映射需要 chk 字段,仅检查是否存在 + struct RefCheck { + chk: Option, + } + let has_ref = + RefCheck::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + check_sql, + [del_id.to_string().into(), tenant_id.into()], + )) + .one(db) + .await?; + if has_ref.is_some() { + return Err(AppError::Validation(format!( + "记录 {} 存在关联的 {} 记录,无法删除", + del_id, relation.entity + ))); + } + } + crate::manifest::OnDeleteStrategy::Nullify => { + let nullify_sql = format!( + "UPDATE \"{}\" SET data = jsonb_set(data, '{{{}}}', 'null'), updated_at = NOW() WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL", + rel_table, fk, fk + ); + db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + nullify_sql, + [del_id.to_string().into(), tenant_id.into()], + )) + .await?; + } + crate::manifest::OnDeleteStrategy::Cascade => { + let cascade_sql = format!( + "UPDATE \"{}\" SET deleted_at = NOW(), updated_at = NOW() WHERE data->>'{}' = $1 AND tenant_id = $2 AND deleted_at IS NULL", + rel_table, fk + ); + db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + cascade_sql, + [del_id.to_string().into(), tenant_id.into()], + )) + .await?; + } + } + } + } + + let placeholders: Vec = ids + .iter() + .enumerate() + .map(|(i, _)| format!("${}", i + 2)) + .collect(); + let sql = format!( + "UPDATE \"{}\" SET deleted_at = NOW(), updated_at = NOW() \ + WHERE tenant_id = $1 AND id IN ({}) AND deleted_at IS NULL", + info.table_name, + placeholders.join(", ") + ); + let mut values = vec![tenant_id.into()]; + for id in &ids { + values.push((*id).into()); + } + let result = db + .execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .await?; + result.rows_affected() + } + "batch_update" => { + let update_data = req.data.ok_or_else(|| { + AppError::Validation("batch_update 需要 data 字段".to_string()) + })?; + let mut set_expr = "data".to_string(); + if let Some(obj) = update_data.as_object() { + for key in obj.keys() { + let clean_key = sanitize_identifier(key); + set_expr = format!( + "jsonb_set({}, '{{{}}}', $2::jsonb->'{}', true)", + set_expr, clean_key, clean_key + ); + } + } + let placeholders: Vec = ids + .iter() + .enumerate() + .map(|(i, _)| format!("${}", i + 3)) + .collect(); + let sql = format!( + "UPDATE \"{}\" SET data = {}, updated_at = NOW(), updated_by = $1, version = version + 1 \ + WHERE tenant_id = $1 AND id IN ({}) AND deleted_at IS NULL", + info.table_name, + set_expr, + placeholders.join(", ") + ); + let mut values = vec![operator_id.into()]; + values.push( + serde_json::to_string(&update_data) + .unwrap_or_default() + .into(), + ); + for id in &ids { + values.push((*id).into()); + } + let result = db + .execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .await?; + result.rows_affected() + } + _ => { + return Err(AppError::Validation(format!( + "不支持的批量操作: {}", + req.action + ))); + } + }; + + Ok(affected) + } + + /// 统计记录数(支持过滤和搜索) + pub async fn count( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + filter: Option, + search: Option, + scope: Option, + ) -> AppResult { + let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?; + + let entity_fields = info.fields()?; + let search_tuple = { + let searchable: Vec<&str> = entity_fields + .iter() + .filter(|f| f.searchable == Some(true)) + .map(|f| f.name.as_str()) + .collect(); + match (searchable.is_empty(), &search) { + (false, Some(kw)) => Some((searchable.join(","), kw.clone())), + _ => None, + } + }; + + let (mut sql, mut values) = DynamicTableManager::build_filtered_count_sql( + &info.table_name, + tenant_id, + filter, + search_tuple, + ) + .map_err(AppError::Validation)?; + + // 合并数据权限条件 + let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1); + if !scope_condition.0.is_empty() { + sql = merge_scope_condition(sql, &scope_condition); + values.extend(scope_condition.1); + } + + #[derive(FromQueryResult)] + struct CountResult { + count: i64, + } + + let result = CountResult::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .one(db) + .await? + .map(|r| r.count as u64) + .unwrap_or(0); + + Ok(result) + } + + /// 聚合查询 — 按字段分组计数 + /// 返回 [(分组键, 计数), ...] + pub async fn aggregate( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + group_by_field: &str, + filter: Option, + scope: Option, + ) -> AppResult> { + let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?; + + let (mut sql, mut values) = DynamicTableManager::build_aggregate_sql( + &info.table_name, + tenant_id, + group_by_field, + filter, + ) + .map_err(AppError::Validation)?; + + // 合并数据权限条件 + let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1); + if !scope_condition.0.is_empty() { + sql = merge_scope_condition(sql, &scope_condition); + values.extend(scope_condition.1); + } + + #[derive(FromQueryResult)] + struct AggRow { + key: Option, + count: i64, + } + + let rows = AggRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .all(db) + .await?; + + let result = rows + .into_iter() + .map(|r| (r.key.unwrap_or_default(), r.count)) + .collect(); + + Ok(result) + } + + /// 多聚合查询 — 支持 COUNT + SUM/AVG/MIN/MAX + #[allow(clippy::too_many_arguments)] + pub async fn aggregate_multi( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + group_by_field: &str, + aggregations: &[(String, String)], + filter: Option, + scope: Option, + ) -> AppResult> { + let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?; + + let (mut sql, mut values) = DynamicTableManager::build_aggregate_multi_sql( + &info.table_name, + tenant_id, + group_by_field, + aggregations, + filter, + ) + .map_err(AppError::Validation)?; + + let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1); + if !scope_condition.0.is_empty() { + sql = merge_scope_condition(sql, &scope_condition); + values.extend(scope_condition.1); + } + + // 使用 json_agg 包装整行,返回 JSON 数组 + let json_sql = format!("SELECT json_agg(row_to_json(t)) as data FROM ({}) t", sql); + + #[derive(Debug, FromQueryResult)] + struct JsonResult { + data: Option, + } + + let result = JsonResult::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + json_sql, + values, + )) + .one(db) + .await?; + + let json_rows: Vec = result + .and_then(|r| r.data) + .and_then(|d| d.as_array().cloned()) + .unwrap_or_default(); + + let rows = json_rows + .into_iter() + .map(|v| AggregateMultiRow { + key: v + .get("key") + .and_then(|k| k.as_str()) + .unwrap_or_default() + .to_string(), + count: v.get("count").and_then(|c| c.as_i64()).unwrap_or(0), + metrics: v + .as_object() + .map(|m| { + m.iter() + .filter(|(k, _)| *k != "key" && *k != "count") + .map(|(k, v)| (k.clone(), v.as_f64().unwrap_or(0.0))) + .collect() + }) + .unwrap_or_default(), + }) + .collect(); + + Ok(rows) + } + + /// 聚合查询(预留 Redis 缓存接口) + pub async fn aggregate_cached( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + group_by_field: &str, + filter: Option, + ) -> AppResult> { + // TODO: 未来版本添加 Redis 缓存层 + Self::aggregate( + plugin_id, + entity_name, + tenant_id, + db, + group_by_field, + filter, + None, + ) + .await + } + + /// 时间序列聚合 — 按时间字段截断为 day/week/month 统计计数 + #[allow(clippy::too_many_arguments)] + pub async fn timeseries( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + time_field: &str, + time_grain: &str, + start: Option, + end: Option, + scope: Option, + ) -> AppResult> { + let info = resolve_entity_info(plugin_id, entity_name, tenant_id, db).await?; + + let (mut sql, mut values) = DynamicTableManager::build_timeseries_sql( + &info.table_name, + tenant_id, + time_field, + time_grain, + start.as_deref(), + end.as_deref(), + ) + .map_err(AppError::Validation)?; + + // 合并数据权限条件 + let scope_condition = build_scope_sql(&scope, &info.generated_fields, values.len() + 1); + if !scope_condition.0.is_empty() { + sql = merge_scope_condition(sql, &scope_condition); + values.extend(scope_condition.1); + } + + #[derive(FromQueryResult)] + struct TsRow { + period: Option, + count: i64, + } + + let rows = TsRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .all(db) + .await?; + + Ok(rows + .into_iter() + .map(|r| crate::data_dto::TimeseriesItem { + period: r.period.unwrap_or_default(), + count: r.count, + }) + .collect()) + } + + /// 对账扫描: 检查指定插件所有实体的跨插件引用是否有悬空引用 + pub async fn reconcile_references( + plugin_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AppResult { + let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?; + + // 获取该插件所有实体 + let entities = plugin_entity::Entity::find() + .filter(plugin_entity::Column::PluginId.eq(plugin_id)) + .filter(plugin_entity::Column::TenantId.eq(tenant_id)) + .filter(plugin_entity::Column::DeletedAt.is_null()) + .all(db) + .await?; + + let mut valid_count: i64 = 0; + let mut dangling_count: i64 = 0; + let mut details = Vec::new(); + + for entity_rec in &entities { + let schema: crate::manifest::PluginEntity = + serde_json::from_value(entity_rec.schema_json.clone()) + .map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?; + + // 找出所有有 ref_entity 的字段 + let ref_fields: Vec<&PluginField> = schema + .fields + .iter() + .filter(|f| f.ref_entity.is_some()) + .collect(); + + if ref_fields.is_empty() { + continue; + } + + let table_name = DynamicTableManager::table_name(&manifest_id, &entity_rec.entity_name); + + for field in &ref_fields { + let col = sanitize_identifier(&field.name); + + #[derive(FromQueryResult)] + #[allow(dead_code)] // FromQueryResult 映射需要 id 字段,通过 is_some 检查 + struct RefRow { + id: Uuid, + // 动态列 — SeaORM 无法直接映射,用 JSON 构建 + } + + // 查询所有有 ref 值的记录 + let ref_sql = format!( + "SELECT id, {} as ref_val FROM {} WHERE tenant_id = $1 AND deleted_at IS NULL AND {} IS NOT NULL", + col, table_name, col, + ); + + #[derive(FromQueryResult)] + struct RefValRow { + id: Uuid, + ref_val: String, + } + + let rows = RefValRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + ref_sql, + [tenant_id.into()], + )) + .all(db) + .await?; + + for row in rows { + // 验证 ref_val 是有效的 UUID 且目标记录存在 + let Ok(target_uuid) = Uuid::parse_str(&row.ref_val) else { + continue; + }; + + let ref_entity_name = field.ref_entity.as_deref().unwrap_or(""); + let ref_plugin = field.ref_plugin.as_deref().unwrap_or(&manifest_id); + let target_table = DynamicTableManager::table_name(ref_plugin, ref_entity_name); + + let check_sql = format!( + "SELECT COUNT(*) as cnt FROM {} WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL", + target_table, + ); + + #[derive(FromQueryResult)] + struct CountRow { + cnt: i64, + } + + let count_row = CountRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + check_sql, + [target_uuid.into(), tenant_id.into()], + )) + .one(db) + .await? + .unwrap_or(CountRow { cnt: 0 }); + + if count_row.cnt > 0 { + valid_count += 1; + } else { + dangling_count += 1; + details.push(crate::data_dto::DanglingRef { + entity: entity_rec.entity_name.clone(), + field: field.name.clone(), + record_id: row.id.to_string(), + dangling_value: row.ref_val, + }); + } + } + } + } + + Ok(crate::data_dto::ReconciliationReport { + valid_count, + dangling_count, + details, + }) + } +} + +/// 从 plugins 表解析 manifest metadata.id(如 "erp-crm") +pub async fn resolve_manifest_id( + plugin_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, +) -> AppResult { + let model = plugin::Entity::find() + .filter(plugin::Column::Id.eq(plugin_id)) + .filter(plugin::Column::TenantId.eq(tenant_id)) + .filter(plugin::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| AppError::NotFound(format!("插件 {} 不存在", plugin_id)))?; + + let manifest: crate::manifest::PluginManifest = serde_json::from_value(model.manifest_json) + .map_err(|e| AppError::Internal(format!("解析插件 manifest 失败: {}", e)))?; + + Ok(manifest.metadata.id) +} + +/// 从 plugin_entities 表获取实体完整信息(带租户隔离) +/// 注意:此函数不填充 generated_fields,仅用于非 list 场景 +async fn resolve_entity_info( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, +) -> AppResult { + let entity = plugin_entity::Entity::find() + .filter(plugin_entity::Column::PluginId.eq(plugin_id)) + .filter(plugin_entity::Column::TenantId.eq(tenant_id)) + .filter(plugin_entity::Column::EntityName.eq(entity_name)) + .filter(plugin_entity::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| { + AppError::NotFound(format!("插件实体 {}/{} 不存在", plugin_id, entity_name)) + })?; + + Ok(EntityInfo { + table_name: entity.table_name, + schema_json: entity.schema_json, + generated_fields: vec![], // 旧路径,不追踪 generated_fields + }) +} + +/// 从缓存或数据库获取实体信息(带 generated_fields 解析) +pub async fn resolve_entity_info_cached( + plugin_id: Uuid, + entity_name: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + cache: &moka::sync::Cache, +) -> AppResult { + let cache_key = format!("{}:{}:{}", plugin_id, entity_name, tenant_id); + if let Some(info) = cache.get(&cache_key) { + return Ok(info); + } + + let entity = plugin_entity::Entity::find() + .filter(plugin_entity::Column::PluginId.eq(plugin_id)) + .filter(plugin_entity::Column::TenantId.eq(tenant_id)) + .filter(plugin_entity::Column::EntityName.eq(entity_name)) + .filter(plugin_entity::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| { + AppError::NotFound(format!("插件实体 {}/{} 不存在", plugin_id, entity_name)) + })?; + + // 解析 generated_fields + let entity_def: crate::manifest::PluginEntity = + serde_json::from_value(entity.schema_json.clone()) + .map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?; + let generated_fields: Vec = entity_def + .fields + .iter() + .filter(|f| f.field_type.supports_generated_column()) + .filter(|f| { + f.unique + || f.sortable == Some(true) + || f.filterable == Some(true) + || (f.required && (f.sortable == Some(true) || f.filterable == Some(true))) + }) + .map(|f| sanitize_identifier(&f.name)) + .collect(); + + let info = EntityInfo { + table_name: entity.table_name, + schema_json: entity.schema_json, + generated_fields, + }; + + cache.insert(cache_key, info.clone()); + Ok(info) +} + +/// 跨插件实体解析 — 按 manifest_id + entity_name 查找目标插件的实体信息 +pub async fn resolve_cross_plugin_entity( + target_manifest_id: &str, + entity_name: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, +) -> AppResult { + let entity = plugin_entity::Entity::find() + .filter(plugin_entity::Column::ManifestId.eq(target_manifest_id)) + .filter(plugin_entity::Column::EntityName.eq(entity_name)) + .filter(plugin_entity::Column::TenantId.eq(tenant_id)) + .filter(plugin_entity::Column::DeletedAt.is_null()) + .one(db) + .await? + .ok_or_else(|| { + AppError::NotFound(format!( + "跨插件实体 {}/{} 不存在或未公开", + target_manifest_id, entity_name + )) + })?; + + let entity_def: crate::manifest::PluginEntity = + serde_json::from_value(entity.schema_json.clone()) + .map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?; + let generated_fields: Vec = entity_def + .fields + .iter() + .filter(|f| f.field_type.supports_generated_column()) + .filter(|f| { + f.unique + || f.sortable == Some(true) + || f.filterable == Some(true) + || (f.required && (f.sortable == Some(true) || f.filterable == Some(true))) + }) + .map(|f| sanitize_identifier(&f.name)) + .collect(); + + Ok(EntityInfo { + table_name: entity.table_name, + schema_json: entity.schema_json, + generated_fields, + }) +} + +/// 检查目标插件是否安装且活跃 +pub async fn is_plugin_active( + target_manifest_id: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, +) -> bool { + // 通过 plugin_entities 的 manifest_id 找到 plugin_id,再检查 plugins 表状态 + let entity = plugin_entity::Entity::find() + .filter(plugin_entity::Column::ManifestId.eq(target_manifest_id)) + .filter(plugin_entity::Column::TenantId.eq(tenant_id)) + .filter(plugin_entity::Column::DeletedAt.is_null()) + .one(db) + .await; + + let Some(entity) = entity.ok().flatten() else { + return false; + }; + + let plugin = plugin::Entity::find_by_id(entity.plugin_id) + .filter(plugin::Column::TenantId.eq(tenant_id)) + .filter(plugin::Column::DeletedAt.is_null()) + .one(db) + .await; + + matches!(plugin.ok().flatten(), Some(p) if p.status == "running" || p.status == "installed") +} + +/// 校验数据:检查 required 字段 + 正则校验 +fn validate_data(data: &serde_json::Value, fields: &[PluginField]) -> AppResult<()> { + let obj = data + .as_object() + .ok_or_else(|| AppError::Validation("data 必须是 JSON 对象".to_string()))?; + + for field in fields { + let label = field.display_name.as_deref().unwrap_or(&field.name); + + // required 检查 + if field.required && !obj.contains_key(&field.name) { + return Err(AppError::Validation(format!("字段 '{}' 不能为空", label))); + } + + // 正则校验 + if let Some(validation) = &field.validation + && let Some(pattern) = &validation.pattern + && let Some(val) = obj.get(&field.name) + { + let str_val = val.as_str().unwrap_or(""); + if !str_val.is_empty() { + let re = regex::Regex::new(pattern) + .map_err(|e| AppError::Internal(format!("正则表达式编译失败: {}", e)))?; + if !re.is_match(str_val) { + let default_msg = format!("字段 '{}' 格式不正确", label); + let msg = validation.message.as_deref().unwrap_or(&default_msg); + return Err(AppError::Validation(msg.to_string())); + } + } + } + } + + Ok(()) +} + +/// 校验外键引用 — 检查 ref_entity 字段指向的记录是否存在 +/// 支持同插件引用和跨插件引用(ref_plugin 字段) +/// 核心原则:跨插件引用目标插件未安装时跳过校验(软警告) +#[allow(clippy::too_many_arguments)] +async fn validate_ref_entities( + data: &serde_json::Value, + fields: &[PluginField], + current_entity: &str, + plugin_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + is_create: bool, + record_id: Option, +) -> AppResult<()> { + let obj = data + .as_object() + .ok_or_else(|| AppError::Validation("data 必须是 JSON 对象".to_string()))?; + + for field in fields { + let Some(ref_entity_name) = &field.ref_entity else { + continue; + }; + let Some(val) = obj.get(&field.name) else { + continue; + }; + let str_val = val.as_str().unwrap_or("").trim().to_string(); + + if str_val.is_empty() && !field.required { + continue; + } + if str_val.is_empty() { + continue; + } + + let ref_id = Uuid::parse_str(&str_val).map_err(|_| { + AppError::Validation(format!( + "字段 '{}' 的值 '{}' 不是有效的 UUID", + field.display_name.as_deref().unwrap_or(&field.name), + str_val + )) + })?; + + // 自引用 + create:跳过(记录尚未存在) + if ref_entity_name == current_entity && field.ref_plugin.is_none() && is_create { + continue; + } + // 自引用 + update:检查是否引用自身 + if ref_entity_name == current_entity + && field.ref_plugin.is_none() + && !is_create + && let Some(rid) = record_id + && ref_id == rid + { + continue; + } + + // 确定目标表名 + let ref_table = if let Some(target_plugin) = &field.ref_plugin { + // 跨插件引用 — 检查目标插件是否活跃 + if !is_plugin_active(target_plugin, tenant_id, db).await { + // 目标插件未安装/禁用 → 跳过校验(软警告,不阻塞) + tracing::debug!( + field = %field.name, + target_plugin = %target_plugin, + "跨插件引用目标插件未活跃,跳过校验" + ); + continue; + } + // 目标插件活跃 → 解析目标表名 + match resolve_cross_plugin_entity(target_plugin, ref_entity_name, tenant_id, db).await { + Ok(info) => info.table_name, + Err(e) => { + tracing::warn!( + field = %field.name, + target_plugin = %target_plugin, + entity = %ref_entity_name, + error = %e, + "跨插件实体解析失败,跳过校验" + ); + continue; + } + } + } else { + // 同插件引用 — 使用原有逻辑 + let manifest_id = resolve_manifest_id(plugin_id, tenant_id, db).await?; + DynamicTableManager::table_name(&manifest_id, ref_entity_name) + }; + + let check_sql = format!( + "SELECT 1 as check_result FROM \"{}\" WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL LIMIT 1", + ref_table + ); + #[derive(FromQueryResult)] + #[allow(dead_code)] // FromQueryResult 映射需要 check_result 字段,仅检查是否存在 + struct ExistsCheck { + check_result: Option, + } + let result = ExistsCheck::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + check_sql, + [ref_id.into(), tenant_id.into()], + )) + .one(db) + .await?; + + if result.is_none() { + return Err(AppError::Validation(format!( + "引用的 {} 记录不存在(ID: {})", + ref_entity_name, ref_id + ))); + } + } + Ok(()) +} + +/// 循环引用检测 — 用于 no_cycle 字段 +async fn check_no_cycle( + record_id: Uuid, + field: &PluginField, + data: &serde_json::Value, + table_name: &str, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, +) -> AppResult<()> { + let Some(val) = data.get(&field.name) else { + return Ok(()); + }; + let new_parent = val.as_str().unwrap_or("").trim().to_string(); + if new_parent.is_empty() { + return Ok(()); + } + + let new_parent_id = Uuid::parse_str(&new_parent) + .map_err(|_| AppError::Validation("parent_id 不是有效的 UUID".to_string()))?; + + let field_name = sanitize_identifier(&field.name); + let mut visited = vec![record_id]; + let mut current_id = new_parent_id; + + for _ in 0..100 { + if visited.contains(¤t_id) { + let label = field.display_name.as_deref().unwrap_or(&field.name); + return Err(AppError::Validation(format!( + "字段 '{}' 形成循环引用", + label + ))); + } + visited.push(current_id); + + let query_sql = format!( + "SELECT data->>'{}' as parent FROM \"{}\" WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL", + field_name, table_name + ); + #[derive(FromQueryResult)] + struct ParentRow { + parent: Option, + } + let row = ParentRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + query_sql, + [current_id.into(), tenant_id.into()], + )) + .one(db) + .await?; + + match row { + Some(r) => { + let parent = r.parent.unwrap_or_default().trim().to_string(); + if parent.is_empty() { + break; + } + current_id = Uuid::parse_str(&parent) + .map_err(|_| AppError::Internal("parent_id 不是有效的 UUID".to_string()))?; + } + None => break, + } + } + Ok(()) +} + +/// 从 DataScopeParams 构建 SQL 条件片段和参数 +fn build_scope_sql( + scope: &Option, + generated_fields: &[String], + next_param_idx: usize, +) -> (String, Vec) { + match scope { + Some(s) => DynamicTableManager::build_data_scope_condition_with_params( + &s.scope_level, + &s.user_id, + &s.owner_field, + &s.dept_member_ids, + next_param_idx, + generated_fields, + ), + None => (String::new(), vec![]), + } +} + +/// 将数据权限条件合并到现有 SQL 中 +/// +/// `scope_condition` 是 `(sql_fragment, params)` 元组。 +/// sql_fragment 格式为 `"field = $N OR ..."`,可直接拼接到 WHERE 子句。 +fn merge_scope_condition(sql: String, scope_condition: &(String, Vec)) -> String { + if scope_condition.0.is_empty() { + return sql; + } + // 在 "deleted_at IS NULL" 之后追加 scope 条件 + // 因为所有查询都包含 WHERE ... AND "deleted_at" IS NULL ... + // 我们在合适的位置追加 AND (scope_condition) + if sql.contains("\"deleted_at\" IS NULL") { + sql.replace( + "\"deleted_at\" IS NULL", + &format!("\"deleted_at\" IS NULL AND ({})", scope_condition.0), + ) + } else if sql.contains("deleted_at IS NULL") { + sql.replace( + "deleted_at IS NULL", + &format!("deleted_at IS NULL AND ({})", scope_condition.0), + ) + } else { + // 回退:直接追加到 WHERE 子句末尾 + sql + } +} + +#[cfg(test)] +mod validate_tests { + use super::*; + use crate::manifest::{FieldValidation, PluginField, PluginFieldType}; + + fn make_field(name: &str, pattern: Option<&str>, message: Option<&str>) -> PluginField { + PluginField { + name: name.to_string(), + field_type: PluginFieldType::String, + required: false, + validation: pattern.map(|p| FieldValidation { + pattern: Some(p.to_string()), + message: message.map(|m| m.to_string()), + }), + ..PluginField::default_for_field() + } + } + + #[test] + fn validate_phone_pattern_rejects_invalid() { + let fields = vec![make_field( + "phone", + Some("^1[3-9]\\d{9}$"), + Some("手机号格式不正确"), + )]; + let data = serde_json::json!({"phone": "1234"}); + let result = validate_data(&data, &fields); + assert!(result.is_err()); + } + + #[test] + fn validate_phone_pattern_accepts_valid() { + let fields = vec![make_field( + "phone", + Some("^1[3-9]\\d{9}$"), + Some("手机号格式不正确"), + )]; + let data = serde_json::json!({"phone": "13812345678"}); + let result = validate_data(&data, &fields); + assert!(result.is_ok()); + } + + #[test] + fn validate_empty_optional_field_skips_pattern() { + let fields = vec![make_field("phone", Some("^1[3-9]\\d{9}$"), None)]; + let data = serde_json::json!({"phone": ""}); + let result = validate_data(&data, &fields); + assert!(result.is_ok()); + } +} diff --git a/crates/erp-plugin/src/dto.rs b/crates/erp-plugin/src/dto.rs new file mode 100644 index 0000000..54ec9dd --- /dev/null +++ b/crates/erp-plugin/src/dto.rs @@ -0,0 +1,68 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use validator::Validate; + +/// 插件信息响应 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PluginResp { + pub id: Uuid, + pub name: String, + pub version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + pub status: String, + pub config: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub installed_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled_at: Option>, + pub entities: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub permissions: Option>, + pub record_version: i32, +} + +/// 插件实体信息 +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PluginEntityResp { + pub name: String, + pub display_name: String, + pub table_name: String, +} + +/// 插件权限信息 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PluginPermissionResp { + pub code: String, + pub name: String, + pub description: String, +} + +/// 插件健康检查响应 +#[derive(Debug, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PluginHealthResp { + pub plugin_id: Uuid, + pub status: String, + pub details: serde_json::Value, +} + +/// 更新插件配置请求 +#[derive(Debug, Serialize, Deserialize, Validate, utoipa::ToSchema)] +pub struct UpdatePluginConfigReq { + pub config: serde_json::Value, + pub version: i32, +} + +/// 插件列表查询参数 +#[derive(Debug, Serialize, Deserialize, Validate, utoipa::IntoParams)] +pub struct PluginListParams { + pub page: Option, + pub page_size: Option, + #[validate(length(max = 20, message = "状态值无效"))] + pub status: Option, + #[validate(length(max = 100, message = "搜索关键词过长"))] + pub search: Option, +} diff --git a/crates/erp-plugin/src/dynamic_table.rs b/crates/erp-plugin/src/dynamic_table.rs new file mode 100644 index 0000000..48b39ac --- /dev/null +++ b/crates/erp-plugin/src/dynamic_table.rs @@ -0,0 +1,1759 @@ +use sea_orm::{ConnectionTrait, DatabaseConnection, FromQueryResult, Statement, Value}; +use uuid::Uuid; + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; + +use crate::error::{PluginError, PluginResult}; +use crate::manifest::{PluginEntity, PluginField, PluginFieldType}; + +/// 消毒标识符:只保留 ASCII 字母、数字、下划线,限制 63 字节(PostgreSQL NAMEDATALEN-1) +pub(crate) fn sanitize_identifier(input: &str) -> String { + input + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' { + c + } else { + '_' + } + }) + .take(63) + .collect() +} + +/// Schema 演进字段差异 +pub struct FieldDiff { + pub new_filterable: Vec, + pub new_sortable: Vec, + pub new_searchable: Vec, +} + +/// 动态表管理器 — 处理插件动态创建/删除的数据库表 +pub struct DynamicTableManager; + +impl DynamicTableManager { + /// 生成动态表名: `plugin_{sanitized_id}_{sanitized_entity}` + pub fn table_name(plugin_id: &str, entity_name: &str) -> String { + let sanitized_id = sanitize_identifier(plugin_id); + let sanitized_entity = sanitize_identifier(entity_name); + format!("plugin_{}_{}", sanitized_id, sanitized_entity) + } + + /// 生成包含 Generated Column 的建表 DDL + pub fn build_create_table_sql(plugin_id: &str, entity: &PluginEntity) -> String { + let table_name = Self::table_name(plugin_id, &entity.name); + + let mut gen_cols = Vec::new(); + let mut indexes = Vec::new(); + + for field in &entity.fields { + if !field.field_type.supports_generated_column() { + continue; + } + // 提取规则:unique / sortable / filterable + let should_extract = field.unique + || field.sortable == Some(true) + || field.filterable == Some(true) + || (field.required + && (field.sortable == Some(true) || field.filterable == Some(true))); + + if !should_extract { + continue; + } + + let col_name = format!("_f_{}", sanitize_identifier(&field.name)); + let sql_type = field.field_type.generated_sql_type(); + let expr = field + .field_type + .generated_expr(&sanitize_identifier(&field.name)); + + gen_cols.push(format!( + " \"{}\" {} GENERATED ALWAYS AS ({}) STORED", + col_name, sql_type, expr + )); + + // 索引策略 + let col_idx = format!("{}_{}", sanitize_identifier(&table_name), col_name); + if field.unique { + indexes.push(format!( + "CREATE UNIQUE INDEX IF NOT EXISTS \"idx_{}_uniq\" ON \"{}\" (tenant_id, \"{}\") WHERE deleted_at IS NULL", + col_idx, table_name, col_name + )); + } else { + indexes.push(format!( + "CREATE INDEX IF NOT EXISTS \"idx_{}\" ON \"{}\" (tenant_id, \"{}\") WHERE deleted_at IS NULL", + col_idx, table_name, col_name + )); + } + } + + // pg_trgm 索引 + for field in &entity.fields { + if field.searchable == Some(true) && matches!(field.field_type, PluginFieldType::String) + { + let sf = sanitize_identifier(&field.name); + indexes.push(format!( + "CREATE INDEX IF NOT EXISTS \"idx_{}_{}_trgm\" ON \"{}\" USING GIN ((data->>'{}') gin_trgm_ops) WHERE deleted_at IS NULL", + sanitize_identifier(&table_name), sf, table_name, sf + )); + } + } + + // 覆盖索引 + indexes.push(format!( + "CREATE INDEX IF NOT EXISTS \"idx_{}_tenant_cover\" ON \"{}\" (tenant_id, created_at DESC) INCLUDE (id, data, updated_at, version) WHERE deleted_at IS NULL", + sanitize_identifier(&table_name), table_name + )); + + let gen_cols_sql = if gen_cols.is_empty() { + String::new() + } else { + format!(",\n{}", gen_cols.join(",\n")) + }; + + format!( + "CREATE TABLE IF NOT EXISTS \"{}\" (\ + \"id\" UUID PRIMARY KEY DEFAULT gen_random_uuid(), \ + \"tenant_id\" UUID NOT NULL, \ + \"data\" JSONB NOT NULL DEFAULT '{{}}'{gen_cols}, \ + \"created_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \ + \"updated_at\" TIMESTAMPTZ NOT NULL DEFAULT NOW(), \ + \"created_by\" UUID, \ + \"updated_by\" UUID, \ + \"deleted_at\" TIMESTAMPTZ, \ + \"version\" INT NOT NULL DEFAULT 1);\n\ + {}", + table_name, + indexes.join(";\n"), + gen_cols = gen_cols_sql, + ) + } + + /// 创建动态表(使用 Generated Column + pg_trgm + 覆盖索引) + pub async fn create_table( + db: &DatabaseConnection, + plugin_id: &str, + entity: &PluginEntity, + ) -> PluginResult<()> { + let ddl = Self::build_create_table_sql(plugin_id, entity); + for sql in ddl.split(';').map(|s| s.trim()).filter(|s| !s.is_empty()) { + tracing::info!(sql = %sql, "Executing DDL"); + db.execute_unprepared(sql).await.map_err(|e| { + tracing::error!(sql = %sql, error = %e, "DDL execution failed"); + PluginError::DatabaseError(e.to_string()) + })?; + } + tracing::info!( + plugin_id = %plugin_id, + entity = %entity.name, + "Dynamic table created with Generated Columns" + ); + Ok(()) + } + + /// 删除动态表 + pub async fn drop_table( + db: &DatabaseConnection, + plugin_id: &str, + entity_name: &str, + ) -> PluginResult<()> { + let table_name = Self::table_name(plugin_id, entity_name); + let sql = format!("DROP TABLE IF EXISTS \"{}\"", table_name); + db.execute(Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + sql, + )) + .await + .map_err(|e| PluginError::DatabaseError(e.to_string()))?; + tracing::info!(table = %table_name, "Dynamic table dropped"); + Ok(()) + } + + /// Schema 演进:对比新旧实体字段,返回需要新增 Generated Column 的差异 + pub fn diff_entity_fields(old: &PluginEntity, new: &PluginEntity) -> FieldDiff { + let old_names: std::collections::HashSet = + old.fields.iter().map(|f| f.name.clone()).collect(); + + let mut new_filterable = Vec::new(); + let mut new_sortable = Vec::new(); + let mut new_searchable = Vec::new(); + + for field in &new.fields { + if old_names.contains(&field.name) { + continue; + } + // 新增字段 + 需要 Generated Column 的条件 + let needs_gen = + field.unique || field.sortable == Some(true) || field.filterable == Some(true); + if needs_gen { + new_filterable.push(field.clone()); + if field.sortable == Some(true) { + new_sortable.push(field.clone()); + } + } + if field.searchable == Some(true) && matches!(field.field_type, PluginFieldType::String) + { + new_searchable.push(field.clone()); + } + } + + FieldDiff { + new_filterable, + new_sortable, + new_searchable, + } + } + + /// Schema 演进:为已有实体新增 Generated Column 和索引 + pub async fn alter_add_generated_columns( + db: &DatabaseConnection, + plugin_id: &str, + entity: &PluginEntity, + diff: &FieldDiff, + ) -> PluginResult<()> { + let table_name = Self::table_name(plugin_id, &entity.name); + let mut statements = Vec::new(); + + for field in &diff.new_filterable { + if !field.field_type.supports_generated_column() { + continue; + } + let col_name = format!("_f_{}", sanitize_identifier(&field.name)); + let sql_type = field.field_type.generated_sql_type(); + let expr = field + .field_type + .generated_expr(&sanitize_identifier(&field.name)); + let _safe_field = sanitize_identifier(&field.name); + + statements.push(format!( + "ALTER TABLE \"{}\" ADD COLUMN IF NOT EXISTS \"{}\" {} GENERATED ALWAYS AS ({}) STORED", + table_name, col_name, sql_type, expr + )); + + let col_idx = format!("{}_{}", sanitize_identifier(&table_name), col_name); + if field.unique { + statements.push(format!( + "CREATE UNIQUE INDEX IF NOT EXISTS \"idx_{}_uniq\" ON \"{}\" (tenant_id, \"{}\") WHERE deleted_at IS NULL", + col_idx, table_name, col_name + )); + } else { + statements.push(format!( + "CREATE INDEX IF NOT EXISTS \"idx_{}\" ON \"{}\" (tenant_id, \"{}\") WHERE deleted_at IS NULL", + col_idx, table_name, col_name + )); + } + } + + for field in &diff.new_searchable { + let sf = sanitize_identifier(&field.name); + let col_name = format!("_f_{}", sf); + let col_idx = format!("{}_{}trgm", sanitize_identifier(&table_name), col_name); + statements.push(format!( + "CREATE INDEX IF NOT EXISTS \"idx_{}\" ON \"{}\" USING gin (\"{}\" gin_trgm_ops) WHERE deleted_at IS NULL AND \"{}\" IS NOT NULL", + col_idx, table_name, col_name, col_name + )); + } + + for sql in &statements { + tracing::info!(sql = %sql, "Executing ALTER TABLE"); + db.execute_unprepared(sql).await.map_err(|e| { + tracing::error!(sql = %sql, error = %e, "ALTER TABLE failed"); + PluginError::DatabaseError(e.to_string()) + })?; + } + + tracing::info!( + table = %table_name, + added_columns = diff.new_filterable.len(), + added_search_indexes = diff.new_searchable.len(), + "Schema evolution: Generated Columns added" + ); + Ok(()) + } + + /// 检查表是否存在 + pub async fn table_exists(db: &DatabaseConnection, table_name: &str) -> PluginResult { + #[derive(FromQueryResult)] + struct ExistsResult { + exists: bool, + } + let result = ExistsResult::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = $1)", + [table_name.into()], + )) + .one(db) + .await + .map_err(|e| PluginError::DatabaseError(e.to_string()))?; + + Ok(result.map(|r| r.exists).unwrap_or(false)) + } + + /// 构建 INSERT SQL + pub fn build_insert_sql( + table_name: &str, + tenant_id: Uuid, + user_id: Uuid, + data: &serde_json::Value, + ) -> (String, Vec) { + let id = Uuid::now_v7(); + Self::build_insert_sql_with_id(table_name, id, tenant_id, user_id, data) + } + + /// 构建 INSERT SQL(指定 ID) + pub fn build_insert_sql_with_id( + table_name: &str, + id: Uuid, + tenant_id: Uuid, + user_id: Uuid, + data: &serde_json::Value, + ) -> (String, Vec) { + let sql = format!( + "INSERT INTO \"{}\" (id, tenant_id, data, created_by, updated_by, version) \ + VALUES ($1, $2, $3::jsonb, $4, $5, 1) \ + RETURNING id, tenant_id, data, created_at, updated_at, version", + table_name + ); + let values = vec![ + id.into(), + tenant_id.into(), + serde_json::to_string(data).unwrap_or_default().into(), + user_id.into(), + user_id.into(), + ]; + (sql, values) + } + + /// 构建 SELECT SQL + pub fn build_query_sql( + table_name: &str, + tenant_id: Uuid, + limit: u64, + offset: u64, + ) -> (String, Vec) { + let sql = format!( + "SELECT id, data, created_at, updated_at, version \ + FROM \"{}\" \ + WHERE tenant_id = $1 AND deleted_at IS NULL \ + ORDER BY created_at DESC \ + LIMIT $2 OFFSET $3", + table_name + ); + let values = vec![ + tenant_id.into(), + (limit as i64).into(), + (offset as i64).into(), + ]; + (sql, values) + } + + /// 构建 COUNT SQL + pub fn build_count_sql(table_name: &str, tenant_id: Uuid) -> (String, Vec) { + let sql = format!( + "SELECT COUNT(*) as count FROM \"{}\" WHERE tenant_id = $1 AND deleted_at IS NULL", + table_name + ); + let values = vec![tenant_id.into()]; + (sql, values) + } + + /// 构建 UPDATE SQL(含乐观锁) + pub fn build_update_sql( + table_name: &str, + id: Uuid, + tenant_id: Uuid, + user_id: Uuid, + data: &serde_json::Value, + version: i32, + ) -> (String, Vec) { + let sql = format!( + "UPDATE \"{}\" \ + SET data = $1::jsonb, updated_at = NOW(), updated_by = $2, version = version + 1 \ + WHERE id = $3 AND tenant_id = $4 AND version = $5 AND deleted_at IS NULL \ + RETURNING id, data, created_at, updated_at, version", + table_name + ); + let values = vec![ + serde_json::to_string(data).unwrap_or_default().into(), + user_id.into(), + id.into(), + tenant_id.into(), + version.into(), + ]; + (sql, values) + } + + /// 构建 PATCH SQL — 只更新 data 中提供的字段,未提供的保持不变 + /// 使用 jsonb_set 逐层合并,实现部分更新 + pub fn build_patch_sql( + table_name: &str, + id: Uuid, + tenant_id: Uuid, + user_id: Uuid, + partial_data: serde_json::Value, + version: i32, + ) -> (String, Vec) { + let mut set_expr = "data".to_string(); + if let Some(obj) = partial_data.as_object() { + for key in obj.keys() { + let clean_key = sanitize_identifier(key); + set_expr = format!( + "jsonb_set({}, '{{{}}}', $1::jsonb->'{}', true)", + set_expr, clean_key, clean_key + ); + } + } + + let sql = format!( + "UPDATE \"{}\" \ + SET data = {}, updated_at = NOW(), updated_by = $2, version = version + 1 \ + WHERE id = $3 AND tenant_id = $4 AND version = $5 AND deleted_at IS NULL \ + RETURNING id, data, created_at, updated_at, version", + table_name, set_expr + ); + let values = vec![ + serde_json::to_string(&partial_data) + .unwrap_or_default() + .into(), + user_id.into(), + id.into(), + tenant_id.into(), + version.into(), + ]; + (sql, values) + } + + /// 构建 DELETE SQL(软删除) + pub fn build_delete_sql(table_name: &str, id: Uuid, tenant_id: Uuid) -> (String, Vec) { + let sql = format!( + "UPDATE \"{}\" \ + SET deleted_at = NOW(), updated_at = NOW() \ + WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL", + table_name + ); + let values = vec![id.into(), tenant_id.into()]; + (sql, values) + } + + /// 构建单条查询 SQL + pub fn build_get_by_id_sql( + table_name: &str, + id: Uuid, + tenant_id: Uuid, + ) -> (String, Vec) { + let sql = format!( + "SELECT id, data, created_at, updated_at, version \ + FROM \"{}\" \ + WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL", + table_name + ); + let values = vec![id.into(), tenant_id.into()]; + (sql, values) + } + + /// 构建唯一索引 SQL(供测试验证) + pub fn build_unique_index_sql(table_name: &str, field_name: &str) -> String { + let sanitized_field = sanitize_identifier(field_name); + let idx_name = format!( + "idx_{}_{}_uniq", + sanitize_identifier(table_name), + sanitized_field + ); + format!( + "CREATE UNIQUE INDEX IF NOT EXISTS \"{}\" ON \"{}\" (\"data\"->>'{}') WHERE \"deleted_at\" IS NULL", + idx_name, table_name, sanitized_field + ) + } + + /// 构建带过滤条件的 COUNT SQL + /// 复用 build_filtered_query_sql 的条件构建逻辑,但只做 COUNT + pub fn build_filtered_count_sql( + table_name: &str, + tenant_id: Uuid, + filter: Option, + search: Option<(String, String)>, // (searchable_fields_csv, keyword) + ) -> Result<(String, Vec), String> { + let mut conditions = vec![ + format!("\"tenant_id\" = ${}", 1), + "\"deleted_at\" IS NULL".to_string(), + ]; + let mut param_idx = 2; + let mut values: Vec = vec![tenant_id.into()]; + + // 处理 filter(与 build_filtered_query_sql 保持一致) + if let Some(f) = filter + && let Some(obj) = f.as_object() + { + for (key, val) in obj { + let clean_key = sanitize_identifier(key); + if clean_key.is_empty() { + return Err(format!("无效的过滤字段名: {}", key)); + } + conditions.push(format!("\"data\"->>'{}' = ${}", clean_key, param_idx)); + values.push(Value::String(Some(Box::new( + val.as_str().unwrap_or("").to_string(), + )))); + param_idx += 1; + } + } + + // 处理 search(与 build_filtered_query_sql 保持一致) + if let Some((fields_csv, keyword)) = search { + let escaped = keyword.replace('%', "\\%").replace('_', "\\_"); + let fields: Vec<&str> = fields_csv.split(',').collect(); + let search_param_idx = param_idx; + let search_conditions: Vec = fields + .iter() + .map(|f| { + let clean = sanitize_identifier(f.trim()); + format!("\"data\"->>'{}' ILIKE ${}", clean, search_param_idx) + }) + .collect(); + conditions.push(format!("({})", search_conditions.join(" OR "))); + values.push(Value::String(Some(Box::new(format!("%{}%", escaped))))); + } + + let sql = format!( + "SELECT COUNT(*) as count FROM \"{}\" WHERE {}", + table_name, + conditions.join(" AND "), + ); + + Ok((sql, values)) + } + + /// 构建聚合查询 SQL — 按 JSONB 字段分组计数 + /// SELECT data->>'group_field' as key, COUNT(*) as count + /// FROM table WHERE tenant_id = $1 AND deleted_at IS NULL [AND filter...] + /// GROUP BY data->>'group_field' ORDER BY count DESC + pub fn build_aggregate_sql( + table_name: &str, + tenant_id: Uuid, + group_by_field: &str, + filter: Option, + ) -> Result<(String, Vec), String> { + let clean_group = sanitize_identifier(group_by_field); + if clean_group.is_empty() { + return Err(format!("无效的分组字段名: {}", group_by_field)); + } + + let mut conditions = vec![ + format!("\"tenant_id\" = ${}", 1), + "\"deleted_at\" IS NULL".to_string(), + ]; + let mut param_idx = 2; + let mut values: Vec = vec![tenant_id.into()]; + + // 处理 filter + if let Some(f) = filter + && let Some(obj) = f.as_object() + { + for (key, val) in obj { + let clean_key = sanitize_identifier(key); + if clean_key.is_empty() { + return Err(format!("无效的过滤字段名: {}", key)); + } + conditions.push(format!("\"data\"->>'{}' = ${}", clean_key, param_idx)); + values.push(Value::String(Some(Box::new( + val.as_str().unwrap_or("").to_string(), + )))); + param_idx += 1; + } + } + + let sql = format!( + "SELECT \"data\"->>'{}' as key, COUNT(*) as count \ + FROM \"{}\" \ + WHERE {} \ + GROUP BY \"data\"->>'{}' \ + ORDER BY count DESC", + clean_group, + table_name, + conditions.join(" AND "), + clean_group, + ); + + Ok((sql, values)) + } + + /// 构建多聚合函数 SQL(支持 COUNT/SUM/AVG/MIN/MAX) + pub fn build_aggregate_multi_sql( + table_name: &str, + tenant_id: Uuid, + group_by_field: &str, + aggregations: &[(String, String)], // (func, field) e.g. ("sum", "amount") + filter: Option, + ) -> Result<(String, Vec), String> { + let clean_group = sanitize_identifier(group_by_field); + if clean_group.is_empty() { + return Err(format!("无效的分组字段名: {}", group_by_field)); + } + + let mut conditions = vec![ + format!("\"tenant_id\" = ${}", 1), + "\"deleted_at\" IS NULL".to_string(), + ]; + let mut param_idx = 2; + let mut values: Vec = vec![tenant_id.into()]; + + if let Some(f) = filter + && let Some(obj) = f.as_object() + { + for (key, val) in obj { + let clean_key = sanitize_identifier(key); + if clean_key.is_empty() { + return Err(format!("无效的过滤字段名: {}", key)); + } + conditions.push(format!("\"data\"->>'{}' = ${}", clean_key, param_idx)); + values.push(Value::String(Some(Box::new( + val.as_str().unwrap_or("").to_string(), + )))); + param_idx += 1; + } + } + + let mut select_parts = vec![ + format!("\"_f_{}\" as key", clean_group), + "COUNT(*) as count".to_string(), + ]; + + for (func, field) in aggregations { + let clean_field = sanitize_identifier(field); + let func_lower = func.to_lowercase(); + match func_lower.as_str() { + "sum" => select_parts.push(format!( + "COALESCE(SUM(\"_f_{}\"), 0) as sum_{}", + clean_field, clean_field + )), + "avg" => select_parts.push(format!( + "COALESCE(AVG(\"_f_{}\"), 0) as avg_{}", + clean_field, clean_field + )), + "min" => select_parts.push(format!( + "MIN(\"_f_{}\") as min_{}", + clean_field, clean_field + )), + "max" => select_parts.push(format!( + "MAX(\"_f_{}\") as max_{}", + clean_field, clean_field + )), + _ => {} + } + } + + let sql = format!( + "SELECT {} \ + FROM \"{}\" \ + WHERE {} \ + GROUP BY \"_f_{}\" \ + ORDER BY count DESC", + select_parts.join(", "), + table_name, + conditions.join(" AND "), + clean_group, + ); + + Ok((sql, values)) + } + + /// 构建带过滤条件的查询 SQL + #[allow(clippy::too_many_arguments)] + pub fn build_filtered_query_sql( + table_name: &str, + tenant_id: Uuid, + limit: u64, + offset: u64, + filter: Option, + search: Option<(String, String)>, // (searchable_fields_csv, keyword) + sort_by: Option, + sort_order: Option, + ) -> Result<(String, Vec), String> { + let mut conditions = vec![ + format!("\"tenant_id\" = ${}", 1), + "\"deleted_at\" IS NULL".to_string(), + ]; + let mut param_idx = 2; + let mut values: Vec = vec![tenant_id.into()]; + + // 处理 filter + if let Some(f) = filter + && let Some(obj) = f.as_object() + { + for (key, val) in obj { + let clean_key = sanitize_identifier(key); + if clean_key.is_empty() { + return Err(format!("无效的过滤字段名: {}", key)); + } + conditions.push(format!("\"data\"->>'{}' = ${}", clean_key, param_idx)); + values.push(Value::String(Some(Box::new( + val.as_str().unwrap_or("").to_string(), + )))); + param_idx += 1; + } + } + + // 处理 search — 所有 searchable 字段共享同一个 ILIKE 参数 + if let Some((fields_csv, keyword)) = search { + let escaped = keyword.replace('%', "\\%").replace('_', "\\_"); + let fields: Vec<&str> = fields_csv.split(',').collect(); + let search_param_idx = param_idx; + let search_conditions: Vec = fields + .iter() + .map(|f| { + let clean = sanitize_identifier(f.trim()); + format!("\"data\"->>'{}' ILIKE ${}", clean, search_param_idx) + }) + .collect(); + conditions.push(format!("({})", search_conditions.join(" OR "))); + values.push(Value::String(Some(Box::new(format!("%{}%", escaped))))); + param_idx += 1; + } + + // 处理 sort + let order_clause = if let Some(sb) = sort_by { + let clean = sanitize_identifier(&sb); + if clean.is_empty() { + return Err(format!("无效的排序字段名: {}", sb)); + } + let dir = match sort_order.as_deref() { + Some("asc") | Some("ASC") => "ASC", + _ => "DESC", + }; + format!("ORDER BY \"data\"->>'{}' {}", clean, dir) + } else { + "ORDER BY \"created_at\" DESC".to_string() + }; + + let sql = format!( + "SELECT id, data, created_at, updated_at, version FROM \"{}\" WHERE {} {} LIMIT ${} OFFSET ${}", + table_name, + conditions.join(" AND "), + order_clause, + param_idx, + param_idx + 1, + ); + values.push((limit as i64).into()); + values.push((offset as i64).into()); + + Ok((sql, values)) + } + + /// 返回字段引用函数 — Generated Column 存在时用 _f_{name} + pub fn field_reference_fn(generated_fields: &[String]) -> impl Fn(&str) -> String + '_ { + move |field_name: &str| { + let clean = sanitize_identifier(field_name); + if generated_fields.contains(&clean) { + format!("\"_f_{}\"", clean) + } else { + format!("\"data\"->>'{}'", clean) + } + } + } + + /// 扩展版查询构建 — 支持 Generated Column 路由 + #[allow(clippy::too_many_arguments)] + pub fn build_filtered_query_sql_ex( + table_name: &str, + tenant_id: Uuid, + limit: u64, + offset: u64, + filter: Option, + search: Option<(String, String)>, + sort_by: Option, + sort_order: Option, + generated_fields: &[String], + ) -> Result<(String, Vec), String> { + let ref_fn = Self::field_reference_fn(generated_fields); + + let mut conditions = vec![ + format!("\"tenant_id\" = ${}", 1), + "\"deleted_at\" IS NULL".to_string(), + ]; + let mut param_idx = 2; + let mut values: Vec = vec![tenant_id.into()]; + + // filter + if let Some(f) = filter + && let Some(obj) = f.as_object() + { + for (key, val) in obj { + let clean_key = sanitize_identifier(key); + if clean_key.is_empty() { + return Err(format!("无效的过滤字段名: {}", key)); + } + conditions.push(format!("{} = ${}", ref_fn(&clean_key), param_idx)); + values.push(Value::String(Some(Box::new( + val.as_str().unwrap_or("").to_string(), + )))); + param_idx += 1; + } + } + + // search + if let Some((fields_csv, keyword)) = search { + let escaped = keyword.replace('%', "\\%").replace('_', "\\_"); + let fields: Vec<&str> = fields_csv.split(',').collect(); + let search_param_idx = param_idx; + let search_conditions: Vec = fields + .iter() + .map(|f| { + let clean = sanitize_identifier(f.trim()); + format!("\"data\"->>'{}' ILIKE ${}", clean, search_param_idx) + }) + .collect(); + conditions.push(format!("({})", search_conditions.join(" OR "))); + values.push(Value::String(Some(Box::new(format!("%{}%", escaped))))); + param_idx += 1; + } + + // sort + let order_clause = if let Some(sb) = sort_by { + let clean = sanitize_identifier(&sb); + if clean.is_empty() { + return Err(format!("无效的排序字段名: {}", sb)); + } + let dir = match sort_order.as_deref() { + Some("asc") | Some("ASC") => "ASC", + _ => "DESC", + }; + format!("ORDER BY {} {}", ref_fn(&clean), dir) + } else { + "ORDER BY \"created_at\" DESC".to_string() + }; + + let sql = format!( + "SELECT id, data, created_at, updated_at, version FROM \"{}\" WHERE {} {} LIMIT ${} OFFSET ${}", + table_name, + conditions.join(" AND "), + order_clause, + param_idx, + param_idx + 1, + ); + values.push((limit as i64).into()); + values.push((offset as i64).into()); + + Ok((sql, values)) + } + + /// 构建数据范围 SQL 条件 — 用于行级数据权限过滤 + /// + /// 根据权限范围级别 (scope_level) 生成对应的 WHERE 子句和参数: + /// - "all": 无额外条件(空字符串) + /// - "self": 只能看自己创建/拥有的数据 + /// - "department" / "department_tree": 能看部门成员的数据 + /// + /// 返回 (sql_fragment, params),sql_fragment 可直接拼接到 WHERE 子句中 + pub fn build_data_scope_condition_with_params( + scope_level: &str, + current_user_id: &Uuid, + owner_field: &str, + dept_member_ids: &[Uuid], + start_param_idx: usize, + generated_fields: &[String], + ) -> (String, Vec) { + let ref_fn = Self::field_reference_fn(generated_fields); + let owner_ref = ref_fn(owner_field); + match scope_level { + "self" => ( + format!( + "({} = ${} OR \"created_by\" = ${})", + owner_ref, start_param_idx, start_param_idx + ), + vec![ + current_user_id.to_string().into(), + (*current_user_id).into(), + ], + ), + "department" | "department_tree" => { + if dept_member_ids.is_empty() { + // 部门成员为空时退化为 self 范围 + ( + format!( + "({} = ${} OR \"created_by\" = ${})", + owner_ref, start_param_idx, start_param_idx + ), + vec![ + current_user_id.to_string().into(), + (*current_user_id).into(), + ], + ) + } else { + let placeholders: Vec = dept_member_ids + .iter() + .enumerate() + .map(|(i, _)| format!("${}", start_param_idx + i)) + .collect(); + let values: Vec = dept_member_ids + .iter() + .map(|id| id.to_string().into()) + .collect(); + ( + format!("{} IN ({})", owner_ref, placeholders.join(", ")), + values, + ) + } + } + "all" => (String::new(), vec![]), + _ => (String::new(), vec![]), + } + } + + /// 编码游标 + pub fn encode_cursor(values: &[String], id: &Uuid) -> String { + let obj = serde_json::json!({ + "v": values, + "id": id.to_string(), + }); + BASE64.encode(obj.to_string()) + } + + /// 解码游标 + pub fn decode_cursor(cursor: &str) -> Result<(Vec, Uuid), String> { + let json_str = BASE64 + .decode(cursor) + .map_err(|e| format!("游标 Base64 解码失败: {}", e))?; + let obj: serde_json::Value = + serde_json::from_slice(&json_str).map_err(|e| format!("游标 JSON 解析失败: {}", e))?; + let values = obj["v"] + .as_array() + .ok_or("游标缺少 v 字段")? + .iter() + .map(|v| v.as_str().unwrap_or("").to_string()) + .collect(); + let id = obj["id"].as_str().ok_or("游标缺少 id 字段")?; + let id = Uuid::parse_str(id).map_err(|e| format!("游标 id 解析失败: {}", e))?; + Ok((values, id)) + } + + /// 构建 Keyset 分页 SQL + pub fn build_keyset_query_sql( + table_name: &str, + tenant_id: Uuid, + limit: u64, + cursor: Option, + sort_column: Option, + sort_direction: &str, + generated_fields: &[String], + ) -> Result<(String, Vec), String> { + let dir = match sort_direction { + "ASC" => "ASC", + _ => "DESC", + }; + let ref_fn = Self::field_reference_fn(generated_fields); + let sort_col = sort_column + .as_deref() + .map(ref_fn) + .unwrap_or("\"created_at\"".to_string()); + + let mut values: Vec = vec![tenant_id.into()]; + let mut param_idx = 2; + + let cursor_condition = if let Some(c) = cursor { + let (sort_vals, cursor_id) = Self::decode_cursor(&c)?; + let cond = format!( + "ROW({}, \"id\") {} (${}, ${})", + sort_col, + if dir == "ASC" { ">" } else { "<" }, + param_idx, + param_idx + 1 + ); + values.push(Value::String(Some(Box::new( + sort_vals.first().cloned().unwrap_or_default(), + )))); + values.push(cursor_id.into()); + param_idx += 2; + Some(cond) + } else { + None + }; + + let where_extra = cursor_condition + .map(|c| format!(" AND {}", c)) + .unwrap_or_default(); + + let sql = format!( + "SELECT id, data, created_at, updated_at, version FROM \"{}\" \ + WHERE \"tenant_id\" = $1 AND \"deleted_at\" IS NULL{} \ + ORDER BY {}, \"id\" {} LIMIT ${}", + table_name, where_extra, sort_col, dir, param_idx, + ); + values.push((limit as i64).into()); + + Ok((sql, values)) + } + + /// 构建时间序列查询 SQL + pub fn build_timeseries_sql( + table_name: &str, + tenant_id: Uuid, + time_field: &str, + time_grain: &str, + start: Option<&str>, + end: Option<&str>, + ) -> Result<(String, Vec), String> { + let clean_field = sanitize_identifier(time_field); + let grain = match time_grain { + "day" | "week" | "month" => time_grain, + _ => return Err(format!("不支持的 time_grain: {}", time_grain)), + }; + + let mut conditions = vec![ + "\"tenant_id\" = $1".to_string(), + "\"deleted_at\" IS NULL".to_string(), + ]; + let mut values: Vec = vec![tenant_id.into()]; + let mut param_idx = 2; + + if let Some(s) = start { + conditions.push(format!( + "(data->>'{}')::timestamp >= ${}", + clean_field, param_idx + )); + values.push(Value::String(Some(Box::new(s.to_string())))); + param_idx += 1; + } + if let Some(e) = end { + conditions.push(format!( + "(data->>'{}')::timestamp < ${}", + clean_field, param_idx + )); + values.push(Value::String(Some(Box::new(e.to_string())))); + } + + let sql = format!( + "SELECT to_char(date_trunc('{}', (data->>'{}')::timestamp), 'YYYY-MM-DD') as period, \ + COUNT(*) as count \ + FROM \"{}\" WHERE {} \ + GROUP BY date_trunc('{}', (data->>'{}')::timestamp) \ + ORDER BY period", + grain, + clean_field, + table_name, + conditions.join(" AND "), + grain, + clean_field, + ); + + Ok((sql, values)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_unique_index_sql_uses_create_unique_index() { + let sql = DynamicTableManager::build_unique_index_sql("plugin_test", "code"); + assert!( + sql.contains("CREATE UNIQUE INDEX"), + "Expected UNIQUE index, got: {}", + sql + ); + } + + #[test] + fn test_build_filtered_query_sql_with_filter() { + let (sql, values) = DynamicTableManager::build_filtered_query_sql( + "plugin_test_customer", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + 20, + 0, + Some(serde_json::json!({"customer_id": "abc-123"})), + None, + None, + None, + ) + .unwrap(); + assert!( + sql.contains("\"data\"->>'customer_id' ="), + "Expected filter in SQL, got: {}", + sql + ); + assert!(sql.contains("tenant_id"), "Expected tenant_id filter"); + // 验证参数值 + assert_eq!(values.len(), 4); // tenant_id + filter_value + limit + offset + } + + #[test] + fn test_build_filtered_query_sql_sanitizes_keys() { + let result = DynamicTableManager::build_filtered_query_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + 20, + 0, + Some(serde_json::json!({"evil'; DROP TABLE--": "value"})), + None, + None, + None, + ); + // 恶意 key 被清理为合法标识符 + let (sql, _) = result.unwrap(); + assert!( + !sql.contains("DROP TABLE"), + "SQL should not contain injection: {}", + sql + ); + assert!( + sql.contains("evil___DROP_TABLE__"), + "Key should be sanitized: {}", + sql + ); + } + + #[test] + fn test_build_filtered_query_sql_with_search() { + let (sql, values) = DynamicTableManager::build_filtered_query_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + 20, + 0, + None, + Some(("name,code".to_string(), "测试关键词".to_string())), + None, + None, + ) + .unwrap(); + assert!(sql.contains("ILIKE"), "Expected ILIKE in SQL, got: {}", sql); + // 验证搜索参数值包含 %...% + if let Value::String(Some(s)) = &values[1] { + assert!( + s.contains("测试关键词"), + "Search value should contain keyword" + ); + assert!(s.starts_with('%'), "Search value should start with %"); + } + } + + #[test] + fn test_build_filtered_query_sql_with_sort() { + let (sql, _) = DynamicTableManager::build_filtered_query_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + 20, + 0, + None, + None, + Some("name".to_string()), + Some("asc".to_string()), + ) + .unwrap(); + assert!( + sql.contains("ORDER BY \"data\"->>'name' ASC"), + "Expected sort clause, got: {}", + sql + ); + } + + #[test] + fn test_build_filtered_query_sql_default_sort() { + let (sql, _) = DynamicTableManager::build_filtered_query_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + 20, + 0, + None, + None, + None, + None, + ) + .unwrap(); + assert!( + sql.contains("ORDER BY \"created_at\" DESC"), + "Expected default sort, got: {}", + sql + ); + } + + // ===== build_patch_sql 测试 ===== + + #[test] + fn test_build_patch_sql_merges_fields() { + let (sql, values) = DynamicTableManager::build_patch_sql( + "plugin_test_customer", + Uuid::parse_str("00000000-0000-0000-0000-000000000099").unwrap(), + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + Uuid::parse_str("00000000-0000-0000-0000-000000000050").unwrap(), + serde_json::json!({"level": "vip", "status": "active"}), + 3, + ); + assert!(sql.contains("jsonb_set"), "PATCH 应使用 jsonb_set 合并"); + assert!(sql.contains("version = version + 1"), "PATCH 应更新版本号"); + assert!(sql.contains("WHERE id = $3"), "应有 id 条件"); + assert!(sql.contains("version = $5"), "应有乐观锁"); + assert_eq!(values.len(), 5, "应有 5 个参数"); + } + + // ===== build_filtered_count_sql 测试 ===== + + #[test] + fn test_build_filtered_count_sql_basic() { + let (sql, values) = DynamicTableManager::build_filtered_count_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + None, + None, + ) + .unwrap(); + assert!(sql.contains("COUNT(*)"), "Expected COUNT(*), got: {}", sql); + assert!(sql.contains("tenant_id"), "Expected tenant_id filter"); + assert!(sql.contains("deleted_at"), "Expected soft delete filter"); + assert_eq!(values.len(), 1); // 仅 tenant_id + } + + #[test] + fn test_build_filtered_count_sql_with_filter() { + let (sql, values) = DynamicTableManager::build_filtered_count_sql( + "plugin_test_customer", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + Some(serde_json::json!({"status": "active"})), + None, + ) + .unwrap(); + assert!( + sql.contains("\"data\"->>'status' ="), + "Expected filter, got: {}", + sql + ); + assert_eq!(values.len(), 2); // tenant_id + filter_value + } + + #[test] + fn test_build_filtered_count_sql_with_search() { + let (sql, values) = DynamicTableManager::build_filtered_count_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + None, + Some(("name,code".to_string(), "搜索词".to_string())), + ) + .unwrap(); + assert!(sql.contains("ILIKE"), "Expected ILIKE, got: {}", sql); + assert_eq!(values.len(), 2); // tenant_id + search_param + } + + #[test] + fn test_build_filtered_count_sql_no_limit_offset() { + // COUNT SQL 不应包含 LIMIT/OFFSET + let (sql, _) = DynamicTableManager::build_filtered_count_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + None, + None, + ) + .unwrap(); + assert!(!sql.contains("LIMIT"), "COUNT SQL 不应包含 LIMIT"); + assert!(!sql.contains("OFFSET"), "COUNT SQL 不应包含 OFFSET"); + } + + // ===== build_aggregate_sql 测试 ===== + + #[test] + fn test_build_aggregate_sql_basic() { + let (sql, values) = DynamicTableManager::build_aggregate_sql( + "plugin_test_customer", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "status", + None, + ) + .unwrap(); + assert!(sql.contains("GROUP BY"), "Expected GROUP BY, got: {}", sql); + assert!( + sql.contains("\"data\"->>'status'"), + "Expected group field, got: {}", + sql + ); + assert!( + sql.contains("ORDER BY count DESC"), + "Expected ORDER BY count DESC, got: {}", + sql + ); + assert_eq!(values.len(), 1); // 仅 tenant_id + } + + #[test] + fn test_build_aggregate_sql_with_filter() { + let (sql, values) = DynamicTableManager::build_aggregate_sql( + "plugin_test_customer", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "region", + Some(serde_json::json!({"status": "active"})), + ) + .unwrap(); + assert!( + sql.contains("\"data\"->>'region'"), + "Expected group field, got: {}", + sql + ); + assert!( + sql.contains("\"data\"->>'status' ="), + "Expected filter, got: {}", + sql + ); + assert_eq!(values.len(), 2); // tenant_id + filter_value + } + + #[test] + fn test_build_aggregate_sql_sanitizes_group_field() { + let result = DynamicTableManager::build_aggregate_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "evil'; DROP TABLE--", + None, + ); + let (sql, _) = result.unwrap(); + assert!(!sql.contains("DROP TABLE"), "SQL 不应包含注入: {}", sql); + assert!( + sql.contains("evil___DROP_TABLE__"), + "字段名应被清理: {}", + sql + ); + } + + #[test] + fn test_build_aggregate_sql_empty_field_rejected() { + let result = DynamicTableManager::build_aggregate_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "", + None, + ); + assert!(result.is_err(), "空字段名应被拒绝"); + } + + // ===== build_create_table_sql (Generated Column) 测试 ===== + + #[test] + fn test_build_create_table_sql_with_generated_columns() { + use crate::manifest::{PluginEntity, PluginField, PluginFieldType}; + + let entity = PluginEntity { + name: "customer".to_string(), + display_name: "客户".to_string(), + fields: vec![ + PluginField { + name: "code".to_string(), + field_type: PluginFieldType::String, + required: true, + unique: true, + display_name: Some("编码".to_string()), + searchable: Some(true), + ..PluginField::default_for_field() + }, + PluginField { + name: "level".to_string(), + field_type: PluginFieldType::String, + filterable: Some(true), + display_name: Some("等级".to_string()), + ..PluginField::default_for_field() + }, + PluginField { + name: "sort_order".to_string(), + field_type: PluginFieldType::Integer, + sortable: Some(true), + display_name: Some("排序".to_string()), + ..PluginField::default_for_field() + }, + ], + indexes: vec![], + relations: vec![], + data_scope: None, + is_public: None, + importable: None, + exportable: None, + }; + + let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity); + assert!(sql.contains("_f_code"), "应包含 _f_code Generated Column"); + assert!(sql.contains("_f_level"), "应包含 _f_level Generated Column"); + assert!( + sql.contains("_f_sort_order"), + "应包含 _f_sort_order Generated Column" + ); + assert!( + sql.contains("GENERATED ALWAYS AS"), + "应包含 GENERATED ALWAYS AS" + ); + assert!(sql.contains("::INTEGER"), "Integer 字段应有类型转换"); + } + + #[test] + fn test_build_create_table_sql_pg_trgm_search_index() { + use crate::manifest::{PluginEntity, PluginField, PluginFieldType}; + + let entity = PluginEntity { + name: "customer".to_string(), + display_name: "客户".to_string(), + fields: vec![PluginField { + name: "name".to_string(), + field_type: PluginFieldType::String, + searchable: Some(true), + display_name: Some("名称".to_string()), + ..PluginField::default_for_field() + }], + indexes: vec![], + relations: vec![], + data_scope: None, + is_public: None, + importable: None, + exportable: None, + }; + + let sql = DynamicTableManager::build_create_table_sql("erp_crm", &entity); + assert!( + sql.contains("gin_trgm_ops"), + "searchable 字段应使用 pg_trgm GIN 索引" + ); + } + + // ===== field_reference_fn + build_filtered_query_sql_ex 测试 ===== + + #[test] + fn test_field_reference_uses_generated_column() { + let generated_fields = vec![ + "code".to_string(), + "status".to_string(), + "level".to_string(), + ]; + let ref_fn = DynamicTableManager::field_reference_fn(&generated_fields); + assert_eq!(ref_fn("code"), "\"_f_code\""); + assert_eq!(ref_fn("status"), "\"_f_status\""); + assert_eq!(ref_fn("name"), "\"data\"->>'name'"); + assert_eq!(ref_fn("remark"), "\"data\"->>'remark'"); + } + + #[test] + fn test_filtered_query_uses_generated_column_for_sort() { + let generated_fields = vec!["code".to_string(), "level".to_string()]; + let (sql, _) = DynamicTableManager::build_filtered_query_sql_ex( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + 20, + 0, + None, + None, + Some("level".to_string()), + Some("asc".to_string()), + &generated_fields, + ) + .unwrap(); + assert!( + sql.contains("ORDER BY \"_f_level\" ASC"), + "排序应使用 Generated Column,got: {}", + sql + ); + } + + #[test] + fn test_filtered_query_uses_generated_column_for_filter() { + let generated_fields = vec!["status".to_string()]; + let (sql, _) = DynamicTableManager::build_filtered_query_sql_ex( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + 20, + 0, + Some(serde_json::json!({"status": "active"})), + None, + None, + None, + &generated_fields, + ) + .unwrap(); + assert!( + sql.contains("\"_f_status\" = $"), + "过滤应使用 Generated Column,got: {}", + sql + ); + } + + // ===== Keyset Pagination 测试 ===== + + #[test] + fn test_keyset_cursor_encode_decode() { + let cursor = DynamicTableManager::encode_cursor( + &["测试值".to_string()], + &Uuid::parse_str("00000000-0000-0000-0000-000000000042").unwrap(), + ); + let decoded = DynamicTableManager::decode_cursor(&cursor).unwrap(); + assert_eq!(decoded.0, vec!["测试值"]); + assert_eq!( + decoded.1, + Uuid::parse_str("00000000-0000-0000-0000-000000000042").unwrap() + ); + } + + #[test] + fn test_keyset_sql_first_page() { + let (sql, _) = DynamicTableManager::build_keyset_query_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + 20, + None, + Some("_f_name".to_string()), + "ASC", + &[], + ) + .unwrap(); + assert!(sql.contains("ORDER BY"), "应有 ORDER BY"); + assert!(!sql.contains("ROW("), "第一页不应有 cursor 条件"); + } + + #[test] + fn test_keyset_sql_with_cursor() { + let cursor = DynamicTableManager::encode_cursor( + &["Alice".to_string()], + &Uuid::parse_str("00000000-0000-0000-0000-000000000100").unwrap(), + ); + let (sql, values) = DynamicTableManager::build_keyset_query_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + 20, + Some(cursor), + Some("_f_name".to_string()), + "ASC", + &[], + ) + .unwrap(); + assert!(sql.contains("ROW("), "cursor 条件应使用 ROW 比较"); + assert!( + values.len() >= 4, + "应有 tenant_id + cursor_val + cursor_id + limit" + ); + } + + // ===== build_data_scope_condition_with_params 测试 ===== + + #[test] + fn test_build_data_scope_condition_self() { + let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params( + "self", + &Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "owner_id", + &[], + 1, + &[], + ); + assert!( + sql.contains("\"data\"->>'owner_id'"), + "self 应包含 owner_id 条件, got: {}", + sql + ); + assert!( + sql.contains("\"created_by\""), + "self 应包含 created_by 条件, got: {}", + sql + ); + assert_eq!(values.len(), 2); + } + + #[test] + fn test_build_data_scope_condition_department() { + let dept_members = vec![ + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(), + ]; + let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params( + "department", + &Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "owner_id", + &dept_members, + 2, + &[], + ); + assert!( + sql.contains("IN"), + "department 应使用 IN 条件, got: {}", + sql + ); + assert!(sql.contains("$2"), "参数索引应从 2 开始, got: {}", sql); + assert!(sql.contains("$3"), "第二个参数索引应为 3, got: {}", sql); + assert_eq!(values.len(), 2); + } + + #[test] + fn test_build_data_scope_condition_department_empty_degrades_to_self() { + let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params( + "department", + &Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "owner_id", + &[], + 1, + &[], + ); + assert!( + sql.contains("\"created_by\""), + "空部门应退化为 self 范围, got: {}", + sql + ); + assert_eq!(values.len(), 2); + } + + #[test] + fn test_build_data_scope_condition_all() { + let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params( + "all", + &Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "owner_id", + &[], + 1, + &[], + ); + assert!(sql.is_empty(), "all 应返回空条件"); + assert!(values.is_empty(), "all 应返回空参数"); + } + + #[test] + fn test_build_data_scope_condition_department_tree() { + let dept_members = vec![ + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(), + Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(), + ]; + let (sql, values) = DynamicTableManager::build_data_scope_condition_with_params( + "department_tree", + &Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "owner_id", + &dept_members, + 5, + &[], + ); + assert!( + sql.contains("IN"), + "department_tree 应使用 IN 条件, got: {}", + sql + ); + assert_eq!(values.len(), 3); + } + + #[test] + fn test_build_data_scope_condition_with_generated_column() { + let generated = vec!["owner_id".to_string()]; + let (sql, _) = DynamicTableManager::build_data_scope_condition_with_params( + "self", + &Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "owner_id", + &[], + 1, + &generated, + ); + assert!( + sql.contains("\"_f_owner_id\""), + "generated column 应使用 _f_ 前缀, got: {}", + sql + ); + } + + // ===== build_timeseries_sql 测试 ===== + + #[test] + fn test_build_timeseries_sql_day_grain() { + let (sql, values) = DynamicTableManager::build_timeseries_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "occurred_at", + "day", + None, + None, + ) + .unwrap(); + assert!(sql.contains("date_trunc('day'"), "应有 day 粒度"); + assert!(sql.contains("GROUP BY"), "应有 GROUP BY"); + assert!(sql.contains("ORDER BY period"), "应按 period 排序"); + assert_eq!(values.len(), 1, "仅 tenant_id"); + } + + #[test] + fn test_build_timeseries_sql_month_grain() { + let (sql, _) = DynamicTableManager::build_timeseries_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "created_date", + "month", + None, + None, + ) + .unwrap(); + assert!(sql.contains("date_trunc('month'"), "应有 month 粒度"); + } + + #[test] + fn test_build_timeseries_sql_with_date_range() { + let (sql, values) = DynamicTableManager::build_timeseries_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "occurred_at", + "week", + Some("2026-01-01"), + Some("2026-04-01"), + ) + .unwrap(); + assert!(sql.contains("date_trunc('week'"), "应有 week 粒度"); + assert!(sql.contains(">="), "应有 start 条件"); + assert!(sql.contains("<"), "应有 end 条件"); + assert_eq!(values.len(), 3, "tenant_id + start + end"); + } + + #[test] + fn test_build_timeseries_sql_invalid_grain() { + let result = DynamicTableManager::build_timeseries_sql( + "plugin_test", + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + "occurred_at", + "hour", + None, + None, + ); + assert!(result.is_err(), "不支持的 grain 应报错"); + } + + // ===== sanitize_identifier SQL 注入防护测试 ===== + + #[test] + fn test_sanitize_removes_special_chars() { + let result = sanitize_identifier("table;name'here\"with`special"); + assert!(!result.contains(';'), "分号应被替换: {}", result); + assert!(!result.contains('\''), "单引号应被替换: {}", result); + assert!(!result.contains('"'), "双引号应被替换: {}", result); + assert!(!result.contains('`'), "反引号应被替换: {}", result); + } + + #[test] + fn test_sanitize_allows_alphanumeric_underscore() { + let result = sanitize_identifier("my_table_123"); + assert_eq!(result, "my_table_123", "合法标识符应原样保留"); + } + + #[test] + fn test_sanitize_handles_drop_table() { + let result = sanitize_identifier("users; DROP TABLE users;"); + assert_eq!( + result, "users__DROP_TABLE_users_", + "DROP TABLE 注入应被清理为下划线: {}", + result + ); + assert!(!result.contains(';'), "不应包含分号: {}", result); + } + + #[test] + fn test_sanitize_handles_sql_comment() { + let result = sanitize_identifier("users--"); + assert_eq!(result, "users__", "SQL 注释应被替换为下划线: {}", result); + assert!(!result.contains('-'), "不应包含连字符: {}", result); + } + + #[test] + fn test_sanitize_handles_union_injection() { + let result = sanitize_identifier("users UNION SELECT"); + assert_eq!( + result, "users_UNION_SELECT", + "UNION 注入中空格应被替换为下划线: {}", + result + ); + assert!(!result.contains(' '), "不应包含空格: {}", result); + } + + #[test] + fn test_sanitize_empty_string() { + let result = sanitize_identifier(""); + assert_eq!(result, "", "空字符串应保持为空"); + } +} diff --git a/crates/erp-plugin/src/engine.rs b/crates/erp-plugin/src/engine.rs new file mode 100644 index 0000000..710790b --- /dev/null +++ b/crates/erp-plugin/src/engine.rs @@ -0,0 +1,875 @@ +use std::collections::HashMap; +use std::panic::AssertUnwindSafe; +use std::sync::Arc; + +use dashmap::DashMap; +use sea_orm::{ + ColumnTrait, ConnectionTrait, DatabaseConnection, EntityTrait, QueryFilter, Statement, + TransactionTrait, +}; +use serde_json::json; +use tokio::sync::RwLock; +use uuid::Uuid; +use wasmtime::component::{Component, HasSelf, Linker}; +use wasmtime::{Config, Engine, Store}; + +use erp_core::events::EventBus; + +use crate::PluginWorld; +use crate::dynamic_table::DynamicTableManager; +use crate::error::{PluginError, PluginResult}; +use crate::host::{HostState, NumberingRule, PendingOp}; +use crate::manifest::PluginManifest; + +/// 从 manifest 的 numbering 声明构建 HostState 缓存映射 +fn numbering_rules_from_manifest(manifest: &PluginManifest) -> HashMap { + let mut rules = HashMap::new(); + if let Some(numbering) = &manifest.numbering { + for n in numbering { + rules.insert( + n.entity.clone(), + NumberingRule { + prefix: n.prefix.clone(), + format: n.format.clone(), + seq_length: n.seq_length, + reset_rule: format!("{:?}", n.reset_rule).to_lowercase(), + }, + ); + } + } + rules +} + +/// 插件引擎配置 +#[derive(Debug, Clone)] +pub struct PluginEngineConfig { + /// 默认 Fuel 限制 + pub default_fuel: u64, + /// 执行超时(秒) + pub execution_timeout_secs: u64, +} + +impl Default for PluginEngineConfig { + fn default() -> Self { + Self { + default_fuel: 10_000_000, + execution_timeout_secs: 30, + } + } +} + +/// 插件运行状态 +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginStatus { + /// 已加载到内存 + Loaded, + /// 已初始化(init() 已调用) + Initialized, + /// 运行中(事件监听已启动) + Running, + /// 错误状态 + Error(String), + /// 已禁用 + Disabled, +} + +/// 已加载的插件实例 +pub struct LoadedPlugin { + pub id: String, + pub manifest: PluginManifest, + pub component: Component, + pub linker: Linker, + pub status: RwLock, + pub event_handles: RwLock>>, + pub metrics: Arc>, +} + +/// 插件运行时指标 +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct RuntimeMetrics { + pub total_invocations: u64, + pub error_count: u64, + pub total_response_ms: f64, + pub fuel_consumed_total: u64, + pub memory_peak_bytes: u64, + pub last_error: Option, + pub last_invocation_at: Option>, +} + +/// WASM 执行上下文 — 传递真实的租户和用户信息 +#[derive(Debug, Clone)] +pub struct ExecutionContext { + pub tenant_id: Uuid, + pub user_id: Uuid, + pub permissions: Vec, +} + +/// 插件引擎 — 管理所有已加载插件的 WASM 运行时 +#[derive(Clone)] +pub struct PluginEngine { + engine: Arc, + db: DatabaseConnection, + event_bus: EventBus, + plugins: Arc>>, + config: PluginEngineConfig, +} + +impl PluginEngine { + /// 创建新的插件引擎 + pub fn new( + db: DatabaseConnection, + event_bus: EventBus, + config: PluginEngineConfig, + ) -> PluginResult { + let mut wasm_config = Config::new(); + wasm_config.wasm_component_model(true); + wasm_config.consume_fuel(true); + let engine = Engine::new(&wasm_config) + .map_err(|e| PluginError::InstantiationError(e.to_string()))?; + + Ok(Self { + engine: Arc::new(engine), + db, + event_bus, + plugins: Arc::new(DashMap::new()), + config, + }) + } + + /// 加载插件到内存(不初始化) + pub async fn load( + &self, + plugin_id: &str, + wasm_bytes: &[u8], + manifest: PluginManifest, + ) -> PluginResult<()> { + if self.plugins.contains_key(plugin_id) { + return Err(PluginError::AlreadyExists(plugin_id.to_string())); + } + + let component = Component::from_binary(&self.engine, wasm_bytes) + .map_err(|e| PluginError::InstantiationError(e.to_string()))?; + + let mut linker = Linker::new(&self.engine); + // 注册 Host API 到 Linker + PluginWorld::add_to_linker::<_, HasSelf>(&mut linker, |state| state) + .map_err(|e| PluginError::InstantiationError(e.to_string()))?; + + let loaded = Arc::new(LoadedPlugin { + id: plugin_id.to_string(), + manifest, + component, + linker, + status: RwLock::new(PluginStatus::Loaded), + event_handles: RwLock::new(vec![]), + metrics: Arc::new(RwLock::new(RuntimeMetrics::default())), + }); + + self.plugins.insert(plugin_id.to_string(), loaded); + tracing::info!(plugin_id, "Plugin loaded into memory"); + Ok(()) + } + + /// 初始化插件(调用 init()) + pub async fn initialize(&self, plugin_id: &str) -> PluginResult<()> { + let loaded = self.get_loaded(plugin_id)?; + + // 检查状态 + { + let status = loaded.status.read().await; + if *status != PluginStatus::Loaded { + return Err(PluginError::InvalidState { + expected: "Loaded".to_string(), + actual: format!("{:?}", *status), + }); + } + } + + let ctx = ExecutionContext { + tenant_id: Uuid::nil(), + user_id: Uuid::nil(), + permissions: vec![], + }; + + let result = self + .execute_wasm(plugin_id, &ctx, |store, instance| { + instance + .erp_plugin_plugin_api() + .call_init(store) + .map_err(|e| PluginError::ExecutionError(e.to_string()))? + .map_err(PluginError::ExecutionError)?; + Ok(()) + }) + .await; + + match result { + Ok(()) => { + *loaded.status.write().await = PluginStatus::Initialized; + tracing::info!(plugin_id, "Plugin initialized"); + Ok(()) + } + Err(e) => { + *loaded.status.write().await = PluginStatus::Error(e.to_string()); + Err(e) + } + } + } + + /// 启动事件监听 + pub async fn start_event_listener(&self, plugin_id: &str) -> PluginResult<()> { + let loaded = self.get_loaded(plugin_id)?; + + // 检查状态 + { + let status = loaded.status.read().await; + if *status != PluginStatus::Initialized { + return Err(PluginError::InvalidState { + expected: "Initialized".to_string(), + actual: format!("{:?}", *status), + }); + } + } + + let events_config = &loaded.manifest.events; + if let Some(events) = events_config { + for pattern in &events.subscribe { + let (mut rx, sub_handle) = self.event_bus.subscribe_filtered(pattern.clone()); + let pid = plugin_id.to_string(); + let engine = self.clone(); + + let join_handle = tokio::spawn(async move { + // sub_handle 保存在此 task 中,task 结束时自动 drop 触发优雅取消 + let _sub_guard = sub_handle; + while let Some(event) = rx.recv().await { + if let Err(e) = engine + .handle_event_inner( + &pid, + &event.event_type, + &event.payload, + event.tenant_id, + ) + .await + { + tracing::error!( + plugin_id = %pid, + error = %e, + "Plugin event handler failed" + ); + } + } + }); + + loaded.event_handles.write().await.push(join_handle); + } + } + + *loaded.status.write().await = PluginStatus::Running; + tracing::info!(plugin_id, "Plugin event listener started"); + Ok(()) + } + + /// 处理单个事件 + pub async fn handle_event( + &self, + plugin_id: &str, + event_type: &str, + payload: &serde_json::Value, + tenant_id: Uuid, + ) -> PluginResult<()> { + self.handle_event_inner(plugin_id, event_type, payload, tenant_id) + .await + } + + async fn handle_event_inner( + &self, + plugin_id: &str, + event_type: &str, + payload: &serde_json::Value, + tenant_id: Uuid, + ) -> PluginResult<()> { + let payload_bytes = serde_json::to_vec(payload).unwrap_or_default(); + let event_type = event_type.to_owned(); + + let ctx = ExecutionContext { + tenant_id, + user_id: Uuid::nil(), + permissions: vec![], + }; + + self.execute_wasm(plugin_id, &ctx, move |store, instance| { + instance + .erp_plugin_plugin_api() + .call_handle_event(store, &event_type, &payload_bytes) + .map_err(|e| PluginError::ExecutionError(e.to_string()))? + .map_err(PluginError::ExecutionError)?; + Ok(()) + }) + .await + } + + /// 租户创建时调用插件的 on_tenant_created + pub async fn on_tenant_created(&self, plugin_id: &str, tenant_id: Uuid) -> PluginResult<()> { + let tenant_id_str = tenant_id.to_string(); + + let ctx = ExecutionContext { + tenant_id, + user_id: Uuid::nil(), + permissions: vec![], + }; + + self.execute_wasm(plugin_id, &ctx, move |store, instance| { + instance + .erp_plugin_plugin_api() + .call_on_tenant_created(store, &tenant_id_str) + .map_err(|e| PluginError::ExecutionError(e.to_string()))? + .map_err(PluginError::ExecutionError)?; + Ok(()) + }) + .await + } + + /// 禁用插件(停止事件监听 + 更新状态) + pub async fn disable(&self, plugin_id: &str) -> PluginResult<()> { + let loaded = self.get_loaded(plugin_id)?; + + // 取消所有事件监听 + let mut handles = loaded.event_handles.write().await; + for handle in handles.drain(..) { + handle.abort(); + } + drop(handles); + + *loaded.status.write().await = PluginStatus::Disabled; + tracing::info!(plugin_id, "Plugin disabled"); + Ok(()) + } + + /// 从内存卸载插件 + pub async fn unload(&self, plugin_id: &str) -> PluginResult<()> { + if self.plugins.contains_key(plugin_id) { + self.disable(plugin_id).await.ok(); + } + self.plugins.remove(plugin_id); + tracing::info!(plugin_id, "Plugin unloaded"); + Ok(()) + } + + /// 将插件从一个 key 重命名为另一个 key(用于热更新的原子替换) + pub async fn rename_plugin(&self, old_id: &str, new_id: &str) -> PluginResult<()> { + let (_, loaded) = self + .plugins + .remove(old_id) + .ok_or_else(|| PluginError::NotFound(old_id.to_string()))?; + let mut loaded = Arc::try_unwrap(loaded) + .map_err(|_| PluginError::ExecutionError("插件仍被引用,无法重命名".to_string()))?; + loaded.id = new_id.to_string(); + self.plugins.insert(new_id.to_string(), Arc::new(loaded)); + tracing::info!(old_id, new_id, "Plugin renamed"); + Ok(()) + } + + /// 健康检查 + pub async fn health_check(&self, plugin_id: &str) -> PluginResult { + let loaded = self.get_loaded(plugin_id)?; + let status = loaded.status.read().await; + match &*status { + PluginStatus::Running => Ok(json!({ + "status": "healthy", + "plugin_id": plugin_id, + })), + PluginStatus::Error(e) => Ok(json!({ + "status": "error", + "plugin_id": plugin_id, + "error": e, + })), + other => Ok(json!({ + "status": "unhealthy", + "plugin_id": plugin_id, + "state": format!("{:?}", other), + })), + } + } + + /// 列出所有已加载插件的信息 + pub fn list_plugins(&self) -> Vec { + self.plugins + .iter() + .map(|entry| { + let loaded = entry.value(); + PluginInfo { + id: loaded.id.clone(), + name: loaded.manifest.metadata.name.clone(), + version: loaded.manifest.metadata.version.clone(), + } + }) + .collect() + } + + /// 获取插件清单 + pub fn get_manifest(&self, plugin_id: &str) -> Option { + self.plugins + .get(plugin_id) + .map(|entry| entry.manifest.clone()) + } + + /// 获取插件运行时指标 + pub async fn get_metrics(&self, plugin_id: &str) -> PluginResult { + let loaded = self.get_loaded(plugin_id)?; + let metrics = loaded.metrics.read().await; + Ok(metrics.clone()) + } + + /// 刷新插件内存配置(配置变更后调用) + pub async fn refresh_config(&self, plugin_id: &str) -> PluginResult<()> { + // 扫描所有已加载插件,找到匹配 manifest_id 的插件 + for entry in self.plugins.iter() { + if entry.value().id == plugin_id { + // 配置会在下次 execute_wasm 时从数据库自动重新加载 + // 这里只清理可能缓存的旧配置 + tracing::info!( + plugin_id, + "Plugin config refresh scheduled (loaded on next invocation)" + ); + return Ok(()); + } + } + Ok(()) + } + + /// 检查插件是否正在运行 + pub async fn is_running(&self, plugin_id: &str) -> bool { + if let Some(loaded) = self.plugins.get(plugin_id) { + matches!(*loaded.status.read().await, PluginStatus::Running) + } else { + false + } + } + + /// 恢复数据库中状态为 running/enabled 的插件。 + /// + /// 服务器重启后调用此方法,重新加载 WASM 到内存并启动事件监听。 + pub async fn recover_plugins(&self, db: &DatabaseConnection) -> PluginResult> { + use crate::entity::plugin; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + // 查询所有运行中的插件 + let running_plugins = plugin::Entity::find() + .filter(plugin::Column::Status.eq("running")) + .filter(plugin::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| PluginError::DatabaseError(e.to_string()))?; + + let mut recovered = Vec::new(); + for model in running_plugins { + let tenant_id = model.tenant_id; + let manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone()) + .map_err(|e| PluginError::InvalidManifest(e.to_string()))?; + let plugin_id_str = &manifest.metadata.id; + + // 跳过已被其他租户加载的同 ID 插件(WASM 二进制相同,数据隔离在 DB 层) + if self.plugins.contains_key(plugin_id_str) { + tracing::info!( + plugin_id = %plugin_id_str, + tenant_id = %tenant_id, + "Plugin already loaded by another tenant, skipping duplicate load" + ); + recovered.push(plugin_id_str.clone()); + continue; + } + + // 加载 WASM 到内存 + if let Err(e) = self + .load(plugin_id_str, &model.wasm_binary, manifest.clone()) + .await + { + tracing::error!( + plugin_id = %plugin_id_str, + tenant_id = %tenant_id, + error = %e, + "Failed to recover plugin (load)" + ); + continue; + } + + // 初始化 + if let Err(e) = self.initialize(plugin_id_str).await { + tracing::error!( + plugin_id = %plugin_id_str, + tenant_id = %tenant_id, + error = %e, + "Failed to recover plugin (initialize)" + ); + continue; + } + + // 启动事件监听 + if let Err(e) = self.start_event_listener(plugin_id_str).await { + tracing::error!( + plugin_id = %plugin_id_str, + tenant_id = %tenant_id, + error = %e, + "Failed to recover plugin (start_event_listener)" + ); + continue; + } + + tracing::info!( + plugin_id = %plugin_id_str, + tenant_id = %tenant_id, + "Plugin recovered" + ); + recovered.push(plugin_id_str.clone()); + } + + tracing::info!(count = recovered.len(), "Plugins recovered"); + Ok(recovered) + } + + // ---- 内部方法 ---- + + fn get_loaded(&self, plugin_id: &str) -> PluginResult> { + self.plugins + .get(plugin_id) + .map(|e| e.value().clone()) + .ok_or_else(|| PluginError::NotFound(plugin_id.to_string())) + } + + /// 在 spawn_blocking + catch_unwind + fuel + timeout 中执行 WASM 操作, + /// 执行完成后自动刷新 pending_ops 到数据库。 + async fn execute_wasm( + &self, + plugin_id: &str, + exec_ctx: &ExecutionContext, + operation: F, + ) -> PluginResult + where + F: FnOnce(&mut Store, &PluginWorld) -> PluginResult + + Send + + std::panic::UnwindSafe + + 'static, + R: Send + 'static, + { + let loaded = self.get_loaded(plugin_id)?; + + // 构建跨插件实体映射(从 manifest 的 ref_plugin 字段提取) + let cross_plugin_entities = + Self::build_cross_plugin_map(&loaded.manifest, &self.db, exec_ctx.tenant_id).await; + + // 加载插件配置(从数据库) + let plugin_config = Self::load_plugin_config(plugin_id, exec_ctx.tenant_id, &self.db).await; + + // 创建新的 Store + HostState,使用真实的租户/用户上下文 + // 传入 db 和 event_bus 启用混合执行模式(插件可自主查询数据) + let mut state = HostState::new_with_db( + plugin_id.to_string(), + exec_ctx.tenant_id, + exec_ctx.user_id, + exec_ctx.permissions.clone(), + self.db.clone(), + self.event_bus.clone(), + ); + state.cross_plugin_entities = cross_plugin_entities; + // 注入编号规则和插件配置 + state.numbering_rules = numbering_rules_from_manifest(&loaded.manifest); + state.plugin_config = plugin_config; + let mut store = Store::new(&self.engine, state); + store + .set_fuel(self.config.default_fuel) + .map_err(|e| PluginError::ExecutionError(e.to_string()))?; + store.limiter(|state| &mut state.limits); + + // 实例化 + let instance = + PluginWorld::instantiate_async(&mut store, &loaded.component, &loaded.linker) + .await + .map_err(|e| PluginError::InstantiationError(e.to_string()))?; + + let timeout_secs = self.config.execution_timeout_secs; + let pid_owned = plugin_id.to_owned(); + let start = std::time::Instant::now(); + + // spawn_blocking 闭包执行 WASM,正常完成时收集 pending_ops + let (result, pending_ops): (PluginResult, Vec) = tokio::time::timeout( + std::time::Duration::from_secs(timeout_secs), + tokio::task::spawn_blocking(move || { + match std::panic::catch_unwind(AssertUnwindSafe(|| { + let r = operation(&mut store, &instance); + // catch_unwind 内部不能调用 into_data(需要 &mut self), + // 但这里 operation 已完成,store 仍可用 + let ops = std::mem::take(&mut store.data_mut().pending_ops); + (r, ops) + })) { + Ok((r, ops)) => (r, ops), + Err(_) => { + // panic 后丢弃所有 pending_ops,避免半完成状态写入数据库 + tracing::warn!(plugin = %pid_owned, "WASM panic, discarding pending ops"); + ( + Err(PluginError::ExecutionError("WASM panic".to_string())), + Vec::new(), + ) + } + } + }), + ) + .await + .map_err(|_| PluginError::ExecutionError(format!("插件执行超时 ({}s)", timeout_secs)))? + .map_err(|e| PluginError::ExecutionError(e.to_string()))?; + + // 更新运行时指标 + let elapsed_ms = start.elapsed().as_millis() as f64; + { + let mut metrics = loaded.metrics.write().await; + metrics.total_invocations += 1; + metrics.total_response_ms += elapsed_ms; + metrics.last_invocation_at = Some(chrono::Utc::now()); + if result.is_err() { + metrics.error_count += 1; + metrics.last_error = result.as_ref().err().map(|e| e.to_string()); + } + } + + // 刷新写操作到数据库 + Self::flush_ops( + &self.db, + plugin_id, + pending_ops, + exec_ctx.tenant_id, + exec_ctx.user_id, + &self.event_bus, + ) + .await?; + + result + } + + /// 从数据库加载插件配置(通过 manifest metadata.id 匹配) + fn load_plugin_config( + plugin_id: &str, + tenant_id: Uuid, + db: &DatabaseConnection, + ) -> std::pin::Pin + Send + 'static>> + { + let db = db.clone(); + let pid = plugin_id.to_string(); + Box::pin(async move { + use sea_orm::FromQueryResult; + #[derive(Debug, FromQueryResult)] + struct ConfigRow { + config_json: serde_json::Value, + } + ConfigRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "SELECT config_json FROM plugins WHERE tenant_id = $1\n\ + AND deleted_at IS NULL\n\ + AND manifest_json->'metadata'->>'id' = $2\n\ + LIMIT 1", + [tenant_id.into(), pid.into()], + )) + .one(&db) + .await + .ok() + .flatten() + .map(|r| r.config_json) + .unwrap_or_default() + }) + } + + /// 从 manifest 的 ref_plugin 字段构建跨插件实体映射 + /// 返回: { "erp-crm.customer" → "plugin_erp_crm__customer", ... } + async fn build_cross_plugin_map( + manifest: &crate::manifest::PluginManifest, + db: &DatabaseConnection, + tenant_id: Uuid, + ) -> HashMap { + let mut map = HashMap::new(); + let Some(schema) = &manifest.schema else { + return map; + }; + + for entity in &schema.entities { + for field in &entity.fields { + if let (Some(target_plugin), Some(ref_entity)) = + (&field.ref_plugin, &field.ref_entity) + { + let key = format!("{}.{}", target_plugin, ref_entity); + // 从 plugin_entities 表查找目标表名 + let table_name = crate::entity::plugin_entity::Entity::find() + .filter( + crate::entity::plugin_entity::Column::ManifestId + .eq(target_plugin.as_str()), + ) + .filter( + crate::entity::plugin_entity::Column::EntityName + .eq(ref_entity.as_str()), + ) + .filter(crate::entity::plugin_entity::Column::TenantId.eq(tenant_id)) + .filter(crate::entity::plugin_entity::Column::DeletedAt.is_null()) + .one(db) + .await + .ok() + .flatten() + .map(|e| e.table_name); + + if let Some(tn) = table_name { + map.insert(key, tn); + } + } + } + } + + map + } + + /// 刷新 HostState 中的 pending_ops 到数据库。 + /// + /// 使用事务包裹所有数据库操作确保原子性。 + /// 事件发布在事务提交后执行(best-effort)。 + pub(crate) async fn flush_ops( + db: &DatabaseConnection, + plugin_id: &str, + ops: Vec, + tenant_id: Uuid, + user_id: Uuid, + event_bus: &EventBus, + ) -> PluginResult<()> { + if ops.is_empty() { + return Ok(()); + } + + // 使用事务确保所有数据库操作的原子性 + let txn = db + .begin() + .await + .map_err(|e| PluginError::DatabaseError(e.to_string()))?; + + for op in &ops { + match op { + PendingOp::Insert { id, entity, data } => { + let table_name = DynamicTableManager::table_name(plugin_id, entity); + let parsed_data: serde_json::Value = + serde_json::from_slice(data).unwrap_or_default(); + let id_uuid = id + .parse::() + .map_err(|e| PluginError::ExecutionError(format!("无效的 ID: {}", e)))?; + let (sql, values) = DynamicTableManager::build_insert_sql_with_id( + &table_name, + id_uuid, + tenant_id, + user_id, + &parsed_data, + ); + txn.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .await + .map_err(|e| PluginError::DatabaseError(e.to_string()))?; + + tracing::debug!( + plugin_id, + entity = %entity, + "Flushed INSERT op" + ); + } + PendingOp::Update { + entity, + id, + data, + version, + } => { + let table_name = DynamicTableManager::table_name(plugin_id, entity); + let parsed_data: serde_json::Value = + serde_json::from_slice(data).unwrap_or_default(); + let id_uuid = id + .parse::() + .map_err(|e| PluginError::ExecutionError(format!("无效的 ID: {}", e)))?; + let (sql, values) = DynamicTableManager::build_update_sql( + &table_name, + id_uuid, + tenant_id, + user_id, + &parsed_data, + *version as i32, + ); + txn.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .await + .map_err(|e| PluginError::DatabaseError(e.to_string()))?; + + tracing::debug!( + plugin_id, + entity = %entity, + id = %id, + "Flushed UPDATE op" + ); + } + PendingOp::Delete { entity, id } => { + let table_name = DynamicTableManager::table_name(plugin_id, entity); + let id_uuid = id + .parse::() + .map_err(|e| PluginError::ExecutionError(format!("无效的 ID: {}", e)))?; + let (sql, values) = + DynamicTableManager::build_delete_sql(&table_name, id_uuid, tenant_id); + txn.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .await + .map_err(|e| PluginError::DatabaseError(e.to_string()))?; + + tracing::debug!( + plugin_id, + entity = %entity, + id = %id, + "Flushed DELETE op" + ); + } + PendingOp::PublishEvent { .. } => { + // 事件发布在事务提交后处理 + } + } + } + + // 提交事务 + txn.commit() + .await + .map_err(|e| PluginError::DatabaseError(e.to_string()))?; + + // 事务提交成功后发布事件(best-effort,不阻塞主流程) + for op in ops { + if let PendingOp::PublishEvent { + event_type, + payload, + } = op + { + let parsed_payload: serde_json::Value = + serde_json::from_slice(&payload).unwrap_or_default(); + let event = + erp_core::events::DomainEvent::new(&event_type, tenant_id, parsed_payload); + event_bus.publish(event, db).await; + + tracing::debug!( + plugin_id, + event_type = %event_type, + "Flushed PUBLISH_EVENT op" + ); + } + } + + Ok(()) + } +} + +/// 插件信息摘要 +#[derive(Debug, Clone, serde::Serialize)] +pub struct PluginInfo { + pub id: String, + pub name: String, + pub version: String, +} diff --git a/crates/erp-plugin/src/entity/market_entry.rs b/crates/erp-plugin/src/entity/market_entry.rs new file mode 100644 index 0000000..0b3f840 --- /dev/null +++ b/crates/erp-plugin/src/entity/market_entry.rs @@ -0,0 +1,45 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "plugin_market_entries")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub plugin_id: String, + pub name: String, + pub version: String, + pub description: Option, + pub author: Option, + pub category: Option, + pub tags: Option, + pub icon_url: Option, + pub screenshots: Option, + #[serde(skip)] + pub wasm_binary: Vec, + #[serde(skip_serializing)] + pub manifest_toml: String, + pub wasm_hash: String, + pub min_platform_version: Option, + pub status: String, + pub download_count: i32, + pub rating_avg: Decimal, + pub rating_count: i32, + pub changelog: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::market_review::Entity")] + MarketReview, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MarketReview.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-plugin/src/entity/market_review.rs b/crates/erp-plugin/src/entity/market_review.rs new file mode 100644 index 0000000..b80d9ce --- /dev/null +++ b/crates/erp-plugin/src/entity/market_review.rs @@ -0,0 +1,33 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "plugin_market_reviews")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub user_id: Uuid, + pub market_entry_id: Uuid, + pub rating: i32, + pub review_text: Option, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::market_entry::Entity", + from = "Column::MarketEntryId", + to = "super::market_entry::Column::Id" + )] + MarketEntry, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::MarketEntry.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-plugin/src/entity/mod.rs b/crates/erp-plugin/src/entity/mod.rs new file mode 100644 index 0000000..4febd16 --- /dev/null +++ b/crates/erp-plugin/src/entity/mod.rs @@ -0,0 +1,5 @@ +pub mod market_entry; +pub mod market_review; +pub mod plugin; +pub mod plugin_entity; +pub mod plugin_event_subscription; diff --git a/crates/erp-plugin/src/entity/plugin.rs b/crates/erp-plugin/src/entity/plugin.rs new file mode 100644 index 0000000..a867b9e --- /dev/null +++ b/crates/erp-plugin/src/entity/plugin.rs @@ -0,0 +1,54 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "plugins")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub name: String, + #[sea_orm(column_name = "plugin_version")] + pub plugin_version: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + pub status: String, + pub manifest_json: serde_json::Value, + #[serde(skip)] + pub wasm_binary: Vec, + pub wasm_hash: String, + pub config_json: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub installed_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled_at: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::plugin_entity::Entity")] + PluginEntity, + #[sea_orm(has_many = "super::plugin_event_subscription::Entity")] + PluginEventSubscription, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::PluginEntity.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-plugin/src/entity/plugin_entity.rs b/crates/erp-plugin/src/entity/plugin_entity.rs new file mode 100644 index 0000000..af24934 --- /dev/null +++ b/crates/erp-plugin/src/entity/plugin_entity.rs @@ -0,0 +1,43 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "plugin_entities")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub plugin_id: Uuid, + pub entity_name: String, + pub table_name: String, + pub schema_json: serde_json::Value, + pub manifest_id: String, + pub is_public: bool, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::plugin::Entity", + from = "Column::PluginId", + to = "super::plugin::Column::Id" + )] + Plugin, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Plugin.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-plugin/src/entity/plugin_event_subscription.rs b/crates/erp-plugin/src/entity/plugin_event_subscription.rs new file mode 100644 index 0000000..de73dc1 --- /dev/null +++ b/crates/erp-plugin/src/entity/plugin_event_subscription.rs @@ -0,0 +1,30 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "plugin_event_subscriptions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub plugin_id: Uuid, + pub event_pattern: String, + pub created_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::plugin::Entity", + from = "Column::PluginId", + to = "super::plugin::Column::Id" + )] + Plugin, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Plugin.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-plugin/src/error.rs b/crates/erp-plugin/src/error.rs new file mode 100644 index 0000000..3c95628 --- /dev/null +++ b/crates/erp-plugin/src/error.rs @@ -0,0 +1,55 @@ +use erp_core::error::AppError; + +/// 插件模块错误类型 +#[derive(Debug, thiserror::Error)] +pub enum PluginError { + #[error("插件未找到: {0}")] + NotFound(String), + + #[error("插件已存在: {0}")] + AlreadyExists(String), + + #[error("无效的插件清单: {0}")] + InvalidManifest(String), + + #[error("无效的插件状态: 期望 {expected}, 实际 {actual}")] + InvalidState { expected: String, actual: String }, + + #[error("插件执行错误: {0}")] + ExecutionError(String), + + #[error("插件实例化错误: {0}")] + InstantiationError(String), + + #[error("插件 Fuel 耗尽: {0}")] + FuelExhausted(String), + + #[error("依赖未满足: {0}")] + DependencyNotSatisfied(String), + + #[error("数据库错误: {0}")] + DatabaseError(String), + + #[error("权限不足: {0}")] + PermissionDenied(String), + + #[error("配置校验失败: {0}")] + ValidationError(String), +} + +impl From for AppError { + fn from(err: PluginError) -> Self { + match &err { + PluginError::NotFound(_) => AppError::NotFound(err.to_string()), + PluginError::AlreadyExists(_) => AppError::Conflict(err.to_string()), + PluginError::InvalidManifest(_) + | PluginError::InvalidState { .. } + | PluginError::DependencyNotSatisfied(_) + | PluginError::ValidationError(_) => AppError::Validation(err.to_string()), + PluginError::PermissionDenied(_) => AppError::Forbidden(err.to_string()), + _ => AppError::Internal(err.to_string()), + } + } +} + +pub type PluginResult = Result; diff --git a/crates/erp-plugin/src/handler/data_handler.rs b/crates/erp-plugin/src/handler/data_handler.rs new file mode 100644 index 0000000..85efe4c --- /dev/null +++ b/crates/erp-plugin/src/handler/data_handler.rs @@ -0,0 +1,1121 @@ +use axum::Extension; +use axum::extract::{FromRef, Path, Query, State}; +use axum::response::Json; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::data_dto::{ + AggregateItem, AggregateMultiReq, AggregateMultiRow, AggregateQueryParams, BatchActionReq, + CountQueryParams, CreatePluginDataReq, ExportParams, ImportReq, ImportResult, + PatchPluginDataReq, PluginDataListParams, PluginDataResp, PublicEntityResp, + ReconciliationReport, ResolveLabelsReq, ResolveLabelsResp, TimeseriesItem, TimeseriesParams, + UpdatePluginDataReq, UserViewReq, UserViewResp, +}; +use crate::data_service::{DataScopeParams, PluginDataService, resolve_manifest_id}; +use crate::state::PluginState; +use sea_orm::{ConnectionTrait, Statement}; + +/// 获取当前用户对指定权限的 data_scope 等级 +/// +/// 查询 user_roles -> role_permissions -> permissions 链路, +/// 返回匹配权限的 data_scope 设置,默认 "all"。 +async fn get_data_scope( + ctx: &TenantContext, + permission_code: &str, + db: &sea_orm::DatabaseConnection, +) -> Result { + use sea_orm::{FromQueryResult, Statement}; + + #[derive(FromQueryResult)] + struct ScopeResult { + data_scope: Option, + } + + let result = ScopeResult::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + r#"SELECT rp.data_scope + FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE ur.user_id = $1 AND ur.tenant_id = $2 AND p.code = $3 + LIMIT 1"#, + [ + ctx.user_id.into(), + ctx.tenant_id.into(), + permission_code.into(), + ], + )) + .one(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + Ok(result + .and_then(|r| r.data_scope) + .unwrap_or_else(|| "all".to_string())) +} + +/// 获取部门成员 ID 列表 +/// +/// 当前返回 TenantContext 中的 department_ids。 +/// 未来实现递归查询部门树时将支持 include_sub_depts 参数。 +async fn get_dept_members(ctx: &TenantContext, _include_sub_depts: bool) -> Vec { + // 当前 department_ids 为空时返回空列表 + // 未来实现递归查询部门树 + if ctx.department_ids.is_empty() { + return vec![]; + } + ctx.department_ids.clone() +} + +/// 计算插件数据操作所需的权限码 +/// 格式:{manifest_id}.{entity}.{action},如 erp-crm.customer.list +fn compute_permission_code(manifest_id: &str, entity_name: &str, action: &str) -> String { + let action_suffix = match action { + "list" | "get" => "list", + _ => "manage", + }; + format!("{}.{}.{}", manifest_id, entity_name, action_suffix) +} + +#[utoipa::path( + get, + path = "/api/v1/plugins/{plugin_id}/{entity}", + params(PluginDataListParams), + responses( + (status = 200, description = "成功", body = ApiResponse>), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// GET /api/v1/plugins/{plugin_id}/{entity} — 列表 +pub async fn list_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Query(params): Query, +) -> Result>>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let fine_perm = compute_permission_code(&manifest_id, &entity, "list"); + require_permission(&ctx, &fine_perm)?; + + // 解析数据权限范围 + let scope = resolve_data_scope(&ctx, &manifest_id, &entity, &fine_perm, &state.db).await?; + + let page = params.page.unwrap_or(1); + let page_size = params.page_size.unwrap_or(20); + + // 解析 filter JSON + let filter: Option = params + .filter + .as_ref() + .and_then(|f| serde_json::from_str(f).ok()); + + let (items, total) = PluginDataService::list( + plugin_id, + &entity, + ctx.tenant_id, + page, + page_size, + &state.db, + filter, + params.search, + params.sort_by, + params.sort_order, + &state.entity_cache, + scope, + ) + .await?; + + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: items, + total, + page, + page_size, + total_pages: (total as f64 / page_size as f64).ceil() as u64, + }))) +} + +#[utoipa::path( + post, + path = "/api/v1/plugins/{plugin_id}/{entity}", + request_body = CreatePluginDataReq, + responses( + (status = 200, description = "创建成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// POST /api/v1/plugins/{plugin_id}/{entity} — 创建 +pub async fn create_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Json(req): Json, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let fine_perm = compute_permission_code(&manifest_id, &entity, "create"); + require_permission(&ctx, &fine_perm)?; + + let result = PluginDataService::create( + plugin_id, + &entity, + ctx.tenant_id, + ctx.user_id, + req.data, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(result))) +} + +#[utoipa::path( + get, + path = "/api/v1/plugins/{plugin_id}/{entity}/{id}", + responses( + (status = 200, description = "成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// GET /api/v1/plugins/{plugin_id}/{entity}/{id} — 详情 +pub async fn get_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let fine_perm = compute_permission_code(&manifest_id, &entity, "get"); + require_permission(&ctx, &fine_perm)?; + + let result = + PluginDataService::get_by_id(plugin_id, &entity, id, ctx.tenant_id, &state.db).await?; + + Ok(Json(ApiResponse::ok(result))) +} + +#[utoipa::path( + put, + path = "/api/v1/plugins/{plugin_id}/{entity}/{id}", + request_body = UpdatePluginDataReq, + responses( + (status = 200, description = "更新成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// PUT /api/v1/plugins/{plugin_id}/{entity}/{id} — 更新 +pub async fn update_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>, + Json(req): Json, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let fine_perm = compute_permission_code(&manifest_id, &entity, "update"); + require_permission(&ctx, &fine_perm)?; + + let result = PluginDataService::update( + plugin_id, + &entity, + id, + ctx.tenant_id, + ctx.user_id, + req.data, + req.version, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(result))) +} + +#[utoipa::path( + patch, + path = "/api/v1/plugins/{plugin_id}/{entity}/{id}", + request_body = PatchPluginDataReq, + responses( + (status = 200, description = "部分更新成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// PATCH /api/v1/plugins/{plugin_id}/{entity}/{id} — 部分更新(jsonb_set 合并字段) +pub async fn patch_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>, + Json(req): Json, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let fine_perm = compute_permission_code(&manifest_id, &entity, "update"); + require_permission(&ctx, &fine_perm)?; + + let result = PluginDataService::partial_update( + plugin_id, + &entity, + id, + ctx.tenant_id, + ctx.user_id, + req.data, + req.version, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse::ok(result))) +} + +#[utoipa::path( + delete, + path = "/api/v1/plugins/{plugin_id}/{entity}/{id}", + responses( + (status = 200, description = "删除成功"), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// DELETE /api/v1/plugins/{plugin_id}/{entity}/{id} — 删除 +pub async fn delete_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity, id)): Path<(Uuid, String, Uuid)>, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let fine_perm = compute_permission_code(&manifest_id, &entity, "delete"); + require_permission(&ctx, &fine_perm)?; + + PluginDataService::delete( + plugin_id, + &entity, + id, + ctx.tenant_id, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(()))) +} + +#[utoipa::path( + post, + path = "/api/v1/plugins/{plugin_id}/{entity}/batch", + request_body = BatchActionReq, + responses( + (status = 200, description = "批量操作成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// POST /api/v1/plugins/{plugin_id}/{entity}/batch — 批量操作 (batch_delete / batch_update) +pub async fn batch_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Json(req): Json, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let action_perm = match req.action.as_str() { + "batch_delete" => "delete", + "batch_update" => "update", + _ => "update", + }; + let fine_perm = compute_permission_code(&manifest_id, &entity, action_perm); + require_permission(&ctx, &fine_perm)?; + + let affected = PluginDataService::batch( + plugin_id, + &entity, + ctx.tenant_id, + ctx.user_id, + req, + &state.db, + ) + .await?; + + Ok(Json(ApiResponse::ok(affected))) +} + +#[utoipa::path( + get, + path = "/api/v1/plugins/{plugin_id}/{entity}/count", + params(CountQueryParams), + responses( + (status = 200, description = "成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// GET /api/v1/plugins/{plugin_id}/{entity}/count — 统计计数 +pub async fn count_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Query(params): Query, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let fine_perm = compute_permission_code(&manifest_id, &entity, "list"); + require_permission(&ctx, &fine_perm)?; + + // 解析数据权限范围 + let scope = resolve_data_scope(&ctx, &manifest_id, &entity, &fine_perm, &state.db).await?; + + // 解析 filter JSON + let filter: Option = params + .filter + .as_ref() + .and_then(|f| serde_json::from_str(f).ok()); + + let total = PluginDataService::count( + plugin_id, + &entity, + ctx.tenant_id, + &state.db, + filter, + params.search, + scope, + ) + .await?; + + Ok(Json(ApiResponse::ok(total))) +} + +#[utoipa::path( + get, + path = "/api/v1/plugins/{plugin_id}/{entity}/aggregate", + params(AggregateQueryParams), + responses( + (status = 200, description = "成功", body = ApiResponse>), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// GET /api/v1/plugins/{plugin_id}/{entity}/aggregate — 聚合查询 +pub async fn aggregate_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Query(params): Query, +) -> Result>>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let fine_perm = compute_permission_code(&manifest_id, &entity, "list"); + require_permission(&ctx, &fine_perm)?; + + // 解析数据权限范围 + let scope = resolve_data_scope(&ctx, &manifest_id, &entity, &fine_perm, &state.db).await?; + + // 解析 filter JSON + let filter: Option = params + .filter + .as_ref() + .and_then(|f| serde_json::from_str(f).ok()); + + let rows = PluginDataService::aggregate( + plugin_id, + &entity, + ctx.tenant_id, + &state.db, + ¶ms.group_by, + filter, + scope, + ) + .await?; + + let items = rows + .into_iter() + .map(|(key, count)| AggregateItem { key, count }) + .collect(); + + Ok(Json(ApiResponse::ok(items))) +} + +#[utoipa::path( + get, + path = "/api/v1/plugins/{plugin_id}/{entity}/timeseries", + params(TimeseriesParams), + responses( + (status = 200, description = "时间序列数据", body = ApiResponse>), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// GET /api/v1/plugins/{plugin_id}/{entity}/timeseries — 时间序列聚合 +pub async fn get_plugin_timeseries( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Query(params): Query, +) -> Result>>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let fine_perm = compute_permission_code(&manifest_id, &entity, "list"); + require_permission(&ctx, &fine_perm)?; + + // 解析数据权限范围 + let scope = resolve_data_scope(&ctx, &manifest_id, &entity, &fine_perm, &state.db).await?; + + let result = PluginDataService::timeseries( + plugin_id, + &entity, + ctx.tenant_id, + &state.db, + ¶ms.time_field, + ¶ms.time_grain, + params.start, + params.end, + scope, + ) + .await?; + + Ok(Json(ApiResponse::ok(result))) +} + +/// 解析数据权限范围 — 检查 entity 是否启用 data_scope, +/// 若启用则查询用户对该权限的 scope 等级,返回 DataScopeParams。 +async fn resolve_data_scope( + ctx: &TenantContext, + manifest_id: &str, + entity: &str, + fine_perm: &str, + db: &sea_orm::DatabaseConnection, +) -> Result, AppError> { + let entity_has_scope = check_entity_data_scope(manifest_id, entity, db).await?; + if !entity_has_scope { + return Ok(None); + } + let scope_level = get_data_scope(ctx, fine_perm, db).await?; + if scope_level == "all" { + return Ok(None); + } + let dept_members = get_dept_members(ctx, false).await; + Ok(Some(DataScopeParams { + scope_level, + user_id: ctx.user_id, + dept_member_ids: dept_members, + owner_field: "owner_id".to_string(), + })) +} + +/// 查询 entity 定义是否启用了 data_scope +async fn check_entity_data_scope( + _manifest_id: &str, + entity_name: &str, + db: &sea_orm::DatabaseConnection, +) -> Result { + use crate::entity::plugin_entity; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + let entity = plugin_entity::Entity::find() + .filter(plugin_entity::Column::EntityName.eq(entity_name)) + .filter(plugin_entity::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + let Some(e) = entity else { return Ok(false) }; + + let schema: crate::manifest::PluginEntity = serde_json::from_value(e.schema_json) + .map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?; + + Ok(schema.data_scope.unwrap_or(false)) +} + +#[utoipa::path( + post, + path = "/api/v1/plugins/{plugin_id}/{entity}/aggregate-multi", + request_body = AggregateMultiReq, + responses( + (status = 200, description = "成功", body = ApiResponse>), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// POST /api/v1/plugins/{plugin_id}/{entity}/aggregate-multi — 多聚合查询 +pub async fn aggregate_multi_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Json(body): Json, +) -> Result>>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let fine_perm = compute_permission_code(&manifest_id, &entity, "list"); + require_permission(&ctx, &fine_perm)?; + + let scope = resolve_data_scope(&ctx, &manifest_id, &entity, &fine_perm, &state.db).await?; + + let aggregations: Vec<(String, String)> = body + .aggregations + .iter() + .map(|a| (a.func.clone(), a.field.clone())) + .collect(); + + let rows = PluginDataService::aggregate_multi( + plugin_id, + &entity, + ctx.tenant_id, + &state.db, + &body.group_by, + &aggregations, + body.filter, + scope, + ) + .await?; + + Ok(Json(ApiResponse::ok(rows))) +} + +// ─── 跨插件引用:批量标签解析 ──────────────────────────────────────── + +/// 批量解析引用字段的显示标签 +/// +/// POST /api/v1/plugins/{plugin_id}/{entity}/resolve-labels +pub async fn resolve_ref_labels( + Path((plugin_id, entity)): Path<(Uuid, String)>, + State(state): State, + Extension(ctx): Extension, + Json(body): Json, +) -> Result>, AppError> +where + PluginState: FromRef, +{ + use crate::data_service::{is_plugin_active, resolve_cross_plugin_entity}; + use crate::manifest::PluginEntity; + use sea_orm::{FromQueryResult, Statement}; + + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let fine_perm = compute_permission_code(&manifest_id, &entity, "list"); + require_permission(&ctx, &fine_perm)?; + + // 获取当前实体的 schema + let entity_info = crate::data_service::resolve_entity_info_cached( + plugin_id, + &entity, + ctx.tenant_id, + &state.db, + &state.entity_cache, + ) + .await?; + let entity_def: PluginEntity = serde_json::from_value(entity_info.schema_json) + .map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?; + + let mut labels = serde_json::Map::::new(); + let mut meta = serde_json::Map::::new(); + + for (field_name, uuids) in &body.fields { + // 查找字段定义 + let field_def = entity_def.fields.iter().find(|f| &f.name == field_name); + let Some(field_def) = field_def else { continue }; + let Some(ref_entity_name) = &field_def.ref_entity else { + continue; + }; + + let target_plugin = field_def.ref_plugin.as_deref().unwrap_or(&manifest_id); + let label_field = field_def.ref_label_field.as_deref().unwrap_or("name"); + + let installed = is_plugin_active(target_plugin, ctx.tenant_id, &state.db).await; + + // meta 信息 + meta.insert( + field_name.clone(), + serde_json::json!({ + "target_plugin": target_plugin, + "target_entity": ref_entity_name, + "label_field": label_field, + "plugin_installed": installed, + }), + ); + + if !installed { + // 目标插件未安装 → 所有 UUID 返回 null + let nulls: serde_json::Map = uuids + .iter() + .map(|u| (u.clone(), serde_json::Value::Null)) + .collect(); + labels.insert(field_name.clone(), serde_json::Value::Object(nulls)); + continue; + } + + // 解析目标表名 + let target_table = if field_def.ref_plugin.is_some() { + match resolve_cross_plugin_entity( + target_plugin, + ref_entity_name, + ctx.tenant_id, + &state.db, + ) + .await + { + Ok(info) => info.table_name, + Err(_) => { + let nulls: serde_json::Map = uuids + .iter() + .map(|u| (u.clone(), serde_json::Value::Null)) + .collect(); + labels.insert(field_name.clone(), serde_json::Value::Object(nulls)); + continue; + } + } + } else { + crate::dynamic_table::DynamicTableManager::table_name(target_plugin, ref_entity_name) + }; + + // 批量查询标签 + let uuid_strs: Vec = uuids + .iter() + .filter_map(|u| Uuid::parse_str(u).ok()) + .map(|u| u.to_string()) + .collect(); + if uuid_strs.is_empty() { + labels.insert(field_name.clone(), serde_json::json!({})); + continue; + } + + // 构建 IN 子句参数 + let placeholders: Vec = (2..uuid_strs.len() + 2) + .map(|i| format!("${}", i)) + .collect(); + let sql = format!( + "SELECT id::text, data->>'{}' as label FROM \"{}\" WHERE id IN ({}) AND tenant_id = $1 AND deleted_at IS NULL", + label_field, + target_table, + placeholders.join(", ") + ); + + let mut values: Vec = vec![ctx.tenant_id.into()]; + for u in &uuid_strs { + let uuid: Uuid = u + .parse() + .map_err(|e| AppError::Internal(format!("invalid uuid: {}", e)))?; + values.push(uuid.into()); + } + + #[derive(FromQueryResult)] + struct LabelRow { + id: String, + label: Option, + } + + let rows = LabelRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .all(&state.db) + .await?; + + let mut field_labels: serde_json::Map = serde_json::Map::new(); + // 初始化所有请求的 UUID 为 null + for u in uuids { + field_labels.insert(u.clone(), serde_json::Value::Null); + } + // 用查询结果填充 + for row in rows { + field_labels.insert( + row.id, + serde_json::Value::String(row.label.unwrap_or_default()), + ); + } + + labels.insert(field_name.clone(), serde_json::Value::Object(field_labels)); + } + + Ok(Json(ApiResponse::ok(ResolveLabelsResp { + labels: serde_json::Value::Object(labels), + meta: serde_json::Value::Object(meta), + }))) +} + +// ─── 跨插件引用:实体注册表查询 ──────────────────────────────────────── + +/// 查询所有可跨插件引用的公开实体 +/// +/// GET /api/v1/plugin-registry/entities +pub async fn list_public_entities( + State(state): State, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + PluginState: FromRef, +{ + use crate::entity::plugin_entity; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + let entities = plugin_entity::Entity::find() + .filter(plugin_entity::Column::TenantId.eq(ctx.tenant_id)) + .filter(plugin_entity::Column::IsPublic.eq(true)) + .filter(plugin_entity::Column::DeletedAt.is_null()) + .all(&state.db) + .await?; + + let result: Vec = entities + .iter() + .map(|e| { + let display_name = e + .schema_json + .get("display_name") + .and_then(|v| v.as_str()) + .unwrap_or(&e.entity_name) + .to_string(); + PublicEntityResp { + manifest_id: e.manifest_id.clone(), + plugin_id: e.plugin_id.to_string(), + entity_name: e.entity_name.clone(), + display_name, + } + }) + .collect(); + + Ok(Json(ApiResponse::ok(result))) +} + +// ─── 数据导入导出 ────────────────────────────────────────────────────── + +#[utoipa::path( + get, + path = "/api/v1/plugins/{plugin_id}/{entity}/export", + params(ExportParams), + responses( + (status = 200, description = "导出成功"), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// GET /api/v1/plugins/{plugin_id}/{entity}/export — 导出数据 (JSON/CSV/XLSX) +pub async fn export_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Query(params): Query, +) -> Result +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + use crate::data_dto::ExportPayload; + use axum::body::Body; + use axum::http::{StatusCode, header}; + + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let fine_perm = compute_permission_code(&manifest_id, &entity, "list"); + require_permission(&ctx, &fine_perm)?; + + let scope = resolve_data_scope(&ctx, &manifest_id, &entity, &fine_perm, &state.db).await?; + + let filter: Option = params + .filter + .as_ref() + .and_then(|f| serde_json::from_str(f).ok()); + + let payload = PluginDataService::export( + plugin_id, + &entity, + ctx.tenant_id, + &state.db, + filter, + params.search, + params.sort_by, + params.sort_order, + params.format, + &state.entity_cache, + scope, + ) + .await?; + + let filename = format!( + "{}_export_{}", + entity, + chrono::Utc::now().format("%Y%m%d%H%M%S") + ); + match payload { + ExportPayload::Json(data) => { + let body = serde_json::to_string(&ApiResponse::ok(data)) + .map_err(|e| AppError::Internal(e.to_string()))?; + Ok(axum::response::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .unwrap()) + } + ExportPayload::Csv(bytes) => Ok(axum::response::Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "text/csv; charset=utf-8") + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}.csv\"", filename), + ) + .body(Body::from(bytes)) + .unwrap()), + ExportPayload::Xlsx(bytes) => Ok(axum::response::Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + .header( + header::CONTENT_DISPOSITION, + format!("attachment; filename=\"{}.xlsx\"", filename), + ) + .body(Body::from(bytes)) + .unwrap()), + } +} + +#[utoipa::path( + post, + path = "/api/v1/plugins/{plugin_id}/{entity}/import", + request_body = ImportReq, + responses( + (status = 200, description = "导入完成", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件数据" +)] +/// POST /api/v1/plugins/{plugin_id}/{entity}/import — 导入数据 +pub async fn import_plugin_data( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Json(req): Json, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let fine_perm = compute_permission_code(&manifest_id, &entity, "create"); + require_permission(&ctx, &fine_perm)?; + + let result = PluginDataService::import( + plugin_id, + &entity, + ctx.tenant_id, + ctx.user_id, + req.rows, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(result))) +} + +/// POST /api/v1/plugins/{plugin_id}/reconcile — 对账扫描 +#[utoipa::path( + post, + path = "/api/v1/plugins/{plugin_id}/reconcile", + responses( + (status = 200, description = "对账报告", body = ApiResponse) + ), + tag = "Plugin Data", +)] +pub async fn reconcile_refs( + State(state): State, + Extension(ctx): Extension, + Path(plugin_id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + + let report = + PluginDataService::reconcile_references(plugin_id, ctx.tenant_id, &state.db).await?; + + Ok(Json(ApiResponse::ok(report))) +} + +// ─── 用户自定义视图 CRUD ────────────────────────────────────────────────── + +#[utoipa::path( + get, + path = "/api/v1/plugins/{plugin_id}/{entity}/views", + responses( + (status = 200, description = "视图列表", body = ApiResponse>) + ), + tag = "Plugin Views", +)] +pub async fn list_user_views( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, +) -> Result>>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + + use sea_orm::FromQueryResult; + #[derive(FromQueryResult)] + struct ViewRow { + id: Uuid, + view_name: String, + view_config: serde_json::Value, + is_default: bool, + created_at: Option>, + updated_at: Option>, + } + + let rows = ViewRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "SELECT id, view_name, view_config, is_default, created_at, updated_at \ + FROM plugin_user_views WHERE tenant_id = $1 AND user_id = $2 AND plugin_id = $3 AND entity_name = $4 \ + ORDER BY created_at DESC", + [ctx.tenant_id.into(), ctx.user_id.into(), manifest_id.clone().into(), entity.clone().into()], + )) + .all(&state.db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + let mid = manifest_id.clone(); + let ent = entity.clone(); + let items = rows + .into_iter() + .map(|r| UserViewResp { + id: r.id.to_string(), + plugin_id: mid.clone(), + entity_name: ent.clone(), + view_name: r.view_name, + view_config: r.view_config, + is_default: r.is_default, + created_at: r.created_at, + updated_at: r.updated_at, + }) + .collect(); + + Ok(Json(ApiResponse::ok(items))) +} + +#[utoipa::path( + post, + path = "/api/v1/plugins/{plugin_id}/{entity}/views", + request_body = UserViewReq, + responses( + (status = 200, description = "创建视图", body = ApiResponse) + ), + tag = "Plugin Views", +)] +pub async fn create_user_view( + State(state): State, + Extension(ctx): Extension, + Path((plugin_id, entity)): Path<(Uuid, String)>, + Json(req): Json, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let manifest_id = resolve_manifest_id(plugin_id, ctx.tenant_id, &state.db).await?; + let view_id = Uuid::now_v7(); + let now = chrono::Utc::now(); + let is_default = req.is_default.unwrap_or(false); + let mid = manifest_id.clone(); + let ent = entity.clone(); + let view_name = req.view_name.clone(); + let view_config = req.view_config.clone(); + + if is_default { + state.db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "UPDATE plugin_user_views SET is_default = false \ + WHERE tenant_id = $1 AND user_id = $2 AND plugin_id = $3 AND entity_name = $4 AND is_default = true", + [ctx.tenant_id.into(), ctx.user_id.into(), mid.clone().into(), ent.clone().into()], + )).await.map_err(|e| AppError::Internal(e.to_string()))?; + } + + state.db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "INSERT INTO plugin_user_views (id, tenant_id, user_id, plugin_id, entity_name, view_name, view_config, is_default, created_at, updated_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + [ + view_id.into(), ctx.tenant_id.into(), ctx.user_id.into(), + mid.into(), ent.into(), + view_name.into(), view_config.into(), is_default.into(), + now.into(), now.into(), + ], + )).await.map_err(|e| AppError::Internal(e.to_string()))?; + + Ok(Json(ApiResponse::ok(UserViewResp { + id: view_id.to_string(), + plugin_id: manifest_id, + entity_name: entity, + view_name: req.view_name, + view_config: req.view_config, + is_default, + created_at: Some(now), + updated_at: Some(now), + }))) +} + +/// DELETE /api/v1/plugins/{plugin_id}/{entity}/views/{view_id} — 删除视图 +pub async fn delete_user_view( + State(state): State, + Extension(ctx): Extension, + Path((_plugin_id, _entity, view_id)): Path<(Uuid, String, Uuid)>, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + state + .db + .execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "DELETE FROM plugin_user_views WHERE id = $1 AND tenant_id = $2 AND user_id = $3", + [view_id.into(), ctx.tenant_id.into(), ctx.user_id.into()], + )) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + Ok(Json(ApiResponse::ok(()))) +} diff --git a/crates/erp-plugin/src/handler/market_handler.rs b/crates/erp-plugin/src/handler/market_handler.rs new file mode 100644 index 0000000..fabf692 --- /dev/null +++ b/crates/erp-plugin/src/handler/market_handler.rs @@ -0,0 +1,386 @@ +use axum::Extension; +use axum::extract::{FromRef, Path, Query, State}; +use axum::response::Json; +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, Set, + prelude::Decimal, +}; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +use crate::data_dto::{ + MarketEntryDetailResp, MarketEntryResp, MarketListParams, MarketReviewResp, SubmitReviewReq, +}; +use crate::entity::{market_entry, market_review, plugin}; +use crate::state::PluginState; + +fn entry_to_resp(model: &market_entry::Model) -> MarketEntryResp { + MarketEntryResp { + id: model.id.to_string(), + plugin_id: model.plugin_id.clone(), + name: model.name.clone(), + version: model.version.clone(), + description: model.description.clone(), + author: model.author.clone(), + category: model.category.clone(), + tags: model.tags.clone(), + icon_url: model.icon_url.clone(), + screenshots: model.screenshots.clone(), + min_platform_version: model.min_platform_version.clone(), + status: model.status.clone(), + download_count: model.download_count, + rating_avg: model.rating_avg.to_string().parse().unwrap_or(0.0), + rating_count: model.rating_count, + changelog: model.changelog.clone(), + created_at: Some(model.created_at), + updated_at: Some(model.updated_at), + } +} + +#[utoipa::path( + get, + path = "/api/v1/market/entries", + params(MarketListParams), + responses( + (status = 200, description = "市场条目列表", body = ApiResponse>) + ), + tag = "Plugin Market", +)] +pub async fn list_market_entries( + State(_state): State, + Query(params): Query, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + + let state: PluginState = PluginState::from_ref(&_state); + let db = &state.db; + + let page = params.page.unwrap_or(1); + let page_size = params.page_size.unwrap_or(20).min(100); + + let mut query = + market_entry::Entity::find().filter(market_entry::Column::Status.eq("published")); + + if let Some(ref category) = params.category { + query = query.filter(market_entry::Column::Category.eq(category.as_str())); + } + + if let Some(ref search) = params.search { + query = query.filter( + sea_orm::Condition::any() + .add(market_entry::Column::Name.contains(search.as_str())) + .add(market_entry::Column::Description.contains(search.as_str())) + .add(market_entry::Column::Author.contains(search.as_str())), + ); + } + + query = query.order_by_desc(market_entry::Column::DownloadCount); + + let total = query + .clone() + .count(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + let total_pages = ((total as f64) / (page_size as f64)).ceil() as u64; + + let models = query + .paginate(db, page_size) + .fetch_page(page.saturating_sub(1)) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + let items = models.iter().map(entry_to_resp).collect(); + + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: items, + total, + page, + page_size, + total_pages, + }))) +} + +#[utoipa::path( + get, + path = "/api/v1/market/entries/{id}", + responses( + (status = 200, description = "市场条目详情", body = ApiResponse) + ), + tag = "Plugin Market", +)] +pub async fn get_market_entry( + State(_state): State, + Path(id): Path, + Extension(ctx): Extension, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + + let state: PluginState = PluginState::from_ref(&_state); + let db = &state.db; + + let model = market_entry::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))? + .ok_or_else(|| AppError::NotFound("市场条目不存在".to_string()))?; + + // 解析 manifest 检查依赖 + let mut dependency_warnings = Vec::new(); + if let Ok(manifest) = crate::manifest::parse_manifest(&model.manifest_toml) { + for dep_id in &manifest.metadata.dependencies { + let installed = plugin::Entity::find() + .filter(plugin::Column::Name.eq(dep_id.as_str())) + .one(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + if installed.is_none() { + dependency_warnings.push(format!("依赖插件 '{}' 尚未安装", dep_id)); + } + } + } + + Ok(Json(ApiResponse::ok(MarketEntryDetailResp { + entry: entry_to_resp(&model), + dependency_warnings, + }))) +} + +#[utoipa::path( + post, + path = "/api/v1/market/entries/{id}/install", + responses( + (status = 200, description = "从市场安装插件", body = ApiResponse) + ), + tag = "Plugin Market", +)] +pub async fn install_from_market( + State(_state): State, + Path(id): Path, + Extension(ctx): Extension, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + + let state: PluginState = PluginState::from_ref(&_state); + let db = &state.db; + let engine = &state.engine; + + // 获取市场条目 + let market_model = market_entry::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))? + .ok_or_else(|| AppError::NotFound("市场条目不存在".to_string()))?; + + if market_model.status != "published" { + return Err(AppError::Validation("该插件已下架,无法安装".to_string())); + } + + // 检查是否已安装同 plugin_id 的插件 + let existing = plugin::Entity::find() + .filter(plugin::Column::Name.eq(market_model.plugin_id.as_str())) + .filter(plugin::Column::TenantId.eq(ctx.tenant_id)) + .one(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + if existing.is_some() { + return Err(AppError::Validation( + "该插件已安装,如需更新请使用升级功能".to_string(), + )); + } + + // upload → install → enable 一条龙 + let wasm_binary = market_model.wasm_binary.clone(); + let manifest_toml = market_model.manifest_toml.clone(); + + let plugin_resp = crate::service::PluginService::upload( + ctx.tenant_id, + ctx.user_id, + wasm_binary, + &manifest_toml, + db, + ) + .await?; + + let plugin_id = plugin_resp.id; + let _plugin_resp = + crate::service::PluginService::install(plugin_id, ctx.tenant_id, ctx.user_id, db, engine) + .await?; + + let plugin_resp = + crate::service::PluginService::enable(plugin_id, ctx.tenant_id, ctx.user_id, db, engine) + .await?; + + // 递增下载计数 + let mut active: market_entry::ActiveModel = market_model.into(); + let current = active.download_count.take().unwrap_or(0); + active.download_count = Set(current + 1); + let _ = active + .update(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + Ok(Json(ApiResponse::ok(plugin_resp))) +} + +#[utoipa::path( + get, + path = "/api/v1/market/entries/{id}/reviews", + responses( + (status = 200, description = "评论列表", body = ApiResponse>) + ), + tag = "Plugin Market", +)] +pub async fn list_market_reviews( + State(_state): State, + Path(id): Path, + Extension(ctx): Extension, +) -> Result>>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + + let state: PluginState = PluginState::from_ref(&_state); + let db = &state.db; + + let reviews = market_review::Entity::find() + .filter(market_review::Column::MarketEntryId.eq(id)) + .all(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + let items = reviews + .iter() + .map(|r| MarketReviewResp { + id: r.id.to_string(), + user_id: r.user_id.to_string(), + market_entry_id: r.market_entry_id.to_string(), + rating: r.rating, + review_text: r.review_text.clone(), + created_at: Some(r.created_at), + }) + .collect(); + + Ok(Json(ApiResponse::ok(items))) +} + +#[utoipa::path( + post, + path = "/api/v1/market/entries/{id}/reviews", + responses( + (status = 200, description = "提交评分/评论", body = ApiResponse) + ), + tag = "Plugin Market", +)] +pub async fn submit_market_review( + State(_state): State, + Path(id): Path, + Extension(ctx): Extension, + Json(body): Json, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + + if body.rating < 1 || body.rating > 5 { + return Err(AppError::Validation("评分必须在 1-5 之间".to_string())); + } + + let state: PluginState = PluginState::from_ref(&_state); + let db = &state.db; + + // 验证市场条目存在 + let entry_model = market_entry::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))? + .ok_or_else(|| AppError::NotFound("市场条目不存在".to_string()))?; + + // upsert: 同一用户同一条目只保留最新评论 + let existing = market_review::Entity::find() + .filter(market_review::Column::MarketEntryId.eq(id)) + .filter(market_review::Column::UserId.eq(ctx.user_id)) + .filter(market_review::Column::TenantId.eq(ctx.tenant_id)) + .one(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + let review_model = if let Some(existing) = existing { + let mut active: market_review::ActiveModel = existing.into(); + active.rating = Set(body.rating); + active.review_text = Set(body.review_text); + active + .update(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))? + } else { + let review_id = Uuid::now_v7(); + let now = Utc::now(); + let model = market_review::ActiveModel { + id: Set(review_id), + tenant_id: Set(ctx.tenant_id), + user_id: Set(ctx.user_id), + market_entry_id: Set(id), + rating: Set(body.rating), + review_text: Set(body.review_text), + created_at: Set(now), + }; + model + .insert(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))? + }; + + // 重新计算平均评分 + let all_reviews = market_review::Entity::find() + .filter(market_review::Column::MarketEntryId.eq(id)) + .all(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + let count = all_reviews.len() as i32; + let avg: f64 = if count > 0 { + all_reviews.iter().map(|r| r.rating as f64).sum::() / count as f64 + } else { + 0.0 + }; + + let mut entry_active: market_entry::ActiveModel = entry_model.into(); + let avg_decimal = Decimal::from_f64_retain(avg).unwrap_or_default(); + entry_active.rating_avg = Set(avg_decimal); + entry_active.rating_count = Set(count); + let _ = entry_active + .update(db) + .await + .map_err(|e| AppError::Internal(e.to_string()))?; + + Ok(Json(ApiResponse::ok(MarketReviewResp { + id: review_model.id.to_string(), + user_id: review_model.user_id.to_string(), + market_entry_id: review_model.market_entry_id.to_string(), + rating: review_model.rating, + review_text: review_model.review_text, + created_at: Some(review_model.created_at), + }))) +} diff --git a/crates/erp-plugin/src/handler/mod.rs b/crates/erp-plugin/src/handler/mod.rs new file mode 100644 index 0000000..d36d253 --- /dev/null +++ b/crates/erp-plugin/src/handler/mod.rs @@ -0,0 +1,3 @@ +pub mod data_handler; +pub mod market_handler; +pub mod plugin_handler; diff --git a/crates/erp-plugin/src/handler/plugin_handler.rs b/crates/erp-plugin/src/handler/plugin_handler.rs new file mode 100644 index 0000000..6cbcd73 --- /dev/null +++ b/crates/erp-plugin/src/handler/plugin_handler.rs @@ -0,0 +1,510 @@ +use axum::Extension; +use axum::extract::{FromRef, Multipart, Path, Query, State}; +use axum::response::Json; +use uuid::Uuid; +use validator::Validate; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext}; + +use crate::dto::{PluginHealthResp, PluginListParams, PluginResp, UpdatePluginConfigReq}; +use crate::service::PluginService; +use crate::state::PluginState; + +#[utoipa::path( + post, + path = "/api/v1/admin/plugins/upload", + request_body(content_type = "multipart/form-data"), + responses( + (status = 200, description = "上传成功", body = ApiResponse), + (status = 401, description = "未授权"), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// POST /api/v1/admin/plugins/upload — 上传插件 (multipart: wasm + manifest) +pub async fn upload_plugin( + State(state): State, + Extension(ctx): Extension, + mut multipart: Multipart, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + + let mut wasm_binary: Option> = None; + let mut manifest_toml: Option = None; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| AppError::Validation(format!("Multipart 解析失败: {}", e)))? + { + let name = field.name().unwrap_or(""); + match name { + "wasm" => { + wasm_binary = Some( + field + .bytes() + .await + .map_err(|e| AppError::Validation(format!("读取 WASM 文件失败: {}", e)))? + .to_vec(), + ); + } + "manifest" => { + let bytes = field + .bytes() + .await + .map_err(|e| AppError::Validation(format!("读取 Manifest 失败: {}", e)))?; + let text = String::from_utf8(bytes.to_vec()).map_err(|e| { + AppError::Validation(format!("Manifest 不是有效的 UTF-8: {}", e)) + })?; + manifest_toml = Some(text); + } + _ => {} + } + } + + let wasm = wasm_binary.ok_or_else(|| AppError::Validation("缺少 wasm 文件".to_string()))?; + let manifest = + manifest_toml.ok_or_else(|| AppError::Validation("缺少 manifest 文件".to_string()))?; + + let result = + PluginService::upload(ctx.tenant_id, ctx.user_id, wasm, &manifest, &state.db).await?; + + Ok(Json(ApiResponse::ok(result))) +} + +#[utoipa::path( + get, + path = "/api/v1/admin/plugins", + params(PluginListParams), + responses( + (status = 200, description = "成功", body = ApiResponse>), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// GET /api/v1/admin/plugins — 列表 +pub async fn list_plugins( + State(state): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.list")?; + + let pagination = Pagination { + page: params.page, + page_size: params.page_size, + }; + + let (plugins, total) = PluginService::list( + ctx.tenant_id, + pagination.page.unwrap_or(1), + pagination.page_size.unwrap_or(20), + params.status.as_deref(), + params.search.as_deref(), + &state.db, + ) + .await?; + + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: plugins, + total, + page: pagination.page.unwrap_or(1), + page_size: pagination.page_size.unwrap_or(20), + total_pages: (total as f64 / pagination.page_size.unwrap_or(20) as f64).ceil() as u64, + }))) +} + +#[utoipa::path( + get, + path = "/api/v1/admin/plugins/{id}", + responses( + (status = 200, description = "成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// GET /api/v1/admin/plugins/{id} — 详情 +pub async fn get_plugin( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.list")?; + let result = PluginService::get_by_id(id, ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(result))) +} + +#[utoipa::path( + get, + path = "/api/v1/admin/plugins/{id}/schema", + responses( + (status = 200, description = "成功"), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// GET /api/v1/admin/plugins/{id}/schema — 实体 schema +pub async fn get_plugin_schema( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.list")?; + let schema = PluginService::get_schema(id, ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(schema))) +} + +#[utoipa::path( + post, + path = "/api/v1/admin/plugins/{id}/install", + responses( + (status = 200, description = "安装成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// POST /api/v1/admin/plugins/{id}/install — 安装 +pub async fn install_plugin( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + let result = PluginService::install(id, ctx.tenant_id, ctx.user_id, &state.db, &state.engine) + .await + .map_err(|e| { + tracing::error!(error = %e, "Install failed"); + e + })?; + Ok(Json(ApiResponse::ok(result))) +} + +#[utoipa::path( + post, + path = "/api/v1/admin/plugins/{id}/enable", + responses( + (status = 200, description = "启用成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// POST /api/v1/admin/plugins/{id}/enable — 启用 +pub async fn enable_plugin( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + let result = + PluginService::enable(id, ctx.tenant_id, ctx.user_id, &state.db, &state.engine).await?; + Ok(Json(ApiResponse::ok(result))) +} + +#[utoipa::path( + post, + path = "/api/v1/admin/plugins/{id}/disable", + responses( + (status = 200, description = "停用成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// POST /api/v1/admin/plugins/{id}/disable — 停用 +pub async fn disable_plugin( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + let result = + PluginService::disable(id, ctx.tenant_id, ctx.user_id, &state.db, &state.engine).await?; + Ok(Json(ApiResponse::ok(result))) +} + +#[utoipa::path( + post, + path = "/api/v1/admin/plugins/{id}/uninstall", + responses( + (status = 200, description = "卸载成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// POST /api/v1/admin/plugins/{id}/uninstall — 卸载 +pub async fn uninstall_plugin( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + let result = + PluginService::uninstall(id, ctx.tenant_id, ctx.user_id, &state.db, &state.engine).await?; + Ok(Json(ApiResponse::ok(result))) +} + +#[utoipa::path( + delete, + path = "/api/v1/admin/plugins/{id}", + responses( + (status = 200, description = "清除成功"), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// DELETE /api/v1/admin/plugins/{id} — 清除(软删除) +pub async fn purge_plugin( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + PluginService::purge(id, ctx.tenant_id, ctx.user_id, &state.db).await?; + Ok(Json(ApiResponse::ok(()))) +} + +#[utoipa::path( + get, + path = "/api/v1/admin/plugins/{id}/health", + responses( + (status = 200, description = "健康检查", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// GET /api/v1/admin/plugins/{id}/health — 健康检查 +pub async fn health_check_plugin( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.list")?; + let result = PluginService::health_check(id, ctx.tenant_id, &state.db, &state.engine).await?; + Ok(Json(ApiResponse::ok(result))) +} + +#[utoipa::path( + get, + path = "/api/v1/admin/plugins/{id}/metrics", + responses( + (status = 200, description = "运行时指标", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// GET /api/v1/admin/plugins/{id}/metrics — 运行时指标 +pub async fn get_plugin_metrics( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.list")?; + + // 通过 plugin_id 找到 manifest_id,再查询 metrics + let manifest_id = + crate::data_service::resolve_manifest_id(id, ctx.tenant_id, &state.db).await?; + let metrics = state.engine.get_metrics(&manifest_id).await?; + + let avg_ms = if metrics.total_invocations > 0 { + metrics.total_response_ms / metrics.total_invocations as f64 + } else { + 0.0 + }; + + Ok(Json(ApiResponse::ok(serde_json::json!({ + "plugin_id": manifest_id, + "total_invocations": metrics.total_invocations, + "error_count": metrics.error_count, + "avg_response_ms": avg_ms, + "last_error": metrics.last_error, + "last_invocation_at": metrics.last_invocation_at, + })))) +} + +#[utoipa::path( + put, + path = "/api/v1/admin/plugins/{id}/config", + request_body = UpdatePluginConfigReq, + responses( + (status = 200, description = "更新成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// PUT /api/v1/admin/plugins/{id}/config — 更新配置 +pub async fn update_plugin_config( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + let result = PluginService::update_config( + id, + ctx.tenant_id, + ctx.user_id, + req.config, + req.version, + &state.db, + Some(&state.event_bus), + ) + .await?; + Ok(Json(ApiResponse::ok(result))) +} + +#[utoipa::path( + post, + path = "/api/v1/admin/plugins/{id}/upgrade", + request_body(content_type = "multipart/form-data"), + responses( + (status = 200, description = "升级成功", body = ApiResponse), + ), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// POST /api/v1/admin/plugins/{id}/upgrade — 热更新插件 +/// +/// 上传新版本 WASM + manifest,对比 schema 变更,执行增量 DDL, +/// 更新插件记录。失败时保持旧版本继续运行。 +pub async fn upgrade_plugin( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + mut multipart: Multipart, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + + let mut wasm_binary: Option> = None; + let mut manifest_toml: Option = None; + + while let Some(field) = multipart + .next_field() + .await + .map_err(|e| AppError::Validation(format!("Multipart 解析失败: {}", e)))? + { + let name = field.name().unwrap_or(""); + match name { + "wasm" => { + wasm_binary = Some( + field + .bytes() + .await + .map_err(|e| AppError::Validation(format!("读取 WASM 文件失败: {}", e)))? + .to_vec(), + ); + } + "manifest" => { + let bytes = field + .bytes() + .await + .map_err(|e| AppError::Validation(format!("读取 Manifest 失败: {}", e)))?; + manifest_toml = Some(String::from_utf8(bytes.to_vec()).map_err(|e| { + AppError::Validation(format!("Manifest 不是有效的 UTF-8: {}", e)) + })?); + } + _ => {} + } + } + + let wasm = wasm_binary.ok_or_else(|| AppError::Validation("缺少 wasm 文件".to_string()))?; + let manifest = + manifest_toml.ok_or_else(|| AppError::Validation("缺少 manifest 文件".to_string()))?; + + let result = PluginService::upgrade( + id, + ctx.tenant_id, + ctx.user_id, + wasm, + &manifest, + &state.db, + &state.engine, + ) + .await?; + + Ok(Json(ApiResponse::ok(result))) +} + +#[utoipa::path( + get, + path = "/api/v1/admin/plugins/{id}/validate", + params(("id" = Uuid, Path, description = "插件 ID")), + responses((status = 200, description = "安全验证报告")), + security(("bearer_auth" = [])), + tag = "插件管理" +)] +/// GET /api/v1/admin/plugins/{id}/validate — 获取插件安全验证报告 +pub async fn validate_plugin( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + PluginState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "plugin.admin")?; + + let model = crate::service::find_plugin_model(id, ctx.tenant_id, &state.db).await?; + let manifest: crate::manifest::PluginManifest = + serde_json::from_value(model.manifest_json.clone()) + .map_err(|e| AppError::Validation(format!("manifest 解析失败: {}", e)))?; + + let report = + crate::plugin_validator::validate_plugin_security(&manifest, model.wasm_binary.len())?; + Ok(Json(ApiResponse::ok(report))) +} diff --git a/crates/erp-plugin/src/host.rs b/crates/erp-plugin/src/host.rs new file mode 100644 index 0000000..c6d3a56 --- /dev/null +++ b/crates/erp-plugin/src/host.rs @@ -0,0 +1,438 @@ +use std::collections::HashMap; + +use sea_orm::DatabaseConnection; +use uuid::Uuid; +use wasmtime::StoreLimits; + +use crate::dynamic_table::DynamicTableManager; +use crate::engine::PluginEngine; +use crate::erp::plugin::host_api; + +/// 待刷新的写操作 +#[derive(Debug)] +pub enum PendingOp { + Insert { + id: String, + entity: String, + data: Vec, + }, + Update { + entity: String, + id: String, + data: Vec, + version: i64, + }, + Delete { + entity: String, + id: String, + }, + PublishEvent { + event_type: String, + payload: Vec, + }, +} + +/// Host 端状态 — 绑定到每个 WASM Store 实例 +/// +/// 支持两种执行模式: +/// - **预填充模式**(db = None):读操作从预填充缓存取,向后兼容 +/// - **混合执行模式**(db = Some):读操作走实时 SQL + 写操作保持延迟批量 +pub struct HostState { + pub(crate) limits: StoreLimits, + #[allow(dead_code)] + pub(crate) tenant_id: Uuid, + #[allow(dead_code)] + pub(crate) user_id: Uuid, + pub(crate) permissions: Vec, + pub(crate) plugin_id: String, + // 预填充的读取缓存(向后兼容) + pub(crate) query_results: HashMap>, + pub(crate) config_cache: HashMap>, + pub(crate) current_user_json: Vec, + // 待刷新的写操作 + pub(crate) pending_ops: Vec, + // 日志 + pub(crate) logs: Vec<(String, String)>, + // 混合执行模式:数据库连接和事件总线 + pub(crate) db: Option, + pub(crate) event_bus: Option, + // 跨插件实体映射:"erp-crm.customer" → "plugin_erp_crm__customer" + pub(crate) cross_plugin_entities: HashMap, + // 编号规则映射:"invoice" → "INV-{YEAR}-{SEQ:4}" + pub(crate) numbering_rules: HashMap, + // 插件配置值 + pub(crate) plugin_config: serde_json::Value, +} + +/// 编号规则缓存 +#[derive(Debug, Clone)] +pub struct NumberingRule { + pub prefix: String, + pub format: String, + pub seq_length: u32, + pub reset_rule: String, +} + +impl HostState { + pub fn new( + plugin_id: String, + tenant_id: Uuid, + user_id: Uuid, + permissions: Vec, + ) -> Self { + let current_user = serde_json::json!({ + "id": user_id.to_string(), + "tenant_id": tenant_id.to_string(), + }); + Self { + limits: wasmtime::StoreLimitsBuilder::new().build(), + tenant_id, + user_id, + permissions, + plugin_id, + query_results: HashMap::new(), + config_cache: HashMap::new(), + current_user_json: serde_json::to_vec(¤t_user).unwrap_or_default(), + pending_ops: Vec::new(), + logs: Vec::new(), + db: None, + event_bus: None, + cross_plugin_entities: HashMap::new(), + numbering_rules: HashMap::new(), + plugin_config: serde_json::json!({}), + } + } + + /// 创建带数据库连接的 HostState(混合执行模式) + pub fn new_with_db( + plugin_id: String, + tenant_id: Uuid, + user_id: Uuid, + permissions: Vec, + db: DatabaseConnection, + event_bus: erp_core::events::EventBus, + ) -> Self { + let mut state = Self::new(plugin_id, tenant_id, user_id, permissions); + state.db = Some(db); + state.event_bus = Some(event_bus); + state + } +} + +// 实现 bindgen 生成的 Host trait — 插件调用 Host API 的入口 +impl host_api::Host for HostState { + fn db_insert(&mut self, entity: String, data: Vec) -> Result, String> { + let id = Uuid::now_v7().to_string(); + let response = serde_json::json!({ + "id": id, + "entity": entity, + "status": "queued", + }); + self.pending_ops.push(PendingOp::Insert { + id: id.clone(), + entity, + data, + }); + serde_json::to_vec(&response).map_err(|e| e.to_string()) + } + + fn db_query( + &mut self, + entity: String, + filter: Vec, + pagination: Vec, + ) -> Result, String> { + // 预填充模式(向后兼容) + if self.db.is_none() { + return self + .query_results + .get(&entity) + .cloned() + .ok_or_else(|| format!("实体 '{}' 的查询结果未预填充", entity)); + } + + let db = self.db.clone().ok_or("数据库连接不可用")?; + let event_bus = self.event_bus.clone().ok_or("事件总线不可用")?; + + // 先 flush pending writes(确保读后写一致性) + let ops = std::mem::take(&mut self.pending_ops); + if !ops.is_empty() { + let rt = tokio::runtime::Handle::current(); + rt.block_on(PluginEngine::flush_ops( + &db, + &self.plugin_id, + ops, + self.tenant_id, + self.user_id, + &event_bus, + )) + .map_err(|e| format!("flush pending ops 失败: {}", e))?; + } + + // 解析 filter 和 pagination + let filter_val: Option = if filter.is_empty() { + None + } else { + serde_json::from_slice(&filter).ok() + }; + + let pagination_val: Option = if pagination.is_empty() { + None + } else { + serde_json::from_slice(&pagination).ok() + }; + + // 构建查询 — 支持点分记号跨插件查询(如 "erp-crm.customer") + let table_name = if entity.contains('.') { + self.cross_plugin_entities + .get(&entity) + .cloned() + .ok_or_else(|| format!("跨插件实体 '{}' 未注册", entity))? + } else { + DynamicTableManager::table_name(&self.plugin_id, &entity) + }; + + let limit = pagination_val + .as_ref() + .and_then(|p| p.get("limit")) + .and_then(|v| v.as_u64()) + .unwrap_or(50); + let offset = pagination_val + .as_ref() + .and_then(|p| p.get("offset")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + let (sql, values) = DynamicTableManager::build_filtered_query_sql( + &table_name, + self.tenant_id, + limit, + offset, + filter_val, + None, + None, + None, + ) + .map_err(|e| format!("查询构建失败: {}", e))?; + + // 执行查询 + let rt = tokio::runtime::Handle::current(); + let rows = rt + .block_on(async { + use sea_orm::{FromQueryResult, Statement}; + #[derive(Debug, FromQueryResult)] + struct QueryRow { + data: serde_json::Value, + } + + let results = QueryRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .all(&db) + .await + .map_err(|e| format!("查询执行失败: {}", e))?; + + let items: Vec = results.into_iter().map(|r| r.data).collect(); + + Ok::, String>(items) + }) + .map_err(|e: String| e)?; + + serde_json::to_vec(&rows).map_err(|e| e.to_string()) + } + + fn db_update( + &mut self, + entity: String, + id: String, + data: Vec, + version: i64, + ) -> Result, String> { + let response = serde_json::json!({ + "id": id, + "entity": entity, + "version": version + 1, + "status": "queued", + }); + self.pending_ops.push(PendingOp::Update { + entity, + id, + data, + version, + }); + serde_json::to_vec(&response).map_err(|e| e.to_string()) + } + + fn db_delete(&mut self, entity: String, id: String) -> Result<(), String> { + self.pending_ops.push(PendingOp::Delete { entity, id }); + Ok(()) + } + + fn event_publish(&mut self, event_type: String, payload: Vec) -> Result<(), String> { + self.pending_ops.push(PendingOp::PublishEvent { + event_type, + payload, + }); + Ok(()) + } + + fn config_get(&mut self, key: String) -> Result, String> { + self.config_cache + .get(&key) + .cloned() + .ok_or_else(|| format!("配置项 '{}' 未预填充", key)) + } + + fn log_write(&mut self, level: String, message: String) { + tracing::info!( + plugin = %self.plugin_id, + level = %level, + "Plugin log: {}", + message + ); + self.logs.push((level, message)); + } + + fn current_user(&mut self) -> Result, String> { + Ok(self.current_user_json.clone()) + } + + fn check_permission(&mut self, permission: String) -> Result { + Ok(self.permissions.contains(&permission)) + } + + fn numbering_generate(&mut self, rule_key: String) -> Result { + let rule = self + .numbering_rules + .get(&rule_key) + .ok_or_else(|| format!("编号规则 '{}' 未声明", rule_key))? + .clone(); + + let db = self.db.clone().ok_or("编号生成需要数据库连接")?; + + let _tenant_id = self.tenant_id; + let plugin_id = self.plugin_id.clone(); + + let rt = tokio::runtime::Handle::current(); + + rt.block_on(async { + use sea_orm::{ConnectionTrait, FromQueryResult, Statement}; + + let now = chrono::Utc::now(); + let year = now.format("%Y").to_string(); + let month = now.format("%m").to_string(); + let day = now.format("%d").to_string(); + + // 计算当前周期的 key(用于 reset_rule 判断) + let period_key = match rule.reset_rule.as_str() { + "daily" => format!("{}-{}-{}", year, month, day), + "monthly" => format!("{}-{}", year, month), + "yearly" => year.clone(), + _ => String::new(), // "never" — 不需要周期 key + }; + + // 序列表名(使用 sanitize_identifier 防注入) + let table_name = format!( + "plugin_numbering_seq_{}", + crate::dynamic_table::sanitize_identifier(&plugin_id) + ); + + // 确保序列表存在 + let create_sql = format!( + "CREATE TABLE IF NOT EXISTS {} (\ + rule_key VARCHAR(255) NOT NULL, \ + period_key VARCHAR(64) NOT NULL DEFAULT '', \ + current_val BIGINT NOT NULL DEFAULT 0, \ + PRIMARY KEY (rule_key, period_key)\ + )", + table_name + ); + db.execute(Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + create_sql, + )) + .await + .map_err(|e| format!("创建序列表失败: {}", e))?; + + // 使用 advisory lock 保证并发安全 + // lock_id 基于规则名哈希 + let lock_id: i64 = { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + (plugin_id.clone() + &rule_key).hash(&mut hasher); + (hasher.finish() as i64).abs() + }; + + let lock_sql = format!("SELECT pg_advisory_xact_lock({})", lock_id); + db.execute(Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + lock_sql, + )) + .await + .map_err(|e| format!("获取锁失败: {}", e))?; + + // 读取当前值 + #[derive(Debug, FromQueryResult)] + struct SeqRow { + current_val: i64, + } + + let read_sql = format!( + "SELECT current_val FROM {} WHERE rule_key = $1 AND period_key = $2", + table_name + ); + let current = SeqRow::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + read_sql, + [rule_key.clone().into(), period_key.clone().into()], + )) + .one(&db) + .await + .map_err(|e| format!("读取序列失败: {}", e))?; + + let next_val = current.map(|r| r.current_val + 1).unwrap_or(1); + + // UPSERT 新值 + let upsert_sql = format!( + "INSERT INTO {} (rule_key, period_key, current_val) VALUES ($1, $2, $3) \ + ON CONFLICT (rule_key, period_key) DO UPDATE SET current_val = $3", + table_name + ); + db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + upsert_sql, + [ + rule_key.clone().into(), + period_key.clone().into(), + next_val.into(), + ], + )) + .await + .map_err(|e| format!("更新序列失败: {}", e))?; + + let seq_str = format!("{:0>width$}", next_val, width = rule.seq_length as usize); + + let number = rule + .format + .replace("{PREFIX}", &rule.prefix) + .replace("{YEAR}", &year) + .replace("{MONTH}", &month) + .replace("{DAY}", &day) + .replace(&format!("{{SEQ:{}}}", rule.seq_length), &seq_str) + .replace("{SEQ}", &seq_str); + + Ok(number) + }) + } + + fn setting_get(&mut self, key: String) -> Result, String> { + let config = self + .plugin_config + .as_object() + .ok_or("插件配置不是有效对象")?; + let value = config.get(&key).cloned().unwrap_or(serde_json::Value::Null); + serde_json::to_vec(&value).map_err(|e| e.to_string()) + } +} diff --git a/crates/erp-plugin/src/lib.rs b/crates/erp-plugin/src/lib.rs new file mode 100644 index 0000000..d43e30e --- /dev/null +++ b/crates/erp-plugin/src/lib.rs @@ -0,0 +1,26 @@ +//! ERP WASM 插件运行时 — 生产级 Host API +//! +//! 完整插件管理链路:加载 → 初始化 → 运行 → 停用 → 卸载 + +// bindgen! 生成类型化绑定(包含 Host trait 和 PluginWorld 类型) +// 生成: erp::plugin::host_api::Host trait, PluginWorld 类型 +wasmtime::component::bindgen!({ + path: "wit/plugin.wit", + world: "plugin-world", +}); + +pub mod data_dto; +pub mod data_service; +pub mod dto; +pub mod dynamic_table; +pub mod engine; +pub mod entity; +pub mod error; +pub mod handler; +pub mod host; +pub mod manifest; +pub mod module; +pub mod notification; +pub mod plugin_validator; +pub mod service; +pub mod state; diff --git a/crates/erp-plugin/src/manifest.rs b/crates/erp-plugin/src/manifest.rs new file mode 100644 index 0000000..ad782e6 --- /dev/null +++ b/crates/erp-plugin/src/manifest.rs @@ -0,0 +1,1809 @@ +use serde::{Deserialize, Serialize}; + +use crate::error::{PluginError, PluginResult}; + +/// 插件清单 — 从 TOML 文件解析 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginManifest { + pub metadata: PluginMetadata, + pub schema: Option, + pub events: Option, + pub ui: Option, + pub permissions: Option>, + /// 插件配置项声明 — 平台自动生成配置页面 + #[serde(default)] + pub settings: Option, + /// 编号规则声明 — 绑定实体字段到自动编号 + #[serde(default)] + pub numbering: Option>, + /// 打印模板声明 + #[serde(default)] + pub templates: Option>, + /// 触发事件声明 — 数据 CRUD 时自动发布域事件 + #[serde(default)] + pub trigger_events: Option>, +} + +/// 插件元数据 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginMetadata { + pub id: String, + pub name: String, + pub version: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub author: String, + #[serde(default)] + pub min_platform_version: Option, + #[serde(default)] + pub dependencies: Vec, +} + +/// 插件 Schema — 定义动态实体 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginSchema { + pub entities: Vec, +} + +/// 插件实体定义 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginEntity { + pub name: String, + pub display_name: String, + #[serde(default)] + pub fields: Vec, + #[serde(default)] + pub indexes: Vec, + #[serde(default)] + pub relations: Vec, + #[serde(default)] + pub data_scope: Option, // 是否启用行级数据权限 + #[serde(default)] + pub is_public: Option, // 是否可被其他插件引用 + #[serde(default)] + pub importable: Option, // 是否支持数据导入 + #[serde(default)] + pub exportable: Option, // 是否支持数据导出 +} + +/// 字段校验规则 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldValidation { + pub pattern: Option, // 正则表达式 + pub message: Option, // 校验失败提示 +} + +/// 插件字段定义 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginField { + pub name: String, + pub field_type: PluginFieldType, + #[serde(default)] + pub required: bool, + #[serde(default)] + pub unique: bool, + pub default: Option, + pub display_name: Option, + pub ui_widget: Option, + pub options: Option>, + #[serde(default)] + pub searchable: Option, + #[serde(default)] + pub filterable: Option, + #[serde(default)] + pub sortable: Option, + #[serde(default)] + pub visible_when: Option, + pub ref_entity: Option, // 外键引用的实体名 + pub ref_label_field: Option, // entity_select 下拉显示的字段名 + pub ref_search_fields: Option>, // entity_select 搜索匹配的字段列表 + pub cascade_from: Option, // 级联过滤的来源字段(当前实体) + pub cascade_filter: Option, // 级联过滤的目标字段(引用实体的字段) + pub validation: Option, // 字段校验规则 + #[serde(default)] + pub no_cycle: Option, // 禁止循环引用 + #[serde(default)] + pub scope_role: Option, // 标记为数据权限的"所有者"字段 + pub ref_plugin: Option, // 跨插件引用的目标插件 manifest ID(如 "erp-crm") + pub ref_fallback_label: Option, // 目标插件未安装时的降级显示文本 +} + +/// 字段类型 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginFieldType { + String, + Integer, + Float, + Boolean, + Date, + DateTime, + Json, + Uuid, + Decimal, +} + +impl PluginFieldType { + /// Generated Column 的 SQL 类型 + pub fn generated_sql_type(&self) -> &'static str { + match self { + Self::String | Self::Json => "TEXT", + Self::Integer => "INTEGER", + Self::Float => "DOUBLE PRECISION", + Self::Decimal => "NUMERIC", + Self::Boolean => "BOOLEAN", + Self::Date => "DATE", + // TIMESTAMPTZ cast 不是 immutable,generated column 不支持类型转换,存为 TEXT + Self::DateTime => "TEXT", + Self::Uuid => "UUID", + } + } + + /// Generated Column 的表达式 + pub fn generated_expr(&self, field_name: &str) -> String { + match self { + Self::String | Self::Json | Self::DateTime => format!("data->>'{}'", field_name), + _ => format!("(data->>'{}')::{}", field_name, self.generated_sql_type()), + } + } + + /// 该类型是否适合生成 Generated Column + pub fn supports_generated_column(&self) -> bool { + !matches!(self, Self::Json) + } +} + +impl PluginField { + /// 测试辅助:构造一个全默认值的 PluginField + #[cfg(test)] + pub fn default_for_field() -> Self { + Self { + name: String::new(), + field_type: PluginFieldType::String, + required: false, + unique: false, + default: None, + display_name: None, + ui_widget: None, + options: None, + searchable: None, + filterable: None, + sortable: None, + visible_when: None, + ref_entity: None, + ref_label_field: None, + ref_search_fields: None, + cascade_from: None, + cascade_filter: None, + validation: None, + no_cycle: None, + scope_role: None, + ref_plugin: None, + ref_fallback_label: None, + } + } +} + +/// 索引定义 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginIndex { + pub name: String, + pub fields: Vec, + #[serde(default)] + pub unique: bool, +} + +/// 级联删除策略 +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum OnDeleteStrategy { + Nullify, // 置空外键字段 + Cascade, // 级联软删除 + Restrict, // 存在关联时拒绝删除 +} + +/// 实体关联关系声明 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginRelation { + pub entity: String, + pub foreign_key: String, + pub on_delete: OnDeleteStrategy, + #[serde(default)] + pub name: Option, // 关联名称(UI 显示用) + #[serde(default, alias = "type")] + pub relation_type: Option, // "one_to_many" | "many_to_one" | "many_to_many" + #[serde(default)] + pub display_field: Option, // 关联记录的显示字段 +} + +/// 事件订阅配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginEvents { + pub subscribe: Vec, +} + +/// UI 页面配置 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginUi { + pub pages: Vec, +} + +/// 插件页面类型(tagged enum,TOML 中通过 type 字段区分) +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type")] +pub enum PluginPageType { + #[serde(rename = "crud")] + Crud { + entity: String, + label: String, + #[serde(default)] + icon: Option, + #[serde(default)] + enable_search: Option, + #[serde(default)] + enable_views: Option>, + }, + #[serde(rename = "tree")] + Tree { + entity: String, + label: String, + #[serde(default)] + icon: Option, + id_field: String, + parent_field: String, + label_field: String, + }, + #[serde(rename = "detail")] + Detail { + entity: String, + label: String, + sections: Vec, + }, + #[serde(rename = "tabs")] + Tabs { + label: String, + #[serde(default)] + icon: Option, + tabs: Vec, + }, + #[serde(rename = "graph")] + Graph { + entity: String, + label: String, + #[serde(default)] + icon: Option, + relationship_entity: String, + source_field: String, + target_field: String, + edge_label_field: String, + node_label_field: String, + }, + #[serde(rename = "dashboard")] + Dashboard { + label: String, + #[serde(default)] + icon: Option, + #[serde(default)] + widgets: Vec, + }, + #[serde(rename = "kanban")] + Kanban { + entity: String, + label: String, + #[serde(default)] + icon: Option, + lane_field: String, + #[serde(default)] + lane_order: Vec, + card_title_field: String, + #[serde(default)] + card_subtitle_field: Option, + #[serde(default)] + card_fields: Vec, + #[serde(default)] + enable_drag: Option, + }, +} + +/// Dashboard Widget 类型 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PluginWidget { + #[serde(rename = "stat_cards")] + StatCards { label: String, cards: Vec }, + #[serde(rename = "action_list")] + ActionList { + label: String, + #[serde(default)] + max_items: Option, + queries: Vec, + }, + #[serde(rename = "funnel")] + Funnel { + label: String, + entity: String, + lane_field: String, + #[serde(default)] + value_field: Option, + lane_order: Vec, + }, + #[serde(rename = "card_list")] + CardList { + label: String, + entity: String, + #[serde(default)] + filter: Option, + #[serde(default)] + max_items: Option, + title_field: String, + #[serde(default)] + subtitle_field: Option, + #[serde(default)] + tags: Vec, + }, +} + +/// 统计卡片 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct StatCard { + pub entity: String, + #[serde(default)] + pub aggregate: Option, + #[serde(default)] + pub field: Option, + #[serde(default)] + pub filter: Option, + pub label: String, + #[serde(default)] + pub icon: Option, + #[serde(default)] + pub color: Option, +} + +/// 待办行动查询 +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct ActionQuery { + pub entity: String, + #[serde(default)] + pub filter: Option, + #[serde(default)] + pub sort: Option, + pub label_field: String, + #[serde(default)] + pub subtitle_field: Option, + pub action: String, + #[serde(default)] + pub icon: Option, +} + +/// 插件页面区段(用于 detail 页面类型) +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type")] +pub enum PluginSection { + #[serde(rename = "fields")] + Fields { label: String, fields: Vec }, + #[serde(rename = "crud")] + Crud { + label: String, + entity: String, + #[serde(default)] + filter_field: Option, + #[serde(default)] + enable_views: Option>, + }, +} + +/// 权限定义 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginPermission { + pub code: String, + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub data_scope_levels: Option>, // 支持的数据范围等级 +} + +// ============================================================ +// P2 平台通用服务 — manifest 扩展 +// ============================================================ + +/// 插件配置项声明 — 平台根据此声明自动生成配置页面 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginSettings { + pub fields: Vec, +} + +/// 单个配置字段 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginSettingField { + pub name: String, + pub display_name: String, + #[serde(default)] + pub field_type: PluginSettingType, + #[serde(default)] + pub default_value: Option, + #[serde(default)] + pub required: bool, + #[serde(default)] + pub description: Option, + /// select/multiselect 类型的选项列表 + #[serde(default)] + pub options: Option>, + /// 数值范围 [min, max] + #[serde(default)] + pub range: Option<(f64, f64)>, + /// 分组名称 — 同组的字段在 UI 上放在一起 + #[serde(default)] + pub group: Option, +} + +/// 配置字段类型 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PluginSettingType { + #[default] + Text, + Number, + Boolean, + Select, + Multiselect, + Color, + Date, + Datetime, + Json, +} + +/// 编号规则声明 — 绑定实体字段到自动编号 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginNumbering { + pub entity: String, + pub field: String, + #[serde(default)] + pub prefix: String, + #[serde(default = "default_numbering_format")] + pub format: String, + #[serde(default)] + pub reset_rule: PluginNumberingReset, + #[serde(default = "default_seq_length")] + pub seq_length: u32, + #[serde(default)] + pub separator: Option, +} + +fn default_numbering_format() -> String { + "{PREFIX}-{YEAR}-{SEQ:4}".to_string() +} + +fn default_seq_length() -> u32 { + 4 +} + +/// 编号重置周期 +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum PluginNumberingReset { + #[default] + Never, + Daily, + Monthly, + Yearly, +} + +/// 打印模板声明 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginTemplate { + pub name: String, + pub display_name: String, + pub entity: String, + #[serde(default = "default_template_format")] + pub format: String, + /// 模板文件路径(相对于插件根目录) + #[serde(default)] + pub template_file: Option, + /// 内联 HTML 模板(与 template_file 二选一) + #[serde(default)] + pub template_html: Option, +} + +fn default_template_format() -> String { + "pdf".to_string() +} + +/// 触发事件声明 — 数据 CRUD 操作时自动发布域事件 +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginTriggerEvent { + pub name: String, + pub display_name: String, + #[serde(default)] + pub description: String, + pub entity: String, + pub on: PluginTriggerOn, +} + +/// 触发时机 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PluginTriggerOn { + Create, + Update, + Delete, + CreateOrUpdate, +} + +/// 从 TOML 字符串解析插件清单 +pub fn parse_manifest(toml_str: &str) -> PluginResult { + let manifest: PluginManifest = + toml::from_str(toml_str).map_err(|e| PluginError::InvalidManifest(e.to_string()))?; + + // 验证必填字段 + if manifest.metadata.id.is_empty() { + return Err(PluginError::InvalidManifest( + "metadata.id 不能为空".to_string(), + )); + } + if manifest.metadata.name.is_empty() { + return Err(PluginError::InvalidManifest( + "metadata.name 不能为空".to_string(), + )); + } + + // 验证实体名称 + if let Some(schema) = &manifest.schema { + for entity in &schema.entities { + if entity.name.is_empty() { + return Err(PluginError::InvalidManifest( + "entity.name 不能为空".to_string(), + )); + } + // 验证实体名称只包含合法字符 + if !entity + .name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + { + return Err(PluginError::InvalidManifest(format!( + "entity.name '{}' 只能包含字母、数字和下划线", + entity.name + ))); + } + } + } + + // 验证页面类型配置 + if let Some(ui) = &manifest.ui { + validate_pages(&ui.pages)?; + } + + // 验证编号规则引用的实体存在 + if let Some(numbering) = &manifest.numbering { + let entity_names: Vec<&str> = manifest + .schema + .as_ref() + .map(|s| s.entities.iter().map(|e| e.name.as_str()).collect()) + .unwrap_or_default(); + for rule in numbering { + if !entity_names.contains(&rule.entity.as_str()) { + return Err(PluginError::InvalidManifest(format!( + "numbering 引用了不存在的 entity '{}'", + rule.entity + ))); + } + } + } + + // 验证触发事件引用的实体存在 + if let Some(triggers) = &manifest.trigger_events { + let entity_names: Vec<&str> = manifest + .schema + .as_ref() + .map(|s| s.entities.iter().map(|e| e.name.as_str()).collect()) + .unwrap_or_default(); + for trigger in triggers { + if !entity_names.contains(&trigger.entity.as_str()) { + return Err(PluginError::InvalidManifest(format!( + "trigger_events 引用了不存在的 entity '{}'", + trigger.entity + ))); + } + } + } + + Ok(manifest) +} + +/// 递归验证页面配置 +fn validate_pages(pages: &[PluginPageType]) -> PluginResult<()> { + for page in pages { + match page { + PluginPageType::Crud { entity, .. } => { + if entity.is_empty() { + return Err(PluginError::InvalidManifest( + "crud page 的 entity 不能为空".into(), + )); + } + } + PluginPageType::Tree { + entity: _, + label: _, + icon: _, + id_field, + parent_field, + label_field, + } => { + if id_field.is_empty() || parent_field.is_empty() || label_field.is_empty() { + return Err(PluginError::InvalidManifest( + "tree page 的 id_field/parent_field/label_field 不能为空".into(), + )); + } + } + PluginPageType::Detail { + entity, sections, .. + } => { + if entity.is_empty() { + return Err(PluginError::InvalidManifest( + "detail page 的 entity 不能为空".into(), + )); + } + if sections.is_empty() { + return Err(PluginError::InvalidManifest( + "detail page 的 sections 不能为空".into(), + )); + } + } + PluginPageType::Tabs { tabs, .. } => { + if tabs.is_empty() { + return Err(PluginError::InvalidManifest( + "tabs page 的 tabs 不能为空".into(), + )); + } + validate_pages(tabs)?; + } + PluginPageType::Graph { + entity, + relationship_entity, + source_field, + target_field, + .. + } => { + if entity.is_empty() || relationship_entity.is_empty() { + return Err(PluginError::InvalidManifest( + "graph page 的 entity/relationship_entity 不能为空".into(), + )); + } + if source_field.is_empty() || target_field.is_empty() { + return Err(PluginError::InvalidManifest( + "graph page 的 source_field/target_field 不能为空".into(), + )); + } + } + PluginPageType::Dashboard { .. } => { + // dashboard 无需额外验证 + } + PluginPageType::Kanban { + entity, + lane_field, + card_title_field, + .. + } => { + if entity.is_empty() { + return Err(PluginError::InvalidManifest( + "kanban page 的 entity 不能为空".into(), + )); + } + if lane_field.is_empty() { + return Err(PluginError::InvalidManifest( + "kanban page 的 lane_field 不能为空".into(), + )); + } + if card_title_field.is_empty() { + return Err(PluginError::InvalidManifest( + "kanban page 的 card_title_field 不能为空".into(), + )); + } + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_minimal_manifest() { + let toml = r#" +[metadata] +id = "test-plugin" +name = "测试插件" +version = "0.1.0" +"#; + let manifest = parse_manifest(toml).unwrap(); + assert_eq!(manifest.metadata.id, "test-plugin"); + assert_eq!(manifest.metadata.name, "测试插件"); + assert!(manifest.schema.is_none()); + } + + #[test] + fn parse_full_manifest() { + let toml = r#" +[metadata] +id = "inventory" +name = "进销存" +version = "1.0.0" +description = "简单进销存管理" +author = "ERP Team" + +[schema] +[[schema.entities]] +name = "product" +display_name = "商品" + +[[schema.entities.fields]] +name = "sku" +field_type = "string" +required = true +unique = true +display_name = "SKU 编码" + +[[schema.entities.fields]] +name = "price" +field_type = "decimal" +required = true +display_name = "价格" + +[events] +subscribe = ["workflow.task.completed", "order.*"] + +[ui] +[[ui.pages]] +type = "crud" +entity = "product" +label = "商品管理" +icon = "ShoppingOutlined" + +[[permissions]] +code = "product.list" +name = "查看商品" +description = "查看商品列表" +"#; + let manifest = parse_manifest(toml).unwrap(); + assert_eq!(manifest.metadata.id, "inventory"); + let schema = manifest.schema.unwrap(); + assert_eq!(schema.entities.len(), 1); + assert_eq!(schema.entities[0].name, "product"); + assert_eq!(schema.entities[0].fields.len(), 2); + let events = manifest.events.unwrap(); + assert_eq!(events.subscribe.len(), 2); + let ui = manifest.ui.unwrap(); + assert_eq!(ui.pages.len(), 1); + // 验证新格式解析正确 + match &ui.pages[0] { + PluginPageType::Crud { entity, label, .. } => { + assert_eq!(entity, "product"); + assert_eq!(label, "商品管理"); + } + _ => panic!("Expected Crud page type"), + } + } + + #[test] + fn reject_empty_id() { + let toml = r#" +[metadata] +id = "" +name = "测试" +version = "0.1.0" +"#; + let result = parse_manifest(toml); + assert!(result.is_err()); + } + + #[test] + fn reject_invalid_entity_name() { + let toml = r#" +[metadata] +id = "test" +name = "测试" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "my-table" +display_name = "表格" +"#; + let result = parse_manifest(toml); + assert!(result.is_err()); + } + + #[test] + fn parse_manifest_with_new_fields_and_page_types() { + let toml = r#" +[metadata] +id = "test-plugin" +name = "Test" +version = "0.1.0" +description = "Test" +author = "Test" +min_platform_version = "0.1.0" + +[[schema.entities]] +name = "customer" +display_name = "客户" + +[[schema.entities.fields]] +name = "code" +field_type = "string" +required = true +display_name = "编码" +unique = true +searchable = true +filterable = true +visible_when = "customer_type == 'enterprise'" + +[[ui.pages]] +type = "tabs" +label = "客户管理" +icon = "team" + +[[ui.pages.tabs]] +label = "客户列表" +type = "crud" +entity = "customer" +enable_search = true +enable_views = ["table", "timeline"] + +[[ui.pages]] +type = "detail" +entity = "customer" +label = "客户详情" + +[[ui.pages.sections]] +type = "fields" +label = "基本信息" +fields = ["code", "name"] +"#; + let manifest = parse_manifest(toml).expect("should parse"); + let field = &manifest.schema.as_ref().unwrap().entities[0].fields[0]; + assert_eq!(field.searchable, Some(true)); + assert_eq!(field.filterable, Some(true)); + assert_eq!( + field.visible_when.as_deref(), + Some("customer_type == 'enterprise'") + ); + // 验证页面类型解析 + let ui = manifest.ui.as_ref().unwrap(); + assert_eq!(ui.pages.len(), 2); + // tabs 页面 + match &ui.pages[0] { + PluginPageType::Tabs { label, tabs, .. } => { + assert_eq!(label, "客户管理"); + assert_eq!(tabs.len(), 1); + } + _ => panic!("Expected Tabs page type"), + } + // detail 页面 + match &ui.pages[1] { + PluginPageType::Detail { + entity, sections, .. + } => { + assert_eq!(entity, "customer"); + assert_eq!(sections.len(), 1); + } + _ => panic!("Expected Detail page type"), + } + } + + #[test] + fn reject_empty_entity_in_crud_page() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[ui] +[[ui.pages]] +type = "crud" +entity = "" +label = "测试" +"#; + let result = parse_manifest(toml); + assert!(result.is_err()); + } + + #[test] + fn reject_empty_tabs_in_tabs_page() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[ui] +[[ui.pages]] +type = "tabs" +label = "空标签页" +"#; + let result = parse_manifest(toml); + assert!(result.is_err()); + } + + #[test] + fn field_type_to_sql_mapping() { + assert_eq!(PluginFieldType::String.generated_sql_type(), "TEXT"); + assert_eq!(PluginFieldType::Integer.generated_sql_type(), "INTEGER"); + assert_eq!( + PluginFieldType::Float.generated_sql_type(), + "DOUBLE PRECISION" + ); + assert_eq!(PluginFieldType::Decimal.generated_sql_type(), "NUMERIC"); + assert_eq!(PluginFieldType::Boolean.generated_sql_type(), "BOOLEAN"); + assert_eq!(PluginFieldType::Date.generated_sql_type(), "DATE"); + assert_eq!(PluginFieldType::DateTime.generated_sql_type(), "TEXT"); + assert_eq!(PluginFieldType::Uuid.generated_sql_type(), "UUID"); + assert_eq!(PluginFieldType::Json.generated_sql_type(), "TEXT"); + } + + #[test] + fn field_type_generated_expression() { + assert_eq!( + PluginFieldType::String.generated_expr("name"), + "data->>'name'" + ); + assert_eq!( + PluginFieldType::Integer.generated_expr("age"), + "(data->>'age')::INTEGER" + ); + assert_eq!( + PluginFieldType::Uuid.generated_expr("ref_id"), + "(data->>'ref_id')::UUID" + ); + } + + #[test] + fn parse_field_with_ref_entity() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "contact" +display_name = "联系人" + +[[schema.entities.fields]] +name = "customer_id" +field_type = "uuid" +required = true +display_name = "所属客户" +ref_entity = "customer" +"#; + let manifest = parse_manifest(toml).unwrap(); + let field = &manifest.schema.unwrap().entities[0].fields[0]; + assert_eq!(field.ref_entity.as_deref(), Some("customer")); + } + + #[test] + fn parse_field_with_validation() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "contact" +display_name = "联系人" + +[[schema.entities.fields]] +name = "phone" +field_type = "string" +display_name = "手机号" +validation = { pattern = "^1[3-9]\\d{9}$", message = "手机号格式不正确" } +"#; + let manifest = parse_manifest(toml).unwrap(); + let field = &manifest.schema.unwrap().entities[0].fields[0]; + let v = field.validation.as_ref().unwrap(); + assert_eq!(v.pattern.as_deref(), Some("^1[3-9]\\d{9}$")); + assert_eq!(v.message.as_deref(), Some("手机号格式不正确")); + } + + #[test] + fn parse_field_with_no_cycle() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "customer" +display_name = "客户" + +[[schema.entities.fields]] +name = "parent_id" +field_type = "uuid" +display_name = "上级客户" +ref_entity = "customer" +no_cycle = true +"#; + let manifest = parse_manifest(toml).unwrap(); + let field = &manifest.schema.unwrap().entities[0].fields[0]; + assert_eq!(field.no_cycle, Some(true)); + assert_eq!(field.ref_entity.as_deref(), Some("customer")); + } + + #[test] + fn parse_entity_with_relations() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "customer" +display_name = "客户" + +[[schema.entities.fields]] +name = "code" +field_type = "string" +required = true +display_name = "编码" + +[[schema.entities.relations]] +entity = "contact" +foreign_key = "customer_id" +on_delete = "cascade" + +[[schema.entities.relations]] +entity = "customer_tag" +foreign_key = "customer_id" +on_delete = "cascade" +"#; + let manifest = parse_manifest(toml).unwrap(); + let entity = &manifest.schema.unwrap().entities[0]; + assert_eq!(entity.relations.len(), 2); + assert_eq!(entity.relations[0].entity, "contact"); + assert_eq!(entity.relations[0].foreign_key, "customer_id"); + assert!(matches!( + entity.relations[0].on_delete, + OnDeleteStrategy::Cascade + )); + } + + #[test] + fn parse_entity_with_data_scope() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "customer" +display_name = "客户" +data_scope = true + +[[schema.entities.fields]] +name = "owner_id" +field_type = "uuid" +display_name = "负责人" +scope_role = "owner" +"#; + let manifest = parse_manifest(toml).unwrap(); + let entity = &manifest.schema.unwrap().entities[0]; + assert_eq!(entity.data_scope, Some(true)); + assert_eq!(entity.fields[0].scope_role.as_deref(), Some("owner")); + } + + #[test] + fn parse_permission_with_data_scope_levels() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[[permissions]] +code = "customer.list" +name = "查看客户" +data_scope_levels = ["self", "department", "department_tree", "all"] +"#; + let manifest = parse_manifest(toml).unwrap(); + let perm = &manifest.permissions.unwrap()[0]; + assert_eq!(perm.data_scope_levels.as_ref().unwrap().len(), 4); + } + + #[test] + fn parse_field_with_entity_select() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "contact" +display_name = "联系人" + +[[schema.entities.fields]] +name = "customer_id" +field_type = "uuid" +required = true +display_name = "所属客户" +ui_widget = "entity_select" +ref_label_field = "name" +ref_search_fields = ["name", "code"] +"#; + let manifest = parse_manifest(toml).unwrap(); + let field = &manifest.schema.unwrap().entities[0].fields[0]; + assert_eq!(field.ui_widget.as_deref(), Some("entity_select")); + assert_eq!(field.ref_label_field.as_deref(), Some("name")); + assert_eq!( + field.ref_search_fields.as_deref(), + Some(&["name".to_string(), "code".to_string()][..]) + ); + } + + #[test] + fn parse_field_with_cascade() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "communication" +display_name = "沟通记录" + +[[schema.entities.fields]] +name = "customer_id" +field_type = "uuid" +required = true +display_name = "关联客户" + +[[schema.entities.fields]] +name = "contact_id" +field_type = "uuid" +display_name = "关联联系人" +ui_widget = "entity_select" +ref_label_field = "name" +ref_search_fields = ["name"] +cascade_from = "customer_id" +cascade_filter = "customer_id" +"#; + let manifest = parse_manifest(toml).unwrap(); + let fields = &manifest.schema.unwrap().entities[0].fields; + let contact_field = &fields[1]; + assert_eq!(contact_field.ui_widget.as_deref(), Some("entity_select")); + assert_eq!(contact_field.cascade_from.as_deref(), Some("customer_id")); + assert_eq!(contact_field.cascade_filter.as_deref(), Some("customer_id")); + } + + #[test] + fn parse_field_with_cross_plugin_ref() { + let toml = r#" +[metadata] +id = "erp-inventory" +name = "进销存" +version = "0.2.0" +dependencies = ["erp-crm"] + +[schema] +[[schema.entities]] +name = "sales_order" +display_name = "销售订单" + +[[schema.entities.fields]] +name = "customer_id" +field_type = "uuid" +display_name = "客户" +ui_widget = "entity_select" +ref_plugin = "erp-crm" +ref_entity = "customer" +ref_label_field = "name" +ref_search_fields = ["name", "code"] +ref_fallback_label = "CRM 客户" +"#; + let manifest = parse_manifest(toml).unwrap(); + let field = &manifest.schema.unwrap().entities[0].fields[0]; + assert_eq!(field.ref_plugin.as_deref(), Some("erp-crm")); + assert_eq!(field.ref_entity.as_deref(), Some("customer")); + assert_eq!(field.ref_label_field.as_deref(), Some("name")); + assert_eq!(field.ref_fallback_label.as_deref(), Some("CRM 客户")); + assert_eq!(manifest.metadata.dependencies, vec!["erp-crm"]); + } + + #[test] + fn parse_entity_with_is_public() { + let toml = r#" +[metadata] +id = "erp-crm" +name = "CRM" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "customer" +display_name = "客户" +is_public = true + +[[schema.entities]] +name = "internal_config" +display_name = "内部配置" +"#; + let manifest = parse_manifest(toml).unwrap(); + let entities = &manifest.schema.unwrap().entities; + assert_eq!(entities[0].is_public, Some(true)); + assert_eq!(entities[1].is_public, None); + } + + #[test] + fn parse_relation_with_name_and_type() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "customer" +display_name = "客户" + +[[schema.entities.fields]] +name = "code" +field_type = "string" +display_name = "编码" + +[[schema.entities.relations]] +entity = "contact" +foreign_key = "customer_id" +on_delete = "cascade" +name = "contacts" +type = "one_to_many" +display_field = "name" +"#; + let manifest = parse_manifest(toml).unwrap(); + let relation = &manifest.schema.unwrap().entities[0].relations[0]; + assert_eq!(relation.entity, "contact"); + assert_eq!(relation.name.as_deref(), Some("contacts")); + assert_eq!(relation.relation_type.as_deref(), Some("one_to_many")); + assert_eq!(relation.display_field.as_deref(), Some("name")); + } + + #[test] + fn parse_kanban_page() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[ui] +[[ui.pages]] +type = "kanban" +entity = "customer" +label = "销售漏斗" +icon = "swap" +lane_field = "level" +lane_order = ["potential", "normal", "vip", "svip"] +card_title_field = "name" +card_subtitle_field = "code" +card_fields = ["region", "status"] +enable_drag = true +"#; + let manifest = parse_manifest(toml).unwrap(); + let ui = manifest.ui.unwrap(); + assert_eq!(ui.pages.len(), 1); + match &ui.pages[0] { + PluginPageType::Kanban { + entity, + label, + icon, + lane_field, + lane_order, + card_title_field, + card_subtitle_field, + card_fields, + enable_drag, + } => { + assert_eq!(entity, "customer"); + assert_eq!(label, "销售漏斗"); + assert_eq!(icon.as_deref(), Some("swap")); + assert_eq!(lane_field, "level"); + assert_eq!(lane_order, &["potential", "normal", "vip", "svip"]); + assert_eq!(card_title_field, "name"); + assert_eq!(card_subtitle_field.as_deref(), Some("code")); + assert_eq!(card_fields, &["region", "status"]); + assert_eq!(*enable_drag, Some(true)); + } + _ => panic!("Expected Kanban page type"), + } + } + + #[test] + fn reject_empty_entity_in_kanban_page() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[ui] +[[ui.pages]] +type = "kanban" +entity = "" +label = "测试" +lane_field = "status" +card_title_field = "name" +"#; + let result = parse_manifest(toml); + assert!(result.is_err()); + } + + #[test] + fn reject_empty_lane_field_in_kanban_page() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[ui] +[[ui.pages]] +type = "kanban" +entity = "customer" +label = "测试" +lane_field = "" +card_title_field = "name" +"#; + let result = parse_manifest(toml); + assert!(result.is_err()); + } + + // ============================================================ + // P2 manifest 扩展测试 + // ============================================================ + + #[test] + fn parse_settings_section() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[settings] +[[settings.fields]] +name = "default_tax_rate" +display_name = "默认税率" +field_type = "number" +default_value = 0.13 +range = [0.0, 1.0] +group = "财务" + +[[settings.fields]] +name = "invoice_prefix" +display_name = "发票前缀" +field_type = "text" +default_value = "INV" + +[[settings.fields]] +name = "auto_notify" +display_name = "自动通知" +field_type = "boolean" +default_value = true +description = "发票创建后是否自动发送通知" +"#; + let manifest = parse_manifest(toml).unwrap(); + let settings = manifest.settings.unwrap(); + assert_eq!(settings.fields.len(), 3); + assert_eq!(settings.fields[0].name, "default_tax_rate"); + assert_eq!(settings.fields[0].range, Some((0.0, 1.0))); + assert_eq!(settings.fields[0].group.as_deref(), Some("财务")); + assert_eq!(settings.fields[1].name, "invoice_prefix"); + assert_eq!(settings.fields[2].name, "auto_notify"); + assert!(matches!( + settings.fields[2].field_type, + PluginSettingType::Boolean + )); + } + + #[test] + fn parse_numbering_section() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "invoice" +display_name = "发票" + +[[numbering]] +entity = "invoice" +field = "invoice_no" +prefix = "INV" +format = "{PREFIX}-{YEAR}-{SEQ:4}" +reset_rule = "yearly" +seq_length = 4 +"#; + let manifest = parse_manifest(toml).unwrap(); + let numbering = manifest.numbering.unwrap(); + assert_eq!(numbering.len(), 1); + assert_eq!(numbering[0].entity, "invoice"); + assert_eq!(numbering[0].field, "invoice_no"); + assert_eq!(numbering[0].prefix, "INV"); + assert!(matches!( + numbering[0].reset_rule, + PluginNumberingReset::Yearly + )); + } + + #[test] + fn reject_numbering_with_unknown_entity() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[[numbering]] +entity = "nonexistent" +field = "code" +prefix = "T" +"#; + let result = parse_manifest(toml); + assert!(result.is_err()); + } + + #[test] + fn parse_trigger_events_section() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "invoice" +display_name = "发票" + +[[trigger_events]] +name = "invoice.created" +display_name = "发票创建" +description = "新发票创建时触发" +entity = "invoice" +on = "create" + +[[trigger_events]] +name = "invoice.overdue" +display_name = "发票逾期" +description = "发票超过付款期限未收款" +entity = "invoice" +on = "update" +"#; + let manifest = parse_manifest(toml).unwrap(); + let triggers = manifest.trigger_events.unwrap(); + assert_eq!(triggers.len(), 2); + assert_eq!(triggers[0].name, "invoice.created"); + assert!(matches!(triggers[0].on, PluginTriggerOn::Create)); + assert_eq!(triggers[1].name, "invoice.overdue"); + } + + #[test] + fn reject_trigger_event_with_unknown_entity() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[[trigger_events]] +name = "test.trigger" +display_name = "测试" +entity = "nonexistent" +on = "create" +"#; + let result = parse_manifest(toml); + assert!(result.is_err()); + } + + #[test] + fn parse_entity_with_import_export() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "product" +display_name = "商品" +importable = true +exportable = true + +[[schema.entities]] +name = "internal_log" +display_name = "内部日志" +"#; + let manifest = parse_manifest(toml).unwrap(); + let entities = &manifest.schema.unwrap().entities; + assert_eq!(entities[0].importable, Some(true)); + assert_eq!(entities[0].exportable, Some(true)); + assert_eq!(entities[1].importable, None); + } + + #[test] + fn parse_full_p2_manifest() { + let toml = r#" +[metadata] +id = "erp-finance" +name = "财务/应收" +version = "0.1.0" +description = "财务管理与应收账款" +author = "ERP Team" + +[schema] +[[schema.entities]] +name = "invoice" +display_name = "发票" +importable = true +exportable = true + +[[schema.entities.fields]] +name = "invoice_no" +field_type = "string" +required = true +unique = true +display_name = "发票编号" + +[[schema.entities.fields]] +name = "customer_id" +field_type = "uuid" +display_name = "客户" +ref_plugin = "erp-crm" +ref_entity = "customer" +ref_label_field = "name" +ref_search_fields = ["name"] +ref_fallback_label = "外部客户" + +[[schema.entities]] +name = "payment" +display_name = "收款" + +[settings] +[[settings.fields]] +name = "default_tax_rate" +display_name = "默认税率" +field_type = "number" +default_value = 0.13 +group = "税务" + +[[settings.fields]] +name = "invoice_prefix" +display_name = "发票前缀" +field_type = "text" +default_value = "INV" + +[[numbering]] +entity = "invoice" +field = "invoice_no" +prefix = "INV" +format = "{PREFIX}-{YEAR}-{SEQ:4}" +reset_rule = "yearly" + +[[trigger_events]] +name = "invoice.created" +display_name = "发票创建" +entity = "invoice" +on = "create" + +[[permissions]] +code = "invoice.list" +name = "查看发票" + +[[permissions]] +code = "invoice.manage" +name = "管理发票" +"#; + let manifest = parse_manifest(toml).unwrap(); + assert_eq!(manifest.metadata.id, "erp-finance"); + + // settings + let settings = manifest.settings.unwrap(); + assert_eq!(settings.fields.len(), 2); + + // numbering + let numbering = manifest.numbering.unwrap(); + assert_eq!(numbering.len(), 1); + assert_eq!(numbering[0].entity, "invoice"); + + // trigger_events + let triggers = manifest.trigger_events.unwrap(); + assert_eq!(triggers.len(), 1); + + // import/export on entity + let entities = &manifest.schema.unwrap().entities; + assert_eq!(entities[0].importable, Some(true)); + assert_eq!(entities[0].exportable, Some(true)); + } + + #[test] + fn parse_dashboard_with_widgets() { + let toml = r##" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "invoice" +display_name = "发票" + +[[schema.entities.fields]] +name = "status" +field_type = "string" +display_name = "状态" + +[[schema.entities.fields]] +name = "amount" +field_type = "decimal" +display_name = "金额" + +[ui] +[[ui.pages]] +type = "dashboard" +label = "工作台" +icon = "DashboardOutlined" + +[[ui.pages.widgets]] +type = "stat_cards" +label = "财务概览" + +[[ui.pages.widgets.cards]] +entity = "invoice" +aggregate = "count" +label = "总发票" +icon = "FileTextOutlined" +color = "#1890ff" + +[[ui.pages.widgets.cards]] +entity = "invoice" +aggregate = "sum" +field = "amount" +filter = "status == 'pending'" +label = "待收金额" +icon = "DollarOutlined" +color = "#faad14" + +[[ui.pages.widgets]] +type = "action_list" +label = "紧急待办" +max_items = 5 + +[[ui.pages.widgets.queries]] +entity = "invoice" +filter = "status == 'overdue'" +sort = "due_date asc" +label_field = "invoice_number" +subtitle_field = "amount" +action = "open_invoice" +icon = "warning" + +[[ui.pages.widgets]] +type = "funnel" +label = "商机漏斗" +entity = "invoice" +lane_field = "status" +value_field = "amount" +lane_order = ["pending", "issued", "paid"] + +[[ui.pages.widgets]] +type = "card_list" +label = "活跃项目" +entity = "invoice" +filter = "status == 'active'" +max_items = 10 +title_field = "invoice_number" +subtitle_field = "amount" +tags = ["status"] +"##; + let manifest = parse_manifest(toml).unwrap(); + let ui = manifest.ui.unwrap(); + assert_eq!(ui.pages.len(), 1); + match &ui.pages[0] { + PluginPageType::Dashboard { + label, + icon, + widgets, + } => { + assert_eq!(label, "工作台"); + assert_eq!(icon.as_deref(), Some("DashboardOutlined")); + assert_eq!(widgets.len(), 4); + + // stat_cards + match &widgets[0] { + PluginWidget::StatCards { label, cards } => { + assert_eq!(label, "财务概览"); + assert_eq!(cards.len(), 2); + assert_eq!(cards[0].entity, "invoice"); + assert_eq!(cards[0].aggregate.as_deref(), Some("count")); + assert_eq!(cards[1].aggregate.as_deref(), Some("sum")); + assert_eq!(cards[1].filter.as_deref(), Some("status == 'pending'")); + } + _ => panic!("Expected StatCards"), + } + + // action_list + match &widgets[1] { + PluginWidget::ActionList { + label, + max_items, + queries, + } => { + assert_eq!(label, "紧急待办"); + assert_eq!(*max_items, Some(5)); + assert_eq!(queries.len(), 1); + assert_eq!(queries[0].entity, "invoice"); + assert_eq!(queries[0].action, "open_invoice"); + } + _ => panic!("Expected ActionList"), + } + + // funnel + match &widgets[2] { + PluginWidget::Funnel { + label, + entity, + lane_field, + value_field, + lane_order, + } => { + assert_eq!(label, "商机漏斗"); + assert_eq!(entity, "invoice"); + assert_eq!(lane_field, "status"); + assert_eq!(value_field.as_deref(), Some("amount")); + assert_eq!(lane_order, &["pending", "issued", "paid"]); + } + _ => panic!("Expected Funnel"), + } + + // card_list + match &widgets[3] { + PluginWidget::CardList { + label, + entity, + title_field, + .. + } => { + assert_eq!(label, "活跃项目"); + assert_eq!(entity, "invoice"); + assert_eq!(title_field, "invoice_number"); + } + _ => panic!("Expected CardList"), + } + } + _ => panic!("Expected Dashboard page type"), + } + } +} diff --git a/crates/erp-plugin/src/module.rs b/crates/erp-plugin/src/module.rs new file mode 100644 index 0000000..a01592f --- /dev/null +++ b/crates/erp-plugin/src/module.rs @@ -0,0 +1,200 @@ +use async_trait::async_trait; +use axum::Router; +use axum::routing::{delete, get, post, put}; +use erp_core::module::{ErpModule, PermissionDescriptor}; + +pub struct PluginModule; + +#[async_trait] +impl ErpModule for PluginModule { + fn name(&self) -> &str { + "plugin" + } + + fn dependencies(&self) -> Vec<&str> { + vec!["auth", "config"] + } + + fn permissions(&self) -> Vec { + vec![ + PermissionDescriptor { + code: "plugin.admin".into(), + name: "插件管理".into(), + description: "管理插件全生命周期".into(), + module: "plugin".into(), + }, + PermissionDescriptor { + code: "plugin.list".into(), + name: "查看插件".into(), + description: "查看插件列表".into(), + module: "plugin".into(), + }, + ] + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +impl PluginModule { + /// 插件管理路由(需要 JWT 认证) + pub fn protected_routes() -> Router + where + crate::state::PluginState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + let admin_routes = Router::new() + .route( + "/admin/plugins/upload", + post(crate::handler::plugin_handler::upload_plugin::), + ) + .route( + "/admin/plugins", + get(crate::handler::plugin_handler::list_plugins::), + ) + .route( + "/admin/plugins/{id}", + get(crate::handler::plugin_handler::get_plugin::) + .delete(crate::handler::plugin_handler::purge_plugin::), + ) + .route( + "/admin/plugins/{id}/schema", + get(crate::handler::plugin_handler::get_plugin_schema::), + ) + .route( + "/admin/plugins/{id}/install", + post(crate::handler::plugin_handler::install_plugin::), + ) + .route( + "/admin/plugins/{id}/enable", + post(crate::handler::plugin_handler::enable_plugin::), + ) + .route( + "/admin/plugins/{id}/disable", + post(crate::handler::plugin_handler::disable_plugin::), + ) + .route( + "/admin/plugins/{id}/uninstall", + post(crate::handler::plugin_handler::uninstall_plugin::), + ) + .route( + "/admin/plugins/{id}/health", + get(crate::handler::plugin_handler::health_check_plugin::), + ) + .route( + "/admin/plugins/{id}/metrics", + get(crate::handler::plugin_handler::get_plugin_metrics::), + ) + .route( + "/admin/plugins/{id}/config", + put(crate::handler::plugin_handler::update_plugin_config::), + ) + .route( + "/admin/plugins/{id}/upgrade", + post(crate::handler::plugin_handler::upgrade_plugin::), + ) + .route( + "/admin/plugins/{id}/validate", + get(crate::handler::plugin_handler::validate_plugin::), + ); + + // 插件数据 CRUD 路由 + let data_routes = Router::new() + .route( + "/plugins/{plugin_id}/{entity}", + get(crate::handler::data_handler::list_plugin_data::) + .post(crate::handler::data_handler::create_plugin_data::), + ) + .route( + "/plugins/{plugin_id}/{entity}/{id}", + get(crate::handler::data_handler::get_plugin_data::) + .put(crate::handler::data_handler::update_plugin_data::) + .patch(crate::handler::data_handler::patch_plugin_data::) + .delete(crate::handler::data_handler::delete_plugin_data::), + ) + // 数据统计路由 + .route( + "/plugins/{plugin_id}/{entity}/count", + get(crate::handler::data_handler::count_plugin_data::), + ) + .route( + "/plugins/{plugin_id}/{entity}/aggregate", + get(crate::handler::data_handler::aggregate_plugin_data::), + ) + .route( + "/plugins/{plugin_id}/{entity}/aggregate-multi", + post(crate::handler::data_handler::aggregate_multi_plugin_data::), + ) + // 批量操作路由 + .route( + "/plugins/{plugin_id}/{entity}/batch", + post(crate::handler::data_handler::batch_plugin_data::), + ) + // 时间序列路由 + .route( + "/plugins/{plugin_id}/{entity}/timeseries", + get(crate::handler::data_handler::get_plugin_timeseries::), + ) + // 跨插件引用:批量标签解析 + .route( + "/plugins/{plugin_id}/{entity}/resolve-labels", + post(crate::handler::data_handler::resolve_ref_labels::), + ) + // 数据导入导出 + .route( + "/plugins/{plugin_id}/{entity}/export", + get(crate::handler::data_handler::export_plugin_data::), + ) + .route( + "/plugins/{plugin_id}/{entity}/import", + post(crate::handler::data_handler::import_plugin_data::), + ) + // 对账扫描 + .route( + "/plugins/{plugin_id}/reconcile", + post(crate::handler::data_handler::reconcile_refs::), + ) + // 用户自定义视图 + .route( + "/plugins/{plugin_id}/{entity}/views", + get(crate::handler::data_handler::list_user_views::) + .post(crate::handler::data_handler::create_user_view::), + ) + .route( + "/plugins/{plugin_id}/{entity}/views/{view_id}", + delete(crate::handler::data_handler::delete_user_view::), + ); + + // 实体注册表路由 + let registry_routes = Router::new().route( + "/plugin-registry/entities", + get(crate::handler::data_handler::list_public_entities::), + ); + + // 市场路由 + let market_routes = Router::new() + .route( + "/market/entries", + get(crate::handler::market_handler::list_market_entries::), + ) + .route( + "/market/entries/{id}", + get(crate::handler::market_handler::get_market_entry::), + ) + .route( + "/market/entries/{id}/install", + post(crate::handler::market_handler::install_from_market::), + ) + .route( + "/market/entries/{id}/reviews", + get(crate::handler::market_handler::list_market_reviews::) + .post(crate::handler::market_handler::submit_market_review::), + ); + + admin_routes + .merge(data_routes) + .merge(registry_routes) + .merge(market_routes) + } +} diff --git a/crates/erp-plugin/src/notification.rs b/crates/erp-plugin/src/notification.rs new file mode 100644 index 0000000..056cb96 --- /dev/null +++ b/crates/erp-plugin/src/notification.rs @@ -0,0 +1,109 @@ +use chrono::Utc; +use sea_orm::{ConnectionTrait, FromQueryResult, Statement}; +use uuid::Uuid; + +use erp_core::error::AppResult; +use erp_core::events::{DomainEvent, EventBus}; + +/// 启动插件通知监听器 — 订阅 plugin.trigger.* 事件 +pub fn start_notification_listener(db: sea_orm::DatabaseConnection, event_bus: EventBus) { + let (mut rx, _handle) = event_bus.subscribe_filtered("plugin.trigger.".to_string()); + + tokio::spawn(async move { + while let Some(event) = rx.recv().await { + if let Err(e) = handle_trigger_event(&event, &db).await { + tracing::warn!( + event_type = %event.event_type, + error = %e, + "Failed to handle plugin trigger notification" + ); + } + } + tracing::info!("Plugin notification listener stopped"); + }); +} + +async fn handle_trigger_event( + event: &DomainEvent, + db: &sea_orm::DatabaseConnection, +) -> AppResult<()> { + let plugin_id = event + .payload + .get("plugin_id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let trigger_name = event + .payload + .get("trigger_name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let entity = event + .payload + .get("entity") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let action = event + .payload + .get("action") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + + let title = format!("插件事件: {}.{}", plugin_id, trigger_name); + let body = format!( + "插件 [{}] 的实体 [{}] 触发了 [{}] 事件", + plugin_id, entity, action + ); + + // 查询所有管理员用户 + #[derive(FromQueryResult)] + struct AdminUser { + id: Uuid, + } + + let admins = AdminUser::find_by_statement(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + r#"SELECT u.id FROM users u + JOIN user_roles ur ON ur.user_id = u.id + JOIN roles r ON r.id = ur.role_id + WHERE u.tenant_id = $1 AND r.name = 'admin' AND u.deleted_at IS NULL"#, + [event.tenant_id.into()], + )) + .all(db) + .await + .map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; + + // 为每个管理员插入消息记录 + let now = Utc::now(); + for admin in &admins { + let msg_id = Uuid::now_v7(); + let sql = r#" + INSERT INTO messages (id, tenant_id, sender_type, recipient_id, recipient_type, + title, body, priority, is_read, created_at, updated_at, version) + VALUES ($1, $2, 'system', $3, 'user', $4, $5, 'normal', false, $6, $7, 1) + "#; + db.execute(Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + [ + msg_id.into(), + event.tenant_id.into(), + admin.id.into(), + title.clone().into(), + body.clone().into(), + now.into(), + now.into(), + ], + )) + .await + .map_err(|e| erp_core::error::AppError::Internal(e.to_string()))?; + } + + tracing::info!( + plugin_id = %plugin_id, + trigger = %trigger_name, + admin_count = admins.len(), + "Plugin trigger notification sent" + ); + + Ok(()) +} diff --git a/crates/erp-plugin/src/plugin_validator.rs b/crates/erp-plugin/src/plugin_validator.rs new file mode 100644 index 0000000..bf8b914 --- /dev/null +++ b/crates/erp-plugin/src/plugin_validator.rs @@ -0,0 +1,317 @@ +use crate::error::PluginResult; +use crate::manifest::PluginManifest; + +/// 插件上传时校验报告 +#[derive(Debug, Clone, serde::Serialize)] +pub struct ValidationReport { + pub valid: bool, + pub errors: Vec, + pub warnings: Vec, + pub metrics: PluginMetrics, +} + +/// 插件质量指标 +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct PluginMetrics { + pub entity_count: usize, + pub field_count: usize, + pub page_count: usize, + pub permission_count: usize, + pub relation_count: usize, + pub has_import_export: bool, + pub has_settings: bool, + pub has_numbering: bool, + pub has_trigger_events: bool, + pub wasm_size_bytes: usize, + pub complexity_score: f64, +} + +/// 运行时监控指标 +#[derive(Debug, Clone, Default, serde::Serialize)] +pub struct RuntimeMetrics { + pub error_count: u64, + pub total_invocations: u64, + pub avg_response_ms: f64, + pub fuel_consumption_avg: f64, + pub memory_peak_bytes: u64, + pub last_error: Option, + pub last_error_at: Option>, +} + +impl RuntimeMetrics { + pub fn error_rate(&self) -> f64 { + if self.total_invocations == 0 { + return 0.0; + } + self.error_count as f64 / self.total_invocations as f64 + } +} + +/// 上传时安全扫描 +pub fn validate_plugin_security( + manifest: &PluginManifest, + wasm_size: usize, +) -> PluginResult { + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + + // 1. WASM 大小检查(上限 10MB) + if wasm_size > 10 * 1024 * 1024 { + errors.push(format!("WASM 文件过大: {} bytes (上限 10MB)", wasm_size)); + } else if wasm_size > 5 * 1024 * 1024 { + warnings.push(format!("WASM 文件较大: {} bytes (>5MB)", wasm_size)); + } + + // 2. 实体数量检查(上限 20) + if let Some(schema) = &manifest.schema { + if schema.entities.len() > 20 { + errors.push(format!("实体数量过多: {} (上限 20)", schema.entities.len())); + } + + for entity in &schema.entities { + // 字段数量检查 + if entity.fields.len() > 50 { + errors.push(format!( + "实体 '{}' 字段数量过多: {} (上限 50)", + entity.name, + entity.fields.len() + )); + } + + // 索引数量检查 + if entity.indexes.len() > 10 { + warnings.push(format!( + "实体 '{}' 索引数量较多: {} (>10 可能影响写入性能)", + entity.name, + entity.indexes.len() + )); + } + + // 检查字段中有无潜在 SQL 注入风险的字段名 + for field in &entity.fields { + if field.name.len() > 64 { + errors.push(format!( + "字段名过长: '{}.{}' (上限 64 字符)", + entity.name, field.name + )); + } + if !field + .name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_') + { + errors.push(format!( + "字段名包含非法字符: '{}.{}' (只允许字母、数字、下划线)", + entity.name, field.name + )); + } + } + } + } + + // 3. 权限码命名规范检查 + if let Some(permissions) = &manifest.permissions { + for perm in permissions { + if !perm.code.contains('.') { + warnings.push(format!( + "权限码 '{}' 建议使用 'entity.action' 格式", + perm.code + )); + } + } + } + + // 4. 依赖检查 + if manifest.metadata.dependencies.len() > 5 { + warnings.push(format!( + "依赖数量较多: {} (>5 可能增加安装复杂度)", + manifest.metadata.dependencies.len() + )); + } + + // 5. 计算复杂度分数 + let mut metrics = collect_metrics(manifest, wasm_size); + metrics.complexity_score = calculate_complexity_score(&metrics); + + if metrics.complexity_score > 80.0 { + warnings.push(format!( + "插件复杂度较高: {:.1} (>80 建议拆分)", + metrics.complexity_score + )); + } + + let valid = errors.is_empty(); + Ok(ValidationReport { + valid, + errors, + warnings, + metrics, + }) +} + +/// 收集插件指标 +fn collect_metrics(manifest: &PluginManifest, wasm_size: usize) -> PluginMetrics { + let mut metrics = PluginMetrics { + wasm_size_bytes: wasm_size, + ..Default::default() + }; + + if let Some(schema) = &manifest.schema { + metrics.entity_count = schema.entities.len(); + for entity in &schema.entities { + metrics.field_count += entity.fields.len(); + metrics.relation_count += entity.relations.len(); + if entity.importable == Some(true) || entity.exportable == Some(true) { + metrics.has_import_export = true; + } + } + } + + if let Some(ui) = &manifest.ui { + metrics.page_count = count_pages(&ui.pages); + } + + if let Some(permissions) = &manifest.permissions { + metrics.permission_count = permissions.len(); + } + + metrics.has_settings = manifest.settings.is_some(); + metrics.has_numbering = manifest.numbering.as_ref().is_some_and(|n| !n.is_empty()); + metrics.has_trigger_events = manifest + .trigger_events + .as_ref() + .is_some_and(|t| !t.is_empty()); + + metrics +} + +fn count_pages(pages: &[crate::manifest::PluginPageType]) -> usize { + let mut count = 0; + for page in pages { + count += 1; + if let crate::manifest::PluginPageType::Tabs { tabs, .. } = page { + count += count_pages(tabs); + } + } + count +} + +/// 计算复杂度分数(0-100) +fn calculate_complexity_score(metrics: &PluginMetrics) -> f64 { + let entity_score = (metrics.entity_count as f64 / 20.0) * 30.0; + let field_score = (metrics.field_count as f64 / 100.0) * 20.0; + let page_score = (metrics.page_count as f64 / 20.0) * 15.0; + let relation_score = (metrics.relation_count as f64 / 30.0) * 15.0; + let size_score = (metrics.wasm_size_bytes as f64 / (10.0 * 1024.0 * 1024.0)) * 20.0; + + (entity_score + field_score + page_score + relation_score + size_score).min(100.0) +} + +/// 性能基准测试结果 +#[derive(Debug, Clone, serde::Serialize)] +pub struct BenchmarkResult { + pub create_avg_ms: f64, + pub read_avg_ms: f64, + pub update_avg_ms: f64, + pub delete_avg_ms: f64, + pub list_avg_ms: f64, + pub passed: bool, + pub details: String, +} + +impl BenchmarkResult { + /// 创建操作的阈值: 500ms + pub const CREATE_THRESHOLD_MS: f64 = 500.0; + /// 读取操作的阈值: 200ms + pub const READ_THRESHOLD_MS: f64 = 200.0; + /// 列表查询的阈值: 1000ms + pub const LIST_THRESHOLD_MS: f64 = 1000.0; + + pub fn check(&self) -> bool { + self.create_avg_ms <= Self::CREATE_THRESHOLD_MS + && self.read_avg_ms <= Self::READ_THRESHOLD_MS + && self.list_avg_ms <= Self::LIST_THRESHOLD_MS + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::manifest::parse_manifest; + + #[test] + fn validate_security_basic() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" + +[schema] +[[schema.entities]] +name = "product" +display_name = "商品" + +[[schema.entities.fields]] +name = "sku" +field_type = "string" +required = true +"#; + let manifest = parse_manifest(toml).unwrap(); + let report = validate_plugin_security(&manifest, 1024).unwrap(); + assert!(report.valid); + assert!(report.errors.is_empty()); + } + + #[test] + fn reject_oversized_wasm() { + let toml = r#" +[metadata] +id = "test" +name = "Test" +version = "0.1.0" +"#; + let manifest = parse_manifest(toml).unwrap(); + let report = validate_plugin_security(&manifest, 15 * 1024 * 1024).unwrap(); + assert!(!report.valid); + assert!(report.errors.iter().any(|e| e.contains("WASM 文件过大"))); + } + + #[test] + fn complexity_score_calculation() { + let metrics = PluginMetrics { + entity_count: 5, + field_count: 30, + page_count: 5, + relation_count: 3, + wasm_size_bytes: 500_000, + ..Default::default() + }; + let score = calculate_complexity_score(&metrics); + assert!(score > 0.0 && score < 50.0, "score = {}", score); + } + + #[test] + fn runtime_metrics_error_rate() { + let metrics = RuntimeMetrics { + error_count: 5, + total_invocations: 100, + ..Default::default() + }; + assert!((metrics.error_rate() - 0.05).abs() < 0.001); + } + + #[test] + fn benchmark_threshold_check() { + let result = BenchmarkResult { + create_avg_ms: 300.0, + read_avg_ms: 100.0, + update_avg_ms: 200.0, + delete_avg_ms: 150.0, + list_avg_ms: 800.0, + passed: true, + details: String::new(), + }; + assert!(result.check()); + } +} diff --git a/crates/erp-plugin/src/service.rs b/crates/erp-plugin/src/service.rs new file mode 100644 index 0000000..a78c5dd --- /dev/null +++ b/crates/erp-plugin/src/service.rs @@ -0,0 +1,1136 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, Set, +}; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use uuid::Uuid; + +use erp_core::sea_orm_ext::bump_version; + +use erp_core::error::AppResult; + +use crate::dto::{PluginEntityResp, PluginHealthResp, PluginPermissionResp, PluginResp}; +use crate::dynamic_table::DynamicTableManager; +use crate::engine::PluginEngine; +use crate::entity::{plugin, plugin_entity, plugin_event_subscription}; +use crate::error::PluginError; +use crate::manifest::{PluginManifest, parse_manifest}; + +pub struct PluginService; + +impl PluginService { + /// 上传插件: 解析 manifest + 存储 wasm_binary + status=uploaded + pub async fn upload( + tenant_id: Uuid, + operator_id: Uuid, + wasm_binary: Vec, + manifest_toml: &str, + db: &sea_orm::DatabaseConnection, + ) -> AppResult { + // 解析 manifest + let manifest = parse_manifest(manifest_toml)?; + + // 安全扫描 + let validation = + crate::plugin_validator::validate_plugin_security(&manifest, wasm_binary.len())?; + if !validation.valid { + return Err(PluginError::ValidationError(format!( + "插件安全校验失败: {}", + validation.errors.join("; ") + )) + .into()); + } + + // 计算 WASM hash + let mut hasher = Sha256::new(); + hasher.update(&wasm_binary); + let wasm_hash = format!("{:x}", hasher.finalize()); + + let now = Utc::now(); + let plugin_id = Uuid::now_v7(); + + // 序列化 manifest 为 JSON + let manifest_json = serde_json::to_value(&manifest) + .map_err(|e| PluginError::InvalidManifest(e.to_string()))?; + + let model = plugin::ActiveModel { + id: Set(plugin_id), + tenant_id: Set(tenant_id), + name: Set(manifest.metadata.name.clone()), + plugin_version: Set(manifest.metadata.version.clone()), + description: Set(if manifest.metadata.description.is_empty() { + None + } else { + Some(manifest.metadata.description.clone()) + }), + author: Set(if manifest.metadata.author.is_empty() { + None + } else { + Some(manifest.metadata.author.clone()) + }), + status: Set("uploaded".to_string()), + manifest_json: Set(manifest_json), + wasm_binary: Set(wasm_binary), + wasm_hash: Set(wasm_hash), + config_json: Set(serde_json::json!({})), + error_message: Set(None), + installed_at: Set(None), + enabled_at: Set(None), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(Some(operator_id)), + updated_by: Set(Some(operator_id)), + deleted_at: Set(None), + version: Set(1), + }; + + let model = model.insert(db).await?; + + Ok(plugin_model_to_resp(&model, &manifest, vec![])) + } + + /// 安装插件: 创建动态表 + 注册 entity 记录 + 注册事件订阅 + 注册权限 + status=installed + pub async fn install( + plugin_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + engine: &PluginEngine, + ) -> AppResult { + let model = find_plugin(plugin_id, tenant_id, db).await?; + validate_status(&model.status, "uploaded")?; + + let manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone()) + .map_err(|e| PluginError::InvalidManifest(e.to_string()))?; + + let now = Utc::now(); + + // 创建动态表 + 注册 entity 记录 + let mut entity_resps = Vec::new(); + if let Some(schema) = &manifest.schema { + for (i, entity_def) in schema.entities.iter().enumerate() { + let table_name = + DynamicTableManager::table_name(&manifest.metadata.id, &entity_def.name); + tracing::info!(step = i, entity = %entity_def.name, table = %table_name, "Creating dynamic table"); + + // 创建动态表 + DynamicTableManager::create_table(db, &manifest.metadata.id, entity_def).await + .map_err(|e| { + tracing::error!(entity = %entity_def.name, table = %table_name, error = %e, "Failed to create dynamic table"); + e + })?; + + // 注册 entity 记录 + let entity_id = Uuid::now_v7(); + let entity_model = plugin_entity::ActiveModel { + id: Set(entity_id), + tenant_id: Set(tenant_id), + plugin_id: Set(plugin_id), + entity_name: Set(entity_def.name.clone()), + table_name: Set(table_name.clone()), + schema_json: Set(serde_json::to_value(entity_def).unwrap_or_default()), + manifest_id: Set(manifest.metadata.id.clone()), + is_public: Set(entity_def.is_public.unwrap_or(false)), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(Some(operator_id)), + updated_by: Set(Some(operator_id)), + deleted_at: Set(None), + version: Set(1), + }; + entity_model.insert(db).await?; + + entity_resps.push(PluginEntityResp { + name: entity_def.name.clone(), + display_name: entity_def.display_name.clone(), + table_name, + }); + } + } + + // 注册事件订阅 + if let Some(events) = &manifest.events { + for pattern in &events.subscribe { + let sub_id = Uuid::now_v7(); + let sub_model = plugin_event_subscription::ActiveModel { + id: Set(sub_id), + plugin_id: Set(plugin_id), + event_pattern: Set(pattern.clone()), + created_at: Set(now), + }; + sub_model.insert(db).await?; + } + } + + // 注册插件声明的权限到 permissions 表 + tracing::info!("Registering plugin permissions"); + if let Some(perms) = &manifest.permissions { + register_plugin_permissions( + db, + tenant_id, + operator_id, + &manifest.metadata.id, + perms, + &now, + ) + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to register permissions"); + e + })?; + } + + // 将插件权限自动分配给 admin 角色 + tracing::info!("Granting plugin permissions to admin role"); + grant_permissions_to_admin(db, tenant_id, &manifest.metadata.id).await?; + + // 加载到内存 + tracing::info!(manifest_id = %manifest.metadata.id, "Loading plugin into engine"); + engine + .load(&manifest.metadata.id, &model.wasm_binary, manifest.clone()) + .await?; + + // 更新状态 + let mut active: plugin::ActiveModel = model.into(); + active.version = Set(bump_version(&active.version)); + active.status = Set("installed".to_string()); + active.installed_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(Some(operator_id)); + let model = active.update(db).await?; + + Ok(plugin_model_to_resp(&model, &manifest, entity_resps)) + } + + /// 启用插件: engine.initialize + start_event_listener + status=running + pub async fn enable( + plugin_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + engine: &PluginEngine, + ) -> AppResult { + let model = find_plugin(plugin_id, tenant_id, db).await?; + validate_status_any(&model.status, &["installed", "disabled"])?; + + let manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone()) + .map_err(|e| PluginError::InvalidManifest(e.to_string()))?; + + let plugin_manifest_id = &manifest.metadata.id; + + // 确保插件权限已分配给 admin 角色(幂等操作) + grant_permissions_to_admin(db, tenant_id, plugin_manifest_id).await?; + + // 如果之前是 disabled 状态,需要先卸载再重新加载到内存 + // (disable 只改内存状态但不从 DashMap 移除) + if model.status == "disabled" { + engine.unload(plugin_manifest_id).await.ok(); + engine + .load(plugin_manifest_id, &model.wasm_binary, manifest.clone()) + .await?; + } + + // 初始化(非致命:WASM 插件可能不包含 initialize 逻辑,失败不阻塞启用) + let init_error = match engine.initialize(plugin_manifest_id).await { + Ok(()) => None, + Err(e) => { + tracing::warn!(plugin = %plugin_manifest_id, error = %e, "插件初始化失败(非致命,继续启用)"); + Some(format!("初始化警告: {}", e)) + } + }; + + // 启动事件监听(非致命) + if let Err(e) = engine.start_event_listener(plugin_manifest_id).await { + tracing::warn!(plugin = %plugin_manifest_id, error = %e, "事件监听启动失败(非致命,继续启用)"); + } + + let now = Utc::now(); + let mut active: plugin::ActiveModel = model.into(); + active.version = Set(bump_version(&active.version)); + active.status = Set("running".to_string()); + active.enabled_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(Some(operator_id)); + active.error_message = Set(init_error); + let model = active.update(db).await?; + + let entity_resps = find_plugin_entities(plugin_id, tenant_id, db).await?; + Ok(plugin_model_to_resp(&model, &manifest, entity_resps)) + } + + /// 禁用插件: engine.disable + cancel 事件订阅 + status=disabled + pub async fn disable( + plugin_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + engine: &PluginEngine, + ) -> AppResult { + let model = find_plugin(plugin_id, tenant_id, db).await?; + validate_status_any(&model.status, &["running", "enabled"])?; + + let manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone()) + .map_err(|e| PluginError::InvalidManifest(e.to_string()))?; + + // 禁用引擎 + engine.disable(&manifest.metadata.id).await?; + + let now = Utc::now(); + let mut active: plugin::ActiveModel = model.into(); + active.version = Set(bump_version(&active.version)); + active.status = Set("disabled".to_string()); + active.updated_at = Set(now); + active.updated_by = Set(Some(operator_id)); + let model = active.update(db).await?; + + let entity_resps = find_plugin_entities(plugin_id, tenant_id, db).await?; + Ok(plugin_model_to_resp(&model, &manifest, entity_resps)) + } + + /// 卸载插件: unload + 有条件地 drop 动态表 + 清理权限 + status=uninstalled + pub async fn uninstall( + plugin_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + engine: &PluginEngine, + ) -> AppResult { + let model = find_plugin(plugin_id, tenant_id, db).await?; + validate_status_any(&model.status, &["installed", "disabled"])?; + + let manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone()) + .map_err(|e| PluginError::InvalidManifest(e.to_string()))?; + + // 卸载(如果 disabled 状态,engine 可能仍在内存中) + engine.unload(&manifest.metadata.id).await.ok(); + + // 软删除当前租户的 entity 记录 + let now = Utc::now(); + let tenant_entities = plugin_entity::Entity::find() + .filter(plugin_entity::Column::PluginId.eq(plugin_id)) + .filter(plugin_entity::Column::TenantId.eq(tenant_id)) + .filter(plugin_entity::Column::DeletedAt.is_null()) + .all(db) + .await?; + + for entity in &tenant_entities { + let mut active: plugin_entity::ActiveModel = entity.clone().into(); + active.version = Set(bump_version(&active.version)); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(Some(operator_id)); + active.update(db).await?; + } + + // 仅当没有其他租户的活跃 entity 记录引用相同的 table_name 时才 drop 表 + if let Some(schema) = &manifest.schema { + for entity_def in &schema.entities { + let table_name = + DynamicTableManager::table_name(&manifest.metadata.id, &entity_def.name); + + // 检查是否还有其他租户的活跃 entity 记录引用此表 + let other_tenants_count = plugin_entity::Entity::find() + .filter(plugin_entity::Column::TableName.eq(&table_name)) + .filter(plugin_entity::Column::TenantId.ne(tenant_id)) + .filter(plugin_entity::Column::DeletedAt.is_null()) + .count(db) + .await?; + + if other_tenants_count == 0 { + // 没有其他租户使用,安全删除 + DynamicTableManager::drop_table(db, &manifest.metadata.id, &entity_def.name) + .await + .ok(); + } + } + } + + // 清理此插件注册的权限 + unregister_plugin_permissions(db, tenant_id, &manifest.metadata.id).await?; + + let mut active: plugin::ActiveModel = model.into(); + active.version = Set(bump_version(&active.version)); + active.status = Set("uninstalled".to_string()); + active.updated_at = Set(now); + active.updated_by = Set(Some(operator_id)); + let model = active.update(db).await?; + + Ok(plugin_model_to_resp(&model, &manifest, vec![])) + } + + /// 列表查询 + pub async fn list( + tenant_id: Uuid, + page: u64, + page_size: u64, + status: Option<&str>, + search: Option<&str>, + db: &sea_orm::DatabaseConnection, + ) -> AppResult<(Vec, u64)> { + let mut query = plugin::Entity::find() + .filter(plugin::Column::TenantId.eq(tenant_id)) + .filter(plugin::Column::DeletedAt.is_null()); + + if let Some(s) = status { + query = query.filter(plugin::Column::Status.eq(s)); + } + if let Some(q) = search { + query = query.filter( + plugin::Column::Name + .contains(q) + .or(plugin::Column::Description.contains(q)), + ); + } + + let paginator = query.clone().paginate(db, page_size); + + let total = paginator.num_items().await?; + let models = paginator.fetch_page(page.saturating_sub(1)).await?; + + let mut resps = Vec::with_capacity(models.len()); + + // 批量查询所有插件的 entities(N+1 → 2 固定查询) + let plugin_ids: Vec = models.iter().map(|m| m.id).collect(); + let entities_map = find_batch_plugin_entities(&plugin_ids, tenant_id, db).await; + + for model in models { + let manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone()) + .unwrap_or_else(|_| PluginManifest { + metadata: crate::manifest::PluginMetadata { + id: String::new(), + name: String::new(), + version: String::new(), + description: String::new(), + author: String::new(), + min_platform_version: None, + dependencies: vec![], + }, + schema: None, + events: None, + ui: None, + permissions: None, + settings: None, + numbering: None, + templates: None, + trigger_events: None, + }); + let entities = entities_map.get(&model.id).cloned().unwrap_or_default(); + resps.push(plugin_model_to_resp(&model, &manifest, entities)); + } + + Ok((resps, total)) + } + + /// 按 ID 获取详情 + pub async fn get_by_id( + plugin_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AppResult { + let model = find_plugin(plugin_id, tenant_id, db).await?; + let manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone()) + .map_err(|e| PluginError::InvalidManifest(e.to_string()))?; + let entities = find_plugin_entities(plugin_id, tenant_id, db).await?; + Ok(plugin_model_to_resp(&model, &manifest, entities)) + } + + /// 更新配置 + pub async fn update_config( + plugin_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + config: serde_json::Value, + expected_version: i32, + db: &sea_orm::DatabaseConnection, + event_bus: Option<&erp_core::events::EventBus>, + ) -> AppResult { + let model = find_plugin(plugin_id, tenant_id, db).await?; + + erp_core::error::check_version(expected_version, model.version)?; + + // 校验配置值是否符合 manifest settings 声明 + let manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone()) + .map_err(|e| PluginError::InvalidManifest(e.to_string()))?; + if let Some(settings) = &manifest.settings { + validate_plugin_settings( + config.as_object().ok_or_else(|| { + PluginError::ValidationError("config 必须是 JSON 对象".to_string()) + })?, + &settings.fields, + )?; + } + + let now = Utc::now(); + let manifest_id = manifest.metadata.id.clone(); + let mut active: plugin::ActiveModel = model.into(); + active.version = Set(bump_version(&active.version)); + active.config_json = Set(config); + active.updated_at = Set(now); + active.updated_by = Set(Some(operator_id)); + let model = active.update(db).await?; + + // 发布配置变更事件 + if let Some(bus) = event_bus { + let event = erp_core::events::DomainEvent::new( + "plugin.config.updated", + tenant_id, + serde_json::json!({ + "plugin_id": manifest_id, + "updated_by": operator_id, + }), + ); + bus.publish(event, db).await; + } + + let entities = find_plugin_entities(plugin_id, tenant_id, db) + .await + .unwrap_or_default(); + Ok(plugin_model_to_resp(&model, &manifest, entities)) + } + + /// 健康检查 + pub async fn health_check( + plugin_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + engine: &PluginEngine, + ) -> AppResult { + let model = find_plugin(plugin_id, tenant_id, db).await?; + let manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone()) + .map_err(|e| PluginError::InvalidManifest(e.to_string()))?; + + let details = engine.health_check(&manifest.metadata.id).await?; + + Ok(PluginHealthResp { + plugin_id, + status: details + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + details, + }) + } + + /// 获取插件 Schema + pub async fn get_schema( + plugin_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AppResult { + let model = find_plugin(plugin_id, tenant_id, db).await?; + let manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone()) + .map_err(|e| PluginError::InvalidManifest(e.to_string()))?; + + // 构建 schema 响应:entities + ui 页面配置 + settings + numbering + trigger_events + let mut result = serde_json::Map::new(); + if let Some(schema) = &manifest.schema { + result.insert( + "entities".to_string(), + serde_json::to_value(&schema.entities).unwrap_or_default(), + ); + } + if let Some(ui) = &manifest.ui { + result.insert( + "ui".to_string(), + serde_json::to_value(ui).unwrap_or_default(), + ); + } + if let Some(settings) = &manifest.settings { + result.insert( + "settings".to_string(), + serde_json::to_value(settings).unwrap_or_default(), + ); + } + if let Some(numbering) = &manifest.numbering { + result.insert( + "numbering".to_string(), + serde_json::to_value(numbering).unwrap_or_default(), + ); + } + if let Some(triggers) = &manifest.trigger_events { + result.insert( + "trigger_events".to_string(), + serde_json::to_value(triggers).unwrap_or_default(), + ); + } + Ok(serde_json::Value::Object(result)) + } + + /// 清除插件记录(软删除,仅限已卸载状态) + pub async fn purge( + plugin_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AppResult<()> { + let model = find_plugin(plugin_id, tenant_id, db).await?; + validate_status_any(&model.status, &["uninstalled", "uploaded"])?; + let now = Utc::now(); + let mut active: plugin::ActiveModel = model.into(); + active.version = Set(bump_version(&active.version)); + active.deleted_at = Set(Some(now)); + active.updated_at = Set(now); + active.updated_by = Set(Some(operator_id)); + active.update(db).await?; + Ok(()) + } + + /// 热更新插件 — 上传新版本 WASM + manifest,对比 schema 变更,执行增量 DDL + /// + /// 流程: + /// 1. 解析新 manifest + /// 2. 获取当前插件信息 + /// 3. 对比 schema 变更,为新增实体创建表 + /// 4. 卸载旧 WASM,加载新 WASM + /// 5. 更新数据库记录 + /// 6. 失败时保持旧版本继续运行(回滚) + pub async fn upgrade( + plugin_id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + new_wasm: Vec, + new_manifest_toml: &str, + db: &sea_orm::DatabaseConnection, + engine: &PluginEngine, + ) -> AppResult { + let new_manifest = parse_manifest(new_manifest_toml)?; + + let model = find_plugin(plugin_id, tenant_id, db).await?; + let old_manifest: PluginManifest = serde_json::from_value(model.manifest_json.clone()) + .map_err(|e| PluginError::InvalidManifest(e.to_string()))?; + + let old_version = old_manifest.metadata.version.clone(); + let new_version = new_manifest.metadata.version.clone(); + + if old_manifest.metadata.id != new_manifest.metadata.id { + return Err(PluginError::InvalidManifest(format!( + "插件 ID 不匹配: 旧={}, 新={}", + old_manifest.metadata.id, new_manifest.metadata.id + )) + .into()); + } + + let plugin_manifest_id = &new_manifest.metadata.id; + + // 对比 schema — 为新增实体创建动态表 + 已有实体字段演进 + if let Some(new_schema) = &new_manifest.schema { + let old_schema = old_manifest.schema.as_ref(); + + for entity in &new_schema.entities { + let old_entity = + old_schema.and_then(|s| s.entities.iter().find(|e| e.name == entity.name)); + + match old_entity { + None => { + tracing::info!(entity = %entity.name, "创建新增实体表"); + DynamicTableManager::create_table(db, plugin_manifest_id, entity).await?; + } + Some(old) => { + let diff = DynamicTableManager::diff_entity_fields(old, entity); + if !diff.new_filterable.is_empty() || !diff.new_searchable.is_empty() { + tracing::info!( + entity = %entity.name, + new_cols = diff.new_filterable.len(), + new_search = diff.new_searchable.len(), + "Schema 演进:新增 Generated Column" + ); + DynamicTableManager::alter_add_generated_columns( + db, + plugin_manifest_id, + entity, + &diff, + ) + .await?; + } + } + } + } + } + + // 先加载新版本到临时 key,确保成功后再替换旧版本(原子回滚) + let temp_id = format!("{}__upgrade_{}", plugin_manifest_id, Uuid::now_v7()); + engine + .load(&temp_id, &new_wasm, new_manifest.clone()) + .await + .map_err(|e| { + tracing::error!(error = %e, "新版本 WASM 加载失败,旧版本仍在运行"); + e + })?; + + // 新版本加载成功,卸载旧版本并重命名新版本为正式 key + engine.unload(plugin_manifest_id).await.ok(); + engine.rename_plugin(&temp_id, plugin_manifest_id).await?; + + // 更新数据库记录 + let wasm_hash = { + let mut hasher = Sha256::new(); + hasher.update(&new_wasm); + format!("{:x}", hasher.finalize()) + }; + + let now = Utc::now(); + let mut active: plugin::ActiveModel = model.into(); + active.wasm_binary = Set(new_wasm); + active.wasm_hash = Set(wasm_hash); + active.manifest_json = Set(serde_json::to_value(&new_manifest) + .map_err(|e| PluginError::InvalidManifest(e.to_string()))?); + active.plugin_version = Set(new_version.clone()); + active.updated_at = Set(now); + active.updated_by = Set(Some(operator_id)); + active.version = Set(bump_version(&active.version)); + + let updated = active + .update(db) + .await + .map_err(|e| PluginError::DatabaseError(e.to_string()))?; + + // 更新 plugin_entities 表中的 schema_json + if let Some(schema) = &new_manifest.schema { + for entity in &schema.entities { + let entity_model = plugin_entity::Entity::find() + .filter(plugin_entity::Column::PluginId.eq(plugin_id)) + .filter(plugin_entity::Column::TenantId.eq(tenant_id)) + .filter(plugin_entity::Column::EntityName.eq(&entity.name)) + .filter(plugin_entity::Column::DeletedAt.is_null()) + .one(db) + .await + .map_err(|e| PluginError::DatabaseError(e.to_string()))?; + + if let Some(em) = entity_model { + let mut active: plugin_entity::ActiveModel = em.into(); + active.version = Set(bump_version(&active.version)); + active.schema_json = Set(serde_json::to_value(entity) + .map_err(|e| PluginError::InvalidManifest(e.to_string()))?); + active.updated_at = Set(now); + active.updated_by = Set(Some(operator_id)); + active + .update(db) + .await + .map_err(|e| PluginError::DatabaseError(e.to_string()))?; + } + } + } + + tracing::info!( + plugin_id = %plugin_id, + old_version = %old_version, + new_version = %new_version, + "插件热更新成功" + ); + + let entities = find_plugin_entities(plugin_id, tenant_id, db).await?; + Ok(plugin_model_to_resp(&updated, &new_manifest, entities)) + } +} + +// ---- 内部辅助 ---- + +async fn find_plugin( + plugin_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, +) -> AppResult { + plugin::Entity::find_by_id(plugin_id) + .one(db) + .await? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or_else(|| erp_core::error::AppError::NotFound(format!("插件 {} 不存在", plugin_id))) +} + +/// 公开的插件查询 — 供 handler 使用 +pub async fn find_plugin_model( + plugin_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, +) -> AppResult { + find_plugin(plugin_id, tenant_id, db).await +} + +/// 批量查询多插件的 entities,返回 plugin_id → Vec 映射。 +async fn find_batch_plugin_entities( + plugin_ids: &[Uuid], + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, +) -> HashMap> { + if plugin_ids.is_empty() { + return HashMap::new(); + } + + let entities = plugin_entity::Entity::find() + .filter(plugin_entity::Column::PluginId.is_in(plugin_ids.iter().copied())) + .filter(plugin_entity::Column::TenantId.eq(tenant_id)) + .filter(plugin_entity::Column::DeletedAt.is_null()) + .all(db) + .await + .unwrap_or_default(); + + let mut result: HashMap> = HashMap::new(); + for e in entities { + result + .entry(e.plugin_id) + .or_default() + .push(PluginEntityResp { + name: e.entity_name.clone(), + display_name: e.entity_name, + table_name: e.table_name, + }); + } + result +} + +async fn find_plugin_entities( + plugin_id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, +) -> AppResult> { + let entities = plugin_entity::Entity::find() + .filter(plugin_entity::Column::PluginId.eq(plugin_id)) + .filter(plugin_entity::Column::TenantId.eq(tenant_id)) + .filter(plugin_entity::Column::DeletedAt.is_null()) + .all(db) + .await?; + + Ok(entities + .into_iter() + .map(|e| PluginEntityResp { + name: e.entity_name.clone(), + display_name: e.entity_name, + table_name: e.table_name, + }) + .collect()) +} + +fn validate_status(actual: &str, expected: &str) -> AppResult<()> { + if actual != expected { + return Err(PluginError::InvalidState { + expected: expected.to_string(), + actual: actual.to_string(), + } + .into()); + } + Ok(()) +} + +fn validate_status_any(actual: &str, expected: &[&str]) -> AppResult<()> { + if !expected.contains(&actual) { + return Err(PluginError::InvalidState { + expected: expected.join(" 或 "), + actual: actual.to_string(), + } + .into()); + } + Ok(()) +} + +/// 校验配置值是否符合 manifest settings 声明 +fn validate_plugin_settings( + config: &serde_json::Map, + fields: &[crate::manifest::PluginSettingField], +) -> AppResult<()> { + use crate::manifest::PluginSettingType; + + for field in fields { + let value = config.get(&field.name); + + // 必填校验 + if field.required { + match value { + None | Some(serde_json::Value::Null) => { + return Err(PluginError::ValidationError(format!( + "配置项 '{}' ({}) 为必填", + field.name, field.display_name + )) + .into()); + } + Some(serde_json::Value::String(s)) if s.is_empty() => { + return Err(PluginError::ValidationError(format!( + "配置项 '{}' ({}) 不能为空", + field.name, field.display_name + )) + .into()); + } + _ => {} + } + } + + // 类型校验 + if let Some(val) = value + && !val.is_null() + { + let type_ok = match field.field_type { + PluginSettingType::Text => val.is_string(), + PluginSettingType::Number => val.is_number(), + PluginSettingType::Boolean => val.is_boolean(), + PluginSettingType::Select => val.is_string(), + PluginSettingType::Multiselect => val.is_array(), + PluginSettingType::Color => val.is_string(), + PluginSettingType::Date => val.is_string(), + PluginSettingType::Datetime => val.is_string(), + PluginSettingType::Json => true, + }; + if !type_ok { + return Err(PluginError::ValidationError(format!( + "配置项 '{}' 类型错误,期望 {:?}", + field.name, field.field_type + )) + .into()); + } + + // 数值范围校验 + if let Some((min, max)) = field.range + && let Some(n) = val.as_f64() + && (n < min || n > max) + { + return Err(PluginError::ValidationError(format!( + "配置项 '{}' ({}) 的值 {} 超出范围 [{}, {}]", + field.name, field.display_name, n, min, max + )) + .into()); + } + } + } + Ok(()) +} + +fn plugin_model_to_resp( + model: &plugin::Model, + manifest: &PluginManifest, + entities: Vec, +) -> PluginResp { + let permissions = manifest.permissions.as_ref().map(|perms| { + perms + .iter() + .map(|p| PluginPermissionResp { + code: p.code.clone(), + name: p.name.clone(), + description: p.description.clone(), + }) + .collect() + }); + + PluginResp { + id: model.id, + name: model.name.clone(), + version: model.plugin_version.clone(), + description: model.description.clone(), + author: model.author.clone(), + status: model.status.clone(), + config: model.config_json.clone(), + installed_at: model.installed_at, + enabled_at: model.enabled_at, + entities, + permissions, + record_version: model.version, + } +} + +/// 将插件声明的权限注册到 permissions 表。 +/// +/// 使用 raw SQL 避免依赖 erp-auth 的 entity 类型。 +/// 权限码格式:`{plugin_manifest_id}.{code}`(如 `erp-crm.customer.list`)。 +/// 使用 `ON CONFLICT DO NOTHING` 保证幂等。 +async fn register_plugin_permissions( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + operator_id: Uuid, + plugin_manifest_id: &str, + perms: &[crate::manifest::PluginPermission], + now: &chrono::DateTime, +) -> AppResult<()> { + for perm in perms { + let full_code = format!("{}.{}", plugin_manifest_id, perm.code); + let resource = plugin_manifest_id.to_string(); + let action = perm.code.clone(); + let description: Option = if perm.description.is_empty() { + None + } else { + Some(perm.description.clone()) + }; + + let sql = r#" + INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version) + VALUES (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $7, $8, $8, NULL, 1) + ON CONFLICT (tenant_id, code) WHERE deleted_at IS NULL DO NOTHING + "#; + + db.execute(sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + vec![ + sea_orm::Value::from(tenant_id), + sea_orm::Value::from(full_code.clone()), + sea_orm::Value::from(perm.name.clone()), + sea_orm::Value::from(resource), + sea_orm::Value::from(action), + sea_orm::Value::from(description), + sea_orm::Value::from(*now), + sea_orm::Value::from(operator_id), + ], + )) + .await + .map_err(|e| { + tracing::error!( + plugin = plugin_manifest_id, + permission = %full_code, + error = %e, + "注册插件权限失败" + ); + PluginError::DatabaseError(format!("注册插件权限 {} 失败: {}", full_code, e)) + })?; + } + + tracing::info!( + plugin = plugin_manifest_id, + count = perms.len(), + tenant_id = %tenant_id, + "插件权限注册完成" + ); + Ok(()) +} + +/// 将插件的所有权限分配给 admin 角色。 +/// +/// 使用 raw SQL 按 manifest_id 前缀匹配权限,INSERT 到 role_permissions。 +/// ON CONFLICT DO NOTHING 保证幂等。 +pub async fn grant_permissions_to_admin( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + plugin_manifest_id: &str, +) -> AppResult<()> { + let prefix = format!("{}.%", plugin_manifest_id); + + let sql = r#" + INSERT INTO role_permissions (tenant_id, role_id, permission_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) + SELECT + p.tenant_id, + r.id, + p.id, + 'all', + NOW(), NOW(), + r.id, r.id, + NULL, 1 + FROM permissions p + CROSS JOIN roles r + WHERE p.tenant_id = $1 + AND r.tenant_id = $1 + AND r.code = 'admin' + AND r.deleted_at IS NULL + AND p.code LIKE $2 + AND p.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM role_permissions rp + WHERE rp.permission_id = p.id + AND rp.role_id = r.id + AND rp.deleted_at IS NULL + ) + ON CONFLICT (role_id, permission_id) DO NOTHING + "#; + + let result = db + .execute(sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + vec![ + sea_orm::Value::from(tenant_id), + sea_orm::Value::from(prefix.clone()), + ], + )) + .await + .map_err(|e| { + tracing::error!( + plugin = plugin_manifest_id, + error = %e, + "分配插件权限给 admin 角色失败" + ); + PluginError::DatabaseError(format!("分配插件权限给 admin 角色失败: {}", e)) + })?; + + let rows = result.rows_affected(); + tracing::info!( + plugin = plugin_manifest_id, + rows_affected = rows, + tenant_id = %tenant_id, + "插件权限已分配给 admin 角色" + ); + Ok(()) +} + +/// 清理插件注册的权限(软删除)。 +/// +/// 使用 raw SQL 按前缀匹配清理:`{plugin_manifest_id}.%`。 +/// 同时清理 role_permissions 中对这些权限的关联。 +async fn unregister_plugin_permissions( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, + plugin_manifest_id: &str, +) -> AppResult<()> { + let prefix = format!("{}.%", plugin_manifest_id); + let now = chrono::Utc::now(); + + // 先软删除 role_permissions 中的关联 + let rp_sql = r#" + UPDATE role_permissions + SET deleted_at = $1, updated_at = $1 + WHERE permission_id IN ( + SELECT id FROM permissions + WHERE tenant_id = $2 + AND code LIKE $3 + AND deleted_at IS NULL + ) + AND deleted_at IS NULL + "#; + db.execute(sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + rp_sql, + vec![ + sea_orm::Value::from(now), + sea_orm::Value::from(tenant_id), + sea_orm::Value::from(prefix.clone()), + ], + )) + .await + .map_err(|e| { + tracing::error!( + plugin = plugin_manifest_id, + error = %e, + "清理插件权限角色关联失败" + ); + PluginError::DatabaseError(format!("清理插件权限角色关联失败: {}", e)) + })?; + + // 再软删除 permissions + let perm_sql = r#" + UPDATE permissions + SET deleted_at = $1, updated_at = $1 + WHERE tenant_id = $2 + AND code LIKE $3 + AND deleted_at IS NULL + "#; + db.execute(sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + perm_sql, + vec![ + sea_orm::Value::from(now), + sea_orm::Value::from(tenant_id), + sea_orm::Value::from(prefix), + ], + )) + .await + .map_err(|e| { + tracing::error!( + plugin = plugin_manifest_id, + error = %e, + "清理插件权限失败" + ); + PluginError::DatabaseError(format!("清理插件权限失败: {}", e)) + })?; + + tracing::info!( + plugin = plugin_manifest_id, + tenant_id = %tenant_id, + "插件权限清理完成" + ); + Ok(()) +} diff --git a/crates/erp-plugin/src/state.rs b/crates/erp-plugin/src/state.rs new file mode 100644 index 0000000..7cb4df9 --- /dev/null +++ b/crates/erp-plugin/src/state.rs @@ -0,0 +1,52 @@ +use std::time::Duration; + +use moka::sync::Cache; +use sea_orm::DatabaseConnection; + +use erp_core::error::{AppError, AppResult}; +use erp_core::events::EventBus; + +use crate::engine::PluginEngine; + +/// 插件模块共享状态 — 用于 Axum State 提取 +#[derive(Clone)] +pub struct PluginState { + pub db: DatabaseConnection, + pub event_bus: EventBus, + pub engine: PluginEngine, + /// Schema 缓存 — key: "{plugin_id}:{entity_name}:{tenant_id}" + pub entity_cache: Cache, +} + +/// 缓存的实体信息 +#[derive(Clone, Debug)] +pub struct EntityInfo { + pub table_name: String, + pub schema_json: serde_json::Value, + pub generated_fields: Vec, +} + +impl EntityInfo { + /// 从 schema_json 解析字段列表 + pub fn fields(&self) -> AppResult> { + let entity_def: crate::manifest::PluginEntity = + serde_json::from_value(self.schema_json.clone()) + .map_err(|e| AppError::Internal(format!("解析 entity schema 失败: {}", e)))?; + Ok(entity_def.fields) + } +} + +impl PluginState { + pub fn new(db: DatabaseConnection, event_bus: EventBus, engine: PluginEngine) -> Self { + let entity_cache = Cache::builder() + .max_capacity(1000) + .time_to_idle(Duration::from_secs(300)) + .build(); + Self { + db, + event_bus, + engine, + entity_cache, + } + } +} diff --git a/crates/erp-plugin/wit/plugin.wit b/crates/erp-plugin/wit/plugin.wit new file mode 100644 index 0000000..e801831 --- /dev/null +++ b/crates/erp-plugin/wit/plugin.wit @@ -0,0 +1,54 @@ +package erp:plugin; + +/// 宿主暴露给插件的 API(插件 import 这些函数) +interface host-api { + /// 插入记录(宿主自动注入 tenant_id, id, created_at 等标准字段) + db-insert: func(entity: string, data: list) -> result, string>; + + /// 查询记录(自动注入 tenant_id 过滤 + 软删除过滤) + db-query: func(entity: string, filter: list, pagination: list) -> result, string>; + + /// 更新记录(自动检查 version 乐观锁) + db-update: func(entity: string, id: string, data: list, version: s64) -> result, string>; + + /// 软删除记录 + db-delete: func(entity: string, id: string) -> result<_, string>; + + /// 发布领域事件 + event-publish: func(event-type: string, payload: list) -> result<_, string>; + + /// 读取系统配置 + config-get: func(key: string) -> result, string>; + + /// 写日志(自动关联 tenant_id + plugin_id) + log-write: func(level: string, message: string); + + /// 获取当前用户信息 + current-user: func() -> result, string>; + + /// 检查当前用户权限 + check-permission: func(permission: string) -> result; + + /// 根据编号规则生成下一个编号(如 INV-2026-0001) + numbering-generate: func(rule-key: string) -> result; + + /// 读取插件配置项 + setting-get: func(key: string) -> result, string>; +} + +/// 插件导出的 API(宿主调用这些函数) +interface plugin-api { + /// 插件初始化(加载时调用一次) + init: func() -> result<_, string>; + + /// 租户创建时调用 + on-tenant-created: func(tenant-id: string) -> result<_, string>; + + /// 处理订阅的事件 + handle-event: func(event-type: string, payload: list) -> result<_, string>; +} + +world plugin-world { + import host-api; + export plugin-api; +} diff --git a/crates/erp-server/Cargo.toml b/crates/erp-server/Cargo.toml new file mode 100644 index 0000000..d8e5bf4 --- /dev/null +++ b/crates/erp-server/Cargo.toml @@ -0,0 +1,50 @@ +[package] +name = "erp-server" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "erp-server" +path = "src/main.rs" + +[dependencies] +erp-core.workspace = true +tokio.workspace = true +axum.workspace = true +tower.workspace = true +tower-http.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +config.workspace = true +sea-orm.workspace = true +sqlx.workspace = true +redis.workspace = true +utoipa.workspace = true +serde_json.workspace = true +serde.workspace = true +erp-server-migration = { path = "migration" } +erp-auth.workspace = true +erp-config.workspace = true +erp-workflow.workspace = true +erp-message.workspace = true +erp-plugin.workspace = true +erp-diary.workspace = true +anyhow.workspace = true +uuid.workspace = true +chrono.workspace = true +moka = { version = "0.12", features = ["sync"] } +metrics.workspace = true +metrics-exporter-prometheus.workspace = true +hmac = "0.12" +sha2 = "0.10" +hex = "0.4" + +[dev-dependencies] +erp-auth = { workspace = true } +erp-plugin = { workspace = true } +erp-workflow = { workspace = true } +erp-core = { workspace = true } +async-trait.workspace = true +futures.workspace = true +sha2.workspace = true +hex.workspace = true diff --git a/crates/erp-server/config/default.toml b/crates/erp-server/config/default.toml new file mode 100644 index 0000000..a6873f9 --- /dev/null +++ b/crates/erp-server/config/default.toml @@ -0,0 +1,69 @@ +[server] +host = "0.0.0.0" +port = 3000 + +[database] +url = "__MUST_SET_VIA_ENV__" +max_connections = 20 +min_connections = 5 + +[redis] +url = "__MUST_SET_VIA_ENV__" + +[jwt] +secret = "__MUST_SET_VIA_ENV__" +access_token_ttl = "15m" +refresh_token_ttl = "7d" + +[auth] +super_admin_password = "__MUST_SET_VIA_ENV__" + +[log] +level = "info" + +[cors] +# Comma-separated allowed origins. Use "*" for development only. +allowed_origins = "http://localhost:5173,http://localhost:5174,http://localhost:5175,http://localhost:5176,http://localhost:3000" + +[wechat] +appid = "__MUST_SET_VIA_ENV__" +secret = "__MUST_SET_VIA_ENV__" +# dev_mode = true 跳过 jscode2session,允许微信开发者工具模拟器登录 +# 生产环境必须为 false(默认) +dev_mode = false + +[health] +aes_key = "__MUST_SET_VIA_ENV__" +hmac_key = "__MUST_SET_VIA_ENV__" + +[crypto] +kek = "__MUST_SET_VIA_ENV__" + +[ai] +default_provider = "ollama" +# AI API 密钥。留空则禁用 AI 功能;生产环境必须通过 ERP__AI__API_KEY 设置。 +api_key = "" +model = "qwen3:4b" +max_tokens = 2048 +temperature = 0.3 +cache_ttl_seconds = 604800 +rate_limit_patient_daily = 10 + +[ai.providers.ollama] +provider_type = "ollama" +base_url = "http://localhost:11434" +default_model = "qwen3:4b" +max_tokens = 2048 +temperature = 0.3 +is_enabled = true + +[storage] +upload_dir = "./uploads" +max_file_size = "10MB" +# 签名 URL 密钥(生产环境必须通过 ERP__STORAGE__SECRET_KEY 环境变量设置) +secret_key = "dev-only-secret-key-change-in-production" + +[rate_limit] +# Redis 不可达时是否拒绝请求(fail-close)。默认 true = 安全优先。 +# 开发环境可设为 false 以避免 Redis 依赖:ERP__RATE_LIMIT__FAIL_CLOSE=false +fail_close = true diff --git a/crates/erp-server/migration/Cargo.toml b/crates/erp-server/migration/Cargo.toml new file mode 100644 index 0000000..56edb9c --- /dev/null +++ b/crates/erp-server/migration/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "erp-server-migration" +version = "0.1.0" +edition = "2024" + +[dependencies] +sea-orm-migration.workspace = true +tokio.workspace = true diff --git a/crates/erp-server/migration/src/lib.rs b/crates/erp-server/migration/src/lib.rs new file mode 100644 index 0000000..a301722 --- /dev/null +++ b/crates/erp-server/migration/src/lib.rs @@ -0,0 +1,120 @@ +#![allow(clippy::too_many_arguments)] + +pub use sea_orm_migration::prelude::*; + +mod m20260410_000001_create_tenant; +mod m20260411_000002_create_users; +mod m20260411_000003_create_user_credentials; +mod m20260411_000004_create_user_tokens; +mod m20260411_000005_create_roles; +mod m20260411_000006_create_permissions; +mod m20260411_000007_create_role_permissions; +mod m20260411_000008_create_user_roles; +mod m20260411_000009_create_organizations; +mod m20260411_000010_create_departments; +mod m20260411_000011_create_positions; +mod m20260412_000012_create_dictionaries; +mod m20260412_000013_create_dictionary_items; +mod m20260412_000014_create_menus; +mod m20260412_000015_create_menu_roles; +mod m20260412_000016_create_settings; +mod m20260412_000017_create_numbering_rules; +mod m20260412_000018_create_process_definitions; +mod m20260412_000019_create_process_instances; +mod m20260412_000020_create_tokens; +mod m20260412_000021_create_tasks; +mod m20260412_000022_create_process_variables; +mod m20260413_000023_create_message_templates; +mod m20260413_000024_create_messages; +mod m20260413_000025_create_message_subscriptions; +mod m20260413_000026_create_audit_logs; +mod m20260414_000027_fix_unique_indexes_soft_delete; +mod m20260414_000028_add_standard_fields_to_tokens; +mod m20260414_000029_add_standard_fields_to_process_variables; +mod m20260415_000030_add_version_to_message_tables; +mod m20260416_000031_create_domain_events; +mod m20260414_000032_fix_settings_unique_index_null; +mod m20260417_000033_create_plugins; +mod m20260417_000034_seed_plugin_permissions; +mod m20260418_000035_pg_trgm_and_entity_columns; +mod m20260418_000036_add_data_scope_to_role_permissions; +mod m20260419_000037_create_user_departments; +mod m20260419_000039_entity_registry_columns; +mod m20260419_000040_plugin_market; +mod m20260419_000041_plugin_user_views; +mod m20260423_000043_create_wechat_users; +mod m20260427_000062_create_tenant_crypto_keys; +mod m20260427_000084_domain_events_cleanup; +mod m20260427_000085_processed_events; +mod m20260427_000086_enable_rls_all_tables; +mod m20260427_000087_audit_logs_hash_chain; +mod m20260428_000088_rls_policy_strict; +mod m20260428_000089_blind_indexes; +mod m20260428_000091_dead_letter_events; +mod m20260504_000106_create_api_clients; +mod m20260513_000144_enforce_version_optimistic_lock; +mod m20260518_000149_fix_admin_permissions; +mod m20260529_000169_supplement_rls_for_new_tables; + +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m20260410_000001_create_tenant::Migration), + Box::new(m20260411_000002_create_users::Migration), + Box::new(m20260411_000003_create_user_credentials::Migration), + Box::new(m20260411_000004_create_user_tokens::Migration), + Box::new(m20260411_000005_create_roles::Migration), + Box::new(m20260411_000006_create_permissions::Migration), + Box::new(m20260411_000007_create_role_permissions::Migration), + Box::new(m20260411_000008_create_user_roles::Migration), + Box::new(m20260411_000009_create_organizations::Migration), + Box::new(m20260411_000010_create_departments::Migration), + Box::new(m20260411_000011_create_positions::Migration), + Box::new(m20260412_000012_create_dictionaries::Migration), + Box::new(m20260412_000013_create_dictionary_items::Migration), + Box::new(m20260412_000014_create_menus::Migration), + Box::new(m20260412_000015_create_menu_roles::Migration), + Box::new(m20260412_000016_create_settings::Migration), + Box::new(m20260412_000017_create_numbering_rules::Migration), + Box::new(m20260412_000018_create_process_definitions::Migration), + Box::new(m20260412_000019_create_process_instances::Migration), + Box::new(m20260412_000020_create_tokens::Migration), + Box::new(m20260412_000021_create_tasks::Migration), + Box::new(m20260412_000022_create_process_variables::Migration), + Box::new(m20260413_000023_create_message_templates::Migration), + Box::new(m20260413_000024_create_messages::Migration), + Box::new(m20260413_000025_create_message_subscriptions::Migration), + Box::new(m20260413_000026_create_audit_logs::Migration), + Box::new(m20260414_000027_fix_unique_indexes_soft_delete::Migration), + Box::new(m20260414_000028_add_standard_fields_to_tokens::Migration), + Box::new(m20260414_000029_add_standard_fields_to_process_variables::Migration), + Box::new(m20260415_000030_add_version_to_message_tables::Migration), + Box::new(m20260416_000031_create_domain_events::Migration), + Box::new(m20260414_000032_fix_settings_unique_index_null::Migration), + Box::new(m20260417_000033_create_plugins::Migration), + Box::new(m20260417_000034_seed_plugin_permissions::Migration), + Box::new(m20260418_000035_pg_trgm_and_entity_columns::Migration), + Box::new(m20260418_000036_add_data_scope_to_role_permissions::Migration), + Box::new(m20260419_000037_create_user_departments::Migration), + Box::new(m20260419_000039_entity_registry_columns::Migration), + Box::new(m20260419_000040_plugin_market::Migration), + Box::new(m20260419_000041_plugin_user_views::Migration), + Box::new(m20260423_000043_create_wechat_users::Migration), + Box::new(m20260427_000062_create_tenant_crypto_keys::Migration), + Box::new(m20260427_000084_domain_events_cleanup::Migration), + Box::new(m20260427_000085_processed_events::Migration), + Box::new(m20260427_000086_enable_rls_all_tables::Migration), + Box::new(m20260427_000087_audit_logs_hash_chain::Migration), + Box::new(m20260428_000088_rls_policy_strict::Migration), + Box::new(m20260428_000089_blind_indexes::Migration), + Box::new(m20260428_000091_dead_letter_events::Migration), + Box::new(m20260504_000106_create_api_clients::Migration), + Box::new(m20260513_000144_enforce_version_optimistic_lock::Migration), + Box::new(m20260518_000149_fix_admin_permissions::Migration), + Box::new(m20260529_000169_supplement_rls_for_new_tables::Migration), + ] + } +} diff --git a/crates/erp-server/migration/src/m20260410_000001_create_tenant.rs b/crates/erp-server/migration/src/m20260410_000001_create_tenant.rs new file mode 100644 index 0000000..282996d --- /dev/null +++ b/crates/erp-server/migration/src/m20260410_000001_create_tenant.rs @@ -0,0 +1,69 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Tenant::Table) + .if_not_exists() + .col(ColumnDef::new(Tenant::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(Tenant::Name).string().not_null()) + .col( + ColumnDef::new(Tenant::Code) + .string() + .not_null() + .unique_key(), + ) + .col( + ColumnDef::new(Tenant::Status) + .string() + .not_null() + .default("active"), + ) + .col(ColumnDef::new(Tenant::Settings).json().null()) + .col( + ColumnDef::new(Tenant::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Tenant::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Tenant::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Tenant::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Tenant { + Table, + Id, + Name, + Code, + Status, + Settings, + CreatedAt, + UpdatedAt, + DeletedAt, +} diff --git a/crates/erp-server/migration/src/m20260411_000002_create_users.rs b/crates/erp-server/migration/src/m20260411_000002_create_users.rs new file mode 100644 index 0000000..8d6bc9d --- /dev/null +++ b/crates/erp-server/migration/src/m20260411_000002_create_users.rs @@ -0,0 +1,104 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Users::Table) + .if_not_exists() + .col(ColumnDef::new(Users::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(Users::TenantId).uuid().not_null()) + .col(ColumnDef::new(Users::Username).string().not_null()) + .col(ColumnDef::new(Users::Email).string().null()) + .col(ColumnDef::new(Users::Phone).string().null()) + .col(ColumnDef::new(Users::DisplayName).string().null()) + .col(ColumnDef::new(Users::AvatarUrl).string().null()) + .col( + ColumnDef::new(Users::Status) + .string() + .not_null() + .default("active"), + ) + .col( + ColumnDef::new(Users::LastLoginAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Users::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Users::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Users::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(Users::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(Users::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Users::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_users_tenant_id") + .table(Users::Table) + .col(Users::TenantId) + .to_owned(), + ) + .await?; + + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_users_tenant_username ON users (tenant_id, username) WHERE deleted_at IS NULL".to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Users::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, + TenantId, + Username, + Email, + Phone, + DisplayName, + AvatarUrl, + Status, + LastLoginAt, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/migration/src/m20260411_000003_create_user_credentials.rs b/crates/erp-server/migration/src/m20260411_000003_create_user_credentials.rs new file mode 100644 index 0000000..69c05c9 --- /dev/null +++ b/crates/erp-server/migration/src/m20260411_000003_create_user_credentials.rs @@ -0,0 +1,127 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(UserCredentials::Table) + .if_not_exists() + .col( + ColumnDef::new(UserCredentials::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(UserCredentials::TenantId).uuid().not_null()) + .col(ColumnDef::new(UserCredentials::UserId).uuid().not_null()) + .col( + ColumnDef::new(UserCredentials::CredentialType) + .string() + .not_null() + .default("password"), + ) + .col( + ColumnDef::new(UserCredentials::CredentialData) + .json() + .null(), + ) + .col( + ColumnDef::new(UserCredentials::Verified) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(UserCredentials::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(UserCredentials::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(UserCredentials::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(UserCredentials::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(UserCredentials::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(UserCredentials::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + &mut ForeignKey::create() + .name("fk_user_credentials_user_id") + .from(UserCredentials::Table, UserCredentials::UserId) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_user_credentials_tenant_id") + .table(UserCredentials::Table) + .col(UserCredentials::TenantId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_user_credentials_user_id") + .table(UserCredentials::Table) + .col(UserCredentials::UserId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(UserCredentials::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum UserCredentials { + Table, + Id, + TenantId, + UserId, + CredentialType, + CredentialData, + Verified, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} diff --git a/crates/erp-server/migration/src/m20260411_000004_create_user_tokens.rs b/crates/erp-server/migration/src/m20260411_000004_create_user_tokens.rs new file mode 100644 index 0000000..9fbda31 --- /dev/null +++ b/crates/erp-server/migration/src/m20260411_000004_create_user_tokens.rs @@ -0,0 +1,140 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(UserTokens::Table) + .if_not_exists() + .col( + ColumnDef::new(UserTokens::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(UserTokens::TenantId).uuid().not_null()) + .col(ColumnDef::new(UserTokens::UserId).uuid().not_null()) + .col( + ColumnDef::new(UserTokens::TokenHash) + .string() + .not_null() + .unique_key(), + ) + .col(ColumnDef::new(UserTokens::TokenType).string().not_null()) + .col( + ColumnDef::new(UserTokens::ExpiresAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(UserTokens::RevokedAt) + .timestamp_with_time_zone() + .null(), + ) + .col(ColumnDef::new(UserTokens::DeviceInfo).string().null()) + .col( + ColumnDef::new(UserTokens::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(UserTokens::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(UserTokens::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(UserTokens::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(UserTokens::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(UserTokens::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + &mut ForeignKey::create() + .name("fk_user_tokens_user_id") + .from(UserTokens::Table, UserTokens::UserId) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_user_tokens_tenant_id") + .table(UserTokens::Table) + .col(UserTokens::TenantId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_user_tokens_user_id") + .table(UserTokens::Table) + .col(UserTokens::UserId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_user_tokens_token_hash") + .table(UserTokens::Table) + .col(UserTokens::TokenHash) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(UserTokens::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum UserTokens { + Table, + Id, + TenantId, + UserId, + TokenHash, + TokenType, + ExpiresAt, + RevokedAt, + DeviceInfo, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} diff --git a/crates/erp-server/migration/src/m20260411_000005_create_roles.rs b/crates/erp-server/migration/src/m20260411_000005_create_roles.rs new file mode 100644 index 0000000..e424fce --- /dev/null +++ b/crates/erp-server/migration/src/m20260411_000005_create_roles.rs @@ -0,0 +1,101 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Roles::Table) + .if_not_exists() + .col(ColumnDef::new(Roles::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(Roles::TenantId).uuid().not_null()) + .col(ColumnDef::new(Roles::Name).string().not_null()) + .col(ColumnDef::new(Roles::Code).string().not_null()) + .col(ColumnDef::new(Roles::Description).text().null()) + .col( + ColumnDef::new(Roles::IsSystem) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(Roles::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Roles::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Roles::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(Roles::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(Roles::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Roles::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_roles_tenant_id") + .table(Roles::Table) + .col(Roles::TenantId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_roles_tenant_code") + .table(Roles::Table) + .col(Roles::TenantId) + .col(Roles::Code) + .unique() + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Roles::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Roles { + Table, + Id, + TenantId, + Name, + Code, + Description, + IsSystem, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/migration/src/m20260411_000006_create_permissions.rs b/crates/erp-server/migration/src/m20260411_000006_create_permissions.rs new file mode 100644 index 0000000..7eaad0f --- /dev/null +++ b/crates/erp-server/migration/src/m20260411_000006_create_permissions.rs @@ -0,0 +1,103 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Permissions::Table) + .if_not_exists() + .col( + ColumnDef::new(Permissions::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Permissions::TenantId).uuid().not_null()) + .col(ColumnDef::new(Permissions::Code).string().not_null()) + .col(ColumnDef::new(Permissions::Name).string().not_null()) + .col(ColumnDef::new(Permissions::Resource).string().not_null()) + .col(ColumnDef::new(Permissions::Action).string().not_null()) + .col(ColumnDef::new(Permissions::Description).text().null()) + .col( + ColumnDef::new(Permissions::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Permissions::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Permissions::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(Permissions::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(Permissions::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Permissions::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_permissions_tenant_id") + .table(Permissions::Table) + .col(Permissions::TenantId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_permissions_tenant_code") + .table(Permissions::Table) + .col(Permissions::TenantId) + .col(Permissions::Code) + .unique() + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Permissions::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Permissions { + Table, + Id, + TenantId, + Code, + Name, + Resource, + Action, + Description, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/migration/src/m20260411_000007_create_role_permissions.rs b/crates/erp-server/migration/src/m20260411_000007_create_role_permissions.rs new file mode 100644 index 0000000..0535761 --- /dev/null +++ b/crates/erp-server/migration/src/m20260411_000007_create_role_permissions.rs @@ -0,0 +1,115 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(RolePermissions::Table) + .if_not_exists() + .col(ColumnDef::new(RolePermissions::RoleId).uuid().not_null()) + .col( + ColumnDef::new(RolePermissions::PermissionId) + .uuid() + .not_null(), + ) + .col(ColumnDef::new(RolePermissions::TenantId).uuid().not_null()) + .col( + ColumnDef::new(RolePermissions::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(RolePermissions::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(RolePermissions::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(RolePermissions::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(RolePermissions::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(RolePermissions::Version) + .integer() + .not_null() + .default(1), + ) + .primary_key( + Index::create() + .col(RolePermissions::RoleId) + .col(RolePermissions::PermissionId), + ) + .foreign_key( + &mut ForeignKey::create() + .name("fk_role_permissions_role_id") + .from(RolePermissions::Table, RolePermissions::RoleId) + .to(Roles::Table, Roles::Id) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .foreign_key( + &mut ForeignKey::create() + .name("fk_role_permissions_permission_id") + .from(RolePermissions::Table, RolePermissions::PermissionId) + .to(Permissions::Table, Permissions::Id) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_role_permissions_tenant_id") + .table(RolePermissions::Table) + .col(RolePermissions::TenantId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(RolePermissions::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum RolePermissions { + Table, + RoleId, + PermissionId, + TenantId, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum Roles { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Permissions { + Table, + Id, +} diff --git a/crates/erp-server/migration/src/m20260411_000008_create_user_roles.rs b/crates/erp-server/migration/src/m20260411_000008_create_user_roles.rs new file mode 100644 index 0000000..bd155d1 --- /dev/null +++ b/crates/erp-server/migration/src/m20260411_000008_create_user_roles.rs @@ -0,0 +1,111 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(UserRoles::Table) + .if_not_exists() + .col(ColumnDef::new(UserRoles::UserId).uuid().not_null()) + .col(ColumnDef::new(UserRoles::RoleId).uuid().not_null()) + .col(ColumnDef::new(UserRoles::TenantId).uuid().not_null()) + .col( + ColumnDef::new(UserRoles::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(UserRoles::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(UserRoles::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(UserRoles::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(UserRoles::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(UserRoles::Version) + .integer() + .not_null() + .default(1), + ) + .primary_key( + Index::create() + .col(UserRoles::UserId) + .col(UserRoles::RoleId), + ) + .foreign_key( + &mut ForeignKey::create() + .name("fk_user_roles_user_id") + .from(UserRoles::Table, UserRoles::UserId) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .foreign_key( + &mut ForeignKey::create() + .name("fk_user_roles_role_id") + .from(UserRoles::Table, UserRoles::RoleId) + .to(Roles::Table, Roles::Id) + .on_delete(ForeignKeyAction::Cascade) + .to_owned(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_user_roles_tenant_id") + .table(UserRoles::Table) + .col(UserRoles::TenantId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(UserRoles::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum UserRoles { + Table, + UserId, + RoleId, + TenantId, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Roles { + Table, + Id, +} diff --git a/crates/erp-server/migration/src/m20260411_000009_create_organizations.rs b/crates/erp-server/migration/src/m20260411_000009_create_organizations.rs new file mode 100644 index 0000000..134e376 --- /dev/null +++ b/crates/erp-server/migration/src/m20260411_000009_create_organizations.rs @@ -0,0 +1,116 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Organizations::Table) + .if_not_exists() + .col( + ColumnDef::new(Organizations::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Organizations::TenantId).uuid().not_null()) + .col(ColumnDef::new(Organizations::Name).string().not_null()) + .col(ColumnDef::new(Organizations::Code).string().null()) + .col(ColumnDef::new(Organizations::ParentId).uuid().null()) + .col(ColumnDef::new(Organizations::Path).string().null()) + .col( + ColumnDef::new(Organizations::Level) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(Organizations::SortOrder) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(Organizations::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Organizations::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Organizations::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(Organizations::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(Organizations::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Organizations::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + &mut ForeignKey::create() + .name("fk_organizations_parent_id") + .from(Organizations::Table, Organizations::ParentId) + .to(Organizations::Table, Organizations::Id) + .on_delete(ForeignKeyAction::Restrict) + .to_owned(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_organizations_tenant_id") + .table(Organizations::Table) + .col(Organizations::TenantId) + .to_owned(), + ) + .await?; + + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_organizations_tenant_code ON organizations (tenant_id, code) WHERE code IS NOT NULL".to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Organizations::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Organizations { + Table, + Id, + TenantId, + Name, + Code, + ParentId, + Path, + Level, + SortOrder, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/migration/src/m20260411_000010_create_departments.rs b/crates/erp-server/migration/src/m20260411_000010_create_departments.rs new file mode 100644 index 0000000..86db99e --- /dev/null +++ b/crates/erp-server/migration/src/m20260411_000010_create_departments.rs @@ -0,0 +1,146 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Departments::Table) + .if_not_exists() + .col( + ColumnDef::new(Departments::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Departments::TenantId).uuid().not_null()) + .col(ColumnDef::new(Departments::OrgId).uuid().not_null()) + .col(ColumnDef::new(Departments::Name).string().not_null()) + .col(ColumnDef::new(Departments::Code).string().null()) + .col(ColumnDef::new(Departments::ParentId).uuid().null()) + .col(ColumnDef::new(Departments::ManagerId).uuid().null()) + .col(ColumnDef::new(Departments::Path).string().null()) + .col( + ColumnDef::new(Departments::SortOrder) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(Departments::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Departments::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Departments::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(Departments::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(Departments::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Departments::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + &mut ForeignKey::create() + .name("fk_departments_org_id") + .from(Departments::Table, Departments::OrgId) + .to(Organizations::Table, Organizations::Id) + .on_delete(ForeignKeyAction::Restrict) + .to_owned(), + ) + .foreign_key( + &mut ForeignKey::create() + .name("fk_departments_parent_id") + .from(Departments::Table, Departments::ParentId) + .to(Departments::Table, Departments::Id) + .on_delete(ForeignKeyAction::Restrict) + .to_owned(), + ) + .foreign_key( + &mut ForeignKey::create() + .name("fk_departments_manager_id") + .from(Departments::Table, Departments::ManagerId) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::SetNull) + .to_owned(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_departments_tenant_id") + .table(Departments::Table) + .col(Departments::TenantId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_departments_org_id") + .table(Departments::Table) + .col(Departments::OrgId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Departments::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Departments { + Table, + Id, + TenantId, + OrgId, + Name, + Code, + ParentId, + ManagerId, + Path, + SortOrder, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum Organizations { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} diff --git a/crates/erp-server/migration/src/m20260411_000011_create_positions.rs b/crates/erp-server/migration/src/m20260411_000011_create_positions.rs new file mode 100644 index 0000000..1f2733e --- /dev/null +++ b/crates/erp-server/migration/src/m20260411_000011_create_positions.rs @@ -0,0 +1,125 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Positions::Table) + .if_not_exists() + .col( + ColumnDef::new(Positions::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Positions::TenantId).uuid().not_null()) + .col(ColumnDef::new(Positions::DeptId).uuid().not_null()) + .col(ColumnDef::new(Positions::Name).string().not_null()) + .col(ColumnDef::new(Positions::Code).string().null()) + .col( + ColumnDef::new(Positions::Level) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(Positions::SortOrder) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(Positions::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Positions::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Positions::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(Positions::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(Positions::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Positions::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + &mut ForeignKey::create() + .name("fk_positions_dept_id") + .from(Positions::Table, Positions::DeptId) + .to(Departments::Table, Departments::Id) + .on_delete(ForeignKeyAction::Restrict) + .to_owned(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_positions_tenant_id") + .table(Positions::Table) + .col(Positions::TenantId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_positions_dept_id") + .table(Positions::Table) + .col(Positions::DeptId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Positions::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Positions { + Table, + Id, + TenantId, + DeptId, + Name, + Code, + Level, + SortOrder, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum Departments { + Table, + Id, +} diff --git a/crates/erp-server/migration/src/m20260412_000012_create_dictionaries.rs b/crates/erp-server/migration/src/m20260412_000012_create_dictionaries.rs new file mode 100644 index 0000000..340bece --- /dev/null +++ b/crates/erp-server/migration/src/m20260412_000012_create_dictionaries.rs @@ -0,0 +1,92 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Dictionaries::Table) + .if_not_exists() + .col( + ColumnDef::new(Dictionaries::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Dictionaries::TenantId).uuid().not_null()) + .col(ColumnDef::new(Dictionaries::Name).string().not_null()) + .col(ColumnDef::new(Dictionaries::Code).string().not_null()) + .col(ColumnDef::new(Dictionaries::Description).text().null()) + .col( + ColumnDef::new(Dictionaries::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Dictionaries::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Dictionaries::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(Dictionaries::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(Dictionaries::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Dictionaries::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_dictionaries_tenant_id") + .table(Dictionaries::Table) + .col(Dictionaries::TenantId) + .to_owned(), + ) + .await?; + + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_dictionaries_tenant_code ON dictionaries (tenant_id, code) WHERE deleted_at IS NULL".to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Dictionaries::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Dictionaries { + Table, + Id, + TenantId, + Name, + Code, + Description, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/migration/src/m20260412_000013_create_dictionary_items.rs b/crates/erp-server/migration/src/m20260412_000013_create_dictionary_items.rs new file mode 100644 index 0000000..ffb5793 --- /dev/null +++ b/crates/erp-server/migration/src/m20260412_000013_create_dictionary_items.rs @@ -0,0 +1,118 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(DictionaryItems::Table) + .if_not_exists() + .col( + ColumnDef::new(DictionaryItems::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(DictionaryItems::TenantId).uuid().not_null()) + .col( + ColumnDef::new(DictionaryItems::DictionaryId) + .uuid() + .not_null(), + ) + .col(ColumnDef::new(DictionaryItems::Label).string().not_null()) + .col(ColumnDef::new(DictionaryItems::Value).string().not_null()) + .col( + ColumnDef::new(DictionaryItems::SortOrder) + .integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(DictionaryItems::Color).string().null()) + .col( + ColumnDef::new(DictionaryItems::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(DictionaryItems::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(DictionaryItems::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(DictionaryItems::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(DictionaryItems::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(DictionaryItems::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + ForeignKey::create() + .name("fk_dict_items_dictionary") + .from(DictionaryItems::Table, DictionaryItems::DictionaryId) + .to(Dictionaries::Table, Dictionaries::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_dict_items_dictionary_id") + .table(DictionaryItems::Table) + .col(DictionaryItems::DictionaryId) + .to_owned(), + ) + .await?; + + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_dict_items_dict_value ON dictionary_items (dictionary_id, value) WHERE deleted_at IS NULL".to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(DictionaryItems::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Dictionaries { + Table, + Id, +} + +#[derive(DeriveIden)] +enum DictionaryItems { + Table, + Id, + TenantId, + DictionaryId, + Label, + Value, + SortOrder, + Color, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/migration/src/m20260412_000014_create_menus.rs b/crates/erp-server/migration/src/m20260412_000014_create_menus.rs new file mode 100644 index 0000000..c491458 --- /dev/null +++ b/crates/erp-server/migration/src/m20260412_000014_create_menus.rs @@ -0,0 +1,124 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Menus::Table) + .if_not_exists() + .col(ColumnDef::new(Menus::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(Menus::TenantId).uuid().not_null()) + .col(ColumnDef::new(Menus::ParentId).uuid().null()) + .col(ColumnDef::new(Menus::Title).string().not_null()) + .col(ColumnDef::new(Menus::Path).string().null()) + .col(ColumnDef::new(Menus::Icon).string().null()) + .col( + ColumnDef::new(Menus::SortOrder) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(Menus::Visible) + .boolean() + .not_null() + .default(true), + ) + .col( + ColumnDef::new(Menus::MenuType) + .string() + .not_null() + .default("page"), + ) + .col(ColumnDef::new(Menus::Permission).string().null()) + .col( + ColumnDef::new(Menus::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Menus::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Menus::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(Menus::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(Menus::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Menus::Version) + .integer() + .not_null() + .default(1), + ) + .foreign_key( + ForeignKey::create() + .name("fk_menus_parent") + .from(Menus::Table, Menus::ParentId) + .to(Menus::Table, Menus::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_menus_tenant_id") + .table(Menus::Table) + .col(Menus::TenantId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_menus_parent_id") + .table(Menus::Table) + .col(Menus::ParentId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Menus::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Menus { + Table, + Id, + TenantId, + ParentId, + Title, + Path, + Icon, + SortOrder, + Visible, + MenuType, + Permission, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/migration/src/m20260412_000015_create_menu_roles.rs b/crates/erp-server/migration/src/m20260412_000015_create_menu_roles.rs new file mode 100644 index 0000000..b6fee45 --- /dev/null +++ b/crates/erp-server/migration/src/m20260412_000015_create_menu_roles.rs @@ -0,0 +1,96 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(MenuRoles::Table) + .if_not_exists() + .col(ColumnDef::new(MenuRoles::MenuId).uuid().not_null()) + .col(ColumnDef::new(MenuRoles::RoleId).uuid().not_null()) + .col(ColumnDef::new(MenuRoles::TenantId).uuid().not_null()) + .col(ColumnDef::new(MenuRoles::Id).uuid().not_null()) + .col( + ColumnDef::new(MenuRoles::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(MenuRoles::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(MenuRoles::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(MenuRoles::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(MenuRoles::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(MenuRoles::Version) + .integer() + .not_null() + .default(1), + ) + .primary_key(Index::create().col(MenuRoles::Id)) + .to_owned(), + ) + .await?; + + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_menu_roles_unique ON menu_roles (menu_id, role_id) WHERE deleted_at IS NULL".to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + manager + .create_index( + Index::create() + .name("idx_menu_roles_menu_id") + .table(MenuRoles::Table) + .col(MenuRoles::MenuId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_menu_roles_role_id") + .table(MenuRoles::Table) + .col(MenuRoles::RoleId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(MenuRoles::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum MenuRoles { + Table, + Id, + MenuId, + RoleId, + TenantId, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/migration/src/m20260412_000016_create_settings.rs b/crates/erp-server/migration/src/m20260412_000016_create_settings.rs new file mode 100644 index 0000000..2df5833 --- /dev/null +++ b/crates/erp-server/migration/src/m20260412_000016_create_settings.rs @@ -0,0 +1,94 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Settings::Table) + .if_not_exists() + .col(ColumnDef::new(Settings::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(Settings::TenantId).uuid().not_null()) + .col(ColumnDef::new(Settings::Scope).string().not_null()) + .col(ColumnDef::new(Settings::ScopeId).uuid().null()) + .col(ColumnDef::new(Settings::SettingKey).string().not_null()) + .col( + ColumnDef::new(Settings::SettingValue) + .json_binary() + .not_null() + .default(Expr::val("{}")), + ) + .col( + ColumnDef::new(Settings::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Settings::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Settings::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(Settings::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(Settings::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Settings::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_settings_tenant_id") + .table(Settings::Table) + .col(Settings::TenantId) + .to_owned(), + ) + .await?; + + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_settings_scope_key ON settings (tenant_id, scope, scope_id, setting_key) WHERE deleted_at IS NULL".to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Settings::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Settings { + Table, + Id, + TenantId, + Scope, + ScopeId, + SettingKey, + SettingValue, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/migration/src/m20260412_000017_create_numbering_rules.rs b/crates/erp-server/migration/src/m20260412_000017_create_numbering_rules.rs new file mode 100644 index 0000000..b3d42df --- /dev/null +++ b/crates/erp-server/migration/src/m20260412_000017_create_numbering_rules.rs @@ -0,0 +1,136 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(NumberingRules::Table) + .if_not_exists() + .col( + ColumnDef::new(NumberingRules::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(NumberingRules::TenantId).uuid().not_null()) + .col(ColumnDef::new(NumberingRules::Name).string().not_null()) + .col(ColumnDef::new(NumberingRules::Code).string().not_null()) + .col( + ColumnDef::new(NumberingRules::Prefix) + .string() + .not_null() + .default(""), + ) + .col(ColumnDef::new(NumberingRules::DateFormat).string().null()) + .col( + ColumnDef::new(NumberingRules::SeqLength) + .integer() + .not_null() + .default(4), + ) + .col( + ColumnDef::new(NumberingRules::SeqStart) + .integer() + .not_null() + .default(1), + ) + .col( + ColumnDef::new(NumberingRules::SeqCurrent) + .big_integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(NumberingRules::Separator) + .string() + .not_null() + .default("-"), + ) + .col( + ColumnDef::new(NumberingRules::ResetCycle) + .string() + .not_null() + .default("never"), + ) + .col(ColumnDef::new(NumberingRules::LastResetDate).date().null()) + .col( + ColumnDef::new(NumberingRules::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(NumberingRules::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(NumberingRules::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(NumberingRules::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(NumberingRules::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(NumberingRules::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_numbering_rules_tenant_id") + .table(NumberingRules::Table) + .col(NumberingRules::TenantId) + .to_owned(), + ) + .await?; + + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_numbering_rules_tenant_code ON numbering_rules (tenant_id, code) WHERE deleted_at IS NULL".to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(NumberingRules::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum NumberingRules { + Table, + Id, + TenantId, + Name, + Code, + Prefix, + DateFormat, + SeqLength, + SeqStart, + SeqCurrent, + Separator, + ResetCycle, + LastResetDate, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/migration/src/m20260412_000018_create_process_definitions.rs b/crates/erp-server/migration/src/m20260412_000018_create_process_definitions.rs new file mode 100644 index 0000000..d909e23 --- /dev/null +++ b/crates/erp-server/migration/src/m20260412_000018_create_process_definitions.rs @@ -0,0 +1,138 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ProcessDefinitions::Table) + .if_not_exists() + .col( + ColumnDef::new(ProcessDefinitions::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(ProcessDefinitions::TenantId) + .uuid() + .not_null(), + ) + .col(ColumnDef::new(ProcessDefinitions::Name).string().not_null()) + .col(ColumnDef::new(ProcessDefinitions::Key).string().not_null()) + .col( + ColumnDef::new(ProcessDefinitions::Version) + .integer() + .not_null() + .default(1), + ) + .col(ColumnDef::new(ProcessDefinitions::Category).string().null()) + .col( + ColumnDef::new(ProcessDefinitions::Description) + .text() + .null(), + ) + .col( + ColumnDef::new(ProcessDefinitions::Nodes) + .json_binary() + .not_null() + .default(Expr::val("[]")), + ) + .col( + ColumnDef::new(ProcessDefinitions::Edges) + .json_binary() + .not_null() + .default(Expr::val("[]")), + ) + .col( + ColumnDef::new(ProcessDefinitions::Status) + .string() + .not_null() + .default("draft"), + ) + .col( + ColumnDef::new(ProcessDefinitions::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(ProcessDefinitions::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(ProcessDefinitions::CreatedBy) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(ProcessDefinitions::UpdatedBy) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(ProcessDefinitions::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(ProcessDefinitions::VersionField) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_process_definitions_tenant_id") + .table(ProcessDefinitions::Table) + .col(ProcessDefinitions::TenantId) + .to_owned(), + ) + .await?; + + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_process_definitions_key_version ON process_definitions (tenant_id, key, version) WHERE deleted_at IS NULL".to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(ProcessDefinitions::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum ProcessDefinitions { + Table, + Id, + TenantId, + Name, + Key, + Version, + Category, + Description, + Nodes, + Edges, + Status, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + VersionField, +} diff --git a/crates/erp-server/migration/src/m20260412_000019_create_process_instances.rs b/crates/erp-server/migration/src/m20260412_000019_create_process_instances.rs new file mode 100644 index 0000000..8de4985 --- /dev/null +++ b/crates/erp-server/migration/src/m20260412_000019_create_process_instances.rs @@ -0,0 +1,144 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ProcessInstances::Table) + .if_not_exists() + .col( + ColumnDef::new(ProcessInstances::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(ProcessInstances::TenantId).uuid().not_null()) + .col( + ColumnDef::new(ProcessInstances::DefinitionId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(ProcessInstances::BusinessKey) + .string() + .null(), + ) + .col( + ColumnDef::new(ProcessInstances::Status) + .string() + .not_null() + .default("running"), + ) + .col( + ColumnDef::new(ProcessInstances::StartedBy) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(ProcessInstances::StartedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(ProcessInstances::CompletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(ProcessInstances::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(ProcessInstances::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(ProcessInstances::CreatedBy) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(ProcessInstances::UpdatedBy) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(ProcessInstances::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(ProcessInstances::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_instances_tenant_status") + .table(ProcessInstances::Table) + .col(ProcessInstances::TenantId) + .col(ProcessInstances::Status) + .to_owned(), + ) + .await?; + + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_instances_definition") + .from(ProcessInstances::Table, ProcessInstances::DefinitionId) + .to(ProcessDefinitions::Table, ProcessDefinitions::Id) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(ProcessInstances::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum ProcessInstances { + Table, + Id, + TenantId, + DefinitionId, + BusinessKey, + Status, + StartedBy, + StartedAt, + CompletedAt, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum ProcessDefinitions { + Table, + Id, +} diff --git a/crates/erp-server/migration/src/m20260412_000020_create_tokens.rs b/crates/erp-server/migration/src/m20260412_000020_create_tokens.rs new file mode 100644 index 0000000..5af0191 --- /dev/null +++ b/crates/erp-server/migration/src/m20260412_000020_create_tokens.rs @@ -0,0 +1,85 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Tokens::Table) + .if_not_exists() + .col(ColumnDef::new(Tokens::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(Tokens::TenantId).uuid().not_null()) + .col(ColumnDef::new(Tokens::InstanceId).uuid().not_null()) + .col(ColumnDef::new(Tokens::NodeId).string().not_null()) + .col( + ColumnDef::new(Tokens::Status) + .string() + .not_null() + .default("active"), + ) + .col( + ColumnDef::new(Tokens::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Tokens::ConsumedAt) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_tokens_instance") + .table(Tokens::Table) + .col(Tokens::InstanceId) + .to_owned(), + ) + .await?; + + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_tokens_instance") + .from(Tokens::Table, Tokens::InstanceId) + .to(ProcessInstances::Table, ProcessInstances::Id) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Tokens::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Tokens { + Table, + Id, + TenantId, + InstanceId, + NodeId, + Status, + CreatedAt, + ConsumedAt, +} + +#[derive(DeriveIden)] +enum ProcessInstances { + Table, + Id, +} diff --git a/crates/erp-server/migration/src/m20260412_000021_create_tasks.rs b/crates/erp-server/migration/src/m20260412_000021_create_tasks.rs new file mode 100644 index 0000000..18aa89c --- /dev/null +++ b/crates/erp-server/migration/src/m20260412_000021_create_tasks.rs @@ -0,0 +1,155 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Tasks::Table) + .if_not_exists() + .col(ColumnDef::new(Tasks::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(Tasks::TenantId).uuid().not_null()) + .col(ColumnDef::new(Tasks::InstanceId).uuid().not_null()) + .col(ColumnDef::new(Tasks::TokenId).uuid().not_null()) + .col(ColumnDef::new(Tasks::NodeId).string().not_null()) + .col(ColumnDef::new(Tasks::NodeName).string().null()) + .col(ColumnDef::new(Tasks::AssigneeId).uuid().null()) + .col(ColumnDef::new(Tasks::CandidateGroups).json_binary().null()) + .col( + ColumnDef::new(Tasks::Status) + .string() + .not_null() + .default("pending"), + ) + .col(ColumnDef::new(Tasks::Outcome).string().null()) + .col(ColumnDef::new(Tasks::FormData).json_binary().null()) + .col( + ColumnDef::new(Tasks::DueDate) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Tasks::CompletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Tasks::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Tasks::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Tasks::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(Tasks::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(Tasks::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Tasks::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_tasks_assignee") + .table(Tasks::Table) + .col(Tasks::TenantId) + .col(Tasks::AssigneeId) + .col(Tasks::Status) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_tasks_instance") + .table(Tasks::Table) + .col(Tasks::InstanceId) + .to_owned(), + ) + .await?; + + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_tasks_instance") + .from(Tasks::Table, Tasks::InstanceId) + .to(ProcessInstances::Table, ProcessInstances::Id) + .to_owned(), + ) + .await?; + + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_tasks_token") + .from(Tasks::Table, Tasks::TokenId) + .to(Tokens::Table, Tokens::Id) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Tasks::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Tasks { + Table, + Id, + TenantId, + InstanceId, + TokenId, + NodeId, + NodeName, + AssigneeId, + CandidateGroups, + Status, + Outcome, + FormData, + DueDate, + CompletedAt, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} + +#[derive(DeriveIden)] +enum ProcessInstances { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Tokens { + Table, + Id, +} diff --git a/crates/erp-server/migration/src/m20260412_000022_create_process_variables.rs b/crates/erp-server/migration/src/m20260412_000022_create_process_variables.rs new file mode 100644 index 0000000..4b0c1e4 --- /dev/null +++ b/crates/erp-server/migration/src/m20260412_000022_create_process_variables.rs @@ -0,0 +1,96 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ProcessVariables::Table) + .if_not_exists() + .col( + ColumnDef::new(ProcessVariables::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(ProcessVariables::TenantId).uuid().not_null()) + .col( + ColumnDef::new(ProcessVariables::InstanceId) + .uuid() + .not_null(), + ) + .col(ColumnDef::new(ProcessVariables::Name).string().not_null()) + .col( + ColumnDef::new(ProcessVariables::VarType) + .string() + .not_null() + .default("string"), + ) + .col(ColumnDef::new(ProcessVariables::ValueString).text().null()) + .col( + ColumnDef::new(ProcessVariables::ValueNumber) + .double() + .null(), + ) + .col( + ColumnDef::new(ProcessVariables::ValueBoolean) + .boolean() + .null(), + ) + .col( + ColumnDef::new(ProcessVariables::ValueDate) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_process_variables_instance_name ON process_variables (instance_id, name)".to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_variables_instance") + .from(ProcessVariables::Table, ProcessVariables::InstanceId) + .to(ProcessInstances::Table, ProcessInstances::Id) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(ProcessVariables::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum ProcessVariables { + Table, + Id, + TenantId, + InstanceId, + Name, + VarType, + ValueString, + ValueNumber, + ValueBoolean, + ValueDate, +} + +#[derive(DeriveIden)] +enum ProcessInstances { + Table, + Id, +} diff --git a/crates/erp-server/migration/src/m20260413_000023_create_message_templates.rs b/crates/erp-server/migration/src/m20260413_000023_create_message_templates.rs new file mode 100644 index 0000000..77b9f13 --- /dev/null +++ b/crates/erp-server/migration/src/m20260413_000023_create_message_templates.rs @@ -0,0 +1,105 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(MessageTemplates::Table) + .if_not_exists() + .col( + ColumnDef::new(MessageTemplates::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(MessageTemplates::TenantId).uuid().not_null()) + .col(ColumnDef::new(MessageTemplates::Name).string().not_null()) + .col(ColumnDef::new(MessageTemplates::Code).string().not_null()) + .col( + ColumnDef::new(MessageTemplates::Channel) + .string() + .not_null() + .default("in_app"), + ) + .col( + ColumnDef::new(MessageTemplates::TitleTemplate) + .string() + .not_null(), + ) + .col( + ColumnDef::new(MessageTemplates::BodyTemplate) + .text() + .not_null(), + ) + .col( + ColumnDef::new(MessageTemplates::Language) + .string() + .not_null() + .default("zh-CN"), + ) + .col( + ColumnDef::new(MessageTemplates::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(MessageTemplates::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(MessageTemplates::CreatedBy) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(MessageTemplates::UpdatedBy) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(MessageTemplates::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_message_templates_tenant_code ON message_templates (tenant_id, code) WHERE deleted_at IS NULL".to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(MessageTemplates::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum MessageTemplates { + Table, + Id, + TenantId, + Name, + Code, + Channel, + TitleTemplate, + BodyTemplate, + Language, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, +} diff --git a/crates/erp-server/migration/src/m20260413_000024_create_messages.rs b/crates/erp-server/migration/src/m20260413_000024_create_messages.rs new file mode 100644 index 0000000..38a3628 --- /dev/null +++ b/crates/erp-server/migration/src/m20260413_000024_create_messages.rs @@ -0,0 +1,162 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Messages::Table) + .if_not_exists() + .col(ColumnDef::new(Messages::Id).uuid().not_null().primary_key()) + .col(ColumnDef::new(Messages::TenantId).uuid().not_null()) + .col(ColumnDef::new(Messages::TemplateId).uuid().null()) + .col(ColumnDef::new(Messages::SenderId).uuid().null()) + .col( + ColumnDef::new(Messages::SenderType) + .string() + .not_null() + .default("system"), + ) + .col(ColumnDef::new(Messages::RecipientId).uuid().not_null()) + .col( + ColumnDef::new(Messages::RecipientType) + .string() + .not_null() + .default("user"), + ) + .col(ColumnDef::new(Messages::Title).string().not_null()) + .col(ColumnDef::new(Messages::Body).text().not_null()) + .col( + ColumnDef::new(Messages::Priority) + .string() + .not_null() + .default("normal"), + ) + .col(ColumnDef::new(Messages::BusinessType).string().null()) + .col(ColumnDef::new(Messages::BusinessId).uuid().null()) + .col( + ColumnDef::new(Messages::IsRead) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(Messages::ReadAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Messages::IsArchived) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(Messages::ArchivedAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Messages::SentAt) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Messages::Status) + .string() + .not_null() + .default("sent"), + ) + .col( + ColumnDef::new(Messages::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(Messages::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col(ColumnDef::new(Messages::CreatedBy).uuid().not_null()) + .col(ColumnDef::new(Messages::UpdatedBy).uuid().not_null()) + .col( + ColumnDef::new(Messages::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE INDEX idx_messages_tenant_recipient ON messages (tenant_id, recipient_id) WHERE deleted_at IS NULL".to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE INDEX idx_messages_tenant_recipient_unread ON messages (tenant_id, recipient_id) WHERE deleted_at IS NULL AND is_read = false".to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE INDEX idx_messages_tenant_business ON messages (tenant_id, business_type, business_id) WHERE deleted_at IS NULL".to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_messages_template") + .from(Messages::Table, Messages::TemplateId) + .to(MessageTemplates::Table, MessageTemplates::Id) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Messages::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum Messages { + Table, + Id, + TenantId, + TemplateId, + SenderId, + SenderType, + RecipientId, + RecipientType, + Title, + Body, + Priority, + BusinessType, + BusinessId, + IsRead, + ReadAt, + IsArchived, + ArchivedAt, + SentAt, + Status, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, +} + +#[derive(DeriveIden)] +enum MessageTemplates { + Table, + Id, +} diff --git a/crates/erp-server/migration/src/m20260413_000025_create_message_subscriptions.rs b/crates/erp-server/migration/src/m20260413_000025_create_message_subscriptions.rs new file mode 100644 index 0000000..8a355d3 --- /dev/null +++ b/crates/erp-server/migration/src/m20260413_000025_create_message_subscriptions.rs @@ -0,0 +1,112 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(MessageSubscriptions::Table) + .if_not_exists() + .col( + ColumnDef::new(MessageSubscriptions::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(MessageSubscriptions::TenantId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(MessageSubscriptions::UserId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(MessageSubscriptions::NotificationTypes) + .json() + .null(), + ) + .col( + ColumnDef::new(MessageSubscriptions::ChannelPreferences) + .json() + .null(), + ) + .col( + ColumnDef::new(MessageSubscriptions::DndEnabled) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(MessageSubscriptions::DndStart) + .string() + .null(), + ) + .col(ColumnDef::new(MessageSubscriptions::DndEnd).string().null()) + .col( + ColumnDef::new(MessageSubscriptions::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(MessageSubscriptions::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(MessageSubscriptions::CreatedBy) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(MessageSubscriptions::UpdatedBy) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(MessageSubscriptions::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_message_subscriptions_tenant_user ON message_subscriptions (tenant_id, user_id) WHERE deleted_at IS NULL".to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(MessageSubscriptions::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum MessageSubscriptions { + Table, + Id, + TenantId, + UserId, + NotificationTypes, + ChannelPreferences, + DndEnabled, + DndStart, + DndEnd, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, +} diff --git a/crates/erp-server/migration/src/m20260413_000026_create_audit_logs.rs b/crates/erp-server/migration/src/m20260413_000026_create_audit_logs.rs new file mode 100644 index 0000000..4aed077 --- /dev/null +++ b/crates/erp-server/migration/src/m20260413_000026_create_audit_logs.rs @@ -0,0 +1,81 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(AuditLogs::Table) + .if_not_exists() + .col( + ColumnDef::new(AuditLogs::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(AuditLogs::TenantId).uuid().not_null()) + .col(ColumnDef::new(AuditLogs::UserId).uuid().null()) + .col(ColumnDef::new(AuditLogs::Action).string().not_null()) + .col(ColumnDef::new(AuditLogs::ResourceType).string().not_null()) + .col(ColumnDef::new(AuditLogs::ResourceId).uuid().null()) + .col(ColumnDef::new(AuditLogs::OldValue).json().null()) + .col(ColumnDef::new(AuditLogs::NewValue).json().null()) + .col(ColumnDef::new(AuditLogs::IpAddress).string().null()) + .col(ColumnDef::new(AuditLogs::UserAgent).text().null()) + .col( + ColumnDef::new(AuditLogs::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await?; + + manager + .get_connection() + .execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE INDEX idx_audit_logs_tenant ON audit_logs (tenant_id)".to_string(), + )) + .await + .map_err(|e| DbErr::Custom(e.to_string()))?; + + manager + .get_connection() + .execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE INDEX idx_audit_logs_resource ON audit_logs (resource_type, resource_id)" + .to_string(), + )) + .await + .map_err(|e| DbErr::Custom(e.to_string()))?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(AuditLogs::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum AuditLogs { + Table, + Id, + TenantId, + UserId, + Action, + ResourceType, + ResourceId, + OldValue, + NewValue, + IpAddress, + UserAgent, + CreatedAt, +} diff --git a/crates/erp-server/migration/src/m20260414_000027_fix_unique_indexes_soft_delete.rs b/crates/erp-server/migration/src/m20260414_000027_fix_unique_indexes_soft_delete.rs new file mode 100644 index 0000000..f343424 --- /dev/null +++ b/crates/erp-server/migration/src/m20260414_000027_fix_unique_indexes_soft_delete.rs @@ -0,0 +1,92 @@ +use sea_orm::DatabaseBackend; +use sea_orm::Statement; +use sea_orm_migration::prelude::*; + +/// Recreate unique indexes on roles and permissions to include soft-delete awareness. +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute(Statement::from_string( + DatabaseBackend::Postgres, + "DROP INDEX IF EXISTS idx_roles_tenant_code".to_string(), + )) + .await?; + + manager + .get_connection() + .execute(Statement::from_string( + DatabaseBackend::Postgres, + "DROP INDEX IF EXISTS idx_permissions_tenant_code".to_string(), + )) + .await?; + + manager + .get_connection() + .execute(Statement::from_string( + DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_roles_tenant_code \ + ON roles (tenant_id, code) \ + WHERE deleted_at IS NULL" + .to_string(), + )) + .await?; + + manager + .get_connection() + .execute(Statement::from_string( + DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_permissions_tenant_code \ + ON permissions (tenant_id, code) \ + WHERE deleted_at IS NULL" + .to_string(), + )) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute(Statement::from_string( + DatabaseBackend::Postgres, + "DROP INDEX IF EXISTS idx_roles_tenant_code".to_string(), + )) + .await?; + + manager + .get_connection() + .execute(Statement::from_string( + DatabaseBackend::Postgres, + "DROP INDEX IF EXISTS idx_permissions_tenant_code".to_string(), + )) + .await?; + + manager + .get_connection() + .execute(Statement::from_string( + DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_roles_tenant_code \ + ON roles (tenant_id, code)" + .to_string(), + )) + .await?; + + manager + .get_connection() + .execute(Statement::from_string( + DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_permissions_tenant_code \ + ON permissions (tenant_id, code)" + .to_string(), + )) + .await?; + + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260414_000028_add_standard_fields_to_tokens.rs b/crates/erp-server/migration/src/m20260414_000028_add_standard_fields_to_tokens.rs new file mode 100644 index 0000000..a25f4e9 --- /dev/null +++ b/crates/erp-server/migration/src/m20260414_000028_add_standard_fields_to_tokens.rs @@ -0,0 +1,74 @@ +use sea_orm_migration::prelude::*; + +/// 为 tokens 表添加缺失的标准字段: updated_at, created_by, updated_by, deleted_at, version。 +/// +/// tokens 表原始迁移缺少 ERP 标准要求的审计和乐观锁字段。 +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Tokens::Table) + .add_column( + ColumnDef::new(Tokens::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .add_column( + ColumnDef::new(Tokens::CreatedBy) + .uuid() + .not_null() + .default(Expr::val("00000000-0000-0000-0000-000000000000")), + ) + .add_column( + ColumnDef::new(Tokens::UpdatedBy) + .uuid() + .not_null() + .default(Expr::val("00000000-0000-0000-0000-000000000000")), + ) + .add_column( + ColumnDef::new(Tokens::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .add_column( + ColumnDef::new(Tokens::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Tokens::Table) + .drop_column(Tokens::UpdatedAt) + .drop_column(Tokens::CreatedBy) + .drop_column(Tokens::UpdatedBy) + .drop_column(Tokens::DeletedAt) + .drop_column(Tokens::Version) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum Tokens { + Table, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/migration/src/m20260414_000029_add_standard_fields_to_process_variables.rs b/crates/erp-server/migration/src/m20260414_000029_add_standard_fields_to_process_variables.rs new file mode 100644 index 0000000..f0f2ecc --- /dev/null +++ b/crates/erp-server/migration/src/m20260414_000029_add_standard_fields_to_process_variables.rs @@ -0,0 +1,82 @@ +use sea_orm_migration::prelude::*; + +/// 为 process_variables 表添加缺失的标准字段: created_at, updated_at, created_by, updated_by, deleted_at, version。 +/// +/// process_variables 表原始迁移缺少 ERP 标准要求的审计和乐观锁字段。 +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(ProcessVariables::Table) + .add_column( + ColumnDef::new(ProcessVariables::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .add_column( + ColumnDef::new(ProcessVariables::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .add_column( + ColumnDef::new(ProcessVariables::CreatedBy) + .uuid() + .not_null() + .default(Expr::val("00000000-0000-0000-0000-000000000000")), + ) + .add_column( + ColumnDef::new(ProcessVariables::UpdatedBy) + .uuid() + .not_null() + .default(Expr::val("00000000-0000-0000-0000-000000000000")), + ) + .add_column( + ColumnDef::new(ProcessVariables::DeletedAt) + .timestamp_with_time_zone() + .null(), + ) + .add_column( + ColumnDef::new(ProcessVariables::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(ProcessVariables::Table) + .drop_column(ProcessVariables::CreatedAt) + .drop_column(ProcessVariables::UpdatedAt) + .drop_column(ProcessVariables::CreatedBy) + .drop_column(ProcessVariables::UpdatedBy) + .drop_column(ProcessVariables::DeletedAt) + .drop_column(ProcessVariables::Version) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum ProcessVariables { + Table, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/migration/src/m20260414_000032_fix_settings_unique_index_null.rs b/crates/erp-server/migration/src/m20260414_000032_fix_settings_unique_index_null.rs new file mode 100644 index 0000000..e5f77a9 --- /dev/null +++ b/crates/erp-server/migration/src/m20260414_000032_fix_settings_unique_index_null.rs @@ -0,0 +1,65 @@ +use sea_orm_migration::prelude::*; + +/// 修复 settings 表唯一索引:原索引使用 scope_id 列,当 scope_id 为 NULL 时 +/// PostgreSQL B-tree 不认为两行重复(NULL != NULL),导致可插入重复数据。 +/// 修复方案:使用 COALESCE(scope_id, '00000000-0000-0000-0000-000000000000') +/// 将 NULL 转为固定 UUID,使索引能正确约束 NULL scope_id 的行。 +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 1. 删除旧索引 + manager + .get_connection() + .execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "DROP INDEX IF EXISTS idx_settings_scope_key".to_string(), + )) + .await + .map_err(|e| DbErr::Custom(e.to_string()))?; + + // 2. 先清理可能已存在的重复数据(保留每组最新的一条) + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + r#" + DELETE FROM settings a USING settings b + WHERE a.id < b.id + AND a.tenant_id = b.tenant_id + AND a.scope = b.scope + AND a.setting_key = b.setting_key + AND a.deleted_at IS NULL + AND b.deleted_at IS NULL + AND COALESCE(a.scope_id, '00000000-0000-0000-0000-000000000000') = COALESCE(b.scope_id, '00000000-0000-0000-0000-000000000000') + "#.to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + // 3. 创建新索引,使用 COALESCE 处理 NULL scope_id + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_settings_scope_key ON settings (tenant_id, scope, COALESCE(scope_id, '00000000-0000-0000-0000-000000000000'), setting_key) WHERE deleted_at IS NULL".to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 回滚:删除新索引,恢复旧索引 + manager + .get_connection() + .execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "DROP INDEX IF EXISTS idx_settings_scope_key".to_string(), + )) + .await + .map_err(|e| DbErr::Custom(e.to_string()))?; + + manager.get_connection().execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "CREATE UNIQUE INDEX idx_settings_scope_key ON settings (tenant_id, scope, scope_id, setting_key) WHERE deleted_at IS NULL".to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260415_000030_add_version_to_message_tables.rs b/crates/erp-server/migration/src/m20260415_000030_add_version_to_message_tables.rs new file mode 100644 index 0000000..0c4caf8 --- /dev/null +++ b/crates/erp-server/migration/src/m20260415_000030_add_version_to_message_tables.rs @@ -0,0 +1,104 @@ +use sea_orm_migration::prelude::*; + +/// 为三个消息表添加缺失的 version 列(乐观锁字段)。 +/// +/// CLAUDE.md 要求所有表包含 version 字段用于乐观锁,但消息模块的三个表遗漏了。 +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // message_templates + manager + .alter_table( + Table::alter() + .table(MessageTemplates::Table) + .add_column( + ColumnDef::new(MessageTemplates::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + // messages + manager + .alter_table( + Table::alter() + .table(Messages::Table) + .add_column( + ColumnDef::new(Messages::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + // message_subscriptions + manager + .alter_table( + Table::alter() + .table(MessageSubscriptions::Table) + .add_column( + ColumnDef::new(MessageSubscriptions::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(MessageTemplates::Table) + .drop_column(MessageTemplates::Version) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Messages::Table) + .drop_column(Messages::Version) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(MessageSubscriptions::Table) + .drop_column(MessageSubscriptions::Version) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum MessageTemplates { + Table, + Version, +} + +#[derive(DeriveIden)] +enum Messages { + Table, + Version, +} + +#[derive(DeriveIden)] +enum MessageSubscriptions { + Table, + Version, +} diff --git a/crates/erp-server/migration/src/m20260416_000031_create_domain_events.rs b/crates/erp-server/migration/src/m20260416_000031_create_domain_events.rs new file mode 100644 index 0000000..3a0c6ba --- /dev/null +++ b/crates/erp-server/migration/src/m20260416_000031_create_domain_events.rs @@ -0,0 +1,84 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Alias::new("domain_events")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("event_type")) + .string_len(200) + .not_null(), + ) + .col(ColumnDef::new(Alias::new("payload")).json().null()) + .col(ColumnDef::new(Alias::new("correlation_id")).uuid().null()) + .col( + ColumnDef::new(Alias::new("status")) + .string_len(20) + .not_null() + .default("pending"), + ) + .col( + ColumnDef::new(Alias::new("attempts")) + .integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(Alias::new("last_error")).text().null()) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("published_at")) + .timestamp_with_time_zone() + .null(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_domain_events_status") + .table(Alias::new("domain_events")) + .col(Alias::new("status")) + .col(Alias::new("created_at")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_domain_events_tenant") + .table(Alias::new("domain_events")) + .col(Alias::new("tenant_id")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Alias::new("domain_events")).to_owned()) + .await + } +} diff --git a/crates/erp-server/migration/src/m20260417_000033_create_plugins.rs b/crates/erp-server/migration/src/m20260417_000033_create_plugins.rs new file mode 100644 index 0000000..dd5d8cb --- /dev/null +++ b/crates/erp-server/migration/src/m20260417_000033_create_plugins.rs @@ -0,0 +1,248 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 1. plugins 表 — 插件注册与生命周期 + manager + .create_table( + Table::create() + .table(Alias::new("plugins")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("name")) + .string_len(200) + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("plugin_version")) + .string_len(50) + .not_null(), + ) + .col(ColumnDef::new(Alias::new("description")).text().null()) + .col(ColumnDef::new(Alias::new("author")).string_len(200).null()) + .col( + ColumnDef::new(Alias::new("status")) + .string_len(20) + .not_null() + .default("uploaded"), + ) + .col( + ColumnDef::new(Alias::new("manifest_json")) + .json() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("wasm_binary")) + .binary() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("wasm_hash")) + .string_len(64) + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("config_json")) + .json() + .not_null() + .default(Expr::val("{}")), + ) + .col(ColumnDef::new(Alias::new("error_message")).text().null()) + .col( + ColumnDef::new(Alias::new("installed_at")) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Alias::new("enabled_at")) + .timestamp_with_time_zone() + .null(), + ) + // 标准字段 + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Alias::new("created_by")).uuid().null()) + .col(ColumnDef::new(Alias::new("updated_by")).uuid().null()) + .col( + ColumnDef::new(Alias::new("deleted_at")) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Alias::new("version")) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_plugins_tenant_status") + .table(Alias::new("plugins")) + .col(Alias::new("tenant_id")) + .col(Alias::new("status")) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_plugins_name") + .table(Alias::new("plugins")) + .col(Alias::new("name")) + .to_owned(), + ) + .await?; + + // 2. plugin_entities 表 — 插件动态表注册 + manager + .create_table( + Table::create() + .table(Alias::new("plugin_entities")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null()) + .col(ColumnDef::new(Alias::new("plugin_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("entity_name")) + .string_len(100) + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("table_name")) + .string_len(200) + .not_null(), + ) + .col(ColumnDef::new(Alias::new("schema_json")).json().not_null()) + // 标准字段 + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Alias::new("created_by")).uuid().null()) + .col(ColumnDef::new(Alias::new("updated_by")).uuid().null()) + .col( + ColumnDef::new(Alias::new("deleted_at")) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Alias::new("version")) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_plugin_entities_plugin") + .table(Alias::new("plugin_entities")) + .col(Alias::new("plugin_id")) + .to_owned(), + ) + .await?; + + // 3. plugin_event_subscriptions 表 — 事件订阅 + manager + .create_table( + Table::create() + .table(Alias::new("plugin_event_subscriptions")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("plugin_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("event_pattern")) + .string_len(200) + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_plugin_event_subs_plugin") + .table(Alias::new("plugin_event_subscriptions")) + .col(Alias::new("plugin_id")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(Alias::new("plugin_event_subscriptions")) + .to_owned(), + ) + .await?; + manager + .drop_table( + Table::drop() + .table(Alias::new("plugin_entities")) + .to_owned(), + ) + .await?; + manager + .drop_table(Table::drop().table(Alias::new("plugins")).to_owned()) + .await + } +} diff --git a/crates/erp-server/migration/src/m20260417_000034_seed_plugin_permissions.rs b/crates/erp-server/migration/src/m20260417_000034_seed_plugin_permissions.rs new file mode 100644 index 0000000..4069f28 --- /dev/null +++ b/crates/erp-server/migration/src/m20260417_000034_seed_plugin_permissions.rs @@ -0,0 +1,84 @@ +use sea_orm_migration::prelude::*; + +/// 为已存在的租户补充 plugin 模块权限,并分配给 admin 角色。 +/// seed_tenant_auth 只在租户创建时执行,已存在的租户缺少 plugin 相关权限。 +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // 插入 plugin 权限(如果不存在) + db.execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + r#" + INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version) + SELECT gen_random_uuid(), t.id, 'plugin.admin', '插件管理', 'plugin', 'admin', '管理插件全生命周期', NOW(), NOW(), '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000', NULL, 1 + FROM tenant t + WHERE NOT EXISTS ( + SELECT 1 FROM permissions p WHERE p.code = 'plugin.admin' AND p.tenant_id = t.id AND p.deleted_at IS NULL + ) + "#.to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + db.execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + r#" + INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version) + SELECT gen_random_uuid(), t.id, 'plugin.list', '查看插件', 'plugin', 'list', '查看插件列表', NOW(), NOW(), '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000', NULL, 1 + FROM tenant t + WHERE NOT EXISTS ( + SELECT 1 FROM permissions p WHERE p.code = 'plugin.list' AND p.tenant_id = t.id AND p.deleted_at IS NULL + ) + "#.to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + // 将 plugin 权限分配给 admin 角色(如果尚未分配) + db.execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + r#" + INSERT INTO role_permissions (role_id, permission_id, tenant_id, created_at, updated_at, created_by, updated_by, deleted_at, version) + SELECT r.id, p.id, r.tenant_id, NOW(), NOW(), '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000', NULL, 1 + FROM roles r + JOIN permissions p ON p.tenant_id = r.tenant_id AND p.code IN ('plugin.admin', 'plugin.list') AND p.deleted_at IS NULL + WHERE r.code = 'admin' AND r.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM role_permissions rp + WHERE rp.role_id = r.id AND rp.permission_id = p.id AND rp.deleted_at IS NULL + ) + "#.to_string(), + )).await.map_err(|e| DbErr::Custom(e.to_string()))?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // 删除 plugin 权限的角色关联 + db.execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + r#" + DELETE FROM role_permissions + WHERE permission_id IN ( + SELECT id FROM permissions WHERE code IN ('plugin.admin', 'plugin.list') + ) + "# + .to_string(), + )) + .await + .map_err(|e| DbErr::Custom(e.to_string()))?; + + // 删除 plugin 权限 + db.execute(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "DELETE FROM permissions WHERE code IN ('plugin.admin', 'plugin.list')".to_string(), + )) + .await + .map_err(|e| DbErr::Custom(e.to_string()))?; + + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260418_000035_pg_trgm_and_entity_columns.rs b/crates/erp-server/migration/src/m20260418_000035_pg_trgm_and_entity_columns.rs new file mode 100644 index 0000000..218d8d3 --- /dev/null +++ b/crates/erp-server/migration/src/m20260418_000035_pg_trgm_and_entity_columns.rs @@ -0,0 +1,85 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 启用 pg_trgm 扩展(加速 ILIKE '%keyword%' 搜索) + manager + .get_connection() + .execute_unprepared("CREATE EXTENSION IF NOT EXISTS pg_trgm") + .await?; + + // 插件实体列元数据表 — 记录哪些字段被提取为 Generated Column + manager + .create_table( + Table::create() + .table(Alias::new("plugin_entity_columns")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .default(Expr::cust("gen_random_uuid()")) + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("plugin_entity_id")) + .uuid() + .not_null(), + ) + .col(ColumnDef::new(Alias::new("field_name")).string().not_null()) + .col( + ColumnDef::new(Alias::new("column_name")) + .string() + .not_null(), + ) + .col(ColumnDef::new(Alias::new("sql_type")).string().not_null()) + .col( + ColumnDef::new(Alias::new("is_generated")) + .boolean() + .not_null() + .default(true), + ) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::cust("NOW()")), + ) + .to_owned(), + ) + .await?; + + // plugin_entity_id 外键 + manager + .create_foreign_key( + ForeignKey::create() + .name("fk_plugin_entity_columns_entity") + .from( + Alias::new("plugin_entity_columns"), + Alias::new("plugin_entity_id"), + ) + .to(Alias::new("plugin_entities"), Alias::new("id")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(Alias::new("plugin_entity_columns")) + .to_owned(), + ) + .await?; + // pg_trgm 不卸载(其他功能可能依赖) + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260418_000036_add_data_scope_to_role_permissions.rs b/crates/erp-server/migration/src/m20260418_000036_add_data_scope_to_role_permissions.rs new file mode 100644 index 0000000..1ab2eb1 --- /dev/null +++ b/crates/erp-server/migration/src/m20260418_000036_add_data_scope_to_role_permissions.rs @@ -0,0 +1,37 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 添加 data_scope 列 — 行级数据权限范围 + // 可选值: all, self, department, department_tree + manager + .alter_table( + Table::alter() + .table(Alias::new("role_permissions")) + .add_column( + ColumnDef::new(Alias::new("data_scope")) + .string() + .not_null() + .default("all"), + ) + .to_owned(), + ) + .await?; + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Alias::new("role_permissions")) + .drop_column(Alias::new("data_scope")) + .to_owned(), + ) + .await + } +} diff --git a/crates/erp-server/migration/src/m20260419_000037_create_user_departments.rs b/crates/erp-server/migration/src/m20260419_000037_create_user_departments.rs new file mode 100644 index 0000000..a86a7d5 --- /dev/null +++ b/crates/erp-server/migration/src/m20260419_000037_create_user_departments.rs @@ -0,0 +1,94 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Alias::new("user_departments")) + .if_not_exists() + .col(ColumnDef::new(Alias::new("user_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("department_id")) + .uuid() + .not_null(), + ) + .col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("is_primary")) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Alias::new("created_by")).uuid()) + .col(ColumnDef::new(Alias::new("updated_by")).uuid()) + .col(ColumnDef::new(Alias::new("deleted_at")).timestamp_with_time_zone()) + .col( + ColumnDef::new(Alias::new("version")) + .integer() + .not_null() + .default(1), + ) + .primary_key( + Index::create() + .col(Alias::new("user_id")) + .col(Alias::new("department_id")), + ) + .to_owned(), + ) + .await?; + + // 索引:按租户 + 用户查询部门 + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_user_departments_tenant_user") + .table(Alias::new("user_departments")) + .col(Alias::new("tenant_id")) + .col(Alias::new("user_id")) + .to_owned(), + ) + .await?; + + // 索引:按部门查询成员 + manager + .create_index( + Index::create() + .if_not_exists() + .name("idx_user_departments_dept") + .table(Alias::new("user_departments")) + .col(Alias::new("department_id")) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(Alias::new("user_departments")) + .to_owned(), + ) + .await + } +} diff --git a/crates/erp-server/migration/src/m20260419_000039_entity_registry_columns.rs b/crates/erp-server/migration/src/m20260419_000039_entity_registry_columns.rs new file mode 100644 index 0000000..62cf2fb --- /dev/null +++ b/crates/erp-server/migration/src/m20260419_000039_entity_registry_columns.rs @@ -0,0 +1,51 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // plugin_entities 新增 manifest_id 列 — 避免跨插件查询时 JOIN plugins 表 + manager + .get_connection() + .execute_unprepared( + r#" +ALTER TABLE plugin_entities +ADD COLUMN IF NOT EXISTS manifest_id TEXT NOT NULL DEFAULT ''; + +ALTER TABLE plugin_entities +ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT false; + +-- 回填 manifest_id(从 plugins.manifest_json 提取 metadata.id) +UPDATE plugin_entities pe +SET manifest_id = COALESCE(p.manifest_json->'metadata'->>'id', '') +FROM plugins p +WHERE pe.plugin_id = p.id AND pe.deleted_at IS NULL; + +-- 跨插件实体查找索引 +CREATE INDEX IF NOT EXISTS idx_plugin_entities_cross_ref +ON plugin_entities (manifest_id, entity_name, tenant_id) +WHERE deleted_at IS NULL; +"#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared( + r#" +DROP INDEX IF EXISTS idx_plugin_entities_cross_ref; +ALTER TABLE plugin_entities DROP COLUMN IF EXISTS is_public; +ALTER TABLE plugin_entities DROP COLUMN IF EXISTS manifest_id; +"#, + ) + .await?; + + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260419_000040_plugin_market.rs b/crates/erp-server/migration/src/m20260419_000040_plugin_market.rs new file mode 100644 index 0000000..bf3530f --- /dev/null +++ b/crates/erp-server/migration/src/m20260419_000040_plugin_market.rs @@ -0,0 +1,146 @@ +use sea_orm_migration::prelude::*; + +/// 插件市场目录表 — P4 插件市场基础设施 +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Alias::new("plugin_market_entries")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("plugin_id")).string().not_null()) + .col(ColumnDef::new(Alias::new("name")).string().not_null()) + .col(ColumnDef::new(Alias::new("version")).string().not_null()) + .col(ColumnDef::new(Alias::new("description")).text()) + .col(ColumnDef::new(Alias::new("author")).string()) + .col(ColumnDef::new(Alias::new("category")).string()) // 行业分类 + .col(ColumnDef::new(Alias::new("tags")).json()) // 标签列表 + .col(ColumnDef::new(Alias::new("icon_url")).string()) + .col(ColumnDef::new(Alias::new("screenshots")).json()) // 截图 URL 列表 + .col( + ColumnDef::new(Alias::new("wasm_binary")) + .binary() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("manifest_toml")) + .text() + .not_null(), + ) + .col(ColumnDef::new(Alias::new("wasm_hash")).string().not_null()) + .col(ColumnDef::new(Alias::new("min_platform_version")).string()) + .col( + ColumnDef::new(Alias::new("status")) + .string() + .not_null() + .default("published"), + ) // published | suspended + .col( + ColumnDef::new(Alias::new("download_count")) + .integer() + .not_null() + .default(0), + ) + .col( + ColumnDef::new(Alias::new("rating_avg")) + .decimal() + .not_null() + .default(0.0), + ) + .col( + ColumnDef::new(Alias::new("rating_count")) + .integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(Alias::new("changelog")).text()) // 版本更新日志 + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await?; + + // 插件市场评论/评分表 + manager + .create_table( + Table::create() + .table(Alias::new("plugin_market_reviews")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null()) + .col(ColumnDef::new(Alias::new("user_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("market_entry_id")) + .uuid() + .not_null(), + ) + .col(ColumnDef::new(Alias::new("rating")).integer().not_null()) // 1-5 + .col(ColumnDef::new(Alias::new("review_text")).text()) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await?; + + // 唯一索引:每个用户对每个市场条目只能评一次 + manager + .create_index( + Index::create() + .if_not_exists() + .unique() + .name("uq_market_review_tenant_user_entry") + .table(Alias::new("plugin_market_reviews")) + .col(Alias::new("tenant_id")) + .col(Alias::new("user_id")) + .col(Alias::new("market_entry_id")) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(Alias::new("plugin_market_reviews")) + .to_owned(), + ) + .await?; + manager + .drop_table( + Table::drop() + .table(Alias::new("plugin_market_entries")) + .to_owned(), + ) + .await + } +} diff --git a/crates/erp-server/migration/src/m20260419_000041_plugin_user_views.rs b/crates/erp-server/migration/src/m20260419_000041_plugin_user_views.rs new file mode 100644 index 0000000..248a179 --- /dev/null +++ b/crates/erp-server/migration/src/m20260419_000041_plugin_user_views.rs @@ -0,0 +1,63 @@ +use sea_orm_migration::prelude::*; + +/// 插件用户视图 — 用户自定义的列表视图配置 +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Alias::new("plugin_user_views")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null()) + .col(ColumnDef::new(Alias::new("user_id")).uuid().not_null()) + .col(ColumnDef::new(Alias::new("plugin_id")).string().not_null()) + .col( + ColumnDef::new(Alias::new("entity_name")) + .string() + .not_null(), + ) + .col(ColumnDef::new(Alias::new("view_name")).string().not_null()) + .col(ColumnDef::new(Alias::new("view_config")).json().not_null()) + .col( + ColumnDef::new(Alias::new("is_default")) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(Alias::new("plugin_user_views")) + .to_owned(), + ) + .await + } +} diff --git a/crates/erp-server/migration/src/m20260423_000043_create_wechat_users.rs b/crates/erp-server/migration/src/m20260423_000043_create_wechat_users.rs new file mode 100644 index 0000000..2a6c90a --- /dev/null +++ b/crates/erp-server/migration/src/m20260423_000043_create_wechat_users.rs @@ -0,0 +1,88 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(WechatUsers::Table) + .if_not_exists() + .col( + ColumnDef::new(WechatUsers::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(WechatUsers::TenantId).uuid().not_null()) + .col(ColumnDef::new(WechatUsers::Openid).string().not_null()) + .col(ColumnDef::new(WechatUsers::UnionId).string()) + .col(ColumnDef::new(WechatUsers::UserId).uuid().not_null()) + .col(ColumnDef::new(WechatUsers::Phone).string()) + .col( + ColumnDef::new(WechatUsers::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(WechatUsers::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(WechatUsers::CreatedBy).uuid()) + .col(ColumnDef::new(WechatUsers::UpdatedBy).uuid()) + .col(ColumnDef::new(WechatUsers::DeletedAt).timestamp_with_time_zone()) + .col( + ColumnDef::new(WechatUsers::Version) + .integer() + .not_null() + .default(1), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_wechat_users_openid") + .table(WechatUsers::Table) + .col(WechatUsers::Openid) + .col(WechatUsers::TenantId) + .unique() + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index(Index::drop().name("idx_wechat_users_openid").to_owned()) + .await?; + manager + .drop_table(Table::drop().table(WechatUsers::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum WechatUsers { + Table, + Id, + TenantId, + Openid, + UnionId, + UserId, + Phone, + CreatedAt, + UpdatedAt, + DeletedAt, + CreatedBy, + UpdatedBy, + Version, +} diff --git a/crates/erp-server/migration/src/m20260427_000062_create_tenant_crypto_keys.rs b/crates/erp-server/migration/src/m20260427_000062_create_tenant_crypto_keys.rs new file mode 100644 index 0000000..244fae2 --- /dev/null +++ b/crates/erp-server/migration/src/m20260427_000062_create_tenant_crypto_keys.rs @@ -0,0 +1,102 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(TenantCryptoKey::Table) + .col( + ColumnDef::new(TenantCryptoKey::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(TenantCryptoKey::TenantId).uuid().not_null()) + .col( + ColumnDef::new(TenantCryptoKey::EncryptedDek) + .string_len(128) + .not_null(), + ) + .col( + ColumnDef::new(TenantCryptoKey::KeyVersion) + .integer() + .not_null() + .default(1), + ) + .col( + ColumnDef::new(TenantCryptoKey::IsActive) + .boolean() + .not_null() + .default(true), + ) + .col( + ColumnDef::new(TenantCryptoKey::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(TenantCryptoKey::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(TenantCryptoKey::CreatedBy).uuid()) + .col(ColumnDef::new(TenantCryptoKey::UpdatedBy).uuid()) + .col(ColumnDef::new(TenantCryptoKey::DeletedAt).timestamp_with_time_zone()) + .col( + ColumnDef::new(TenantCryptoKey::Version) + .integer() + .not_null() + .default(1), + ) + .index( + Index::create() + .col(TenantCryptoKey::TenantId) + .col(TenantCryptoKey::KeyVersion) + .unique(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_tenant_crypto_keys_tenant") + .table(TenantCryptoKey::Table) + .col(TenantCryptoKey::TenantId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(TenantCryptoKey::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum TenantCryptoKey { + Table, + Id, + TenantId, + EncryptedDek, + KeyVersion, + IsActive, + CreatedAt, + UpdatedAt, + CreatedBy, + UpdatedBy, + DeletedAt, + Version, +} diff --git a/crates/erp-server/migration/src/m20260427_000084_domain_events_cleanup.rs b/crates/erp-server/migration/src/m20260427_000084_domain_events_cleanup.rs new file mode 100644 index 0000000..38b3759 --- /dev/null +++ b/crates/erp-server/migration/src/m20260427_000084_domain_events_cleanup.rs @@ -0,0 +1,128 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 归档表 — 与 domain_events 结构相同,用于存放 >90 天的已发布事件 + manager + .create_table( + Table::create() + .table(Alias::new("domain_events_archive")) + .if_not_exists() + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("event_type")) + .string_len(200) + .not_null(), + ) + .col(ColumnDef::new(Alias::new("payload")).json().null()) + .col(ColumnDef::new(Alias::new("correlation_id")).uuid().null()) + .col( + ColumnDef::new(Alias::new("status")) + .string_len(20) + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("attempts")) + .integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(Alias::new("last_error")).text().null()) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("published_at")) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Alias::new("archived_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_domain_events_archive_created") + .table(Alias::new("domain_events_archive")) + .col(Alias::new("created_at")) + .to_owned(), + ) + .await?; + + // 清理函数:将 >90 天的已发布事件迁移到归档表 + manager + .get_connection() + .execute_unprepared( + r#" + CREATE OR REPLACE FUNCTION cleanup_old_published_events( + retention_days INT DEFAULT 90, + batch_size INT DEFAULT 1000 + ) RETURNS INT AS $$ + DECLARE + moved_count INT; + BEGIN + INSERT INTO domain_events_archive (id, tenant_id, event_type, payload, correlation_id, status, attempts, last_error, created_at, published_at) + SELECT id, tenant_id, event_type, payload, correlation_id, status, attempts, last_error, created_at, published_at + FROM domain_events + WHERE status = 'published' + AND published_at < NOW() - (retention_days || ' days')::INTERVAL + ORDER BY created_at ASC + LIMIT batch_size; + + GET DIAGNOSTICS moved_count = ROW_COUNT; + + DELETE FROM domain_events + WHERE ctid IN ( + SELECT ctid FROM domain_events + WHERE status = 'published' + AND published_at < NOW() - (retention_days || ' days')::INTERVAL + ORDER BY created_at ASC + LIMIT batch_size + ); + + RETURN moved_count; + END; + $$ LANGUAGE plpgsql; + "#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("DROP FUNCTION IF EXISTS cleanup_old_published_events(INT, INT);") + .await?; + + manager + .drop_table( + Table::drop() + .table(Alias::new("domain_events_archive")) + .to_owned(), + ) + .await?; + + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260427_000085_processed_events.rs b/crates/erp-server/migration/src/m20260427_000085_processed_events.rs new file mode 100644 index 0000000..837aa47 --- /dev/null +++ b/crates/erp-server/migration/src/m20260427_000085_processed_events.rs @@ -0,0 +1,81 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Alias::new("processed_events")) + .if_not_exists() + .col(ColumnDef::new(Alias::new("event_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("consumer_id")) + .string_len(200) + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("processed_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .primary_key( + Index::create() + .col(Alias::new("event_id")) + .col(Alias::new("consumer_id")), + ) + .to_owned(), + ) + .await?; + + // 7 天 TTL 清理函数 + manager + .get_connection() + .execute_unprepared( + r#" + CREATE OR REPLACE FUNCTION cleanup_old_processed_events( + retention_days INT DEFAULT 7, + batch_size INT DEFAULT 1000 + ) RETURNS INT AS $$ + DECLARE + deleted_count INT; + BEGIN + DELETE FROM processed_events + WHERE ctid IN ( + SELECT ctid FROM processed_events + WHERE processed_at < NOW() - (retention_days || ' days')::INTERVAL + LIMIT batch_size + ); + + GET DIAGNOSTICS deleted_count = ROW_COUNT; + RETURN deleted_count; + END; + $$ LANGUAGE plpgsql; + "#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .get_connection() + .execute_unprepared("DROP FUNCTION IF EXISTS cleanup_old_processed_events(INT, INT);") + .await?; + + manager + .drop_table( + Table::drop() + .table(Alias::new("processed_events")) + .to_owned(), + ) + .await?; + + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260427_000086_enable_rls_all_tables.rs b/crates/erp-server/migration/src/m20260427_000086_enable_rls_all_tables.rs new file mode 100644 index 0000000..5d05892 --- /dev/null +++ b/crates/erp-server/migration/src/m20260427_000086_enable_rls_all_tables.rs @@ -0,0 +1,75 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + + // PL/pgSQL 动态为所有含 tenant_id 列的表启用 RLS + conn.execute_unprepared( + r#" + DO $$ + DECLARE + tbl TEXT; + BEGIN + FOR tbl IN + SELECT c.table_name FROM information_schema.columns c + JOIN information_schema.tables t + ON c.table_name = t.table_name AND c.table_schema = t.table_schema + WHERE c.column_name = 'tenant_id' + AND c.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + ORDER BY c.table_name + LOOP + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl); + EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON %I', tbl); + EXECUTE format( + 'CREATE POLICY tenant_isolation ON %I USING ( + current_setting(''app.current_tenant_id'', true) = '''' + OR tenant_id = current_setting(''app.current_tenant_id'', true)::uuid + )', + tbl + ); + END LOOP; + END; + $$; + "#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + + conn.execute_unprepared( + r#" + DO $$ + DECLARE + tbl TEXT; + BEGIN + FOR tbl IN + SELECT c.table_name FROM information_schema.columns c + JOIN information_schema.tables t + ON c.table_name = t.table_name AND c.table_schema = t.table_schema + WHERE c.column_name = 'tenant_id' + AND c.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + ORDER BY c.table_name + LOOP + EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON %I', tbl); + EXECUTE format('ALTER TABLE %I DISABLE ROW LEVEL SECURITY', tbl); + END LOOP; + END; + $$; + "#, + ) + .await?; + + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260427_000087_audit_logs_hash_chain.rs b/crates/erp-server/migration/src/m20260427_000087_audit_logs_hash_chain.rs new file mode 100644 index 0000000..6aebd6a --- /dev/null +++ b/crates/erp-server/migration/src/m20260427_000087_audit_logs_hash_chain.rs @@ -0,0 +1,51 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + + conn.execute_unprepared("ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS prev_hash TEXT") + .await?; + + conn.execute_unprepared("ALTER TABLE audit_logs ADD COLUMN IF NOT EXISTS record_hash TEXT") + .await?; + + // 为 record_hash 创建索引(用于快速查找最新哈希) + conn.execute_unprepared( + "CREATE INDEX IF NOT EXISTS idx_audit_logs_record_hash + ON audit_logs (record_hash) WHERE record_hash IS NOT NULL", + ) + .await?; + + // 按 tenant_id + created_at DESC 查找最新哈希的索引 + conn.execute_unprepared( + "CREATE INDEX IF NOT EXISTS idx_audit_logs_tenant_created + ON audit_logs (tenant_id, created_at DESC)", + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + + conn.execute_unprepared("DROP INDEX IF EXISTS idx_audit_logs_tenant_created") + .await?; + + conn.execute_unprepared("DROP INDEX IF EXISTS idx_audit_logs_record_hash") + .await?; + + conn.execute_unprepared("ALTER TABLE audit_logs DROP COLUMN IF EXISTS record_hash") + .await?; + + conn.execute_unprepared("ALTER TABLE audit_logs DROP COLUMN IF EXISTS prev_hash") + .await?; + + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260428_000088_rls_policy_strict.rs b/crates/erp-server/migration/src/m20260428_000088_rls_policy_strict.rs new file mode 100644 index 0000000..ba98094 --- /dev/null +++ b/crates/erp-server/migration/src/m20260428_000088_rls_policy_strict.rs @@ -0,0 +1,82 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + + // 替换所有表的 RLS 策略:移除空字符串绕过条件 + // 原策略允许 current_setting(...) = '' 时通过(绕过 RLS),现在要求变量已设置且匹配 + conn.execute_unprepared( + r#" + DO $$ + DECLARE + tbl TEXT; + BEGIN + FOR tbl IN + SELECT c.table_name FROM information_schema.columns c + JOIN information_schema.tables t + ON c.table_name = t.table_name AND c.table_schema = t.table_schema + WHERE c.column_name = 'tenant_id' + AND c.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + ORDER BY c.table_name + LOOP + EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON %I', tbl); + EXECUTE format( + 'CREATE POLICY tenant_isolation ON %I USING ( + current_setting(''app.current_tenant_id'', true) != '''' + AND tenant_id = current_setting(''app.current_tenant_id'', true)::uuid + )', + tbl + ); + END LOOP; + END; + $$; + "#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + + // 回滚:恢复允许空字符串绕过的原策略 + conn.execute_unprepared( + r#" + DO $$ + DECLARE + tbl TEXT; + BEGIN + FOR tbl IN + SELECT c.table_name FROM information_schema.columns c + JOIN information_schema.tables t + ON c.table_name = t.table_name AND c.table_schema = t.table_schema + WHERE c.column_name = 'tenant_id' + AND c.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + ORDER BY c.table_name + LOOP + EXECUTE format('DROP POLICY IF EXISTS tenant_isolation ON %I', tbl); + EXECUTE format( + 'CREATE POLICY tenant_isolation ON %I USING ( + current_setting(''app.current_tenant_id'', true) = '''' + OR tenant_id = current_setting(''app.current_tenant_id'', true)::uuid + )', + tbl + ); + END LOOP; + END; + $$; + "#, + ) + .await?; + + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260428_000089_blind_indexes.rs b/crates/erp-server/migration/src/m20260428_000089_blind_indexes.rs new file mode 100644 index 0000000..c3943eb --- /dev/null +++ b/crates/erp-server/migration/src/m20260428_000089_blind_indexes.rs @@ -0,0 +1,93 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[derive(Iden)] +enum BlindIndex { + Table, + Id, + TenantId, + EntityType, + EntityId, + FieldName, + BlindHash, + CreatedAt, + UpdatedAt, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(BlindIndex::Table) + .col( + ColumnDef::new(BlindIndex::Id) + .uuid() + .not_null() + .primary_key() + .default(PgFunc::gen_random_uuid()), + ) + .col(ColumnDef::new(BlindIndex::TenantId).uuid().not_null()) + .col( + ColumnDef::new(BlindIndex::EntityType) + .string_len(64) + .not_null(), + ) + .col(ColumnDef::new(BlindIndex::EntityId).uuid().not_null()) + .col( + ColumnDef::new(BlindIndex::FieldName) + .string_len(64) + .not_null(), + ) + .col( + ColumnDef::new(BlindIndex::BlindHash) + .string_len(64) + .not_null(), + ) + .col( + ColumnDef::new(BlindIndex::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(BlindIndex::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .index( + Index::create() + .col(BlindIndex::TenantId) + .col(BlindIndex::EntityType) + .col(BlindIndex::FieldName) + .col(BlindIndex::BlindHash) + .unique(), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_blind_hashes") + .table(BlindIndex::Table) + .col(BlindIndex::TenantId) + .col(BlindIndex::EntityType) + .col(BlindIndex::FieldName) + .col(BlindIndex::BlindHash) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(BlindIndex::Table).to_owned()) + .await + } +} diff --git a/crates/erp-server/migration/src/m20260428_000091_dead_letter_events.rs b/crates/erp-server/migration/src/m20260428_000091_dead_letter_events.rs new file mode 100644 index 0000000..47d3c42 --- /dev/null +++ b/crates/erp-server/migration/src/m20260428_000091_dead_letter_events.rs @@ -0,0 +1,76 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[derive(Iden)] +enum DeadLetterEvent { + Table, + Id, + TenantId, + OriginalEventId, + EventType, + Payload, + ConsumerId, + Attempts, + LastError, + CreatedAt, + ResolvedAt, +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(DeadLetterEvent::Table) + .col( + ColumnDef::new(DeadLetterEvent::Id) + .uuid() + .not_null() + .primary_key() + .default(PgFunc::gen_random_uuid()), + ) + .col(ColumnDef::new(DeadLetterEvent::TenantId).uuid()) + .col( + ColumnDef::new(DeadLetterEvent::OriginalEventId) + .uuid() + .not_null(), + ) + .col( + ColumnDef::new(DeadLetterEvent::EventType) + .string_len(128) + .not_null(), + ) + .col(ColumnDef::new(DeadLetterEvent::Payload).json_binary()) + .col( + ColumnDef::new(DeadLetterEvent::ConsumerId) + .string_len(128) + .not_null(), + ) + .col( + ColumnDef::new(DeadLetterEvent::Attempts) + .integer() + .not_null() + .default(0), + ) + .col(ColumnDef::new(DeadLetterEvent::LastError).text()) + .col( + ColumnDef::new(DeadLetterEvent::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(DeadLetterEvent::ResolvedAt).timestamp_with_time_zone()) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(DeadLetterEvent::Table).to_owned()) + .await + } +} diff --git a/crates/erp-server/migration/src/m20260504_000106_create_api_clients.rs b/crates/erp-server/migration/src/m20260504_000106_create_api_clients.rs new file mode 100644 index 0000000..066aad1 --- /dev/null +++ b/crates/erp-server/migration/src/m20260504_000106_create_api_clients.rs @@ -0,0 +1,116 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Alias::new("api_clients")) + .col( + ColumnDef::new(Alias::new("id")) + .uuid() + .not_null() + .default(Expr::cust("gen_random_uuid()")), + ) + .col(ColumnDef::new(Alias::new("tenant_id")).uuid().not_null()) + .col( + ColumnDef::new(Alias::new("client_id")) + .string_len(128) + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("client_secret_hash")) + .string_len(256) + .not_null(), + ) + .col( + ColumnDef::new(Alias::new("client_name")) + .string_len(200) + .not_null(), + ) + .col(ColumnDef::new(Alias::new("scopes")).json().not_null()) + .col( + ColumnDef::new(Alias::new("allowed_patient_ids")) + .json() + .null(), + ) + .col( + ColumnDef::new(Alias::new("rate_limit_per_minute")) + .integer() + .not_null() + .default(60), + ) + .col( + ColumnDef::new(Alias::new("is_active")) + .boolean() + .not_null() + .default(true), + ) + .col( + ColumnDef::new(Alias::new("token_lifetime_seconds")) + .integer() + .not_null() + .default(3600), + ) + .col( + ColumnDef::new(Alias::new("created_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::cust("NOW()")), + ) + .col( + ColumnDef::new(Alias::new("updated_at")) + .timestamp_with_time_zone() + .not_null() + .default(Expr::cust("NOW()")), + ) + .col(ColumnDef::new(Alias::new("created_by")).uuid().null()) + .col(ColumnDef::new(Alias::new("updated_by")).uuid().null()) + .col( + ColumnDef::new(Alias::new("deleted_at")) + .timestamp_with_time_zone() + .null(), + ) + .col( + ColumnDef::new(Alias::new("version")) + .integer() + .not_null() + .default(1), + ) + .primary_key(Index::create().col(Alias::new("id"))) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_api_clients_client_id_unique") + .table(Alias::new("api_clients")) + .col(Alias::new("client_id")) + .unique() + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_api_clients_tenant_id") + .table(Alias::new("api_clients")) + .col(Alias::new("tenant_id")) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Alias::new("api_clients")).to_owned()) + .await + } +} diff --git a/crates/erp-server/migration/src/m20260513_000144_enforce_version_optimistic_lock.rs b/crates/erp-server/migration/src/m20260513_000144_enforce_version_optimistic_lock.rs new file mode 100644 index 0000000..9f067d8 --- /dev/null +++ b/crates/erp-server/migration/src/m20260513_000144_enforce_version_optimistic_lock.rs @@ -0,0 +1,115 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // 1. 创建触发器函数:适用于 `version` 列(erp-health / erp-auth / erp-config 等所有模块) + db.execute_unprepared( + r#" +CREATE OR REPLACE FUNCTION enforce_version() RETURNS trigger AS $$ +BEGIN + IF NEW.version IS DISTINCT FROM OLD.version + 1 THEN + RAISE EXCEPTION 'Optimistic lock conflict on %: expected version %, got %', + TG_TABLE_NAME, OLD.version + 1, NEW.version; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +"#, + ) + .await?; + + // 2. 创建触发器函数:适用于 `version_lock` 列(erp-ai 模块) + db.execute_unprepared( + r#" +CREATE OR REPLACE FUNCTION enforce_version_lock() RETURNS trigger AS $$ +BEGIN + IF NEW.version_lock IS DISTINCT FROM OLD.version_lock + 1 THEN + RAISE EXCEPTION 'Optimistic lock conflict on %: expected version_lock %, got %', + TG_TABLE_NAME, OLD.version_lock + 1, NEW.version_lock; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; +"#, + ) + .await?; + + // 3. 自动发现所有含 version / version_lock 的表并绑定触发器 + // 排除 market_entry(version 是 String,非乐观锁) + db.execute_unprepared( + r#" +DO $$ +DECLARE + rec RECORD; + trig_name text; +BEGIN + FOR rec IN + SELECT table_name, column_name + FROM information_schema.columns + WHERE column_name IN ('version', 'version_lock') + AND table_schema = 'public' + AND table_name NOT IN ('market_entry', 'process_definitions', 'ai_prompt') + LOOP + IF rec.column_name = 'version' THEN + trig_name := 'trg_enforce_version'; + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE ON %I + FOR EACH ROW EXECUTE FUNCTION enforce_version()', + trig_name, rec.table_name + ); + ELSE + trig_name := 'trg_enforce_version_lock'; + EXECUTE format( + 'CREATE TRIGGER %I BEFORE UPDATE ON %I + FOR EACH ROW EXECUTE FUNCTION enforce_version_lock()', + trig_name, rec.table_name + ); + END IF; + END LOOP; +END; +$$; +"#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // 1. 删除所有乐观锁触发器 + db.execute_unprepared( + r#" +DO $$ +DECLARE + rec RECORD; +BEGIN + FOR rec IN + SELECT event_object_table AS table_name, trigger_name + FROM information_schema.triggers + WHERE trigger_name IN ('trg_enforce_version', 'trg_enforce_version_lock') + LOOP + EXECUTE format('DROP TRIGGER IF EXISTS %I ON %I', rec.trigger_name, rec.table_name); + END LOOP; +END; +$$; +"#, + ) + .await?; + + // 2. 删除触发器函数 + db.execute_unprepared("DROP FUNCTION IF EXISTS enforce_version() CASCADE") + .await?; + db.execute_unprepared("DROP FUNCTION IF EXISTS enforce_version_lock() CASCADE") + .await?; + + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260518_000149_fix_admin_permissions.rs b/crates/erp-server/migration/src/m20260518_000149_fix_admin_permissions.rs new file mode 100644 index 0000000..fee47c3 --- /dev/null +++ b/crates/erp-server/migration/src/m20260518_000149_fix_admin_permissions.rs @@ -0,0 +1,78 @@ +//! 修复 admin 角色权限绑定 +//! +//! 根因链: +//! 1. m20260506_000126 对部分角色执行了软删除(SET deleted_at = NOW()) +//! 2. m20260508_000131 执行 `DELETE FROM role_permissions WHERE deleted_at IS NOT NULL` +//! 物理删除了所有被软删除的记录 +//! 3. m20260508_000131 只重新分配了 doctor/nurse/operator 的权限,遗漏了 admin 角色 +//! 4. 后续的 assign_permissions API 调用可能在内部先软删除再 INSERT, +//! INSERT 失败时 admin 权限全部丢失 +//! +//! 本迁移: +//! - Step 1: 恢复所有被软删除的 admin role_permissions(deleted_at IS NOT NULL → NULL) +//! - Step 2: 插入所有缺失的 admin role_permissions(ON CONFLICT DO NOTHING 保证幂等) +//! +//! 覆盖范围:全系统 128 个权限码(auth/config/workflow/message/plugin/health/ai/copilot/points) + +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let db = manager.get_connection(); + + // ================================================================ + // Step 1: 恢复被软删除的 admin role_permissions + // ================================================================ + // 如果 admin 的某些权限记录仍然存在但被软删除了,恢复它们 + db.execute_unprepared( + r#" + UPDATE role_permissions rp + SET deleted_at = NULL, updated_at = NOW(), version = rp.version + 1 + FROM roles r + WHERE rp.role_id = r.id + AND r.code = 'admin' + AND r.deleted_at IS NULL + AND rp.deleted_at IS NOT NULL + "#, + ) + .await?; + + // ================================================================ + // Step 2: 插入缺失的 admin role_permissions + // ================================================================ + // 将 permissions 表中所有未被软删除的权限绑定到 admin 角色 + // ON CONFLICT (role_id, permission_id) DO NOTHING — 已存在(含刚恢复的)的跳过 + db.execute_unprepared( + r#" + INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, + created_at, updated_at, created_by, updated_by, + deleted_at, version) + SELECT r.id, p.id, r.tenant_id, 'all', + NOW(), NOW(), r.id, r.id, + NULL, 1 + FROM roles r + JOIN permissions p ON p.tenant_id = r.tenant_id AND p.deleted_at IS NULL + WHERE r.code = 'admin' AND r.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM role_permissions rp + WHERE rp.role_id = r.id + AND rp.permission_id = p.id + AND rp.deleted_at IS NULL + ) + ON CONFLICT (role_id, permission_id) DO NOTHING + "#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, _manager: &SchemaManager) -> Result<(), DbErr> { + // 不回滚 — 这是修复性迁移,admin 应该始终拥有全部权限 + Ok(()) + } +} diff --git a/crates/erp-server/migration/src/m20260529_000169_supplement_rls_for_new_tables.rs b/crates/erp-server/migration/src/m20260529_000169_supplement_rls_for_new_tables.rs new file mode 100644 index 0000000..cbe1b84 --- /dev/null +++ b/crates/erp-server/migration/src/m20260529_000169_supplement_rls_for_new_tables.rs @@ -0,0 +1,65 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + let conn = manager.get_connection(); + + // 为 m000088 之后创建的新表补充 RLS 策略。 + // 幂等操作:仅影响尚未启用 RLS 或缺少策略的表。 + conn.execute_unprepared( + r#" + DO $$ + DECLARE + tbl TEXT; + policy_exists BOOLEAN; + BEGIN + FOR tbl IN + SELECT c.table_name FROM information_schema.columns c + JOIN information_schema.tables t + ON c.table_name = t.table_name AND c.table_schema = t.table_schema + WHERE c.column_name = 'tenant_id' + AND c.table_schema = 'public' + AND t.table_type = 'BASE TABLE' + ORDER BY c.table_name + LOOP + -- 启用 RLS(幂等) + EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', tbl); + + -- 检查是否已有 tenant_isolation 策略 + SELECT EXISTS( + SELECT 1 FROM pg_policies + WHERE tablename = tbl + AND policyname = 'tenant_isolation' + ) INTO policy_exists; + + IF NOT policy_exists THEN + EXECUTE format( + 'CREATE POLICY tenant_isolation ON %I USING ( + current_setting(''app.current_tenant_id'', true) != '''' + AND tenant_id = current_setting(''app.current_tenant_id'', true)::uuid + )', + tbl + ); + RAISE NOTICE 'Created RLS policy for table: %', tbl; + END IF; + END LOOP; + END; + $$; + "#, + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // 回滚不需要移除 RLS,保持 m000088 的策略不变 + // 此迁移补充的 RLS 策略在 down() 中保留,因为 m000088 已处理回滚 + let _ = manager; + Ok(()) + } +} diff --git a/crates/erp-server/src/config.rs b/crates/erp-server/src/config.rs new file mode 100644 index 0000000..4a2950d --- /dev/null +++ b/crates/erp-server/src/config.rs @@ -0,0 +1,156 @@ +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct AppConfig { + pub server: ServerConfig, + pub database: DatabaseConfig, + pub redis: RedisConfig, + pub jwt: JwtConfig, + pub auth: AuthConfig, + pub log: LogConfig, + pub cors: CorsConfig, + pub wechat: WechatConfig, + pub crypto: CryptoConfig, + pub storage: StorageConfig, + #[serde(default)] + pub rate_limit: RateLimitConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ServerConfig { + pub host: String, + pub port: u16, + #[serde(default = "default_metrics_port")] + pub metrics_port: u16, +} + +fn default_metrics_port() -> u16 { + 9090 +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DatabaseConfig { + pub url: String, + pub max_connections: u32, + pub min_connections: u32, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RedisConfig { + pub url: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct JwtConfig { + pub secret: String, + pub access_token_ttl: String, + pub refresh_token_ttl: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LogConfig { + pub level: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AuthConfig { + pub super_admin_password: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CorsConfig { + /// Comma-separated list of allowed origins. + /// Use "*" to allow all origins (development only). + pub allowed_origins: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct WechatConfig { + pub appid: String, + pub secret: String, + #[serde(default)] + pub dev_mode: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CryptoConfig { + /// Master KEK (64 字符 hex 编码,32 字节)。用于加密保护每租户 DEK。 + /// Phase A 阶段同时作为全局数据加密密钥使用。 + pub kek: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct StorageConfig { + /// 文件上传目录(本地存储) + pub upload_dir: String, + /// 单文件最大大小(如 "10MB") + pub max_file_size: String, + /// 签名 URL 密钥(HMAC-SHA256) + #[serde(default = "default_secret_key")] + pub secret_key: String, +} + +fn default_secret_key() -> String { + #[cfg(debug_assertions)] + { + "dev-only-secret-key-change-in-production".to_string() + } + #[cfg(not(debug_assertions))] + { + panic!("ERP__STORAGE__SECRET_KEY 必须设置(生产环境不允许使用默认签名密钥)") + } +} + +impl StorageConfig { + /// 解析 max_file_size 为字节数 + pub fn max_file_size_bytes(&self) -> u64 { + let s = self.max_file_size.to_uppercase(); + if let Some(num) = s.strip_suffix("MB") { + num.trim().parse::().unwrap_or(10) * 1024 * 1024 + } else if let Some(num) = s.strip_suffix("KB") { + num.trim().parse::().unwrap_or(1024) * 1024 + } else if let Some(num) = s.strip_suffix("GB") { + num.trim().parse::().unwrap_or(1) * 1024 * 1024 * 1024 + } else { + s.parse::().unwrap_or(10 * 1024 * 1024) + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RateLimitConfig { + /// Redis 不可达时是否拒绝请求(fail-close)。 + /// true = 安全优先,Redis 故障时返回 503。 + /// false = 可用性优先,Redis 故障时放行。 + #[serde(default = "default_fail_close")] + pub fail_close: bool, +} + +fn default_fail_close() -> bool { + true +} + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + fail_close: default_fail_close(), + } + } +} + +impl AppConfig { + pub fn load() -> anyhow::Result { + let config = config::Config::builder() + .add_source(config::File::with_name("config/default")) + .add_source(config::Environment::with_prefix("ERP").separator("__")) + .build()?; + let app_config: Self = config.try_deserialize()?; + + // 安全检查:禁止在生产使用默认 JWT 密钥 + if app_config.jwt.secret == "change-me-in-production" { + tracing::warn!("⚠️ JWT 密钥使用默认值,请通过 ERP__JWT__SECRET 环境变量设置安全密钥"); + } + + Ok(app_config) + } +} diff --git a/crates/erp-server/src/db.rs b/crates/erp-server/src/db.rs new file mode 100644 index 0000000..a1db23d --- /dev/null +++ b/crates/erp-server/src/db.rs @@ -0,0 +1,16 @@ +use sea_orm::{ConnectOptions, Database, DatabaseConnection}; +use std::time::Duration; + +use crate::config::DatabaseConfig; + +pub async fn connect(config: &DatabaseConfig) -> anyhow::Result { + let mut opt = ConnectOptions::new(&config.url); + opt.max_connections(config.max_connections) + .min_connections(config.min_connections) + .connect_timeout(Duration::from_secs(10)) + .idle_timeout(Duration::from_secs(600)); + + let db = Database::connect(opt).await?; + tracing::info!("Database connected successfully"); + Ok(db) +} diff --git a/crates/erp-server/src/handlers/analytics.rs b/crates/erp-server/src/handlers/analytics.rs new file mode 100644 index 0000000..ffd8c46 --- /dev/null +++ b/crates/erp-server/src/handlers/analytics.rs @@ -0,0 +1,65 @@ +use axum::Json; +use axum::extract::Extension; +use serde::Deserialize; +use tracing; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; + +const MAX_EVENTS_PER_BATCH: usize = 100; + +#[derive(Debug, Deserialize)] +#[allow(dead_code)] // 客户端上报结构体,字段后续接入分析表时使用 +pub struct AnalyticsEvent { + pub event: String, + pub properties: Option, + #[serde(deserialize_with = "deserialize_flexible_timestamp")] + pub timestamp: Option, + pub page: Option, + pub user_id: Option, + pub patient_id: Option, +} + +fn deserialize_flexible_timestamp<'de, D>(de: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de; + let val = Option::::deserialize(de)?; + match val { + None => Ok(None), + Some(serde_json::Value::String(s)) => Ok(Some(s)), + Some(serde_json::Value::Number(n)) => Ok(Some(n.to_string())), + _ => Err(de::Error::custom("timestamp must be string or number")), + } +} + +#[derive(Debug, Deserialize)] +pub struct BatchRequest { + pub events: Vec, +} + +/// 接收小程序批量埋点事件。 +/// 当前为日志记录模式 — 后续可接入 ClickHouse/PostgreSQL 分析表。 +pub async fn batch( + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> { + require_permission(&ctx, "system.analytics.submit")?; + if req.events.len() > MAX_EVENTS_PER_BATCH { + return Err(AppError::Validation(format!( + "批量埋点事件数不能超过 {} 条", + MAX_EVENTS_PER_BATCH + ))); + } + for evt in &req.events { + tracing::info!( + event = %evt.event, + page = ?evt.page, + properties = ?evt.properties, + "Analytics event received" + ); + } + Ok(Json(ApiResponse::ok(()))) +} diff --git a/crates/erp-server/src/handlers/audit_log.rs b/crates/erp-server/src/handlers/audit_log.rs new file mode 100644 index 0000000..16abc8b --- /dev/null +++ b/crates/erp-server/src/handlers/audit_log.rs @@ -0,0 +1,156 @@ +use axum::Router; +use axum::extract::{Extension, FromRef, Query, State}; +use axum::response::Json; +use axum::routing::get; +use sea_orm::{ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder}; +use serde::{Deserialize, Serialize}; + +use erp_core::entity::audit_log; +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, PaginatedResponse, TenantContext}; + +#[derive(Debug, Deserialize)] +pub struct AuditLogQuery { + pub resource_type: Option, + pub user_id: Option, + pub page: Option, + pub page_size: Option, +} + +#[derive(Debug, Serialize)] +pub struct AuditLogResp { + pub id: uuid::Uuid, + pub tenant_id: uuid::Uuid, + pub user_id: Option, + pub user_name: Option, + pub action: String, + pub resource_type: String, + pub resource_id: Option, + pub old_value: Option, + pub new_value: Option, + pub ip_address: Option, + pub user_agent: Option, + pub created_at: chrono::DateTime, +} + +impl From for AuditLogResp { + fn from(m: audit_log::Model) -> Self { + Self { + id: m.id, + tenant_id: m.tenant_id, + user_id: m.user_id, + user_name: None, + action: m.action, + resource_type: m.resource_type, + resource_id: m.resource_id, + old_value: m.old_value, + new_value: m.new_value, + ip_address: m.ip_address, + user_agent: m.user_agent, + created_at: m.created_at, + } + } +} + +async fn resolve_user_names( + db: &sea_orm::DatabaseConnection, + items: &[audit_log::Model], +) -> std::collections::HashMap { + use erp_auth::entity::user; + + let user_ids: Vec = items + .iter() + .filter_map(|i| i.user_id) + .collect::>() + .into_iter() + .collect(); + + if user_ids.is_empty() { + return std::collections::HashMap::new(); + } + + let users = user::Entity::find() + .filter(user::Column::Id.is_in(user_ids)) + .all(db) + .await + .unwrap_or_default(); + + users + .into_iter() + .map(|u| { + let name = u + .display_name + .filter(|n| !n.is_empty()) + .unwrap_or(u.username); + (u.id, name) + }) + .collect() +} + +/// GET /audit-logs +pub async fn list_audit_logs( + State(db): State, + Extension(ctx): Extension, + Query(params): Query, +) -> Result>>, AppError> +where + sea_orm::DatabaseConnection: FromRef, + S: Clone + Send + Sync + 'static, +{ + let page = params.page.unwrap_or(1).max(1); + let page_size = params.page_size.unwrap_or(20).min(100); + let tenant_id = ctx.tenant_id; + + let mut q = audit_log::Entity::find().filter(audit_log::Column::TenantId.eq(tenant_id)); + + if let Some(rt) = ¶ms.resource_type { + q = q.filter(audit_log::Column::ResourceType.eq(rt.clone())); + } + if let Some(uid) = ¶ms.user_id { + q = q.filter(audit_log::Column::UserId.eq(*uid)); + } + + let paginator = q + .order_by_desc(audit_log::Column::CreatedAt) + .paginate(&db, page_size); + + let total = paginator + .num_items() + .await + .map_err(|e| AppError::Internal(format!("查询审计日志失败: {e}")))?; + + let items = paginator + .fetch_page(page - 1) + .await + .map_err(|e| AppError::Internal(format!("查询审计日志失败: {e}")))?; + + let user_map = resolve_user_names(&db, &items).await; + + let resp_items: Vec = items + .into_iter() + .map(|m| { + let user_name = m.user_id.and_then(|uid| user_map.get(&uid).cloned()); + let mut resp = AuditLogResp::from(m); + resp.user_name = user_name; + resp + }) + .collect(); + + let total_pages = total.div_ceil(page_size); + + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: resp_items, + total, + page, + page_size, + total_pages, + }))) +} + +pub fn audit_log_router() -> Router +where + sea_orm::DatabaseConnection: FromRef, + S: Clone + Send + Sync + 'static, +{ + Router::new().route("/audit-logs", get(list_audit_logs)) +} diff --git a/crates/erp-server/src/handlers/crypto_admin.rs b/crates/erp-server/src/handlers/crypto_admin.rs new file mode 100644 index 0000000..ee1b394 --- /dev/null +++ b/crates/erp-server/src/handlers/crypto_admin.rs @@ -0,0 +1,76 @@ +use axum::Extension; +use axum::Json; +use axum::extract::{FromRef, Path, State}; +use sea_orm::{ConnectionTrait, DatabaseBackend, Statement}; +use serde_json::{Value, json}; +use uuid::Uuid; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, TenantContext}; + +use crate::state::AppState; + +/// POST /api/v1/admin/tenants/:id/rotate-key +/// 密钥轮换 — 生成新 DEK,持久化到 tenant_crypto_keys,使缓存失效 +pub async fn rotate_tenant_key( + State(state): State, + Extension(ctx): Extension, + Path(tenant_id): Path, +) -> Result>, AppError> +where + AppState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "tenant.manage")?; + + // 读取当前最大版本号 + let max_version: Option = { + let row = state.db.query_one(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT COALESCE(MAX(key_version), 0) as v FROM tenant_crypto_keys WHERE tenant_id = $1 AND deleted_at IS NULL", + [tenant_id.into()], + )).await.map_err(|e| AppError::Internal(format!("查询密钥版本失败: {}", e)))?; + row.and_then(|r| r.try_get_by_index::(0).ok()) + }; + let current_version = max_version.unwrap_or(0); + let new_version = current_version + 1; + + // 将旧版本标记为不活跃 + state.db.execute(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "UPDATE tenant_crypto_keys SET is_active = false, updated_at = now() WHERE tenant_id = $1 AND is_active = true AND deleted_at IS NULL", + [tenant_id.into()], + )).await.map_err(|e| AppError::Internal(format!("停用旧 DEK 失败: {}", e)))?; + + // 生成新 DEK 并用 KEK 加密 + let kek = state.pii_crypto.kek(); + let (_new_dek, encrypted_dek) = erp_core::crypto::DekManager::generate_new_dek(kek) + .map_err(|e| AppError::Internal(format!("生成新 DEK 失败: {}", e)))?; + + // 持久化新 DEK + let new_id = Uuid::now_v7(); + state.db.execute(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "INSERT INTO tenant_crypto_keys (id, tenant_id, encrypted_dek, key_version, is_active, created_at, updated_at, version) VALUES ($1, $2, $3, $4, true, now(), now(), 1)", + [new_id.into(), tenant_id.into(), encrypted_dek.into(), new_version.into()], + )).await.map_err(|e| AppError::Internal(format!("存储新 DEK 失败: {}", e)))?; + + // 使 DEK 缓存失效 + state.pii_crypto.invalidate_dek(tenant_id); + + tracing::info!( + tenant_id = %tenant_id, + old_version = current_version, + new_version = new_version, + "密钥轮换完成(新 DEK 已持久化,缓存已清除)" + ); + + Ok(Json(ApiResponse::ok(json!({ + "message": "密钥轮换已完成", + "tenant_id": tenant_id, + "old_version": current_version, + "new_version": new_version, + "note": "后台重加密任务需要单独触发,旧数据仍可用旧 DEK 解密" + })))) +} diff --git a/crates/erp-server/src/handlers/health.rs b/crates/erp-server/src/handlers/health.rs new file mode 100644 index 0000000..6388215 --- /dev/null +++ b/crates/erp-server/src/handlers/health.rs @@ -0,0 +1,135 @@ +use axum::Router; +use axum::extract::State; +use axum::response::Json; +use axum::routing::get; +use serde::Serialize; + +use crate::state::AppState; + +#[derive(Debug, Serialize)] +pub struct HealthResponse { + pub status: String, + pub version: String, + pub modules: Vec, +} + +/// GET /health — 轻量存活检查 +pub async fn health_check(State(state): State) -> Json { + let modules = state + .module_registry + .modules() + .iter() + .map(|m| m.name().to_string()) + .collect(); + + Json(HealthResponse { + status: "ok".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + modules, + }) +} + +#[derive(Debug, Serialize)] +pub struct ReadyResponse { + pub status: String, + pub version: String, + pub database: ComponentStatus, + pub redis: ComponentStatus, + pub modules: Vec, +} + +#[derive(Debug, Serialize)] +pub struct ComponentStatus { + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub latency_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +/// GET /health/ready — 就绪检查(含 DB + Redis 连通性) +pub async fn readiness_check(State(state): State) -> Json { + let modules = state + .module_registry + .modules() + .iter() + .map(|m| m.name().to_string()) + .collect(); + + let (db_status, redis_status) = + tokio::join!(check_database(&state.db), check_redis(&state.redis),); + + let overall = if db_status.status == "ok" && redis_status.status == "ok" { + "ok" + } else if db_status.status == "ok" { + "degraded" + } else { + "unavailable" + }; + + Json(ReadyResponse { + status: overall.to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + database: db_status, + redis: redis_status, + modules, + }) +} + +async fn check_database(db: &sea_orm::DatabaseConnection) -> ComponentStatus { + use sea_orm::ConnectionTrait; + let start = std::time::Instant::now(); + let stmt = + sea_orm::Statement::from_string(sea_orm::DatabaseBackend::Postgres, "SELECT 1".to_string()); + match db.query_one(stmt).await { + Ok(_) => ComponentStatus { + status: "ok".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: None, + }, + Err(e) => { + tracing::error!(error = %e, "Database health check failed"); + ComponentStatus { + status: "error".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: Some("connection failed".to_string()), + } + } + } +} + +async fn check_redis(client: &redis::Client) -> ComponentStatus { + let start = std::time::Instant::now(); + match client.get_multiplexed_async_connection().await { + Ok(mut conn) => match redis::cmd("PING").query_async::(&mut conn).await { + Ok(_) => ComponentStatus { + status: "ok".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: None, + }, + Err(e) => { + tracing::error!(error = %e, "Redis PING failed"); + ComponentStatus { + status: "error".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: Some("connection failed".to_string()), + } + } + }, + Err(e) => { + tracing::error!(error = %e, "Redis connection failed"); + ComponentStatus { + status: "error".to_string(), + latency_ms: Some(start.elapsed().as_millis() as u64), + error: Some("connection failed".to_string()), + } + } + } +} + +pub fn health_check_router() -> Router { + Router::new() + .route("/health", get(health_check)) + .route("/health/live", get(health_check)) + .route("/health/ready", get(readiness_check)) +} diff --git a/crates/erp-server/src/handlers/mod.rs b/crates/erp-server/src/handlers/mod.rs new file mode 100644 index 0000000..02ba6dc --- /dev/null +++ b/crates/erp-server/src/handlers/mod.rs @@ -0,0 +1,6 @@ +pub mod analytics; +pub mod audit_log; +pub mod crypto_admin; +pub mod health; +pub mod openapi; +pub mod upload; diff --git a/crates/erp-server/src/handlers/openapi.rs b/crates/erp-server/src/handlers/openapi.rs new file mode 100644 index 0000000..683aa3f --- /dev/null +++ b/crates/erp-server/src/handlers/openapi.rs @@ -0,0 +1,25 @@ +use axum::response::{IntoResponse, Json, Response}; +use utoipa::OpenApi; + +use crate::{ApiDoc, AuthApiDoc, ConfigApiDoc, MessageApiDoc, WorkflowApiDoc}; + +/// GET /docs/openapi.json +/// +/// 返回 OpenAPI 3.0 规范 JSON 文档,合并所有模块的路径和 schema。 +/// 仅在 debug 模式下可用,生产构建返回 404。 +pub async fn openapi_spec() -> Response { + #[cfg(debug_assertions)] + { + let mut spec = ApiDoc::openapi(); + spec.merge(AuthApiDoc::openapi()); + spec.merge(ConfigApiDoc::openapi()); + spec.merge(WorkflowApiDoc::openapi()); + spec.merge(MessageApiDoc::openapi()); + Json(serde_json::to_value(spec).unwrap_or_default()).into_response() + } + + #[cfg(not(debug_assertions))] + { + (axum::http::StatusCode::NOT_FOUND, "Not Found").into_response() + } +} diff --git a/crates/erp-server/src/handlers/upload.rs b/crates/erp-server/src/handlers/upload.rs new file mode 100644 index 0000000..28163a8 --- /dev/null +++ b/crates/erp-server/src/handlers/upload.rs @@ -0,0 +1,220 @@ +use axum::Extension; +use axum::extract::{FromRef, Multipart, State}; +use axum::response::Json; +use erp_core::error::AppError; +use erp_core::types::{ApiResponse, TenantContext}; +use serde::Serialize; +use uuid::Uuid; + +use crate::state::AppState; + +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct UploadResp { + pub url: String, + pub filename: String, + pub size: u64, + pub content_type: String, +} + +/// 上传单个文件。 +/// +/// 接受 multipart/form-data,将文件保存到本地目录, +/// 返回可通过 `/uploads/` 前缀访问的 URL。 +#[utoipa::path( + post, + path = "/upload", + request_body(content_type = "multipart/form-data"), + responses( + (status = 200, description = "上传成功", body = ApiResponse), + (status = 413, description = "文件过大"), + (status = 400, description = "无文件或不支持的类型"), + ), + tag = "文件上传", +)] +pub async fn upload_file( + State(state): State, + Extension(ctx): Extension, + mut multipart: Multipart, +) -> Result>, AppError> +where + AppState: FromRef, + S: Clone + Send + Sync + 'static, +{ + let max_size = state.config.storage.max_file_size_bytes(); + let upload_dir = &state.config.storage.upload_dir; + + // 确保上传目录存在 + let base_dir = std::path::Path::new(upload_dir); + let tenant_dir = base_dir.join(ctx.tenant_id.to_string()); + tokio::fs::create_dir_all(&tenant_dir) + .await + .map_err(|e| AppError::Internal(format!("创建上传目录失败: {}", e)))?; + + // 读取第一个 field 作为上传文件 + let field = multipart + .next_field() + .await + .map_err(|e| AppError::Validation(format!("读取上传数据失败: {}", e)))? + .ok_or_else(|| AppError::Validation("未找到上传文件".to_string()))?; + + let content_type = field + .content_type() + .unwrap_or("application/octet-stream") + .to_string(); + + // 验证文件类型 + validate_content_type(&content_type)?; + + let original_name = field.name().unwrap_or("file").to_string(); + + let data = field + .bytes() + .await + .map_err(|e| AppError::Validation(format!("读取文件数据失败: {}", e)))?; + + if data.len() as u64 > max_size { + return Err(AppError::Validation(format!( + "文件大小超过限制(最大 {})", + format_size(max_size) + ))); + } + + // 校验 magic bytes:验证文件实际内容与声明的 Content-Type 一致 + validate_magic_bytes(&content_type, &data)?; + + // 生成唯一文件名,保留原始扩展名 + let ext = std::path::Path::new(&original_name) + .extension() + .and_then(|e| e.to_str()) + .unwrap_or("bin"); + let file_id = Uuid::now_v7(); + let filename = format!("{}.{}", file_id, ext); + + let file_path = tenant_dir.join(&filename); + let data_vec: Vec = data.to_vec(); + tokio::fs::write(&file_path, &data_vec) + .await + .map_err(|e| AppError::Internal(format!("写入文件失败: {}", e)))?; + + let url = format!("/uploads/{}/{}", ctx.tenant_id, filename); + + tracing::info!( + tenant_id = %ctx.tenant_id, + filename = %filename, + size = data_vec.len(), + content_type = %content_type, + "文件上传成功" + ); + + Ok(Json(ApiResponse::ok(UploadResp { + url, + filename: original_name, + size: data_vec.len() as u64, + content_type, + }))) +} + +/// 允许的文件类型 +fn validate_content_type(content_type: &str) -> Result<(), AppError> { + const ALLOWED: &[&str] = &[ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "application/pdf", + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-excel", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ]; + + if !ALLOWED.contains(&content_type) { + return Err(AppError::Validation(format!( + "不支持的文件类型: {}", + content_type + ))); + } + Ok(()) +} + +/// 校验文件 magic bytes(文件签名)与声明的 Content-Type 是否一致。 +/// +/// 防止攻击者通过修改 Content-Type 头上传恶意文件。 +/// 对于 Office 格式等复杂签名,跳过 magic bytes 校验(仅依赖白名单)。 +fn validate_magic_bytes(content_type: &str, data: &[u8]) -> Result<(), AppError> { + // 需要至少几个字节才能校验 + if data.is_empty() { + return Err(AppError::Validation("文件内容为空".to_string())); + } + + let signature: &[u8] = match content_type { + "image/jpeg" => { + // JPEG: FF D8 FF + b"\xFF\xD8\xFF" + } + "image/png" => { + // PNG: 89 50 4E 47 0D 0A 1A 0A + b"\x89PNG\r\n\x1A\n" + } + "image/gif" => { + // GIF: 47 49 46 38 (GIF8) + b"GIF8" + } + "image/webp" => { + // WebP: RIFF....WEBP (12 bytes) + // 前 4 字节: 52 49 46 46 (RIFF) + // 字节 8-11: 57 45 42 50 (WEBP) + if data.len() < 12 { + return Err(AppError::Validation( + "文件数据不足,无法验证 WebP 格式".to_string(), + )); + } + let riff_ok = &data[0..4] == b"RIFF"; + let webp_ok = &data[8..12] == b"WEBP"; + if riff_ok && webp_ok { + return Ok(()); + } + return Err(AppError::Validation( + "文件内容与声明的类型 (image/webp) 不匹配".to_string(), + )); + } + "application/pdf" => { + // PDF: 25 50 44 46 (%PDF) + b"%PDF" + } + // Office 格式的 magic bytes 较复杂(OLE2 / ZIP-based OOXML), + // 仅依赖白名单,跳过 magic bytes 校验 + "application/msword" + | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" + | "application/vnd.ms-excel" + | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => { + return Ok(()); + } + _ => return Ok(()), + }; + + if data.len() < signature.len() { + return Err(AppError::Validation( + "文件数据不足,无法验证文件格式".to_string(), + )); + } + + if &data[..signature.len()] != signature { + return Err(AppError::Validation(format!( + "文件内容与声明的类型 ({}) 不匹配", + content_type + ))); + } + + Ok(()) +} + +fn format_size(bytes: u64) -> String { + if bytes >= 1024 * 1024 * 1024 { + format!("{}GB", bytes / (1024 * 1024 * 1024)) + } else if bytes >= 1024 * 1024 { + format!("{}MB", bytes / (1024 * 1024)) + } else { + format!("{}KB", bytes / 1024) + } +} diff --git a/crates/erp-server/src/main.rs b/crates/erp-server/src/main.rs new file mode 100644 index 0000000..f352114 --- /dev/null +++ b/crates/erp-server/src/main.rs @@ -0,0 +1,820 @@ +mod config; +mod db; +mod handlers; +mod middleware; +mod outbox; +mod state; +mod tasks; + +/// OpenAPI 规范定义 — 通过 utoipa derive 合并各模块 schema。 +#[derive(OpenApi)] +#[openapi(info( + title = "ERP Platform API", + version = "0.1.0", + description = "ERP 平台底座 REST API 文档" +))] +struct ApiDoc; + +/// Auth 模块的 OpenAPI 路径收集 +#[derive(OpenApi)] +#[openapi( + paths( + erp_auth::handler::auth_handler::login, + erp_auth::handler::auth_handler::refresh, + erp_auth::handler::auth_handler::logout, + erp_auth::handler::auth_handler::change_password, + erp_auth::handler::user_handler::list_users, + erp_auth::handler::user_handler::create_user, + erp_auth::handler::user_handler::get_user, + erp_auth::handler::user_handler::update_user, + erp_auth::handler::user_handler::delete_user, + erp_auth::handler::user_handler::assign_roles, + erp_auth::handler::role_handler::list_roles, + erp_auth::handler::role_handler::create_role, + erp_auth::handler::role_handler::get_role, + erp_auth::handler::role_handler::update_role, + erp_auth::handler::role_handler::delete_role, + erp_auth::handler::role_handler::assign_permissions, + erp_auth::handler::role_handler::get_role_permissions, + erp_auth::handler::role_handler::list_permissions, + ), + components(schemas( + erp_auth::dto::LoginReq, + erp_auth::dto::LoginResp, + erp_auth::dto::RefreshReq, + erp_auth::dto::UserResp, + erp_auth::dto::CreateUserReq, + erp_auth::dto::UpdateUserReq, + erp_auth::dto::RoleResp, + erp_auth::dto::CreateRoleReq, + erp_auth::dto::UpdateRoleReq, + erp_auth::dto::PermissionResp, + erp_auth::dto::AssignPermissionsReq, + erp_auth::dto::ChangePasswordReq, + )) +)] +struct AuthApiDoc; + +/// Config 模块的 OpenAPI 路径收集 +#[derive(OpenApi)] +#[openapi( + paths( + erp_config::handler::dictionary_handler::list_dictionaries, + erp_config::handler::dictionary_handler::create_dictionary, + erp_config::handler::dictionary_handler::update_dictionary, + erp_config::handler::dictionary_handler::delete_dictionary, + erp_config::handler::dictionary_handler::list_items_by_code, + erp_config::handler::dictionary_handler::create_item, + erp_config::handler::dictionary_handler::update_item, + erp_config::handler::menu_handler::get_menus, + erp_config::handler::menu_handler::create_menu, + erp_config::handler::menu_handler::update_menu, + erp_config::handler::menu_handler::delete_menu, + erp_config::handler::numbering_handler::list_numbering_rules, + erp_config::handler::numbering_handler::create_numbering_rule, + erp_config::handler::numbering_handler::update_numbering_rule, + erp_config::handler::numbering_handler::generate_number, + erp_config::handler::numbering_handler::delete_numbering_rule, + erp_config::handler::theme_handler::get_theme, + erp_config::handler::theme_handler::update_theme, + erp_config::handler::language_handler::list_languages, + erp_config::handler::language_handler::update_language, + erp_config::handler::setting_handler::get_setting, + erp_config::handler::setting_handler::update_setting, + erp_config::handler::setting_handler::delete_setting, + ), + components(schemas( + erp_config::dto::DictionaryResp, + erp_config::dto::CreateDictionaryReq, + erp_config::dto::UpdateDictionaryReq, + erp_config::dto::DictionaryItemResp, + erp_config::dto::CreateDictionaryItemReq, + erp_config::dto::UpdateDictionaryItemReq, + erp_config::dto::MenuResp, + erp_config::dto::CreateMenuReq, + erp_config::dto::UpdateMenuReq, + erp_config::dto::NumberingRuleResp, + erp_config::dto::CreateNumberingRuleReq, + erp_config::dto::UpdateNumberingRuleReq, + erp_config::dto::ThemeResp, + )) +)] +struct ConfigApiDoc; + +/// Workflow 模块的 OpenAPI 路径收集 +#[derive(OpenApi)] +#[openapi( + paths( + erp_workflow::handler::definition_handler::list_definitions, + erp_workflow::handler::definition_handler::create_definition, + erp_workflow::handler::definition_handler::get_definition, + erp_workflow::handler::definition_handler::update_definition, + erp_workflow::handler::definition_handler::publish_definition, + erp_workflow::handler::instance_handler::start_instance, + erp_workflow::handler::instance_handler::list_instances, + erp_workflow::handler::instance_handler::get_instance, + erp_workflow::handler::instance_handler::suspend_instance, + erp_workflow::handler::instance_handler::terminate_instance, + erp_workflow::handler::instance_handler::resume_instance, + erp_workflow::handler::task_handler::list_pending_tasks, + erp_workflow::handler::task_handler::list_completed_tasks, + erp_workflow::handler::task_handler::complete_task, + erp_workflow::handler::task_handler::delegate_task, + ), + components(schemas( + erp_workflow::dto::ProcessDefinitionResp, + erp_workflow::dto::CreateProcessDefinitionReq, + erp_workflow::dto::UpdateProcessDefinitionReq, + erp_workflow::dto::ProcessInstanceResp, + erp_workflow::dto::StartInstanceReq, + erp_workflow::dto::TaskResp, + erp_workflow::dto::CompleteTaskReq, + erp_workflow::dto::DelegateTaskReq, + )) +)] +struct WorkflowApiDoc; + +/// Message 模块的 OpenAPI 路径收集 +#[derive(OpenApi)] +#[openapi( + paths( + erp_message::handler::message_handler::list_messages, + erp_message::handler::message_handler::unread_count, + erp_message::handler::message_handler::send_message, + erp_message::handler::message_handler::mark_read, + erp_message::handler::message_handler::mark_all_read, + erp_message::handler::message_handler::delete_message, + erp_message::handler::template_handler::list_templates, + erp_message::handler::template_handler::create_template, + erp_message::handler::subscription_handler::update_subscription, + ), + components(schemas( + erp_message::dto::MessageResp, + erp_message::dto::SendMessageReq, + erp_message::dto::MessageQuery, + erp_message::dto::UnreadCountResp, + erp_message::dto::MessageTemplateResp, + erp_message::dto::CreateTemplateReq, + erp_message::dto::MessageSubscriptionResp, + erp_message::dto::UpdateSubscriptionReq, + )) +)] +struct MessageApiDoc; + +use axum::Router; +use axum::middleware as axum_middleware; +use config::AppConfig; +use erp_auth::middleware::jwt_auth_middleware_fn; +use state::AppState; +use tower_http::services::ServeDir; +use tracing_subscriber::EnvFilter; +use utoipa::OpenApi; + +use erp_core::events::EventBus; +use erp_core::module::{ErpModule, ModuleContext, ModuleRegistry}; +use erp_server_migration::MigratorTrait; +use sea_orm::{ConnectionTrait, FromQueryResult}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // Load config + let config = AppConfig::load()?; + + // ── 安全检查:拒绝默认密钥 ────────────────────────── + if config.jwt.secret == "__MUST_SET_VIA_ENV__" || config.jwt.secret == "change-me-in-production" + { + tracing::error!("JWT 密钥为默认值,拒绝启动。请设置环境变量 ERP__JWT__SECRET"); + std::process::exit(1); + } + if config.database.url == "__MUST_SET_VIA_ENV__" { + tracing::error!("数据库 URL 为默认占位值,拒绝启动。请设置环境变量 ERP__DATABASE__URL"); + std::process::exit(1); + } + if config.redis.url == "__MUST_SET_VIA_ENV__" { + tracing::error!("Redis URL 为默认占位值,拒绝启动。请设置环境变量 ERP__REDIS__URL"); + std::process::exit(1); + } + if !config.wechat.dev_mode + && (config.wechat.appid == "__MUST_SET_VIA_ENV__" + || config.wechat.secret == "__MUST_SET_VIA_ENV__") + { + tracing::error!( + "微信凭据为默认占位值,拒绝启动。请设置环境变量 ERP__WECHAT__APPID 和 ERP__WECHAT__SECRET" + ); + std::process::exit(1); + } + // Initialize tracing + tracing_subscriber::fmt() + .with_env_filter( + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(&config.log.level)), + ) + .json() + .init(); + + tracing::info!( + version = env!("CARGO_PKG_VERSION"), + "ERP Server starting..." + ); + + // Connect to database + let db = db::connect(&config.database).await?; + + // Run migrations + erp_server_migration::Migrator::up(&db, None).await?; + tracing::info!("Database migrations applied"); + + // Seed default tenant and auth data if not present, and resolve the actual tenant ID + let default_tenant_id = { + #[derive(sea_orm::FromQueryResult)] + struct TenantId { + id: uuid::Uuid, + } + + let existing = TenantId::find_by_statement(sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "SELECT id FROM tenant WHERE deleted_at IS NULL LIMIT 1".to_string(), + )) + .one(&db) + .await + .map_err(|e| anyhow::anyhow!("Failed to query tenants: {}", e))?; + + match existing { + Some(row) => { + tracing::info!(tenant_id = %row.id, "Default tenant already exists, skipping seed"); + row.id + } + None => { + let new_tenant_id = uuid::Uuid::now_v7(); + + // Insert default tenant using raw SQL (no tenant entity in erp-server) + db.execute(sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + "INSERT INTO tenant (id, name, code, status, created_at, updated_at) VALUES ($1, $2, $3, $4, NOW(), NOW())", + [ + new_tenant_id.into(), + "Default Tenant".into(), + "default".into(), + "active".into(), + ], + )) + .await + .map_err(|e| anyhow::anyhow!("Failed to create default tenant: {}", e))?; + + tracing::info!(tenant_id = %new_tenant_id, "Created default tenant"); + + // Seed auth data (permissions, roles, admin user) + erp_auth::service::seed::seed_tenant_auth( + &db, + new_tenant_id, + &config.auth.super_admin_password, + ) + .await + .map_err(|e| anyhow::anyhow!("Failed to seed auth data: {}", e))?; + + tracing::info!(tenant_id = %new_tenant_id, "Default tenant ready with auth seed data"); + + // Seed AI workflow definitions + if let Err(e) = + erp_workflow::service::ai_workflow_seed::ensure_ai_workflows(&db, new_tenant_id) + .await + { + tracing::warn!(error = %e, "Failed to seed AI workflow definitions"); + } + + new_tenant_id + } + } + }; + + // Connect to Redis + let redis_client = redis::Client::open(&config.redis.url[..])?; + tracing::info!("Redis client created"); + + // Initialize event bus (capacity 1024 events) + let event_bus = EventBus::new(1024); + + // Initialize auth module + let auth_module = erp_auth::AuthModule::new(); + tracing::info!( + module = auth_module.name(), + version = auth_module.version(), + "Auth module initialized" + ); + + // Initialize config module + let config_module = erp_config::ConfigModule::new(); + tracing::info!( + module = config_module.name(), + version = config_module.version(), + "Config module initialized" + ); + + // Initialize workflow module + let workflow_module = erp_workflow::WorkflowModule::new(); + tracing::info!( + module = workflow_module.name(), + version = workflow_module.version(), + "Workflow module initialized" + ); + + // Initialize message module + let message_module = erp_message::MessageModule::new(); + tracing::info!( + module = message_module.name(), + version = message_module.version(), + "Message module initialized" + ); + + // Initialize diary module (暖记业务) + let diary_module = erp_diary::DiaryModule; + tracing::info!( + module = diary_module.name(), + version = diary_module.version(), + "Diary module initialized" + ); + + // Initialize module registry and register modules + let registry = ModuleRegistry::new() + .register(auth_module) + .register(config_module) + .register(workflow_module) + .register(message_module) + .register(diary_module); + tracing::info!( + module_count = registry.modules().len(), + "Modules registered" + ); + + // Initialize plugin engine + let plugin_config = erp_plugin::engine::PluginEngineConfig::default(); + let plugin_engine = + erp_plugin::engine::PluginEngine::new(db.clone(), event_bus.clone(), plugin_config)?; + tracing::info!("Plugin engine initialized"); + + // Register plugin module + let plugin_module = erp_plugin::module::PluginModule; + let registry = registry.register(plugin_module); + + // Register event handlers + registry.register_handlers(&event_bus); + + // Startup all modules (按拓扑顺序调用 on_startup) + let module_ctx = ModuleContext { + db: db.clone(), + event_bus: event_bus.clone(), + }; + registry.startup_all(&module_ctx).await?; + tracing::info!("All modules started"); + + // 同步所有模块声明的权限到数据库(upsert) + sync_module_permissions(&db, ®istry, default_tenant_id).await?; + + // 恢复运行中的插件(服务器重启后自动重新加载) + match plugin_engine.recover_plugins(&db).await { + Ok(recovered) => { + let count: usize = recovered.len(); + tracing::info!(count, "Plugins recovered"); + } + Err(e) => { + tracing::error!(error = %e, "Failed to recover plugins"); + } + } + + // Start message event listener (workflow events → message notifications) + erp_message::MessageModule::start_event_listener(db.clone(), event_bus.clone()); + tracing::info!("Message event listener started"); + + // Start plugin notification listener (plugin.trigger.* → admin notifications) + erp_plugin::notification::start_notification_listener(db.clone(), event_bus.clone()); + tracing::info!("Plugin notification listener started"); + + // Start outbox relay (LISTEN/NOTIFY + fallback poll for pending domain events) + outbox::start_outbox_relay(db.clone(), event_bus.clone(), config.database.url.clone()); + tracing::info!("Outbox relay started"); + + // Start timeout checker (scan overdue tasks every 60s) + erp_workflow::WorkflowModule::start_timeout_checker(db.clone(), event_bus.clone()); + tracing::info!("Timeout checker started"); + + let host = config.server.host.clone(); + let port = config.server.port; + + // Extract JWT secret for middleware construction + let jwt_secret = config.jwt.secret.clone(); + + // Build PII crypto — used by auth module for token encryption + let pii_crypto = if config.crypto.kek == "__MUST_SET_VIA_ENV__" { + #[cfg(debug_assertions)] + { + tracing::warn!("⚠️ PII KEK 使用开发默认值,仅用于本地开发"); + erp_core::crypto::PiiCrypto::dev_default() + } + #[cfg(not(debug_assertions))] + { + panic!( + "ERP__CRYPTO__KEK must be set in production. Use a 64-char hex string (32 bytes)." + ); + } + } else { + erp_core::crypto::PiiCrypto::from_kek_hex(&config.crypto.kek) + .expect("PII KEK must be valid 64-char hex (32 bytes). Set ERP__CRYPTO__KEK") + }; + + // Build shared state + + let cron_heartbeat = std::sync::Arc::new(std::sync::atomic::AtomicU64::new( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + )); + + let state = AppState { + db, + config, + event_bus, + module_registry: registry, + redis: redis_client.clone(), + default_tenant_id, + plugin_engine, + plugin_entity_cache: moka::sync::Cache::builder() + .max_capacity(1000) + .time_to_idle(std::time::Duration::from_secs(300)) + .build(), + pii_crypto, + cron_heartbeat: cron_heartbeat.clone(), + }; + + // Start background tasks with heartbeat + tasks::start_event_cleanup(state.db.clone(), state.cron_heartbeat.clone()); + tasks::start_pool_metrics(state.db.clone(), state.cron_heartbeat.clone()); + + // --- Build the router --- + // + // The router is split into two layers: + // 1. Public routes: no JWT required (health, login, refresh) + // 2. Protected routes: JWT required (user CRUD, logout) + // + // Both layers share the same AppState. The protected layer wraps routes + // with the jwt_auth_middleware_fn. + + // Public routes (no authentication, but IP-based rate limiting) + // Layer execution order (outer → inner): account_lockout → rate_limit_by_ip + // So account lockout check runs FIRST, then IP rate limiting + let public_routes = Router::new() + .merge(erp_auth::AuthModule::public_routes()) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + middleware::rate_limit::account_lockout_middleware, + )) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + middleware::rate_limit::rate_limit_by_ip, + )) + .with_state(state.clone()); + + // Refresh token routes — higher rate limit (30/min) than login (5/min) + let refresh_routes = Router::new() + .merge(erp_auth::AuthModule::refresh_routes()) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + middleware::rate_limit::rate_limit_refresh_by_ip, + )) + .with_state(state.clone()); + + // Unthrottled public routes (health, docs, brand) — no rate limiting + let unthrottled_routes = Router::new() + .merge(handlers::health::health_check_router()) + .route( + "/docs/openapi.json", + axum::routing::get(handlers::openapi::openapi_spec), + ) + .merge(erp_config::ConfigModule::public_routes()) + .with_state(state.clone()); + + // Clone jwt_secret for upload auth before protected_routes closure moves it + let secret_for_uploads = jwt_secret.clone(); + + // Protected routes (JWT authentication required) + // User-based rate limiting (100 req/min) applied after JWT auth + let protected_routes = erp_auth::AuthModule::protected_routes() + .merge(erp_config::ConfigModule::protected_routes()) + .merge(erp_workflow::WorkflowModule::protected_routes()) + .merge(erp_message::MessageModule::protected_routes()) + .merge(erp_plugin::module::PluginModule::protected_routes()) + .merge(erp_diary::DiaryModule::protected_routes()) + .merge(handlers::audit_log::audit_log_router()) + .route( + "/upload", + axum::routing::post(handlers::upload::upload_file), + ) + .route( + "/admin/tenants/{id}/rotate-key", + axum::routing::post(handlers::crypto_admin::rotate_tenant_key), + ) + .route( + "/analytics/batch", + axum::routing::post(handlers::analytics::batch), + ) + .layer(axum::middleware::from_fn( + middleware::frozen_module::frozen_module_middleware, + )) + .layer(axum::middleware::from_fn_with_state( + state.clone(), + middleware::rate_limit::rate_limit_by_user, + )) + .layer({ + let db = state.db.clone(); + let jwt_secret_for_auth = jwt_secret.clone(); + axum_middleware::from_fn(move |req, next| { + let secret = jwt_secret_for_auth.clone(); + let db = db.clone(); + async move { jwt_auth_middleware_fn(secret, Some(db), req, next).await } + }) + }) + // Tenant RLS — 在 JWT 之后执行,SET app.current_tenant_id + .layer({ + let db = state.db.clone(); + axum_middleware::from_fn(move |req, next| { + let db = db.clone(); + async move { middleware::tenant_rls::tenant_rls_middleware(db, req, next).await } + }) + }) + .with_state(state.clone()); + + // Merge public + protected into the final application router + // All API routes are nested under /api/v1 + let cors = build_cors_layer(&state.config.cors.allowed_origins); + let upload_dir = state.config.storage.upload_dir.clone(); + let uploads_router = Router::new() + .fallback_service(ServeDir::new(&upload_dir)) + .layer(axum_middleware::from_fn(move |req, next| { + let secret = secret_for_uploads.clone(); + async move { upload_auth_middleware(secret, req, next).await } + })); + let app = Router::new() + .nest( + "/api/v1", + unthrottled_routes + .merge(public_routes) + .merge(refresh_routes) + .merge(protected_routes), + ) + .nest("/uploads", uploads_router) + .layer(axum::middleware::from_fn( + middleware::metrics::metrics_middleware, + )) + .layer(cors) + .layer(axum::middleware::from_fn(security_headers_middleware)); + + // Start Prometheus metrics exporter on a separate port + let metrics_port = state.config.server.metrics_port; + middleware::metrics::start_metrics_server(metrics_port); + + let addr = format!("{}:{}", host, port); + let listener = tokio::net::TcpListener::bind(&addr).await?; + tracing::info!(addr = %addr, "Server listening"); + + // Graceful shutdown on CTRL+C + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await?; + + // 优雅关闭所有模块(按拓扑逆序) + state.module_registry.shutdown_all().await?; + tracing::info!("Server shutdown complete"); + Ok(()) +} + +/// JWT auth middleware for `/uploads` file serving. +/// +/// Accepts token from either `Authorization: Bearer ` header +/// or `?token=` query parameter (for browser `` / direct downloads). +async fn upload_auth_middleware( + jwt_secret: String, + req: axum::extract::Request, + next: axum::middleware::Next, +) -> Result { + use erp_auth::service::token_service::TokenService; + + let token = req + .headers() + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|s| s.to_string()) + .or_else(|| { + req.uri().query().and_then(|q| { + q.split('&').find_map(|pair| { + let (k, v) = pair.split_once('=').unwrap_or((pair, "")); + if k == "token" && !v.is_empty() { + Some(v.to_string()) + } else { + None + } + }) + }) + }); + + let token = token.ok_or(erp_core::error::AppError::Unauthorized)?; + + let claims = TokenService::decode_token(&token, &jwt_secret) + .map_err(|_| erp_core::error::AppError::Unauthorized)?; + + if claims.token_type != "access" { + return Err(erp_core::error::AppError::Unauthorized); + } + + Ok(next.run(req).await) +} + +/// Build a CORS layer from the comma-separated allowed origins config. +/// +/// If the config is "*", allows all origins (development mode). +/// Otherwise, parses each origin as a URL and restricts to those origins only. +fn build_cors_layer(allowed_origins: &str) -> tower_http::cors::CorsLayer { + use axum::http::HeaderValue; + use tower_http::cors::AllowOrigin; + + let origins = allowed_origins + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect::>(); + + if origins.len() == 1 && origins[0] == "*" { + #[cfg(not(debug_assertions))] + { + tracing::error!("CORS wildcard '*' is not allowed in production builds"); + panic!( + "Refusing to start with CORS wildcard in release mode. Set ERP__CORS__ALLOWED_ORIGINS to specific domains." + ); + } + #[cfg(debug_assertions)] + { + tracing::warn!( + "⚠️ CORS 允许所有来源 — 仅限开发环境使用!\ + 生产环境请通过 ERP__CORS__ALLOWED_ORIGINS 设置具体的来源域名" + ); + return tower_http::cors::CorsLayer::permissive(); + } + } + + let allowed: Vec = origins + .iter() + .filter_map(|o| o.parse::().ok()) + .collect(); + + tracing::info!(origins = ?origins, "CORS: restricting to allowed origins"); + + tower_http::cors::CorsLayer::new() + .allow_origin(AllowOrigin::list(allowed)) + .allow_methods([ + axum::http::Method::GET, + axum::http::Method::POST, + axum::http::Method::PUT, + axum::http::Method::DELETE, + axum::http::Method::PATCH, + ]) + .allow_headers([ + axum::http::header::AUTHORIZATION, + axum::http::header::CONTENT_TYPE, + ]) + .allow_credentials(true) +} + +async fn security_headers_middleware( + req: axum::extract::Request, + next: axum::middleware::Next, +) -> axum::response::Response { + use axum::http::{HeaderValue, header}; + + let mut response = next.run(req).await; + let headers = response.headers_mut(); + headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + headers.insert( + header::HeaderName::from_static("x-xss-protection"), + HeaderValue::from_static("1; mode=block"), + ); + headers.insert( + header::HeaderName::from_static("referrer-policy"), + HeaderValue::from_static("strict-origin-when-cross-origin"), + ); + headers.insert( + header::STRICT_TRANSPORT_SECURITY, + HeaderValue::from_static("max-age=63072000; includeSubDomains; preload"), + ); + headers.insert( + header::HeaderName::from_static("content-security-policy"), + HeaderValue::from_static( + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; \ + img-src 'self' data: blob: https:; connect-src 'self' wss:; \ + frame-ancestors 'none'; base-uri 'self'; form-action 'self'", + ), + ); + headers.insert( + header::HeaderName::from_static("permissions-policy"), + HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"), + ); + response +} + +async fn shutdown_signal() { + let ctrl_c = async { + tokio::signal::ctrl_c() + .await + .expect("failed to install CTRL+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + _ = ctrl_c => { + tracing::info!("Received CTRL+C, shutting down gracefully..."); + }, + _ = terminate => { + tracing::info!("Received SIGTERM, shutting down gracefully..."); + }, + } +} + +/// 同步所有模块声明的权限到数据库。 +/// +/// 对每个模块的 `permissions()` 返回的权限执行 upsert: +/// - 新权限:INSERT +/// - 已有权限(同 tenant_id + code):跳过 +/// +/// 同时将新权限分配给 admin 角色。 +async fn sync_module_permissions( + db: &sea_orm::DatabaseConnection, + registry: &erp_core::module::ModuleRegistry, + tenant_id: uuid::Uuid, +) -> Result<(), anyhow::Error> { + let system_user_id = uuid::Uuid::nil(); + let mut total_new = 0u32; + + for module in registry.modules() { + let perms = module.permissions(); + if perms.is_empty() { + continue; + } + + for perm in perms { + let result = db.execute(sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + r#"INSERT INTO permissions (id, tenant_id, code, name, resource, action, description, created_at, updated_at, created_by, updated_by, deleted_at, version) + VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW(), $8, $8, NULL, 1) + ON CONFLICT (tenant_id, code) WHERE deleted_at IS NULL DO NOTHING"#, + [ + uuid::Uuid::now_v7().into(), + tenant_id.into(), + perm.code.clone().into(), + perm.name.clone().into(), + perm.module.clone().into(), + perm.code.split('.').next_back().unwrap_or("manage").into(), + perm.description.clone().into(), + system_user_id.into(), + ], + )).await?; + + let rows = result.rows_affected(); + if rows > 0 { + total_new += 1; + } + } + } + + // 每次启动都确保 admin 角色拥有所有模块权限(防止权限-角色关联缺失) + db.execute(sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + r#"INSERT INTO role_permissions (role_id, permission_id, tenant_id, data_scope, created_at, updated_at, created_by, updated_by, deleted_at, version) + SELECT r.id, p.id, p.tenant_id, 'all', NOW(), NOW(), $1, $1, NULL, 1 + FROM permissions p + JOIN roles r ON r.code = 'admin' AND r.tenant_id = p.tenant_id AND r.deleted_at IS NULL + WHERE p.tenant_id = $2 + ON CONFLICT DO NOTHING"#, + [system_user_id.into(), tenant_id.into()], + )).await?; + + if total_new > 0 { + tracing::info!( + total_new, + "New module permissions synced and bound to admin role" + ); + } + + Ok(()) +} diff --git a/crates/erp-server/src/middleware/frozen_module.rs b/crates/erp-server/src/middleware/frozen_module.rs new file mode 100644 index 0000000..92ce324 --- /dev/null +++ b/crates/erp-server/src/middleware/frozen_module.rs @@ -0,0 +1,37 @@ +use axum::Json; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; + +/// 冻结模块路径前缀列表。 +/// +/// 这些模块前端已通过 FROZEN_ROUTES 守卫拦截,后端也需同步拦截, +/// 防止直接调 API 绕过限制。 +const FROZEN_PREFIXES: &[&str] = &[ + "/api/v1/health/care-plans", + "/api/v1/health/shifts", + "/api/v1/health/family-proxy", + "/api/v1/health/medications", + "/api/v1/health/dialysis", + "/api/v1/health/schedules", +]; + +pub async fn frozen_module_middleware(req: Request, next: Next) -> Response { + let path = req.uri().path(); + + for prefix in FROZEN_PREFIXES { + if path.starts_with(prefix) { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "success": false, + "error": "该功能正在优化中,暂不可用" + })), + ) + .into_response(); + } + } + + next.run(req).await +} diff --git a/crates/erp-server/src/middleware/metrics.rs b/crates/erp-server/src/middleware/metrics.rs new file mode 100644 index 0000000..f332ed4 --- /dev/null +++ b/crates/erp-server/src/middleware/metrics.rs @@ -0,0 +1,126 @@ +use axum::extract::Request; +use axum::http::Method; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use metrics::{counter, histogram}; +use std::time::Instant; + +/// HTTP 请求指标中间件。 +/// +/// 记录两个 Prometheus 指标: +/// - `http_requests_total` — 计数器,标签: method, path, status +/// - `http_request_duration_seconds` — 直方图,标签: method, path, status +pub async fn metrics_middleware(req: Request, next: Next) -> Response { + let method = method_label(req.method()); + let path = path_label(req.uri().path()); + + let start = Instant::now(); + let resp = next.run(req).await; + let elapsed = start.elapsed(); + + let status = resp.status().as_u16().to_string(); + + let labels = [ + ("method", method.clone()), + ("path", path.clone()), + ("status", status.clone()), + ]; + + counter!("http_requests_total", &labels).increment(1); + histogram!("http_request_duration_seconds", &labels).record(elapsed.as_secs_f64()); + + resp +} + +fn method_label(method: &Method) -> String { + method.as_str().to_owned() +} + +/// 归一化路径:将 UUID 段替换为 `:id`,避免高基数。 +fn path_label(path: &str) -> String { + let parts: Vec<&str> = path + .split('/') + .filter(|s| !s.is_empty()) + .map(|s| if looks_like_uuid(s) { ":id" } else { s }) + .collect(); + if parts.is_empty() { + "/".to_string() + } else { + format!("/{}", parts.join("/")) + } +} + +fn looks_like_uuid(s: &str) -> bool { + s.len() == 36 + && s.chars().filter(|c| *c == '-').count() == 4 + && s.chars().all(|c| c.is_ascii_hexdigit() || c == '-') +} + +/// 在独立端口启动 Prometheus exporter。 +pub fn start_metrics_server(port: u16) { + let builder = metrics_exporter_prometheus::PrometheusBuilder::new(); + let recorder = builder.build_recorder(); + let handle = recorder.handle(); + + if let Err(e) = metrics::set_global_recorder(recorder) { + tracing::error!(error = %e, "Failed to install Prometheus recorder"); + return; + } + + tokio::spawn(async move { + let app = axum::Router::new() + .route( + "/metrics", + axum::routing::get(move || { + let handle = handle.clone(); + async move { + let body = handle.render(); + axum::response::IntoResponse::into_response(( + [( + axum::http::header::CONTENT_TYPE, + "text/plain; version=0.0.4", + )], + body, + )) + } + }), + ) + .fallback(|| async { axum::http::StatusCode::NOT_FOUND.into_response() as Response }); + + let addr = format!("0.0.0.0:{port}"); + match tokio::net::TcpListener::bind(&addr).await { + Ok(listener) => { + tracing::info!(addr = %addr, "Prometheus metrics server listening"); + if let Err(e) = axum::serve(listener, app).await { + tracing::error!(error = %e, "Metrics server error"); + } + } + Err(e) => { + tracing::error!(error = %e, addr = %addr, "Failed to bind metrics server"); + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn path_label_normalizes_uuids() { + assert_eq!(path_label("/api/v1/users"), "/api/v1/users"); + assert_eq!( + path_label("/api/v1/users/01234567-89ab-cdef-0123-456789abcdef/posts"), + "/api/v1/users/:id/posts" + ); + assert_eq!(path_label("/"), "/"); + assert_eq!(path_label(""), "/"); + } + + #[test] + fn is_uuid_checks_format() { + assert!(looks_like_uuid("01234567-89ab-cdef-0123-456789abcdef")); + assert!(!looks_like_uuid("not-a-uuid")); + assert!(!looks_like_uuid("short")); + } +} diff --git a/crates/erp-server/src/middleware/mod.rs b/crates/erp-server/src/middleware/mod.rs new file mode 100644 index 0000000..5488eda --- /dev/null +++ b/crates/erp-server/src/middleware/mod.rs @@ -0,0 +1,4 @@ +pub mod frozen_module; +pub mod metrics; +pub mod rate_limit; +pub mod tenant_rls; diff --git a/crates/erp-server/src/middleware/rate_limit.rs b/crates/erp-server/src/middleware/rate_limit.rs new file mode 100644 index 0000000..1b1ec94 --- /dev/null +++ b/crates/erp-server/src/middleware/rate_limit.rs @@ -0,0 +1,326 @@ +use axum::body::Body; +use axum::extract::State; +use axum::http::{Request, StatusCode}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use redis::AsyncCommands; +use serde::Serialize; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::state::AppState; + +/// Redis 连接失败时间戳缓存(毫秒),5 秒内复用失败状态避免重复连接尝试 +static REDIS_LAST_FAIL_MS: AtomicU64 = AtomicU64::new(0); +const REDIS_FAIL_CACHE_SECS: u64 = 5; + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn is_redis_cached_failed() -> bool { + let last = REDIS_LAST_FAIL_MS.load(Ordering::Relaxed); + last > 0 && now_ms().saturating_sub(last) < REDIS_FAIL_CACHE_SECS * 1000 +} + +fn mark_redis_failed() { + REDIS_LAST_FAIL_MS.store(now_ms(), Ordering::Relaxed); +} + +/// 限流错误响应。 +#[derive(Serialize)] +struct RateLimitResponse { + error: String, + message: String, +} + +/// 账户锁定配置。 +const ACCOUNT_LOCKOUT_MAX_FAILURES: i64 = 5; +const ACCOUNT_LOCKOUT_TTL_SECS: i64 = 900; // 15 分钟 + +/// 基于 Redis 的 IP 限流中间件(登录等敏感操作,5 次/分钟)。 +pub async fn rate_limit_by_ip( + State(state): State, + req: Request, + next: Next, +) -> Response { + let identifier = extract_client_ip(req.headers()); + let fail_close = state.config.rate_limit.fail_close; + apply_rate_limit( + RateLimitParams { + redis_client: &state.redis, + fail_close, + max_requests: 5, + window_secs: 60, + prefix: "login", + }, + &identifier, + req, + next, + ) + .await +} + +/// 基于 Redis 的 IP 限流中间件(Token 刷新,30 次/分钟)。 +pub async fn rate_limit_refresh_by_ip( + State(state): State, + req: Request, + next: Next, +) -> Response { + let identifier = extract_client_ip(req.headers()); + let fail_close = state.config.rate_limit.fail_close; + apply_rate_limit( + RateLimitParams { + redis_client: &state.redis, + fail_close, + max_requests: 30, + window_secs: 60, + prefix: "refresh", + }, + &identifier, + req, + next, + ) + .await +} + +/// 基于 Redis 的用户限流中间件。 +/// +/// 从 TenantContext 中读取 user_id 作为标识符。 +pub async fn rate_limit_by_user( + State(state): State, + req: Request, + next: Next, +) -> Response { + let identifier = req + .extensions() + .get::() + .map(|ctx| ctx.user_id.to_string()) + .unwrap_or_else(|| "anonymous".to_string()); + let fail_close = state.config.rate_limit.fail_close; + apply_rate_limit( + RateLimitParams { + redis_client: &state.redis, + fail_close, + max_requests: 300, + window_secs: 60, + prefix: "api", + }, + &identifier, + req, + next, + ) + .await +} + +/// Redis 不可达时的安全响应(fail-close 模式)。 +fn service_unavailable(prefix: &str) -> Response { + let body = RateLimitResponse { + error: "Service Unavailable".to_string(), + message: "服务暂时不可用,请稍后重试".to_string(), + }; + tracing::error!("Redis 不可达,fail-close 模式拒绝请求 [{}]", prefix); + (StatusCode::SERVICE_UNAVAILABLE, axum::Json(body)).into_response() +} + +/// 限流参数,打包以避免函数签名过长。 +struct RateLimitParams<'a> { + redis_client: &'a redis::Client, + fail_close: bool, + max_requests: u64, + window_secs: u64, + prefix: &'a str, +} + +/// 执行限流检查。 +async fn apply_rate_limit( + params: RateLimitParams<'_>, + identifier: &str, + req: Request, + next: Next, +) -> Response { + // 快速路径:Redis 在缓存期内已知不可用,跳过连接尝试 + if is_redis_cached_failed() { + if params.fail_close { + return service_unavailable(params.prefix); + } + return next.run(req).await; + } + + let key = format!("rate_limit:{}:{}", params.prefix, identifier); + + let mut conn = match params.redis_client.get_multiplexed_async_connection().await { + Ok(c) => c, + Err(e) => { + mark_redis_failed(); + tracing::warn!(error = %e, "Redis 连接失败 [{}]({}秒内不再重试)", params.prefix, REDIS_FAIL_CACHE_SECS); + if params.fail_close { + return service_unavailable(params.prefix); + } + return next.run(req).await; + } + }; + + let count: i64 = match redis::cmd("INCR").arg(&key).query_async(&mut conn).await { + Ok(n) => n, + Err(e) => { + mark_redis_failed(); + tracing::warn!(error = %e, "Redis INCR 失败 [{}]", params.prefix); + if params.fail_close { + return service_unavailable(params.prefix); + } + return next.run(req).await; + } + }; + + // 首次请求设置 TTL + if count == 1 { + let _: Result<(), _> = conn.expire(&key, params.window_secs as i64).await; + } + + if count > params.max_requests as i64 { + let body = RateLimitResponse { + error: "Too Many Requests".to_string(), + message: "请求过于频繁,请稍后重试".to_string(), + }; + return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response(); + } + + next.run(req).await +} + +/// 账户级登录锁定中间件。 +/// +/// 针对登录接口(POST /api/v1/auth/login),在 IP 限流之前执行: +/// 1. 解析请求体提取 username +/// 2. 检查 Redis 中该 username 的失败次数 +/// 3. 超过阈值(5次)则拒绝请求 +/// 4. 观察响应状态码:401 递增失败计数,200 清除计数 +pub async fn account_lockout_middleware( + State(state): State, + req: Request, + next: Next, +) -> Response { + let fail_close = state.config.rate_limit.fail_close; + + // 获取 Redis 连接 + let mut conn = match state.redis.get_multiplexed_async_connection().await { + Ok(c) => c, + Err(e) => { + mark_redis_failed(); + tracing::warn!(error = %e, "Redis 连接失败 [login_lockout]"); + if fail_close { + return service_unavailable("login_lockout"); + } + return next.run(req).await; + } + }; + + // 读取请求体以提取 username + let (parts, body) = req.into_parts(); + let bytes = match axum::body::to_bytes(body, 1024).await { + Ok(b) => b, + Err(e) => { + tracing::warn!(error = %e, "读取登录请求体失败,放行"); + let req = Request::from_parts(parts, Body::from(Vec::new())); + return next.run(req).await; + } + }; + + // 解析 username + let username = serde_json::from_slice::(&bytes) + .ok() + .and_then(|v| v.get("username")?.as_str().map(|s| s.to_string())); + + let username = match username { + Some(u) if !u.is_empty() => u, + _ => { + let req = Request::from_parts(parts, Body::from(bytes.to_vec())); + return next.run(req).await; + } + }; + + // 检查账户锁定状态 + let lockout_key = format!("login_fail:{}", username); + let fail_count: i64 = conn.get(&lockout_key).await.unwrap_or(0); + + if fail_count >= ACCOUNT_LOCKOUT_MAX_FAILURES { + tracing::warn!( + username = %username, + fail_count = fail_count, + "账户已被临时锁定" + ); + let body = RateLimitResponse { + error: "Too Many Requests".to_string(), + message: "账户已被临时锁定,请15分钟后重试".to_string(), + }; + return (StatusCode::TOO_MANY_REQUESTS, axum::Json(body)).into_response(); + } + + // 用原始 body 重建请求,转发到 handler + let req = Request::from_parts(parts, Body::from(bytes.to_vec())); + let response = next.run(req).await; + + // 观察响应状态码 + let status = response.status(); + let (parts, body) = response.into_parts(); + + let body_bytes = axum::body::to_bytes(body, 1024 * 1024) + .await + .unwrap_or_default(); + + if status == StatusCode::UNAUTHORIZED { + // 登录失败:递增失败计数 + let new_count: i64 = match redis::cmd("INCR") + .arg(&lockout_key) + .query_async(&mut conn) + .await + { + Ok(n) => n, + Err(e) => { + tracing::warn!(error = %e, "Redis INCR 失败计数失败"); + let resp = Response::from_parts(parts, Body::from(body_bytes.to_vec())); + return resp; + } + }; + + // 首次失败时设置 TTL + if new_count == 1 { + let _: Result<(), _> = conn.expire(&lockout_key, ACCOUNT_LOCKOUT_TTL_SECS).await; + } + + tracing::info!( + username = %username, + fail_count = new_count, + remaining = ACCOUNT_LOCKOUT_MAX_FAILURES - new_count, + "登录失败,递增失败计数" + ); + } else if status.is_success() { + // 登录成功:清除失败计数 + let _: Result<(), _> = conn.del(&lockout_key).await; + tracing::info!(username = %username, "登录成功,清除失败计数"); + } + + // 重建并返回原始响应 + + Response::from_parts(parts, Body::from(body_bytes.to_vec())) +} + +/// 从请求头中提取客户端 IP。 +fn extract_client_ip(headers: &axum::http::HeaderMap) -> String { + headers + .get("x-forwarded-for") + .or_else(|| headers.get("x-real-ip")) + .and_then(|v| v.to_str().ok()) + .map(|s| { + // x-forwarded-for 可能包含多个 IP,取第一个 + s.split(',').next().unwrap_or(s).trim().to_string() + }) + .unwrap_or_else(|| "unknown".to_string()) +} + +// NOTE: rate_limit_by_gateway was removed during base extraction. +// It depended on erp_health::gateway_auth::GatewayAuthContext. +// Projects needing gateway rate limiting should add it in their own middleware. diff --git a/crates/erp-server/src/middleware/tenant_rls.rs b/crates/erp-server/src/middleware/tenant_rls.rs new file mode 100644 index 0000000..e88f918 --- /dev/null +++ b/crates/erp-server/src/middleware/tenant_rls.rs @@ -0,0 +1,50 @@ +use axum::body::Body; +use axum::http::Request; +use axum::middleware::Next; +use axum::response::Response; +use erp_core::types::TenantContext; +use sea_orm::{ConnectionTrait, DatabaseBackend, Statement}; + +/// Tenant RLS 中间件。 +/// +/// 从 request extensions 中提取 `TenantContext`,在数据库连接上设置 +/// `app.current_tenant_id`,使 PostgreSQL RLS 策略自动按租户过滤。 +/// +/// 请求处理完成后自动 RESET 设置,防止连接池复用时泄漏。 +/// +/// SET 失败时仅 warn 不阻断请求(RLS 是安全网,主隔离仍在应用层)。 +pub async fn tenant_rls_middleware( + db: sea_orm::DatabaseConnection, + req: Request, + next: Next, +) -> Response { + let tenant_id = req + .extensions() + .get::() + .map(|ctx| ctx.tenant_id); + + if let Some(tid) = tenant_id { + // SET app.current_tenant_id — RLS 策略读取此值(参数化查询防止注入) + if let Err(e) = db + .execute(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SET app.current_tenant_id = $1", + [tid.into()], + )) + .await + { + tracing::warn!(tenant_id = %tid, error = %e, "SET app.current_tenant_id 失败(RLS 未激活)"); + } + } + + let response = next.run(req).await; + + // RESET — 防止连接池复用时泄漏租户上下文 + if tenant_id.is_some() + && let Err(e) = db.execute_unprepared("RESET app.current_tenant_id").await + { + tracing::debug!(error = %e, "RESET app.current_tenant_id 失败(非致命)"); + } + + response +} diff --git a/crates/erp-server/src/outbox.rs b/crates/erp-server/src/outbox.rs new file mode 100644 index 0000000..747ccc5 --- /dev/null +++ b/crates/erp-server/src/outbox.rs @@ -0,0 +1,137 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set, +}; +use sqlx::postgres::PgListener; +use std::time::Duration; + +use erp_core::entity::domain_event; +use erp_core::events::{DomainEvent, EventBus}; + +const MAX_RETRY: i32 = 5; +const FALLBACK_POLL_INTERVAL_SECS: u64 = 30; +const NOTIFY_CHANNEL: &str = "outbox_channel"; +const RECONNECT_DELAY_SECS: u64 = 5; + +/// 启动 outbox relay 后台任务。 +/// +/// 先执行一次性扫描(处理服务重启前遗留的 pending 事件), +/// 然后通过 PostgreSQL LISTEN/NOTIFY 监听新事件,配合 30s 兜底轮询。 +pub fn start_outbox_relay( + db: sea_orm::DatabaseConnection, + event_bus: EventBus, + database_url: String, +) { + let db_clone = db.clone(); + let event_bus_clone = event_bus.clone(); + let url = database_url.clone(); + + tokio::spawn(async move { + // 启动时立即处理一次(恢复重启前未广播的事件) + match process_pending_events(&db_clone, &event_bus_clone).await { + Ok(count) if count > 0 => tracing::info!(count = count, "启动时 outbox relay 恢复完成"), + Ok(_) => tracing::info!("启动时 outbox relay 无待处理事件"), + Err(e) => tracing::warn!(error = %e, "启动时 outbox relay 处理失败"), + } + + // 进入 LISTEN/NOTIFY 主循环(带自动重连) + loop { + if let Err(e) = run_listener(&db_clone, &event_bus_clone, &url).await { + tracing::warn!(error = %e, "PgListener 断开连接,{}s 后重连", RECONNECT_DELAY_SECS); + } + tokio::time::sleep(Duration::from_secs(RECONNECT_DELAY_SECS)).await; + + // 重连后执行一次兜底扫描 + if let Err(e) = process_pending_events(&db_clone, &event_bus_clone).await { + tracing::warn!(error = %e, "重连后 outbox relay 处理失败"); + } + } + }); +} + +/// 运行 PgListener 监听循环。 +/// +/// 使用 `tokio::select!` 在 LISTEN 通知和 30s 定时器之间竞争, +/// 确保即使 NOTIFY 丢失也能兜底处理。 +async fn run_listener( + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + database_url: &str, +) -> Result<(), sqlx::Error> { + let mut listener = PgListener::connect(database_url).await?; + listener.listen(NOTIFY_CHANNEL).await?; + tracing::info!("Outbox relay LISTEN/NOTIFY 已连接,监听 {}", NOTIFY_CHANNEL); + + let mut fallback = tokio::time::interval(Duration::from_secs(FALLBACK_POLL_INTERVAL_SECS)); + + loop { + tokio::select! { + // LISTEN/NOTIFY 通知触发 + notification = listener.recv() => { + match notification { + Ok(notif) => { + tracing::debug!( + channel = %notif.channel(), + payload = %notif.payload(), + "收到 outbox NOTIFY" + ); + if let Err(e) = process_pending_events(db, event_bus).await { + tracing::warn!(error = %e, "NOTIFY 触发的 outbox 处理失败"); + } + } + Err(e) => return Err(e), + } + } + // 30s 兜底轮询 + _ = fallback.tick() => { + tracing::debug!("outbox relay 兜底轮询触发"); + if let Err(e) = process_pending_events(db, event_bus).await { + tracing::warn!(error = %e, "兜底轮询 outbox 处理失败"); + } + } + } + } +} + +async fn process_pending_events( + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, +) -> Result { + let pending = domain_event::Entity::find() + .filter(domain_event::Column::Status.eq("pending")) + .filter(domain_event::Column::Attempts.lt(MAX_RETRY)) + .order_by_asc(domain_event::Column::CreatedAt) + .limit(100) + .all(db) + .await?; + + if pending.is_empty() { + return Ok(0); + } + + let count = pending.len(); + tracing::info!(count = count, "处理待发领域事件"); + + for event_model in pending { + // 重建 DomainEvent 并广播(保留原始 ID 和时间戳) + let domain_event = DomainEvent { + id: event_model.id, + event_type: event_model.event_type.clone(), + tenant_id: event_model.tenant_id, + payload: event_model.payload.clone().unwrap_or(serde_json::json!({})), + timestamp: event_model.created_at, + correlation_id: event_model.correlation_id.unwrap_or(event_model.id), + }; + + event_bus.broadcast(domain_event); + + // 标记为 published,增加 attempts 计数 + let mut active: domain_event::ActiveModel = event_model.into(); + active.status = Set("published".to_string()); + active.published_at = Set(Some(Utc::now())); + active.attempts = Set(erp_core::sea_orm_ext::bump_version(&active.attempts)); + active.update(db).await?; + } + + Ok(count) +} diff --git a/crates/erp-server/src/state.rs b/crates/erp-server/src/state.rs new file mode 100644 index 0000000..a636792 --- /dev/null +++ b/crates/erp-server/src/state.rs @@ -0,0 +1,121 @@ +use std::sync::atomic::AtomicU64; +use std::sync::Arc; + +use axum::extract::FromRef; +use sea_orm::DatabaseConnection; + +use crate::config::AppConfig; +use erp_core::events::EventBus; +use erp_core::module::ModuleRegistry; + +/// Axum shared application state. +/// All handlers access database connections, configuration, etc. through `State`. +#[derive(Clone)] +pub struct AppState { + pub db: DatabaseConnection, + pub config: AppConfig, + pub event_bus: EventBus, + pub module_registry: ModuleRegistry, + pub redis: redis::Client, + /// 实际的默认租户 ID,从数据库种子数据中获取。 + pub default_tenant_id: uuid::Uuid, + /// 插件引擎 + pub plugin_engine: erp_plugin::engine::PluginEngine, + /// 插件实体缓存 + pub plugin_entity_cache: moka::sync::Cache, + /// PII 加密服务(KEK + DEK 管理) + pub pii_crypto: erp_core::crypto::PiiCrypto, + /// 定时任务心跳(unix timestamp secs),每个 cron tick 更新 + pub cron_heartbeat: Arc, +} + +/// Allow handlers to extract `DatabaseConnection` directly from `State`. +impl FromRef for DatabaseConnection { + fn from_ref(state: &AppState) -> Self { + state.db.clone() + } +} + +/// Allow handlers to extract `EventBus` directly from `State`. +impl FromRef for EventBus { + fn from_ref(state: &AppState) -> Self { + state.event_bus.clone() + } +} + +/// Allow erp-auth handlers to extract their required state without depending on erp-server. +/// +/// This bridges the gap: erp-auth defines `AuthState` with the fields it needs, +/// and erp-server fills them from `AppState`. +impl FromRef for erp_auth::AuthState { + fn from_ref(state: &AppState) -> Self { + use erp_auth::auth_state::parse_ttl; + + Self { + db: state.db.clone(), + event_bus: state.event_bus.clone(), + jwt_secret: state.config.jwt.secret.clone(), + access_ttl_secs: parse_ttl(&state.config.jwt.access_token_ttl), + refresh_ttl_secs: parse_ttl(&state.config.jwt.refresh_token_ttl), + default_tenant_id: state.default_tenant_id, + wechat_appid: state.config.wechat.appid.clone(), + wechat_secret: state.config.wechat.secret.clone(), + wechat_dev_mode: state.config.wechat.dev_mode, + redis: Some(state.redis.clone()), + crypto: state.pii_crypto.clone(), + } + } +} + +/// Allow erp-config handlers to extract their required state without depending on erp-server. +impl FromRef for erp_config::ConfigState { + fn from_ref(state: &AppState) -> Self { + Self { + db: state.db.clone(), + event_bus: state.event_bus.clone(), + } + } +} + +/// Allow erp-workflow handlers to extract their required state without depending on erp-server. +impl FromRef for erp_workflow::WorkflowState { + fn from_ref(state: &AppState) -> Self { + Self { + db: state.db.clone(), + event_bus: state.event_bus.clone(), + } + } +} + +/// Allow erp-message handlers to extract their required state without depending on erp-server. +impl FromRef for erp_message::MessageState { + fn from_ref(state: &AppState) -> Self { + Self { + db: state.db.clone(), + event_bus: state.event_bus.clone(), + } + } +} + +/// Allow erp-plugin handlers to extract their required state. +impl FromRef for erp_plugin::state::PluginState { + fn from_ref(state: &AppState) -> Self { + Self { + db: state.db.clone(), + event_bus: state.event_bus.clone(), + engine: state.plugin_engine.clone(), + entity_cache: state.plugin_entity_cache.clone(), + } + } +} + +/// Allow erp-diary handlers to extract their required state. +impl FromRef for erp_diary::DiaryState { + fn from_ref(state: &AppState) -> Self { + Self { + db: state.db.clone(), + event_bus: state.event_bus.clone(), + crypto: state.pii_crypto.clone(), + } + } +} diff --git a/crates/erp-server/src/tasks.rs b/crates/erp-server/src/tasks.rs new file mode 100644 index 0000000..0280a38 --- /dev/null +++ b/crates/erp-server/src/tasks.rs @@ -0,0 +1,125 @@ +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +fn touch_heartbeat(heartbeat: &Arc) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + heartbeat.store(now, Ordering::Relaxed); +} + +/// 启动事件清理后台任务。 +/// +/// 每日执行一次: +/// - 调用 `cleanup_old_published_events()` 归档 >7 天的已发布事件 +/// - 调用 `cleanup_old_processed_events()` 清理 >7 天的去重记录 +pub fn start_event_cleanup(db: sea_orm::DatabaseConnection, heartbeat: Arc) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(86400)); + loop { + interval.tick().await; + if let Err(e) = run_cleanup(&db).await { + tracing::warn!(error = %e, "事件清理任务执行失败"); + } + touch_heartbeat(&heartbeat); + } + }); + tracing::info!("事件清理任务已启动(每 24 小时执行一次)"); +} + +async fn run_cleanup(db: &sea_orm::DatabaseConnection) -> Result<(), sea_orm::DbErr> { + use sea_orm::ConnectionTrait; + + // 归档 >7 天的已发布事件 + match db + .execute_unprepared("SELECT cleanup_old_published_events(7, 1000)") + .await + { + Ok(result) => { + tracing::info!(rows_affected = result.rows_affected(), "已发布事件归档完成"); + } + Err(e) => tracing::warn!(error = %e, "已发布事件归档失败"), + } + + // 清理 >7 天的去重记录 + match db + .execute_unprepared("SELECT cleanup_old_processed_events(7, 1000)") + .await + { + Ok(result) => { + tracing::info!(rows_affected = result.rows_affected(), "去重记录清理完成"); + } + Err(e) => tracing::warn!(error = %e, "去重记录清理失败"), + } + + Ok(()) +} + +/// 启动 DB 连接池 + EventBus 积压指标采样任务。 +/// +/// 每 30 秒采样一次并导出为 Prometheus gauge: +/// - `db_pool_connections_active` — 当前活跃连接数 +/// - `db_pool_connections_idle` — 当前空闲连接数 +/// - `eventbus_pending_total` — pending 状态的领域事件数 +pub fn start_pool_metrics(db: sea_orm::DatabaseConnection, heartbeat: Arc) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(30)); + loop { + interval.tick().await; + sample_pool_metrics(&db).await; + sample_eventbus_backlog(&db).await; + touch_heartbeat(&heartbeat); + } + }); + tracing::info!("DB 连接池 + EventBus 积压指标采样已启动(每 30 秒采样一次)"); +} + +async fn sample_pool_metrics(db: &sea_orm::DatabaseConnection) { + use sea_orm::FromQueryResult; + + #[derive(FromQueryResult)] + struct CountRow { + cnt: i64, + } + + // 通过 pg_stat_activity 查询当前连接数 + let stmt = sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "SELECT COUNT(*)::bigint AS cnt FROM pg_stat_activity WHERE state = 'active'".to_string(), + ); + if let Ok(Some(row)) = CountRow::find_by_statement(stmt).one(db).await { + metrics::gauge!("db_pool_connections_active").set(row.cnt as f64); + } + + let stmt = sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "SELECT COUNT(*)::bigint AS cnt FROM pg_stat_activity WHERE state = 'idle'".to_string(), + ); + if let Ok(Some(row)) = CountRow::find_by_statement(stmt).one(db).await { + metrics::gauge!("db_pool_connections_idle").set(row.cnt as f64); + } +} + +async fn sample_eventbus_backlog(db: &sea_orm::DatabaseConnection) { + use sea_orm::FromQueryResult; + + #[derive(FromQueryResult)] + struct CountRow { + cnt: i64, + } + + let stmt = sea_orm::Statement::from_string( + sea_orm::DatabaseBackend::Postgres, + "SELECT COUNT(*)::bigint AS cnt FROM domain_events WHERE status = 'pending'".to_string(), + ); + match CountRow::find_by_statement(stmt).one(db).await { + Ok(Some(row)) => { + metrics::gauge!("eventbus_pending_total").set(row.cnt as f64); + } + _ => { + tracing::debug!("EventBus 积压采样:无法获取 pending 事件数"); + } + } +} diff --git a/crates/erp-server/tests/integration.rs b/crates/erp-server/tests/integration.rs new file mode 100644 index 0000000..3afe0ad --- /dev/null +++ b/crates/erp-server/tests/integration.rs @@ -0,0 +1,8 @@ +#[path = "integration/auth_tests.rs"] +mod auth_tests; +#[path = "integration/plugin_tests.rs"] +mod plugin_tests; +#[path = "integration/test_db.rs"] +mod test_db; +#[path = "integration/workflow_tests.rs"] +mod workflow_tests; diff --git a/crates/erp-server/tests/integration/auth_tests.rs b/crates/erp-server/tests/integration/auth_tests.rs new file mode 100644 index 0000000..1d3fb49 --- /dev/null +++ b/crates/erp-server/tests/integration/auth_tests.rs @@ -0,0 +1,129 @@ +use erp_auth::dto::CreateUserReq; +use erp_auth::service::user_service::UserService; +use erp_core::events::EventBus; +use erp_core::types::Pagination; + +use super::test_db::TestDb; + +#[tokio::test] +async fn test_user_crud() { + let test_db = TestDb::new().await; + let db = test_db.db(); + let tenant_id = uuid::Uuid::new_v4(); + let operator_id = uuid::Uuid::new_v4(); + let event_bus = EventBus::new(100); + + // 创建用户 + let user = UserService::create( + tenant_id, + operator_id, + &CreateUserReq { + username: "testuser".to_string(), + password: "TestPass123".to_string(), + email: Some("test@example.com".to_string()), + phone: None, + display_name: Some("测试用户".to_string()), + }, + db, + &event_bus, + ) + .await + .expect("创建用户失败"); + + assert_eq!(user.username, "testuser"); + assert_eq!(user.status, "active"); + + // 按 ID 查询 + let found = UserService::get_by_id(user.id, tenant_id, db) + .await + .expect("查询用户失败"); + assert_eq!(found.username, "testuser"); + assert_eq!(found.email, Some("test@example.com".to_string())); + + // 列表查询 + let (users, total) = UserService::list( + tenant_id, + &Pagination { + page: Some(1), + page_size: Some(10), + }, + None, + db, + ) + .await + .expect("用户列表查询失败"); + assert_eq!(total, 1); + assert_eq!(users[0].username, "testuser"); +} + +#[tokio::test] +async fn test_tenant_isolation() { + let test_db = TestDb::new().await; + let db = test_db.db(); + let tenant_a = uuid::Uuid::new_v4(); + let tenant_b = uuid::Uuid::new_v4(); + let operator_id = uuid::Uuid::new_v4(); + let event_bus = EventBus::new(100); + + // 租户 A 创建用户 + let user_a = UserService::create( + tenant_a, + operator_id, + &CreateUserReq { + username: "user_a".to_string(), + password: "Pass123456".to_string(), + email: None, + phone: None, + display_name: None, + }, + db, + &event_bus, + ) + .await + .unwrap(); + + // 租户 B 列表查询不应看到租户 A 的用户 + let (users_b, total_b) = UserService::list( + tenant_b, + &Pagination { + page: Some(1), + page_size: Some(10), + }, + None, + db, + ) + .await + .unwrap(); + assert_eq!(total_b, 0); + assert!(users_b.is_empty()); + + // 租户 B 通过 ID 查询租户 A 的用户应返回错误 + let result = UserService::get_by_id(user_a.id, tenant_b, db).await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_username_uniqueness_within_tenant() { + let test_db = TestDb::new().await; + let db = test_db.db(); + let tenant_id = uuid::Uuid::new_v4(); + let operator_id = uuid::Uuid::new_v4(); + let event_bus = EventBus::new(100); + + let req = CreateUserReq { + username: "duplicate".to_string(), + password: "Pass123456".to_string(), + email: None, + phone: None, + display_name: None, + }; + + // 第一次创建成功 + UserService::create(tenant_id, operator_id, &req, db, &event_bus) + .await + .expect("创建用户应成功"); + + // 同租户重复用户名应失败 + let result = UserService::create(tenant_id, operator_id, &req, db, &event_bus).await; + assert!(result.is_err()); +} diff --git a/crates/erp-server/tests/integration/plugin_tests.rs b/crates/erp-server/tests/integration/plugin_tests.rs new file mode 100644 index 0000000..f18c357 --- /dev/null +++ b/crates/erp-server/tests/integration/plugin_tests.rs @@ -0,0 +1,213 @@ +use erp_plugin::dynamic_table::DynamicTableManager; +use erp_plugin::manifest::{ + PluginEntity, PluginField, PluginFieldType, PluginManifest, PluginMetadata, PluginSchema, +}; +use sea_orm::{ConnectionTrait, FromQueryResult}; + +use super::test_db::TestDb; + +/// 构造一个最小默认值的 PluginField(外部 crate 无法使用 #[cfg(test)] 的 default_for_field) +fn make_field(name: &str, field_type: PluginFieldType) -> PluginField { + PluginField { + name: name.to_string(), + field_type, + required: false, + unique: false, + default: None, + display_name: None, + ui_widget: None, + options: None, + searchable: None, + filterable: None, + sortable: None, + visible_when: None, + ref_entity: None, + ref_label_field: None, + ref_search_fields: None, + cascade_from: None, + cascade_filter: None, + validation: None, + no_cycle: None, + scope_role: None, + ref_plugin: None, + ref_fallback_label: None, + } +} + +/// 构建测试用 manifest +fn make_test_manifest() -> PluginManifest { + PluginManifest { + metadata: PluginMetadata { + id: "erp-test".to_string(), + name: "测试插件".to_string(), + version: "0.1.0".to_string(), + description: "集成测试用".to_string(), + author: "test".to_string(), + min_platform_version: None, + dependencies: vec![], + }, + schema: Some(PluginSchema { + entities: vec![PluginEntity { + name: "item".to_string(), + display_name: "测试项".to_string(), + is_public: None, + fields: vec![ + PluginField { + name: "code".to_string(), + field_type: PluginFieldType::String, + required: true, + unique: true, + display_name: Some("编码".to_string()), + searchable: Some(true), + ..make_field("code", PluginFieldType::String) + }, + PluginField { + name: "name".to_string(), + field_type: PluginFieldType::String, + required: true, + display_name: Some("名称".to_string()), + searchable: Some(true), + ..make_field("name", PluginFieldType::String) + }, + PluginField { + name: "status".to_string(), + field_type: PluginFieldType::String, + filterable: Some(true), + display_name: Some("状态".to_string()), + ..make_field("status", PluginFieldType::String) + }, + PluginField { + name: "sort_order".to_string(), + field_type: PluginFieldType::Integer, + sortable: Some(true), + display_name: Some("排序".to_string()), + ..make_field("sort_order", PluginFieldType::Integer) + }, + ], + indexes: vec![], + relations: vec![], + data_scope: None, + importable: None, + exportable: None, + }], + }), + events: None, + ui: None, + permissions: None, + settings: None, + numbering: None, + templates: None, + trigger_events: None, + } +} + +#[tokio::test] +async fn test_dynamic_table_create_and_query() { + let test_db = TestDb::new().await; + let db = test_db.db(); + + let manifest = make_test_manifest(); + let entity = &manifest.schema.as_ref().unwrap().entities[0]; + + // 创建动态表 + DynamicTableManager::create_table(db, "erp_test", entity) + .await + .expect("创建动态表失败"); + + let table_name = DynamicTableManager::table_name("erp_test", &entity.name); + + // 验证表存在 + let exists = DynamicTableManager::table_exists(db, &table_name) + .await + .expect("检查表存在失败"); + assert!(exists, "动态表应存在"); + + // 插入数据 + let tenant_id = uuid::Uuid::new_v4(); + let user_id = uuid::Uuid::new_v4(); + let data = serde_json::json!({ + "code": "ITEM001", + "name": "测试项目", + "status": "active", + "sort_order": 1 + }); + + let (sql, values) = + DynamicTableManager::build_insert_sql(&table_name, tenant_id, user_id, &data); + db.execute(sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .await + .expect("插入数据失败"); + + // 查询数据 + let (sql, values) = DynamicTableManager::build_query_sql(&table_name, tenant_id, 10, 0); + #[derive(FromQueryResult)] + struct Row { + id: uuid::Uuid, + data: serde_json::Value, + } + let rows = Row::find_by_statement(sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .all(db) + .await + .expect("查询数据失败"); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].data["code"], "ITEM001"); + assert_eq!(rows[0].data["name"], "测试项目"); +} + +#[tokio::test] +async fn test_tenant_isolation_in_dynamic_table() { + let test_db = TestDb::new().await; + let db = test_db.db(); + + let manifest = make_test_manifest(); + let entity = &manifest.schema.as_ref().unwrap().entities[0]; + + DynamicTableManager::create_table(db, "erp_test_iso", entity) + .await + .expect("创建动态表失败"); + + let table_name = DynamicTableManager::table_name("erp_test_iso", &entity.name); + + let tenant_a = uuid::Uuid::new_v4(); + let tenant_b = uuid::Uuid::new_v4(); + let user_id = uuid::Uuid::new_v4(); + + // 租户 A 插入数据 + let data_a = serde_json::json!({"code": "A001", "name": "租户A数据", "status": "active", "sort_order": 1}); + let (sql, values) = + DynamicTableManager::build_insert_sql(&table_name, tenant_a, user_id, &data_a); + db.execute(sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .await + .unwrap(); + + // 租户 B 查询不应看到租户 A 的数据 + let (sql, values) = DynamicTableManager::build_query_sql(&table_name, tenant_b, 10, 0); + #[derive(FromQueryResult)] + struct Row { + id: uuid::Uuid, + data: serde_json::Value, + } + let rows = Row::find_by_statement(sea_orm::Statement::from_sql_and_values( + sea_orm::DatabaseBackend::Postgres, + sql, + values, + )) + .all(db) + .await + .unwrap(); + + assert!(rows.is_empty(), "租户 B 不应看到租户 A 的数据"); +} diff --git a/crates/erp-server/tests/integration/test_db.rs b/crates/erp-server/tests/integration/test_db.rs new file mode 100644 index 0000000..5f8a4c3 --- /dev/null +++ b/crates/erp-server/tests/integration/test_db.rs @@ -0,0 +1,114 @@ +use sea_orm::{ConnectionTrait, Database, DatabaseBackend, Statement}; +use std::sync::Arc; + +use erp_server_migration::MigratorTrait; + +/// 全局信号量:限制同时创建数据库的测试数量,避免 PostgreSQL 连接耗尽 +static DB_SEMAPHORE: std::sync::OnceLock> = std::sync::OnceLock::new(); + +fn db_semaphore() -> &'static Arc { + DB_SEMAPHORE.get_or_init(|| Arc::new(tokio::sync::Semaphore::new(4))) +} + +/// 测试数据库 — 使用本地 PostgreSQL 创建隔离测试库 +/// +/// 连接本地 PostgreSQL(wiki/infrastructure.md 配置),为每个测试创建独立的测试数据库。 +/// 不依赖 Docker/Testcontainers,与开发环境一致。 +pub struct TestDb { + db: Option, + db_name: String, + _permit: Option, +} + +impl TestDb { + pub async fn new() -> Self { + let permit = db_semaphore() + .clone() + .acquire_owned() + .await + .expect("信号量获取失败"); + + let db_name = format!("erp_test_{}", uuid::Uuid::now_v7().simple()); + + let admin_url = std::env::var("TEST_DB_URL") + .unwrap_or_else(|_| "postgres://postgres:123123@localhost:5432/postgres".to_string()); + + let admin_db = Database::connect(&admin_url) + .await + .expect("连接本地 PostgreSQL 失败,请确认服务正在运行"); + + admin_db + .execute(Statement::from_string( + DatabaseBackend::Postgres, + format!("CREATE DATABASE \"{}\"", db_name), + )) + .await + .expect("创建测试数据库失败"); + + drop(admin_db); + + // 从 admin_url 推导测试库 URL(替换路径部分) + let test_url = if let Some(pos) = admin_url.rfind('/') { + format!("{}/{}", &admin_url[..pos], db_name) + } else { + format!("postgres://postgres:123123@localhost:5432/{}", db_name) + }; + let db = Database::connect(&test_url) + .await + .expect("连接测试数据库失败"); + + // 运行所有迁移 + erp_server_migration::Migrator::up(&db, None) + .await + .expect("执行数据库迁移失败"); + + Self { + db: Some(db), + db_name, + _permit: Some(permit), + } + } + + /// 获取数据库连接引用 + pub fn db(&self) -> &sea_orm::DatabaseConnection { + self.db.as_ref().expect("数据库连接已被释放") + } +} + +impl Drop for TestDb { + fn drop(&mut self) { + let db_name = self.db_name.clone(); + self.db.take(); + + // 尝试在独立线程中清理,避免在 tokio runtime 内创建新 runtime + let _ = std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build(); + if let Ok(rt) = rt { + rt.block_on(async { + let admin_url = std::env::var("TEST_DB_URL") + .unwrap_or_else(|_| "postgres://postgres:123123@localhost:5432/postgres".to_string()); + if let Ok(admin_db) = Database::connect(&admin_url).await { + let disconnect_sql = format!( + "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}'", + db_name + ); + admin_db + .execute(Statement::from_string(DatabaseBackend::Postgres, disconnect_sql)) + .await + .ok(); + + admin_db + .execute(Statement::from_string( + DatabaseBackend::Postgres, + format!("DROP DATABASE IF EXISTS \"{}\"", db_name), + )) + .await + .ok(); + } + }); + } + }); + } +} diff --git a/crates/erp-server/tests/integration/workflow_tests.rs b/crates/erp-server/tests/integration/workflow_tests.rs new file mode 100644 index 0000000..4978ee9 --- /dev/null +++ b/crates/erp-server/tests/integration/workflow_tests.rs @@ -0,0 +1,247 @@ +use erp_core::events::EventBus; +use erp_core::types::Pagination; +use erp_workflow::dto::{ + CompleteTaskReq, CreateProcessDefinitionReq, EdgeDef, NodeDef, NodeType, StartInstanceReq, +}; +use erp_workflow::service::definition_service::DefinitionService; +use erp_workflow::service::instance_service::InstanceService; +use erp_workflow::service::task_service::TaskService; + +use super::test_db::TestDb; + +/// 构建一个最简单的线性流程:开始 → 审批 → 结束 +/// assignee 指向 operator_id,使 list_pending 能查到任务 +fn make_simple_definition( + name: &str, + key: &str, + assignee_id: Option, +) -> CreateProcessDefinitionReq { + CreateProcessDefinitionReq { + name: name.to_string(), + key: key.to_string(), + category: Some("test".to_string()), + description: Some("集成测试流程".to_string()), + nodes: vec![ + NodeDef { + id: "start".to_string(), + node_type: NodeType::StartEvent, + name: "开始".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + NodeDef { + id: "approve".to_string(), + node_type: NodeType::UserTask, + name: "审批".to_string(), + assignee_id, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + NodeDef { + id: "end".to_string(), + node_type: NodeType::EndEvent, + name: "结束".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + ], + edges: vec![ + EdgeDef { + id: "e1".to_string(), + source: "start".to_string(), + target: "approve".to_string(), + condition: None, + label: None, + }, + EdgeDef { + id: "e2".to_string(), + source: "approve".to_string(), + target: "end".to_string(), + condition: None, + label: None, + }, + ], + } +} + +#[tokio::test] +async fn test_workflow_definition_crud() { + let test_db = TestDb::new().await; + let db = test_db.db(); + let tenant_id = uuid::Uuid::new_v4(); + let operator_id = uuid::Uuid::new_v4(); + let event_bus = EventBus::new(100); + + let def = DefinitionService::create( + tenant_id, + operator_id, + &make_simple_definition("测试流程", "test-flow-1", None), + db, + &event_bus, + ) + .await + .expect("创建流程定义失败"); + + assert_eq!(def.name, "测试流程"); + assert_eq!(def.status, "draft"); + + let (defs, total) = DefinitionService::list( + tenant_id, + &Pagination { + page: Some(1), + page_size: Some(10), + }, + db, + ) + .await + .expect("查询流程定义列表失败"); + assert_eq!(total, 1); + assert_eq!(defs[0].name, "测试流程"); + + let found = DefinitionService::get_by_id(def.id, tenant_id, db) + .await + .expect("查询流程定义失败"); + assert_eq!(found.id, def.id); + + let published = DefinitionService::publish(def.id, tenant_id, operator_id, db, &event_bus) + .await + .expect("发布流程定义失败"); + assert_eq!(published.status, "published"); +} + +#[tokio::test] +async fn test_workflow_instance_lifecycle() { + let test_db = TestDb::new().await; + let db = test_db.db(); + let tenant_id = uuid::Uuid::new_v4(); + let operator_id = uuid::Uuid::new_v4(); + let event_bus = EventBus::new(100); + + let def = DefinitionService::create( + tenant_id, + operator_id, + &make_simple_definition("生命周期测试", "lifecycle-flow", Some(operator_id)), + db, + &event_bus, + ) + .await + .expect("创建流程定义失败"); + + let def = DefinitionService::publish(def.id, tenant_id, operator_id, db, &event_bus) + .await + .expect("发布流程定义失败"); + + let instance = InstanceService::start( + tenant_id, + operator_id, + &StartInstanceReq { + definition_id: def.id, + business_key: Some("测试实例".to_string()), + variables: None, + }, + db, + &event_bus, + ) + .await + .expect("启动流程实例失败"); + + assert_eq!(instance.status, "running"); + + let (tasks, task_total) = TaskService::list_pending( + tenant_id, + operator_id, + &Pagination { + page: Some(1), + page_size: Some(10), + }, + db, + ) + .await + .expect("查询待办任务失败"); + assert_eq!(task_total, 1); + assert_eq!(tasks[0].status, "pending"); + + let completed = TaskService::complete( + tasks[0].id, + tenant_id, + operator_id, + &CompleteTaskReq { + outcome: "approved".to_string(), + form_data: Some(serde_json::json!({"comment": "同意"})), + }, + db, + &event_bus, + ) + .await + .expect("完成任务失败"); + assert_eq!(completed.status, "completed"); +} + +#[tokio::test] +async fn test_workflow_tenant_isolation() { + let test_db = TestDb::new().await; + let db = test_db.db(); + let tenant_a = uuid::Uuid::new_v4(); + let tenant_b = uuid::Uuid::new_v4(); + let operator_id = uuid::Uuid::new_v4(); + let event_bus = EventBus::new(100); + + let def_a = DefinitionService::create( + tenant_a, + operator_id, + &make_simple_definition("租户A流程", "tenant-a-flow", None), + db, + &event_bus, + ) + .await + .expect("创建流程定义失败"); + + let (defs_b, total_b) = DefinitionService::list( + tenant_b, + &Pagination { + page: Some(1), + page_size: Some(10), + }, + db, + ) + .await + .expect("查询流程定义列表失败"); + assert_eq!(total_b, 0); + assert!(defs_b.is_empty()); + + let result = DefinitionService::get_by_id(def_a.id, tenant_b, db).await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn test_event_bus_pub_sub() { + let event_bus = EventBus::new(100); + let tenant_id = uuid::Uuid::new_v4(); + + let (mut receiver, _handle) = event_bus.subscribe_filtered("user.".to_string()); + + let event = erp_core::events::DomainEvent::new( + "user.created", + tenant_id, + serde_json::json!({"username": "test"}), + ); + event_bus.broadcast(event); + + let other_event = + erp_core::events::DomainEvent::new("workflow.started", tenant_id, serde_json::json!({})); + event_bus.broadcast(other_event); + + let received = receiver.recv().await; + assert!(received.is_some()); + let received = received.unwrap(); + assert_eq!(received.event_type, "user.created"); + assert_eq!(received.payload["username"], "test"); +} diff --git a/crates/erp-workflow/Cargo.toml b/crates/erp-workflow/Cargo.toml new file mode 100644 index 0000000..e9b145e --- /dev/null +++ b/crates/erp-workflow/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "erp-workflow" +version.workspace = true +edition.workspace = true + +[dependencies] +erp-core.workspace = true +tokio = { workspace = true, features = ["full"] } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +uuid = { workspace = true, features = ["v7", "serde"] } +chrono = { workspace = true, features = ["serde"] } +axum = { workspace = true } +sea-orm = { workspace = true, features = ["sqlx-postgres", "runtime-tokio-rustls", "with-uuid", "with-chrono", "with-json"] } +tracing = { workspace = true } +anyhow.workspace = true +thiserror.workspace = true +utoipa = { workspace = true, features = ["uuid", "chrono"] } +async-trait.workspace = true +validator.workspace = true +reqwest = { workspace = true, features = ["json"] } diff --git a/crates/erp-workflow/src/dto.rs b/crates/erp-workflow/src/dto.rs new file mode 100644 index 0000000..0d932c5 --- /dev/null +++ b/crates/erp-workflow/src/dto.rs @@ -0,0 +1,252 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; +use validator::Validate; + +// --- 流程图节点/边定义 --- + +/// BPMN 节点类型 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema, PartialEq)] +pub enum NodeType { + StartEvent, + EndEvent, + UserTask, + ServiceTask, + ExclusiveGateway, + ParallelGateway, +} + +/// 流程图节点定义 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct NodeDef { + pub id: String, + #[serde(rename = "type")] + pub node_type: NodeType, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub assignee_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub candidate_groups: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_type: Option, + /// 服务任务 HTTP 调用配置 + #[serde(skip_serializing_if = "Option::is_none")] + pub service_config: Option, + /// 前端渲染位置 + #[serde(skip_serializing_if = "Option::is_none")] + pub position: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct NodePosition { + pub x: f64, + pub y: f64, +} + +/// ServiceTask HTTP 调用配置 +#[derive(Debug, Clone, Serialize, Deserialize, Validate, ToSchema)] +pub struct ServiceTaskConfig { + /// 请求 URL(仅允许 http/https 协议,禁止内网地址) + #[validate(length(min = 1, max = 2048), custom(function = "validate_service_url"))] + pub url: String, + /// HTTP 方法(GET / POST),默认 GET + #[serde(default = "default_method")] + #[validate(custom(function = "validate_http_method"))] + pub method: String, + /// POST body 模板(支持从流程变量替换 ${var_name}) + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, +} + +fn default_method() -> String { + "GET".to_string() +} + +fn validate_service_url(value: &str) -> Result<(), validator::ValidationError> { + if !value.starts_with("https://") && !value.starts_with("http://") { + return Err(validator::ValidationError::new("invalid_url_scheme")); + } + if value.contains("127.0.0.1") || value.contains("localhost") || value.contains("0.0.0.0") { + return Err(validator::ValidationError::new("ssrf_blocked")); + } + Ok(()) +} + +fn validate_http_method(value: &str) -> Result<(), validator::ValidationError> { + match value { + "GET" | "POST" => Ok(()), + _ => Err(validator::ValidationError::new("invalid_http_method")), + } +} + +/// 流程图连线定义 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct EdgeDef { + pub id: String, + pub source: String, + pub target: String, + /// 条件表达式(排他网关分支) + #[serde(skip_serializing_if = "Option::is_none")] + pub condition: Option, + /// 前端渲染标签 + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +/// 完整流程图 +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct FlowDiagram { + pub nodes: Vec, + pub edges: Vec, +} + +// --- 流程定义 DTOs --- + +#[derive(Debug, Serialize, ToSchema)] +pub struct ProcessDefinitionResp { + pub id: Uuid, + pub name: String, + pub key: String, + pub version: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub nodes: serde_json::Value, + pub edges: serde_json::Value, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub lock_version: i32, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct CreateProcessDefinitionReq { + #[validate(length(min = 1, max = 200, message = "流程名称不能为空"))] + pub name: String, + #[validate(length(min = 1, max = 100, message = "流程编码不能为空"))] + pub key: String, + pub category: Option, + pub description: Option, + pub nodes: Vec, + pub edges: Vec, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct UpdateProcessDefinitionReq { + #[validate(length(max = 200, message = "流程名称过长"))] + pub name: Option, + pub category: Option, + pub description: Option, + pub nodes: Option>, + pub edges: Option>, + pub version: i32, +} + +// --- 流程实例 DTOs --- + +#[derive(Debug, Serialize, ToSchema)] +pub struct ProcessInstanceResp { + pub id: Uuid, + pub definition_id: Uuid, + pub definition_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub business_key: Option, + pub status: String, + pub started_by: Uuid, + pub started_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option>, + pub created_at: DateTime, + /// 当前活跃的 token 位置 + pub active_tokens: Vec, + pub version: i32, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct StartInstanceReq { + pub definition_id: Uuid, + pub business_key: Option, + /// 初始流程变量 + pub variables: Option>, +} + +// --- Token DTOs --- + +#[derive(Debug, Serialize, ToSchema)] +pub struct TokenResp { + pub id: Uuid, + pub node_id: String, + pub status: String, + pub created_at: DateTime, +} + +// --- 任务 DTOs --- + +#[derive(Debug, Serialize, ToSchema)] +pub struct TaskResp { + pub id: Uuid, + pub instance_id: Uuid, + pub token_id: Uuid, + pub node_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub node_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub assignee_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub candidate_groups: Option, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub outcome: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub form_data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub due_date: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option>, + pub created_at: DateTime, + /// 流程定义名称(用于列表展示) + #[serde(skip_serializing_if = "Option::is_none")] + pub definition_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub business_key: Option, + pub version: i32, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct CompleteTaskReq { + #[validate(length(min = 1, max = 50, message = "审批结果不能为空"))] + pub outcome: String, + pub form_data: Option, +} + +#[derive(Debug, Deserialize, Validate, ToSchema)] +pub struct DelegateTaskReq { + pub delegate_to: Uuid, +} + +// --- 流程变量 DTOs --- + +#[derive(Debug, Serialize, ToSchema)] +pub struct ProcessVariableResp { + pub id: Uuid, + pub name: String, + pub var_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub value_string: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value_boolean: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value_date: Option>, +} + +#[derive(Debug, Clone, Deserialize, Validate, ToSchema)] +pub struct SetVariableReq { + #[validate(length(min = 1, max = 100, message = "变量名不能为空"))] + pub name: String, + pub var_type: Option, + pub value: serde_json::Value, +} diff --git a/crates/erp-workflow/src/engine/executor.rs b/crates/erp-workflow/src/engine/executor.rs new file mode 100644 index 0000000..e63ce09 --- /dev/null +++ b/crates/erp-workflow/src/engine/executor.rs @@ -0,0 +1,704 @@ +use std::collections::HashMap; + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, Set, +}; +use uuid::Uuid; + +use crate::dto::NodeType; +use crate::engine::expression::ExpressionEvaluator; +use crate::engine::model::FlowGraph; +use crate::entity::{process_instance, task, token}; +use crate::error::{WorkflowError, WorkflowResult}; + +/// Token 驱动的流程执行引擎。 +/// +/// 核心职责: +/// - 在流程启动时,于 StartEvent 创建第一个 token +/// - 在任务完成时推进 token 到下一个节点 +/// - 处理网关分支/汇合逻辑 +/// - 在 EndEvent 完成实例 +pub struct FlowExecutor; + +impl FlowExecutor { + /// 启动流程:在 StartEvent 的后继节点创建 token。 + /// + /// 返回创建的 token ID 列表。 + pub async fn start( + instance_id: Uuid, + tenant_id: Uuid, + graph: &FlowGraph, + variables: &HashMap, + txn: &impl ConnectionTrait, + ) -> WorkflowResult> { + let start_id = graph + .start_node_id + .as_ref() + .ok_or_else(|| WorkflowError::InvalidDiagram("流程图没有开始事件".to_string()))?; + + // 获取 StartEvent 的出边,推进到后继节点 + let outgoing = graph.get_outgoing_edges(start_id); + if outgoing.is_empty() { + return Err(WorkflowError::InvalidDiagram( + "开始事件没有出边".to_string(), + )); + } + + // StartEvent 只有一条出边 + let first_edge = &outgoing[0]; + let target_node_id = &first_edge.target; + + Self::create_token_at_node( + instance_id, + tenant_id, + target_node_id, + graph, + variables, + txn, + ) + .await + } + + /// 推进 token:消费当前 token,在下一节点创建新 token。 + /// + /// 返回新创建的 token ID 列表。 + pub async fn advance( + token_id: Uuid, + instance_id: Uuid, + tenant_id: Uuid, + graph: &FlowGraph, + variables: &HashMap, + txn: &impl ConnectionTrait, + ) -> WorkflowResult> { + // 读取当前 token + let current_token = token::Entity::find_by_id(token_id) + .one(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .ok_or_else(|| WorkflowError::NotFound(format!("Token 不存在: {token_id}")))?; + + if current_token.status != "active" { + return Err(WorkflowError::InvalidState(format!( + "Token 状态不是 active: {}", + current_token.status + ))); + } + + let node_id = current_token.node_id.clone(); + + // 消费当前 token + let mut active: token::ActiveModel = current_token.into(); + active.version = Set(active.version.take().unwrap_or(0) + 1); + active.status = Set("consumed".to_string()); + active.consumed_at = Set(Some(Utc::now())); + active + .update(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + // 获取当前节点的出边 + let outgoing = graph.get_outgoing_edges(&node_id); + let current_node = graph + .nodes + .get(&node_id) + .ok_or_else(|| WorkflowError::InvalidDiagram(format!("节点不存在: {node_id}")))?; + + match current_node.node_type { + NodeType::ExclusiveGateway => { + // 排他网关:求值条件,选择一条分支 + Self::advance_exclusive_gateway( + instance_id, + tenant_id, + &outgoing, + graph, + variables, + txn, + ) + .await + } + NodeType::ParallelGateway => { + // 并行网关:为每条出边创建 token + Self::advance_parallel_gateway( + instance_id, + tenant_id, + &outgoing, + graph, + variables, + txn, + ) + .await + } + _ => { + // 普通节点:沿出边前进 + if outgoing.is_empty() { + // 没有出边(理论上只有 EndEvent 会到这里) + Ok(vec![]) + } else { + let mut new_tokens = Vec::new(); + for edge in &outgoing { + let tokens = Self::create_token_at_node( + instance_id, + tenant_id, + &edge.target, + graph, + variables, + txn, + ) + .await?; + new_tokens.extend(tokens); + } + Ok(new_tokens) + } + } + } + } + + /// 排他网关分支:求值条件,选择第一个满足条件的分支。 + async fn advance_exclusive_gateway( + instance_id: Uuid, + tenant_id: Uuid, + outgoing: &[&crate::engine::model::FlowEdge], + graph: &FlowGraph, + variables: &HashMap, + txn: &impl ConnectionTrait, + ) -> WorkflowResult> { + let mut default_target: Option<&str> = None; + let mut matched_target: Option<&str> = None; + + for edge in outgoing { + if let Some(condition) = &edge.condition { + match ExpressionEvaluator::eval(condition, variables) { + Ok(true) => { + matched_target = Some(&edge.target); + break; + } + Ok(false) => continue, + Err(_) => continue, // 条件求值失败,跳过 + } + } else { + // 无条件的边作为默认分支 + default_target = Some(&edge.target); + } + } + + let target = matched_target.or(default_target).ok_or_else(|| { + WorkflowError::ExpressionError("排他网关没有匹配的条件分支".to_string()) + })?; + + Self::create_token_at_node(instance_id, tenant_id, target, graph, variables, txn).await + } + + /// 并行网关分支:为每条出边创建 token。 + async fn advance_parallel_gateway( + instance_id: Uuid, + tenant_id: Uuid, + outgoing: &[&crate::engine::model::FlowEdge], + graph: &FlowGraph, + variables: &HashMap, + txn: &impl ConnectionTrait, + ) -> WorkflowResult> { + let mut new_tokens = Vec::new(); + for edge in outgoing { + let tokens = Self::create_token_at_node( + instance_id, + tenant_id, + &edge.target, + graph, + variables, + txn, + ) + .await?; + new_tokens.extend(tokens); + } + Ok(new_tokens) + } + + /// 在指定节点创建 token,并根据节点类型执行相应逻辑。 + fn create_token_at_node<'a>( + instance_id: Uuid, + tenant_id: Uuid, + node_id: &'a str, + graph: &'a FlowGraph, + variables: &'a HashMap, + txn: &'a impl ConnectionTrait, + ) -> std::pin::Pin>> + Send + 'a>> + { + Box::pin(async move { + let node = graph + .nodes + .get(node_id) + .ok_or_else(|| WorkflowError::InvalidDiagram(format!("节点不存在: {node_id}")))?; + + match node.node_type { + NodeType::EndEvent => { + // 到达 EndEvent,不创建新 token + // 检查实例是否所有 token 都完成 + Self::check_instance_completion(instance_id, tenant_id, txn).await?; + Ok(vec![]) + } + NodeType::ParallelGateway if Self::is_join_gateway(node_id, graph) => { + // 并行网关汇合:等待所有入边 token 到达 + Self::handle_join_gateway( + instance_id, + tenant_id, + node_id, + graph, + variables, + txn, + ) + .await + } + NodeType::ServiceTask => { + // ServiceTask 自动执行 HTTP 调用 + let now = Utc::now(); + let system_user = uuid::Uuid::nil(); + let auto_token_id = Uuid::now_v7(); + + let token_model = token::ActiveModel { + id: Set(auto_token_id), + tenant_id: Set(tenant_id), + instance_id: Set(instance_id), + node_id: Set(node_id.to_string()), + status: Set("consumed".to_string()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user), + updated_by: Set(system_user), + deleted_at: Set(None), + version: Set(1), + consumed_at: Set(Some(now)), + }; + token_model + .insert(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + // 执行 HTTP 调用(如果配置了 service_config) + let var_name = format!("service_task_{node_id}_result"); + let result_value = Self::execute_service_task(node, variables).await; + // 将结果存储为流程变量 + Self::set_process_variable( + instance_id, + tenant_id, + &var_name, + &result_value, + txn, + ) + .await?; + + // 沿出边继续推进 + let outgoing = graph.get_outgoing_edges(node_id); + let mut new_tokens = Vec::new(); + for edge in &outgoing { + let tokens = Self::create_token_at_node( + instance_id, + tenant_id, + &edge.target, + graph, + variables, + txn, + ) + .await?; + new_tokens.extend(tokens); + } + Ok(new_tokens) + } + _ => { + // UserTask / 网关(分支)等:创建活跃 token + let new_token_id = Uuid::now_v7(); + let now = Utc::now(); + + let system_user = uuid::Uuid::nil(); + + let token_model = token::ActiveModel { + id: Set(new_token_id), + tenant_id: Set(tenant_id), + instance_id: Set(instance_id), + node_id: Set(node_id.to_string()), + status: Set("active".to_string()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user), + updated_by: Set(system_user), + deleted_at: Set(None), + version: Set(1), + consumed_at: Set(None), + }; + token_model + .insert(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + // UserTask: 同时创建 task 记录 + if node.node_type == NodeType::UserTask { + let task_model = task::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + instance_id: Set(instance_id), + token_id: Set(new_token_id), + node_id: Set(node_id.to_string()), + node_name: Set(Some(node.name.clone())), + assignee_id: Set(node.assignee_id), + candidate_groups: Set(node + .candidate_groups + .as_ref() + .map(|g| serde_json::to_value(g).unwrap_or_default())), + status: Set("pending".to_string()), + outcome: Set(None), + form_data: Set(None), + due_date: Set(None), + completed_at: Set(None), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(Uuid::nil()), + updated_by: Set(Uuid::nil()), + deleted_at: Set(None), + version: Set(1), + }; + task_model + .insert(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + } + + Ok(vec![new_token_id]) + } + } + }) + } + + /// 判断并行网关是否是汇合模式(入边数 > 出边数,或者入边数 > 1)。 + fn is_join_gateway(node_id: &str, graph: &FlowGraph) -> bool { + let incoming = graph.get_incoming_edges(node_id); + incoming.len() > 1 + } + + /// 处理并行网关汇合逻辑。 + /// + /// 当所有入边的源节点都有已消费的 token 时,创建新 token 推进到后继。 + async fn handle_join_gateway( + instance_id: Uuid, + tenant_id: Uuid, + node_id: &str, + graph: &FlowGraph, + variables: &HashMap, + txn: &impl ConnectionTrait, + ) -> WorkflowResult> { + let incoming = graph.get_incoming_edges(node_id); + + // 检查所有入边的源节点是否都有已消费/已完成的 token + for edge in &incoming { + let has_consumed = token::Entity::find() + .filter(token::Column::InstanceId.eq(instance_id)) + .filter(token::Column::NodeId.eq(&edge.source)) + .filter(token::Column::Status.is_in(["consumed", "active"])) + .one(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + if has_consumed.is_none() { + // 还有分支没有到达,等待 + return Ok(vec![]); + } + + // 检查是否还有活跃的 token(来自其他分支) + let has_active = token::Entity::find() + .filter(token::Column::InstanceId.eq(instance_id)) + .filter(token::Column::NodeId.eq(&edge.source)) + .filter(token::Column::Status.eq("active")) + .one(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + if has_active.is_some() { + // 还有分支在执行中,等待 + return Ok(vec![]); + } + } + + // 所有分支都完成了,先将 consumed tokens 标记为 completed 防止并发重复触发 + for edge in &incoming { + let consumed_tokens = token::Entity::find() + .filter(token::Column::InstanceId.eq(instance_id)) + .filter(token::Column::NodeId.eq(&edge.source)) + .filter(token::Column::Status.eq("consumed")) + .all(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + for t in consumed_tokens { + let ver = t.version; + let mut active: token::ActiveModel = t.into(); + active.status = Set("completed".to_string()); + active.version = Set(ver + 1); + active.updated_at = Set(chrono::Utc::now()); + active + .update(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + } + } + + // 沿出边继续创建新 token + let outgoing = graph.get_outgoing_edges(node_id); + let mut new_tokens = Vec::new(); + for edge in &outgoing { + let tokens = Self::create_token_at_node( + instance_id, + tenant_id, + &edge.target, + graph, + variables, + txn, + ) + .await?; + new_tokens.extend(tokens); + } + Ok(new_tokens) + } + + /// 执行 ServiceTask HTTP 调用。 + /// + /// 根据 `service_config` 中的 url/method/body 发起 HTTP 请求。 + /// 如果没有配置 `service_config` 或调用失败,返回错误信息 JSON 而不是阻塞流程。 + async fn execute_service_task( + node: &crate::engine::model::FlowNode, + variables: &HashMap, + ) -> serde_json::Value { + let config = match &node.service_config { + Some(c) => c, + None => { + tracing::warn!( + node_id = &node.id, + node_name = %node.name, + "ServiceTask 没有 service_config 配置,跳过 HTTP 调用" + ); + return serde_json::json!({ + "status": "skipped", + "reason": "未配置 service_config" + }); + } + }; + + let method = config.method.to_uppercase(); + let url = &config.url; + + tracing::info!( + node_id = &node.id, + node_name = %node.name, + method = %method, + url = %url, + "ServiceTask 开始 HTTP 调用" + ); + + let client = reqwest::Client::new(); + let result = match method.as_str() { + "POST" => { + let body = config.body.as_ref().map(|b| { + // 简单变量替换:${var_name} → variables 中的值 + let mut body_str = b.to_string(); + for (key, val) in variables { + let placeholder = format!("${{{key}}}"); + body_str = body_str.replace(&placeholder, &val.to_string()); + } + body_str + }); + client.post(url).body(body.unwrap_or_default()).send().await + } + _ => { + // 默认 GET + client.get(url).send().await + } + }; + + match result { + Ok(resp) => { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + tracing::info!( + node_id = &node.id, + status = status, + "ServiceTask HTTP 调用完成" + ); + serde_json::json!({ + "status": "success", + "http_status": status, + "body": body, + }) + } + Err(e) => { + tracing::warn!( + node_id = &node.id, + error = %e, + "ServiceTask HTTP 调用失败(流程继续推进)" + ); + serde_json::json!({ + "status": "error", + "error": e.to_string(), + }) + } + } + } + + /// 将流程变量写入 process_variables 表。 + async fn set_process_variable( + instance_id: Uuid, + tenant_id: Uuid, + name: &str, + value: &serde_json::Value, + txn: &impl ConnectionTrait, + ) -> WorkflowResult<()> { + use crate::entity::process_variable; + + let now = Utc::now(); + let system_user = Uuid::nil(); + let var_model = process_variable::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + instance_id: Set(instance_id), + name: Set(name.to_string()), + var_type: Set("json".to_string()), + value_string: Set(Some(value.to_string())), + value_number: Set(None), + value_boolean: Set(None), + value_date: Set(None), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user), + updated_by: Set(system_user), + deleted_at: Set(None), + version: Set(1), + }; + var_model + .insert(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + Ok(()) + } + + /// 检查实例是否所有 token 都已完成,如果是则完成实例。 + async fn check_instance_completion( + instance_id: Uuid, + tenant_id: Uuid, + txn: &impl ConnectionTrait, + ) -> WorkflowResult<()> { + let active_count = token::Entity::find() + .filter(token::Column::InstanceId.eq(instance_id)) + .filter(token::Column::Status.eq("active")) + .count(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + if active_count == 0 { + // 所有 token 都完成,标记实例完成 + let instance = process_instance::Entity::find_by_id(instance_id) + .one(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none()) + .ok_or_else(|| WorkflowError::NotFound(format!("流程实例不存在: {instance_id}")))?; + + let mut active: process_instance::ActiveModel = instance.into(); + active.version = Set(active.version.take().unwrap_or(0) + 1); + active.status = Set("completed".to_string()); + active.completed_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active + .update(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + // 写入完成事件到 outbox,由 relay 广播 + let now = Utc::now(); + let outbox_event = erp_core::entity::domain_event::ActiveModel { + id: Set(Uuid::now_v7()), + tenant_id: Set(tenant_id), + event_type: Set("process_instance.completed".to_string()), + payload: Set(Some(serde_json::json!({ "instance_id": instance_id }))), + correlation_id: Set(Some(Uuid::now_v7())), + status: Set("pending".to_string()), + attempts: Set(0), + last_error: Set(None), + created_at: Set(now), + published_at: Set(None), + }; + outbox_event + .insert(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::{EdgeDef, NodeDef, NodeType}; + + fn make_node(id: &str, node_type: NodeType) -> NodeDef { + NodeDef { + id: id.to_string(), + node_type, + name: id.to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + } + } + + fn make_edge(id: &str, source: &str, target: &str) -> EdgeDef { + EdgeDef { + id: id.to_string(), + source: source.to_string(), + target: target.to_string(), + condition: None, + label: None, + } + } + + #[test] + fn test_is_join_gateway_with_multiple_incoming() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("a", NodeType::UserTask), + make_node("b", NodeType::ServiceTask), + make_node("join", NodeType::ParallelGateway), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![ + make_edge("e1", "start", "a"), + make_edge("e2", "start", "b"), + make_edge("e3", "a", "join"), + make_edge("e4", "b", "join"), + make_edge("e5", "join", "end"), + ]; + let graph = FlowGraph::build(&nodes, &edges); + assert!(FlowExecutor::is_join_gateway("join", &graph)); + } + + #[test] + fn test_is_not_join_gateway_single_incoming() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("fork", NodeType::ParallelGateway), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![ + make_edge("e1", "start", "fork"), + make_edge("e2", "fork", "end"), + ]; + let graph = FlowGraph::build(&nodes, &edges); + assert!(!FlowExecutor::is_join_gateway("fork", &graph)); + } + + #[test] + fn test_is_not_join_gateway_for_nonexistent_node() { + let graph = FlowGraph::build(&[], &[]); + assert!(!FlowExecutor::is_join_gateway("nonexistent", &graph)); + } +} diff --git a/crates/erp-workflow/src/engine/expression.rs b/crates/erp-workflow/src/engine/expression.rs new file mode 100644 index 0000000..4620b17 --- /dev/null +++ b/crates/erp-workflow/src/engine/expression.rs @@ -0,0 +1,431 @@ +use std::collections::HashMap; + +use crate::error::{WorkflowError, WorkflowResult}; + +/// 简单表达式求值器。 +/// +/// 支持的比较运算符:>, >=, <, <=, ==, != +/// 支持 && 和 || 逻辑运算。 +/// 操作数可以是变量名(从 variables map 查找)或字面量(数字、字符串)。 +/// +/// 示例: +/// - `amount > 1000` +/// - `status == "approved"` +/// - `score >= 60 && attendance > 80` +pub struct ExpressionEvaluator; + +impl ExpressionEvaluator { + /// 求值单个条件表达式。 + /// + /// 表达式格式: `{left} {op} {right}` 或复合表达式 `{expr1} && {expr2}` + pub fn eval( + expr: &str, + variables: &HashMap, + ) -> WorkflowResult { + let expr = expr.trim(); + + // 处理逻辑 OR + if let Some(idx) = Self::find_logical_op(expr, "||") { + let left = &expr[..idx]; + let right = &expr[idx + 2..]; + return Ok(Self::eval(left, variables)? || Self::eval(right, variables)?); + } + + // 处理逻辑 AND + if let Some(idx) = Self::find_logical_op(expr, "&&") { + let left = &expr[..idx]; + let right = &expr[idx + 2..]; + return Ok(Self::eval(left, variables)? && Self::eval(right, variables)?); + } + + // 处理单个比较表达式 + Self::eval_comparison(expr, variables) + } + + /// 查找逻辑运算符位置,跳过引号内的内容。 + fn find_logical_op(expr: &str, op: &str) -> Option { + let mut in_string = false; + let mut string_char = ' '; + let chars: Vec = expr.chars().collect(); + let op_chars: Vec = op.chars().collect(); + let op_len = op_chars.len(); + + for i in 0..chars.len().saturating_sub(op_len - 1) { + let c = chars[i]; + + if !in_string && (c == '"' || c == '\'') { + in_string = true; + string_char = c; + continue; + } + if in_string && c == string_char { + in_string = false; + continue; + } + + if in_string { + continue; + } + + if chars[i..].starts_with(&op_chars) { + return Some(i); + } + } + None + } + + /// 求值单个比较表达式。 + fn eval_comparison( + expr: &str, + variables: &HashMap, + ) -> WorkflowResult { + let operators = [">=", "<=", "!=", "==", ">", "<"]; + + for op in &operators { + if let Some(idx) = Self::find_comparison_op(expr, op) { + let left = expr[..idx].trim(); + let right = expr[idx + op.len()..].trim(); + + let left_val = Self::resolve_value(left, variables)?; + let right_val = Self::resolve_value(right, variables)?; + + return Self::compare(&left_val, &right_val, op); + } + } + + Err(WorkflowError::ExpressionError(format!( + "无法解析表达式: '{}'", + expr + ))) + } + + /// 查找比较运算符位置,跳过引号内的内容。 + fn find_comparison_op(expr: &str, op: &str) -> Option { + let mut in_string = false; + let mut string_char = ' '; + let bytes = expr.as_bytes(); + let op_bytes = op.as_bytes(); + let op_len = op_bytes.len(); + + for i in 0..bytes.len().saturating_sub(op_len - 1) { + let c = bytes[i] as char; + + if !in_string && (c == '"' || c == '\'') { + in_string = true; + string_char = c; + continue; + } + if in_string && c == string_char { + in_string = false; + continue; + } + + if in_string { + continue; + } + + if bytes[i..].starts_with(op_bytes) { + // 确保不是被嵌在其他运算符里(如 != 中的 =) + // 对于 > 和 < 检查后面不是 = 或 > + if op == ">" || op == "<" { + if i + op_len < bytes.len() { + let next = bytes[i + op_len] as char; + if next == '=' || (op == ">" && next == '>') { + continue; + } + } + // 也检查前面不是 ! 或 = 或 < 或 > + if i > 0 { + let prev = bytes[i - 1] as char; + if prev == '!' || prev == '=' || prev == '<' || prev == '>' { + continue; + } + } + } + // 对于 ==, >=, <=, != 确保前面不是 ! 或 = (避免匹配到 == 中的第二个 =) + // 这已经通过从长到短匹配处理了 + return Some(i); + } + } + None + } + + /// 解析值:字符串字面量、数字字面量或变量引用。 + fn resolve_value( + token: &str, + variables: &HashMap, + ) -> WorkflowResult { + let token = token.trim(); + + // 字符串字面量 + if (token.starts_with('"') && token.ends_with('"')) + || (token.starts_with('\'') && token.ends_with('\'')) + { + return Ok(serde_json::Value::String( + token[1..token.len() - 1].to_string(), + )); + } + + // 数字字面量 + if let Ok(n) = token.parse::() { + return Ok(serde_json::Value::Number(n.into())); + } + if let Ok(f) = token.parse::() + && let Some(n) = serde_json::Number::from_f64(f) + { + return Ok(serde_json::Value::Number(n)); + } + + // 布尔字面量 + if token == "true" { + return Ok(serde_json::Value::Bool(true)); + } + if token == "false" { + return Ok(serde_json::Value::Bool(false)); + } + + // 变量引用 + if let Some(val) = variables.get(token) { + return Ok(val.clone()); + } + + Err(WorkflowError::ExpressionError(format!( + "未知的变量或值: '{}'", + token + ))) + } + + /// 比较两个 JSON 值。 + fn compare( + left: &serde_json::Value, + right: &serde_json::Value, + op: &str, + ) -> WorkflowResult { + match op { + "==" => Ok(Self::values_equal(left, right)), + "!=" => Ok(!Self::values_equal(left, right)), + ">" => Ok(Self::values_compare(left, right)? == std::cmp::Ordering::Greater), + ">=" => Ok(Self::values_compare(left, right)? != std::cmp::Ordering::Less), + "<" => Ok(Self::values_compare(left, right)? == std::cmp::Ordering::Less), + "<=" => Ok(Self::values_compare(left, right)? != std::cmp::Ordering::Greater), + _ => Err(WorkflowError::ExpressionError(format!( + "不支持的比较运算符: '{}'", + op + ))), + } + } + + fn values_equal(left: &serde_json::Value, right: &serde_json::Value) -> bool { + // 数值比较:允许整数和浮点数互比 + if left.is_number() && right.is_number() { + return left.as_f64() == right.as_f64(); + } + left == right + } + + fn values_compare( + left: &serde_json::Value, + right: &serde_json::Value, + ) -> WorkflowResult { + if left.is_number() && right.is_number() { + let l = left.as_f64().unwrap_or(0.0); + let r = right.as_f64().unwrap_or(0.0); + return Ok(l.partial_cmp(&r).unwrap_or(std::cmp::Ordering::Equal)); + } + + if let (Some(l), Some(r)) = (left.as_str(), right.as_str()) { + return Ok(l.cmp(r)); + } + + Err(WorkflowError::ExpressionError(format!( + "无法比较 {:?} 和 {:?}", + left, right + ))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn make_vars() -> HashMap { + let mut m = HashMap::new(); + m.insert("amount".to_string(), json!(1500)); + m.insert("status".to_string(), json!("approved")); + m.insert("score".to_string(), json!(85)); + m.insert("name".to_string(), json!("Alice")); + m.insert("active".to_string(), json!(true)); + m + } + + #[test] + fn test_number_greater_than() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval("amount > 1000", &vars).unwrap()); + assert!(!ExpressionEvaluator::eval("amount > 2000", &vars).unwrap()); + } + + #[test] + fn test_number_less_than() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval("amount < 2000", &vars).unwrap()); + assert!(!ExpressionEvaluator::eval("amount < 1000", &vars).unwrap()); + } + + #[test] + fn test_number_equals() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval("amount == 1500", &vars).unwrap()); + assert!(!ExpressionEvaluator::eval("amount == 1000", &vars).unwrap()); + } + + #[test] + fn test_string_equals() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval("status == \"approved\"", &vars).unwrap()); + assert!(!ExpressionEvaluator::eval("status == \"rejected\"", &vars).unwrap()); + } + + #[test] + fn test_string_not_equals() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval("status != \"rejected\"", &vars).unwrap()); + } + + #[test] + fn test_greater_or_equal() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval("amount >= 1500", &vars).unwrap()); + assert!(ExpressionEvaluator::eval("amount >= 1000", &vars).unwrap()); + assert!(!ExpressionEvaluator::eval("amount >= 2000", &vars).unwrap()); + } + + #[test] + fn test_logical_and() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval("amount > 1000 && score > 80", &vars).unwrap()); + assert!(!ExpressionEvaluator::eval("amount > 2000 && score > 80", &vars).unwrap()); + } + + #[test] + fn test_logical_or() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval("amount > 2000 || score > 80", &vars).unwrap()); + assert!(!ExpressionEvaluator::eval("amount > 2000 || score > 90", &vars).unwrap()); + } + + #[test] + fn test_unknown_variable() { + let vars = make_vars(); + let result = ExpressionEvaluator::eval("unknown > 0", &vars); + assert!(result.is_err()); + } + + #[test] + fn test_invalid_expression() { + let vars = make_vars(); + let result = ExpressionEvaluator::eval("justavariable", &vars); + assert!(result.is_err()); + } + + // ---- 扩展边界测试 ---- + + #[test] + fn test_less_or_equal() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval("amount <= 1500", &vars).unwrap()); + assert!(ExpressionEvaluator::eval("amount <= 2000", &vars).unwrap()); + assert!(!ExpressionEvaluator::eval("amount <= 1000", &vars).unwrap()); + } + + #[test] + fn test_boolean_equality() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval("active == true", &vars).unwrap()); + assert!(!ExpressionEvaluator::eval("active == false", &vars).unwrap()); + } + + #[test] + fn test_string_literal_with_single_quotes() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval("status == 'approved'", &vars).unwrap()); + } + + #[test] + fn test_float_comparison() { + let mut vars = HashMap::new(); + vars.insert("temperature".to_string(), json!(36.5)); + assert!(ExpressionEvaluator::eval("temperature >= 36.0", &vars).unwrap()); + assert!(ExpressionEvaluator::eval("temperature < 37.0", &vars).unwrap()); + assert!(!ExpressionEvaluator::eval("temperature > 37.5", &vars).unwrap()); + } + + #[test] + fn test_integer_float_cross_comparison() { + let mut vars = HashMap::new(); + vars.insert("val".to_string(), json!(10)); + assert!(ExpressionEvaluator::eval("val == 10.0", &vars).unwrap()); + assert!(ExpressionEvaluator::eval("val >= 9.5", &vars).unwrap()); + } + + #[test] + fn test_string_comparison_lexicographic() { + let vars = make_vars(); + // name = "Alice" + assert!(!ExpressionEvaluator::eval("name > \"Alice\"", &vars).unwrap()); + assert!(ExpressionEvaluator::eval("name >= \"Alice\"", &vars).unwrap()); + assert!(ExpressionEvaluator::eval("name < \"Bob\"", &vars).unwrap()); + assert!(ExpressionEvaluator::eval("name == \"Alice\"", &vars).unwrap()); + } + + #[test] + fn test_compound_and_or() { + let vars = make_vars(); + // amount > 1000 (true) && score > 80 (true) || amount > 3000 (false) + // OR 先绑定,所以是 amount>1000 && score>80 (true) || amount>3000 (false) + // 注意:当前实现从左到右先匹配 ||,所以实际解析为: + // amount > 1000 && score > 80 || amount > 3000 + // find_logical_op 先找 || → split at || + // left = "amount > 1000 && score > 80", right = "amount > 3000" + // left = true, right = false → true || false = true + assert!( + ExpressionEvaluator::eval("amount > 1000 && score > 80 || amount > 3000", &vars) + .unwrap() + ); + } + + #[test] + fn test_compound_all_false() { + let vars = make_vars(); + assert!(!ExpressionEvaluator::eval("amount > 2000 && score > 90", &vars).unwrap()); + } + + #[test] + fn test_number_not_equals() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval("amount != 1000", &vars).unwrap()); + assert!(!ExpressionEvaluator::eval("amount != 1500", &vars).unwrap()); + } + + #[test] + fn test_expression_with_extra_whitespace() { + let vars = make_vars(); + assert!(ExpressionEvaluator::eval(" amount > 1000 ", &vars).unwrap()); + } + + #[test] + fn test_unresolvable_variable_returns_error() { + let vars = HashMap::new(); + let result = ExpressionEvaluator::eval("missing_var > 10", &vars); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("未知的变量")); + } + + #[test] + fn test_empty_expression_returns_error() { + let vars = HashMap::new(); + let result = ExpressionEvaluator::eval("", &vars); + assert!(result.is_err()); + } +} diff --git a/crates/erp-workflow/src/engine/mod.rs b/crates/erp-workflow/src/engine/mod.rs new file mode 100644 index 0000000..6d9b996 --- /dev/null +++ b/crates/erp-workflow/src/engine/mod.rs @@ -0,0 +1,5 @@ +pub mod executor; +pub mod expression; +pub mod model; +pub mod parser; +pub mod timeout; diff --git a/crates/erp-workflow/src/engine/model.rs b/crates/erp-workflow/src/engine/model.rs new file mode 100644 index 0000000..8b68434 --- /dev/null +++ b/crates/erp-workflow/src/engine/model.rs @@ -0,0 +1,385 @@ +use std::collections::HashMap; + +use crate::dto::{EdgeDef, NodeDef, NodeType}; + +/// 内存中的流程图模型,用于执行引擎。 +#[derive(Debug, Clone)] +pub struct FlowGraph { + /// node_id → FlowNode + pub nodes: HashMap, + /// edge_id → FlowEdge + pub edges: HashMap, + /// node_id → 从该节点出发的边列表 + pub outgoing: HashMap>, + /// node_id → 到达该节点的边列表 + pub incoming: HashMap>, + /// StartEvent 的 node_id + pub start_node_id: Option, + /// 所有 EndEvent 的 node_id + pub end_node_ids: Vec, +} + +/// 内存中的节点模型。 +#[derive(Debug, Clone)] +pub struct FlowNode { + pub id: String, + pub node_type: NodeType, + pub name: String, + pub assignee_id: Option, + pub candidate_groups: Option>, + pub service_type: Option, + pub service_config: Option, +} + +/// 内存中的边模型。 +#[derive(Debug, Clone)] +pub struct FlowEdge { + pub id: String, + pub source: String, + pub target: String, + pub condition: Option, + pub label: Option, +} + +impl FlowGraph { + /// 从 DTO 节点和边列表构建 FlowGraph。 + pub fn build(nodes: &[NodeDef], edges: &[EdgeDef]) -> Self { + let mut graph = FlowGraph { + nodes: HashMap::new(), + edges: HashMap::new(), + outgoing: HashMap::new(), + incoming: HashMap::new(), + start_node_id: None, + end_node_ids: Vec::new(), + }; + + for n in nodes { + let flow_node = FlowNode { + id: n.id.clone(), + node_type: n.node_type.clone(), + name: n.name.clone(), + assignee_id: n.assignee_id, + candidate_groups: n.candidate_groups.clone(), + service_type: n.service_type.clone(), + service_config: n.service_config.clone(), + }; + + if n.node_type == NodeType::StartEvent { + graph.start_node_id = Some(n.id.clone()); + } + if n.node_type == NodeType::EndEvent { + graph.end_node_ids.push(n.id.clone()); + } + + graph.nodes.insert(n.id.clone(), flow_node); + graph.outgoing.insert(n.id.clone(), Vec::new()); + graph.incoming.insert(n.id.clone(), Vec::new()); + } + + for e in edges { + graph.edges.insert( + e.id.clone(), + FlowEdge { + id: e.id.clone(), + source: e.source.clone(), + target: e.target.clone(), + condition: e.condition.clone(), + label: e.label.clone(), + }, + ); + + if let Some(out) = graph.outgoing.get_mut(&e.source) { + out.push(e.id.clone()); + } + if let Some(inc) = graph.incoming.get_mut(&e.target) { + inc.push(e.id.clone()); + } + } + + graph + } + + /// 获取节点的出边。 + pub fn get_outgoing_edges(&self, node_id: &str) -> Vec<&FlowEdge> { + self.outgoing + .get(node_id) + .map(|edge_ids| { + edge_ids + .iter() + .filter_map(|eid| self.edges.get(eid)) + .collect() + }) + .unwrap_or_default() + } + + /// 获取节点的入边。 + pub fn get_incoming_edges(&self, node_id: &str) -> Vec<&FlowEdge> { + self.incoming + .get(node_id) + .map(|edge_ids| { + edge_ids + .iter() + .filter_map(|eid| self.edges.get(eid)) + .collect() + }) + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::{EdgeDef, NodeDef, NodeType}; + + fn make_node(id: &str, node_type: NodeType) -> NodeDef { + NodeDef { + id: id.to_string(), + node_type, + name: id.to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + } + } + + fn make_edge(id: &str, source: &str, target: &str) -> EdgeDef { + EdgeDef { + id: id.to_string(), + source: source.to_string(), + target: target.to_string(), + condition: None, + label: None, + } + } + + // ---- build ---- + + #[test] + fn build_empty_graph() { + let graph = FlowGraph::build(&[], &[]); + assert!(graph.nodes.is_empty()); + assert!(graph.edges.is_empty()); + assert!(graph.start_node_id.is_none()); + assert!(graph.end_node_ids.is_empty()); + } + + #[test] + fn build_identifies_start_and_end() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("task1", NodeType::UserTask), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![ + make_edge("e1", "start", "task1"), + make_edge("e2", "task1", "end"), + ]; + + let graph = FlowGraph::build(&nodes, &edges); + + assert_eq!(graph.start_node_id, Some("start".to_string())); + assert_eq!(graph.end_node_ids, vec!["end".to_string()]); + assert_eq!(graph.nodes.len(), 3); + assert_eq!(graph.edges.len(), 2); + } + + #[test] + fn build_multiple_end_events() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("gw", NodeType::ExclusiveGateway), + make_node("end1", NodeType::EndEvent), + make_node("end2", NodeType::EndEvent), + ]; + let edges = vec![ + make_edge("e1", "start", "gw"), + make_edge("e2", "gw", "end1"), + make_edge("e3", "gw", "end2"), + ]; + + let graph = FlowGraph::build(&nodes, &edges); + + assert_eq!(graph.end_node_ids.len(), 2); + assert!(graph.end_node_ids.contains(&"end1".to_string())); + assert!(graph.end_node_ids.contains(&"end2".to_string())); + } + + #[test] + fn build_copies_node_properties() { + let user_id = { + let ts = uuid::Timestamp::now(uuid::NoContext); + uuid::Uuid::new_v7(ts) + }; + let nodes = vec![NodeDef { + id: "task".to_string(), + node_type: NodeType::UserTask, + name: "审批".to_string(), + assignee_id: Some(user_id), + candidate_groups: Some(vec!["managers".to_string()]), + service_type: None, + service_config: None, + position: None, + }]; + let graph = FlowGraph::build(&nodes, &[]); + + let node = graph.nodes.get("task").unwrap(); + assert_eq!(node.name, "审批"); + assert_eq!(node.assignee_id, Some(user_id)); + assert_eq!(node.candidate_groups, Some(vec!["managers".to_string()])); + } + + #[test] + fn build_edge_with_condition_and_label() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![EdgeDef { + id: "e1".to_string(), + source: "start".to_string(), + target: "end".to_string(), + condition: Some("amount > 1000".to_string()), + label: Some("高额审批".to_string()), + }]; + + let graph = FlowGraph::build(&nodes, &edges); + let edge = graph.edges.get("e1").unwrap(); + assert_eq!(edge.condition, Some("amount > 1000".to_string())); + assert_eq!(edge.label, Some("高额审批".to_string())); + } + + #[test] + fn build_edge_to_unknown_node_still_recorded() { + let nodes = vec![make_node("start", NodeType::StartEvent)]; + // edge target "missing" 不在 nodes 中,但 edge 仍被记录 + let edges = vec![make_edge("e1", "start", "missing")]; + let graph = FlowGraph::build(&nodes, &edges); + + assert_eq!(graph.edges.len(), 1); + // outgoing 为 start 有 e1,但 incoming["missing"] 不存在 + assert_eq!(graph.outgoing.get("start").unwrap().len(), 1); + assert!(graph.incoming.get("missing").is_none()); + } + + // ---- get_outgoing_edges ---- + + #[test] + fn outgoing_edges_for_known_node() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![make_edge("e1", "start", "end")]; + let graph = FlowGraph::build(&nodes, &edges); + + let out = graph.get_outgoing_edges("start"); + assert_eq!(out.len(), 1); + assert_eq!(out[0].target, "end"); + } + + #[test] + fn outgoing_edges_for_unknown_node_empty() { + let graph = FlowGraph::build(&[], &[]); + assert!(graph.get_outgoing_edges("nonexistent").is_empty()); + } + + #[test] + fn outgoing_edges_parallel_gateway_fan_out() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("fork", NodeType::ParallelGateway), + make_node("a", NodeType::UserTask), + make_node("b", NodeType::ServiceTask), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![ + make_edge("e1", "start", "fork"), + make_edge("e2", "fork", "a"), + make_edge("e3", "fork", "b"), + ]; + let graph = FlowGraph::build(&nodes, &edges); + + let out = graph.get_outgoing_edges("fork"); + assert_eq!(out.len(), 2); + let targets: Vec<&str> = out.iter().map(|e| e.target.as_str()).collect(); + assert!(targets.contains(&"a")); + assert!(targets.contains(&"b")); + } + + // ---- get_incoming_edges ---- + + #[test] + fn incoming_edges_for_known_node() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![make_edge("e1", "start", "end")]; + let graph = FlowGraph::build(&nodes, &edges); + + let inc = graph.get_incoming_edges("end"); + assert_eq!(inc.len(), 1); + assert_eq!(inc[0].source, "start"); + } + + #[test] + fn incoming_edges_for_unknown_node_empty() { + let graph = FlowGraph::build(&[], &[]); + assert!(graph.get_incoming_edges("nonexistent").is_empty()); + } + + #[test] + fn incoming_edges_join_gateway() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("a", NodeType::UserTask), + make_node("b", NodeType::ServiceTask), + make_node("join", NodeType::ParallelGateway), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![ + make_edge("e1", "start", "a"), + make_edge("e2", "start", "b"), + make_edge("e3", "a", "join"), + make_edge("e4", "b", "join"), + make_edge("e5", "join", "end"), + ]; + let graph = FlowGraph::build(&nodes, &edges); + + let inc = graph.get_incoming_edges("join"); + assert_eq!(inc.len(), 2); + let sources: Vec<&str> = inc.iter().map(|e| e.source.as_str()).collect(); + assert!(sources.contains(&"a")); + assert!(sources.contains(&"b")); + } + + // ---- start/end 节点无入/出边 ---- + + #[test] + fn start_node_has_no_incoming() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![make_edge("e1", "start", "end")]; + let graph = FlowGraph::build(&nodes, &edges); + + assert!(graph.get_incoming_edges("start").is_empty()); + assert_eq!(graph.get_outgoing_edges("start").len(), 1); + } + + #[test] + fn end_node_has_no_outgoing() { + let nodes = vec![ + make_node("start", NodeType::StartEvent), + make_node("end", NodeType::EndEvent), + ]; + let edges = vec![make_edge("e1", "start", "end")]; + let graph = FlowGraph::build(&nodes, &edges); + + assert!(graph.get_outgoing_edges("end").is_empty()); + assert_eq!(graph.get_incoming_edges("end").len(), 1); + } +} diff --git a/crates/erp-workflow/src/engine/parser.rs b/crates/erp-workflow/src/engine/parser.rs new file mode 100644 index 0000000..0f01f47 --- /dev/null +++ b/crates/erp-workflow/src/engine/parser.rs @@ -0,0 +1,491 @@ +use crate::dto::{EdgeDef, NodeDef, NodeType}; +use crate::engine::model::FlowGraph; +use crate::error::{WorkflowError, WorkflowResult}; + +/// 解析节点和边列表为 FlowGraph 并验证合法性。 +pub fn parse_and_validate(nodes: &[NodeDef], edges: &[EdgeDef]) -> WorkflowResult { + // 基本检查:至少有一个节点 + if nodes.is_empty() { + return Err(WorkflowError::InvalidDiagram("流程图不能为空".to_string())); + } + + // 检查恰好 1 个 StartEvent + let start_count = nodes + .iter() + .filter(|n| n.node_type == NodeType::StartEvent) + .count(); + if start_count == 0 { + return Err(WorkflowError::InvalidDiagram( + "流程图必须包含一个开始事件".to_string(), + )); + } + if start_count > 1 { + return Err(WorkflowError::InvalidDiagram( + "流程图只能包含一个开始事件".to_string(), + )); + } + + // 检查至少 1 个 EndEvent + let end_count = nodes + .iter() + .filter(|n| n.node_type == NodeType::EndEvent) + .count(); + if end_count == 0 { + return Err(WorkflowError::InvalidDiagram( + "流程图必须包含至少一个结束事件".to_string(), + )); + } + + // 检查节点 ID 唯一性 + let node_ids: std::collections::HashSet<&str> = nodes.iter().map(|n| n.id.as_str()).collect(); + if node_ids.len() != nodes.len() { + return Err(WorkflowError::InvalidDiagram( + "节点 ID 不能重复".to_string(), + )); + } + + // 检查边引用的节点存在 + for e in edges { + if !node_ids.contains(e.source.as_str()) { + return Err(WorkflowError::InvalidDiagram(format!( + "连线 {} 的源节点 {} 不存在", + e.id, e.source + ))); + } + if !node_ids.contains(e.target.as_str()) { + return Err(WorkflowError::InvalidDiagram(format!( + "连线 {} 的目标节点 {} 不存在", + e.id, e.target + ))); + } + } + + // 构建图 + let graph = FlowGraph::build(nodes, edges); + + // 检查 StartEvent 没有入边 + if let Some(start_id) = &graph.start_node_id { + if !graph.get_incoming_edges(start_id).is_empty() { + return Err(WorkflowError::InvalidDiagram( + "开始事件不能有入边".to_string(), + )); + } + if graph.get_outgoing_edges(start_id).is_empty() { + return Err(WorkflowError::InvalidDiagram( + "开始事件必须有出边".to_string(), + )); + } + } + + // 检查 EndEvent 没有出边 + for end_id in &graph.end_node_ids { + if !graph.get_outgoing_edges(end_id).is_empty() { + return Err(WorkflowError::InvalidDiagram( + "结束事件不能有出边".to_string(), + )); + } + } + + // 检查网关至少有一个入边和一个出边(排除 start/end) + for node in nodes { + match &node.node_type { + NodeType::ExclusiveGateway | NodeType::ParallelGateway => { + let inc = graph.get_incoming_edges(&node.id); + let out = graph.get_outgoing_edges(&node.id); + if inc.is_empty() { + return Err(WorkflowError::InvalidDiagram(format!( + "网关 '{}' 必须有至少一条入边", + node.name + ))); + } + if out.is_empty() { + return Err(WorkflowError::InvalidDiagram(format!( + "网关 '{}' 必须有至少一条出边", + node.name + ))); + } + // 排他网关的出边应该有条件(第一条可以无条件作为默认分支) + if node.node_type == NodeType::ExclusiveGateway && out.len() > 1 { + let with_condition: Vec<_> = + out.iter().filter(|e| e.condition.is_some()).collect(); + if with_condition.is_empty() { + return Err(WorkflowError::InvalidDiagram(format!( + "排他网关 '{}' 有多条出边但没有条件表达式", + node.name + ))); + } + } + } + _ => {} + } + } + + Ok(graph) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dto::NodePosition; + + fn make_start() -> NodeDef { + NodeDef { + id: "start".to_string(), + node_type: NodeType::StartEvent, + name: "开始".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: Some(NodePosition { x: 100.0, y: 100.0 }), + } + } + + fn make_end() -> NodeDef { + NodeDef { + id: "end".to_string(), + node_type: NodeType::EndEvent, + name: "结束".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: Some(NodePosition { x: 100.0, y: 300.0 }), + } + } + + fn make_user_task(id: &str, name: &str) -> NodeDef { + NodeDef { + id: id.to_string(), + node_type: NodeType::UserTask, + name: name.to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + } + } + + fn make_edge(id: &str, source: &str, target: &str) -> EdgeDef { + EdgeDef { + id: id.to_string(), + source: source.to_string(), + target: target.to_string(), + condition: None, + label: None, + } + } + + #[test] + fn test_valid_linear_flow() { + let nodes = vec![make_start(), make_user_task("task1", "审批"), make_end()]; + let edges = vec![ + make_edge("e1", "start", "task1"), + make_edge("e2", "task1", "end"), + ]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_ok()); + let graph = result.unwrap(); + assert_eq!(graph.start_node_id, Some("start".to_string())); + assert_eq!(graph.end_node_ids, vec!["end".to_string()]); + } + + #[test] + fn test_no_start_event() { + let nodes = vec![make_user_task("task1", "审批"), make_end()]; + let edges = vec![make_edge("e1", "task1", "end")]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("开始事件")); + } + + #[test] + fn test_no_end_event() { + let nodes = vec![make_start(), make_user_task("task1", "审批")]; + let edges = vec![make_edge("e1", "start", "task1")]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("结束事件")); + } + + #[test] + fn test_duplicate_node_id() { + let nodes = vec![ + make_start(), + NodeDef { + id: "start".to_string(), // 重复 ID + node_type: NodeType::EndEvent, + name: "结束".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + ]; + let edges = vec![]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + } + + #[test] + fn test_end_event_with_outgoing() { + let nodes = vec![make_start(), make_end()]; + let edges = vec![ + make_edge("e1", "start", "end"), + make_edge("e2", "end", "start"), // 结束事件有出边 + ]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + } + + #[test] + fn test_exclusive_gateway_without_conditions() { + let nodes = vec![ + make_start(), + NodeDef { + id: "gw1".to_string(), + node_type: NodeType::ExclusiveGateway, + name: "判断".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + make_end(), + ]; + let edges = vec![ + make_edge("e1", "start", "gw1"), + make_edge("e2", "gw1", "end"), + make_edge("e3", "gw1", "end"), // 两条出边无条件 + ]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + } + + // ---- 边界测试扩展 ---- + + #[test] + fn test_empty_nodes_rejected() { + let result = parse_and_validate(&[], &[]); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("不能为空")); + } + + #[test] + fn test_multiple_start_events_rejected() { + let nodes = vec![ + make_start(), + NodeDef { + id: "start2".to_string(), + node_type: NodeType::StartEvent, + name: "开始2".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + make_end(), + ]; + let edges = vec![ + make_edge("e1", "start", "end"), + make_edge("e2", "start2", "end"), + ]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("只能包含一个开始事件")); + } + + #[test] + fn test_edge_references_nonexistent_source() { + let nodes = vec![make_start(), make_end()]; + let edges = vec![make_edge("e1", "ghost", "end")]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("源节点") && msg.contains("不存在")); + } + + #[test] + fn test_edge_references_nonexistent_target() { + let nodes = vec![make_start(), make_end()]; + let edges = vec![make_edge("e1", "start", "ghost")]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("目标节点") && msg.contains("不存在")); + } + + #[test] + fn test_start_event_with_incoming_edge_rejected() { + let nodes = vec![make_start(), make_end()]; + let edges = vec![ + make_edge("e1", "start", "end"), + make_edge("e2", "end", "start"), // start 有入边 + ]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("入边")); + } + + #[test] + fn test_start_event_without_outgoing_edge_rejected() { + let nodes = vec![make_start(), make_end()]; + let edges = vec![]; // start 没有出边 + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("出边")); + } + + #[test] + fn test_exclusive_gateway_single_outgoing_ok() { + // 单条出边的排他网关不需要条件 + let nodes = vec![ + make_start(), + NodeDef { + id: "gw".to_string(), + node_type: NodeType::ExclusiveGateway, + name: "判断".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + make_end(), + ]; + let edges = vec![make_edge("e1", "start", "gw"), make_edge("e2", "gw", "end")]; + assert!(parse_and_validate(&nodes, &edges).is_ok()); + } + + #[test] + fn test_exclusive_gateway_with_conditions_ok() { + let nodes = vec![ + make_start(), + NodeDef { + id: "gw".to_string(), + node_type: NodeType::ExclusiveGateway, + name: "金额判断".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + make_user_task("task_a", "小额审批"), + make_user_task("task_b", "大额审批"), + make_end(), + ]; + let edges = vec![ + make_edge("e1", "start", "gw"), + EdgeDef { + id: "e2".to_string(), + source: "gw".to_string(), + target: "task_a".to_string(), + condition: Some("amount <= 1000".to_string()), + label: None, + }, + EdgeDef { + id: "e3".to_string(), + source: "gw".to_string(), + target: "task_b".to_string(), + condition: Some("amount > 1000".to_string()), + label: None, + }, + make_edge("e4", "task_a", "end"), + make_edge("e5", "task_b", "end"), + ]; + assert!(parse_and_validate(&nodes, &edges).is_ok()); + } + + #[test] + fn test_gateway_without_incoming_rejected() { + let nodes = vec![ + make_start(), + NodeDef { + id: "gw".to_string(), + node_type: NodeType::ParallelGateway, + name: "并行".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + make_end(), + ]; + // gw 没有入边 + let edges = vec![ + make_edge("e1", "start", "end"), + make_edge("e2", "gw", "end"), + ]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("入边")); + } + + #[test] + fn test_gateway_without_outgoing_rejected() { + let nodes = vec![ + make_start(), + NodeDef { + id: "gw".to_string(), + node_type: NodeType::ParallelGateway, + name: "并行".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + make_end(), + ]; + // gw 没有出边 + let edges = vec![ + make_edge("e1", "start", "gw"), + make_edge("e2", "start", "end"), + ]; + let result = parse_and_validate(&nodes, &edges); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("出边")); + } + + #[test] + fn test_parallel_gateway_valid() { + let nodes = vec![ + make_start(), + NodeDef { + id: "fork".to_string(), + node_type: NodeType::ParallelGateway, + name: "拆分".to_string(), + assignee_id: None, + candidate_groups: None, + service_type: None, + service_config: None, + position: None, + }, + make_user_task("a", "任务A"), + make_user_task("b", "任务B"), + make_end(), + ]; + let edges = vec![ + make_edge("e1", "start", "fork"), + make_edge("e2", "fork", "a"), + make_edge("e3", "fork", "b"), + make_edge("e4", "a", "end"), + make_edge("e5", "b", "end"), + ]; + assert!(parse_and_validate(&nodes, &edges).is_ok()); + } +} diff --git a/crates/erp-workflow/src/engine/timeout.rs b/crates/erp-workflow/src/engine/timeout.rs new file mode 100644 index 0000000..235a3a6 --- /dev/null +++ b/crates/erp-workflow/src/engine/timeout.rs @@ -0,0 +1,77 @@ +// 超时检查框架 +// +// TimeoutChecker 定期扫描 tasks 表中已超时但仍处于 pending 状态的任务, +// 发布 task.timeout 事件用于升级通知。 + +use chrono::Utc; +use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; +use uuid::Uuid; + +use crate::entity::task; +use crate::error::WorkflowResult; + +/// 超时检查服务。 +pub struct TimeoutChecker; + +impl TimeoutChecker { + /// 查询指定租户下已超时但未完成的任务列表。 + /// + /// 返回 due_date < now 且 status = 'pending' 的任务 ID。 + pub async fn find_overdue_tasks( + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult> { + let now = Utc::now(); + let overdue = task::Entity::find() + .filter(task::Column::TenantId.eq(tenant_id)) + .filter(task::Column::Status.eq("pending")) + .filter(task::Column::DueDate.lt(now)) + .filter(task::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| crate::error::WorkflowError::Validation(e.to_string()))?; + + Ok(overdue.iter().map(|t| t.id).collect()) + } + + /// 查询所有租户中已超时但未完成的任务列表。 + /// + /// 返回 due_date < now 且 status = 'pending' 的任务 ID。 + /// 用于后台定时任务的全量扫描。 + pub async fn find_all_overdue_tasks( + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult> { + let now = Utc::now(); + let overdue = task::Entity::find() + .filter(task::Column::Status.eq("pending")) + .filter(task::Column::DueDate.lt(now)) + .filter(task::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| crate::error::WorkflowError::Validation(e.to_string()))?; + + Ok(overdue.iter().map(|t| t.id).collect()) + } + + /// 查询所有租户中已超时的任务(含详细信息)。 + /// + /// 返回 (task_id, tenant_id, instance_id, assignee_id) 元组, + /// 用于发布 task.timeout 事件。 + pub async fn find_all_overdue_tasks_with_details( + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult)>> { + let now = Utc::now(); + let overdue = task::Entity::find() + .filter(task::Column::Status.eq("pending")) + .filter(task::Column::DueDate.lt(now)) + .filter(task::Column::DeletedAt.is_null()) + .all(db) + .await + .map_err(|e| crate::error::WorkflowError::Validation(e.to_string()))?; + + Ok(overdue + .iter() + .map(|t| (t.id, t.tenant_id, t.instance_id, t.assignee_id)) + .collect()) + } +} diff --git a/crates/erp-workflow/src/entity/mod.rs b/crates/erp-workflow/src/entity/mod.rs new file mode 100644 index 0000000..410e4fd --- /dev/null +++ b/crates/erp-workflow/src/entity/mod.rs @@ -0,0 +1,5 @@ +pub mod process_definition; +pub mod process_instance; +pub mod process_variable; +pub mod task; +pub mod token; diff --git a/crates/erp-workflow/src/entity/process_definition.rs b/crates/erp-workflow/src/entity/process_definition.rs new file mode 100644 index 0000000..fb644d6 --- /dev/null +++ b/crates/erp-workflow/src/entity/process_definition.rs @@ -0,0 +1,43 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "process_definitions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub name: String, + pub key: String, + /// 业务版本号(如同一 key 可存在多个版本),当前固定为 1,未来支持发布新版本时递增。 + pub version: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub category: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub nodes: serde_json::Value, + pub edges: serde_json::Value, + pub status: String, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + /// 乐观锁版本号(ERP 标准审计字段),每次更新递增。 + pub version_field: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::process_instance::Entity")] + ProcessInstance, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ProcessInstance.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-workflow/src/entity/process_instance.rs b/crates/erp-workflow/src/entity/process_instance.rs new file mode 100644 index 0000000..49b052f --- /dev/null +++ b/crates/erp-workflow/src/entity/process_instance.rs @@ -0,0 +1,59 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "process_instances")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub definition_id: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub business_key: Option, + pub status: String, + pub started_by: Uuid, + pub started_at: DateTimeUtc, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::process_definition::Entity", + from = "Column::DefinitionId", + to = "super::process_definition::Column::Id" + )] + ProcessDefinition, + #[sea_orm(has_many = "super::token::Entity")] + Token, + #[sea_orm(has_many = "super::task::Entity")] + Task, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ProcessDefinition.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Token.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Task.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-workflow/src/entity/process_variable.rs b/crates/erp-workflow/src/entity/process_variable.rs new file mode 100644 index 0000000..bd113d0 --- /dev/null +++ b/crates/erp-workflow/src/entity/process_variable.rs @@ -0,0 +1,46 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "process_variables")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub instance_id: Uuid, + pub name: String, + pub var_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub value_string: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value_boolean: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value_date: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::process_instance::Entity", + from = "Column::InstanceId", + to = "super::process_instance::Column::Id" + )] + ProcessInstance, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ProcessInstance.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-workflow/src/entity/task.rs b/crates/erp-workflow/src/entity/task.rs new file mode 100644 index 0000000..ddb8c2d --- /dev/null +++ b/crates/erp-workflow/src/entity/task.rs @@ -0,0 +1,65 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "tasks")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub instance_id: Uuid, + pub token_id: Uuid, + pub node_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub node_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub assignee_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub candidate_groups: Option, + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub outcome: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub form_data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub due_date: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::process_instance::Entity", + from = "Column::InstanceId", + to = "super::process_instance::Column::Id" + )] + ProcessInstance, + #[sea_orm( + belongs_to = "super::token::Entity", + from = "Column::TokenId", + to = "super::token::Column::Id" + )] + Token, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ProcessInstance.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Token.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-workflow/src/entity/token.rs b/crates/erp-workflow/src/entity/token.rs new file mode 100644 index 0000000..db2d750 --- /dev/null +++ b/crates/erp-workflow/src/entity/token.rs @@ -0,0 +1,40 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize)] +#[sea_orm(table_name = "tokens")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub tenant_id: Uuid, + pub instance_id: Uuid, + pub node_id: String, + pub status: String, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, + pub created_by: Uuid, + pub updated_by: Uuid, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, + pub version: i32, + #[serde(skip_serializing_if = "Option::is_none")] + pub consumed_at: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::process_instance::Entity", + from = "Column::InstanceId", + to = "super::process_instance::Column::Id" + )] + ProcessInstance, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ProcessInstance.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/erp-workflow/src/error.rs b/crates/erp-workflow/src/error.rs new file mode 100644 index 0000000..d183585 --- /dev/null +++ b/crates/erp-workflow/src/error.rs @@ -0,0 +1,128 @@ +use erp_core::error::AppError; + +/// Workflow module error types. +#[derive(Debug, thiserror::Error)] +pub enum WorkflowError { + #[error("验证失败: {0}")] + Validation(String), + + #[error("资源未找到: {0}")] + NotFound(String), + + #[error("流程定义已存在: {0}")] + DuplicateDefinition(String), + + #[error("流程图无效: {0}")] + InvalidDiagram(String), + + #[error("流程状态错误: {0}")] + InvalidState(String), + + #[error("表达式求值失败: {0}")] + ExpressionError(String), + + #[error("版本冲突: 数据已被其他操作修改,请刷新后重试")] + VersionMismatch, +} + +impl From> for WorkflowError { + fn from(err: sea_orm::TransactionError) -> Self { + match err { + sea_orm::TransactionError::Connection(err) => { + WorkflowError::Validation(err.to_string()) + } + sea_orm::TransactionError::Transaction(inner) => inner, + } + } +} + +impl From for AppError { + fn from(err: WorkflowError) -> Self { + match err { + WorkflowError::Validation(s) => AppError::Validation(s), + WorkflowError::NotFound(s) => AppError::NotFound(s), + WorkflowError::DuplicateDefinition(s) => AppError::Conflict(s), + WorkflowError::InvalidDiagram(s) => AppError::Validation(s), + WorkflowError::InvalidState(s) => AppError::Validation(s), + WorkflowError::ExpressionError(s) => AppError::Validation(s), + WorkflowError::VersionMismatch => AppError::VersionMismatch, + } + } +} + +pub type WorkflowResult = Result; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validation_maps_to_app_error_validation() { + let err = WorkflowError::Validation("字段缺失".to_string()); + let app: AppError = err.into(); + match app { + AppError::Validation(msg) => assert!(msg.contains("字段缺失")), + other => panic!("期望 AppError::Validation,得到 {:?}", other), + } + } + + #[test] + fn not_found_maps_to_app_error_not_found() { + let err = WorkflowError::NotFound("流程不存在".to_string()); + let app: AppError = err.into(); + match app { + AppError::NotFound(msg) => assert!(msg.contains("流程不存在")), + other => panic!("期望 AppError::NotFound,得到 {:?}", other), + } + } + + #[test] + fn duplicate_definition_maps_to_app_error_conflict() { + let err = WorkflowError::DuplicateDefinition("key 已存在".to_string()); + let app: AppError = err.into(); + match app { + AppError::Conflict(msg) => assert!(msg.contains("key 已存在")), + other => panic!("期望 AppError::Conflict,得到 {:?}", other), + } + } + + #[test] + fn invalid_diagram_maps_to_validation() { + let err = WorkflowError::InvalidDiagram("缺少 StartEvent".to_string()); + let app: AppError = err.into(); + match app { + AppError::Validation(msg) => assert!(msg.contains("缺少 StartEvent")), + other => panic!("期望 AppError::Validation,得到 {:?}", other), + } + } + + #[test] + fn invalid_state_maps_to_validation() { + let err = WorkflowError::InvalidState("流程已结束".to_string()); + let app: AppError = err.into(); + match app { + AppError::Validation(msg) => assert!(msg.contains("流程已结束")), + other => panic!("期望 AppError::Validation,得到 {:?}", other), + } + } + + #[test] + fn expression_error_maps_to_validation() { + let err = WorkflowError::ExpressionError("语法错误".to_string()); + let app: AppError = err.into(); + match app { + AppError::Validation(msg) => assert!(msg.contains("语法错误")), + other => panic!("期望 AppError::Validation,得到 {:?}", other), + } + } + + #[test] + fn version_mismatch_maps_directly() { + let err = WorkflowError::VersionMismatch; + let app: AppError = err.into(); + match app { + AppError::VersionMismatch => {} + other => panic!("期望 AppError::VersionMismatch,得到 {:?}", other), + } + } +} diff --git a/crates/erp-workflow/src/handler/definition_handler.rs b/crates/erp-workflow/src/handler/definition_handler.rs new file mode 100644 index 0000000..77e4811 --- /dev/null +++ b/crates/erp-workflow/src/handler/definition_handler.rs @@ -0,0 +1,200 @@ +use axum::Extension; +use axum::extract::{FromRef, Path, Query, State}; +use axum::response::Json; +use validator::Validate; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext}; +use uuid::Uuid; + +use crate::dto::{CreateProcessDefinitionReq, ProcessDefinitionResp, UpdateProcessDefinitionReq}; +use crate::service::definition_service::DefinitionService; +use crate::workflow_state::WorkflowState; + +#[utoipa::path( + get, + path = "/api/v1/workflow/definitions", + params(Pagination), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "流程定义" +)] +/// GET /api/v1/workflow/definitions +pub async fn list_definitions( + State(state): State, + Extension(ctx): Extension, + Query(pagination): Query, +) -> Result>>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.list")?; + + let (defs, total) = DefinitionService::list(ctx.tenant_id, &pagination, &state.db).await?; + + let page = pagination.page.unwrap_or(1); + let page_size = pagination.limit(); + let total_pages = total.div_ceil(page_size); + + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: defs, + total, + page, + page_size, + total_pages, + }))) +} + +#[utoipa::path( + post, + path = "/api/v1/workflow/definitions", + request_body = CreateProcessDefinitionReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "流程定义" +)] +/// POST /api/v1/workflow/definitions +pub async fn create_definition( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.create")?; + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let resp = DefinitionService::create( + ctx.tenant_id, + ctx.user_id, + &req, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + get, + path = "/api/v1/workflow/definitions/{id}", + params(("id" = Uuid, Path, description = "流程定义ID")), + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "流程定义不存在"), + ), + security(("bearer_auth" = [])), + tag = "流程定义" +)] +/// GET /api/v1/workflow/definitions/{id} +pub async fn get_definition( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.read")?; + + let resp = DefinitionService::get_by_id(id, ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + put, + path = "/api/v1/workflow/definitions/{id}", + params(("id" = Uuid, Path, description = "流程定义ID")), + request_body = UpdateProcessDefinitionReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "流程定义不存在"), + ), + security(("bearer_auth" = [])), + tag = "流程定义" +)] +/// PUT /api/v1/workflow/definitions/{id} +pub async fn update_definition( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.update")?; + + let resp = DefinitionService::update(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?; + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + post, + path = "/api/v1/workflow/definitions/{id}/publish", + params(("id" = Uuid, Path, description = "流程定义ID")), + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "流程定义不存在"), + ), + security(("bearer_auth" = [])), + tag = "流程定义" +)] +/// POST /api/v1/workflow/definitions/{id}/publish +pub async fn publish_definition( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.publish")?; + + let resp = + DefinitionService::publish(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +pub async fn deprecate_definition( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.publish")?; + + let resp = + DefinitionService::deprecate(id, ctx.tenant_id, ctx.user_id, &state.db, &state.event_bus) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} diff --git a/crates/erp-workflow/src/handler/instance_handler.rs b/crates/erp-workflow/src/handler/instance_handler.rs new file mode 100644 index 0000000..29343f8 --- /dev/null +++ b/crates/erp-workflow/src/handler/instance_handler.rs @@ -0,0 +1,206 @@ +use axum::Extension; +use axum::extract::{FromRef, Path, Query, State}; +use axum::response::Json; +use validator::Validate; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext}; +use uuid::Uuid; + +use crate::dto::{ProcessInstanceResp, StartInstanceReq}; +use crate::service::instance_service::InstanceService; +use crate::workflow_state::WorkflowState; + +#[utoipa::path( + post, + path = "/api/v1/workflow/instances", + request_body = StartInstanceReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "流程实例" +)] +/// POST /api/v1/workflow/instances +pub async fn start_instance( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> Result>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.start")?; + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let resp = InstanceService::start( + ctx.tenant_id, + ctx.user_id, + &req, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + get, + path = "/api/v1/workflow/instances", + params(Pagination), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "流程实例" +)] +/// GET /api/v1/workflow/instances +pub async fn list_instances( + State(state): State, + Extension(ctx): Extension, + Query(pagination): Query, +) -> Result>>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.list")?; + + let (instances, total) = InstanceService::list(ctx.tenant_id, &pagination, &state.db).await?; + + let page = pagination.page.unwrap_or(1); + let page_size = pagination.limit(); + let total_pages = total.div_ceil(page_size); + + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: instances, + total, + page, + page_size, + total_pages, + }))) +} + +#[utoipa::path( + get, + path = "/api/v1/workflow/instances/{id}", + params(("id" = Uuid, Path, description = "流程实例ID")), + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "流程实例不存在"), + ), + security(("bearer_auth" = [])), + tag = "流程实例" +)] +/// GET /api/v1/workflow/instances/{id} +pub async fn get_instance( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.read")?; + + let resp = InstanceService::get_by_id(id, ctx.tenant_id, &state.db).await?; + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + post, + path = "/api/v1/workflow/instances/{id}/suspend", + params(("id" = Uuid, Path, description = "流程实例ID")), + responses( + (status = 200, description = "成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "流程实例不存在"), + ), + security(("bearer_auth" = [])), + tag = "流程实例" +)] +/// POST /api/v1/workflow/instances/{id}/suspend +pub async fn suspend_instance( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.update")?; + + InstanceService::suspend(id, ctx.tenant_id, ctx.user_id, &state.db).await?; + Ok(Json(ApiResponse::ok(()))) +} + +#[utoipa::path( + post, + path = "/api/v1/workflow/instances/{id}/terminate", + params(("id" = Uuid, Path, description = "流程实例ID")), + responses( + (status = 200, description = "成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "流程实例不存在"), + ), + security(("bearer_auth" = [])), + tag = "流程实例" +)] +/// POST /api/v1/workflow/instances/{id}/terminate +pub async fn terminate_instance( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.update")?; + + InstanceService::terminate(id, ctx.tenant_id, ctx.user_id, &state.db).await?; + Ok(Json(ApiResponse::ok(()))) +} + +#[utoipa::path( + post, + path = "/api/v1/workflow/instances/{id}/resume", + params(("id" = Uuid, Path, description = "流程实例ID")), + responses( + (status = 200, description = "成功"), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "流程实例不存在"), + ), + security(("bearer_auth" = [])), + tag = "流程实例" +)] +/// POST /api/v1/workflow/instances/{id}/resume +pub async fn resume_instance( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.update")?; + + InstanceService::resume(id, ctx.tenant_id, ctx.user_id, &state.db).await?; + Ok(Json(ApiResponse::ok(()))) +} diff --git a/crates/erp-workflow/src/handler/mod.rs b/crates/erp-workflow/src/handler/mod.rs new file mode 100644 index 0000000..72a6d01 --- /dev/null +++ b/crates/erp-workflow/src/handler/mod.rs @@ -0,0 +1,3 @@ +pub mod definition_handler; +pub mod instance_handler; +pub mod task_handler; diff --git a/crates/erp-workflow/src/handler/task_handler.rs b/crates/erp-workflow/src/handler/task_handler.rs new file mode 100644 index 0000000..e76449c --- /dev/null +++ b/crates/erp-workflow/src/handler/task_handler.rs @@ -0,0 +1,199 @@ +use axum::Extension; +use axum::extract::{FromRef, Path, Query, State}; +use axum::response::Json; +use validator::Validate; + +use erp_core::error::AppError; +use erp_core::rbac::require_permission; +use erp_core::types::{ApiResponse, PaginatedResponse, Pagination, TenantContext}; +use uuid::Uuid; + +use crate::dto::{CompleteTaskReq, DelegateTaskReq, TaskResp}; +use crate::service::task_service::TaskService; +use crate::workflow_state::WorkflowState; + +#[utoipa::path( + get, + path = "/api/v1/workflow/tasks/pending", + params(Pagination), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "流程任务" +)] +/// GET /api/v1/workflow/tasks/pending +pub async fn list_pending_tasks( + State(state): State, + Extension(ctx): Extension, + Query(pagination): Query, +) -> Result>>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.approve")?; + + let (tasks, total) = + TaskService::list_pending(ctx.tenant_id, ctx.user_id, &pagination, &state.db).await?; + + let page = pagination.page.unwrap_or(1); + let page_size = pagination.limit(); + let total_pages = total.div_ceil(page_size); + + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: tasks, + total, + page, + page_size, + total_pages, + }))) +} + +#[utoipa::path( + get, + path = "/api/v1/workflow/tasks/completed", + params(Pagination), + responses( + (status = 200, description = "成功", body = ApiResponse>), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + ), + security(("bearer_auth" = [])), + tag = "流程任务" +)] +/// GET /api/v1/workflow/tasks/completed +pub async fn list_completed_tasks( + State(state): State, + Extension(ctx): Extension, + Query(pagination): Query, +) -> Result>>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.approve")?; + + let (tasks, total) = + TaskService::list_completed(ctx.tenant_id, ctx.user_id, &pagination, &state.db).await?; + + let page = pagination.page.unwrap_or(1); + let page_size = pagination.limit(); + let total_pages = total.div_ceil(page_size); + + Ok(Json(ApiResponse::ok(PaginatedResponse { + data: tasks, + total, + page, + page_size, + total_pages, + }))) +} + +#[utoipa::path( + post, + path = "/api/v1/workflow/tasks/{id}/complete", + params(("id" = Uuid, Path, description = "任务ID")), + request_body = CompleteTaskReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "任务不存在"), + ), + security(("bearer_auth" = [])), + tag = "流程任务" +)] +/// POST /api/v1/workflow/tasks/{id}/complete +pub async fn complete_task( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.approve")?; + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let resp = TaskService::complete( + id, + ctx.tenant_id, + ctx.user_id, + &req, + &state.db, + &state.event_bus, + ) + .await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + post, + path = "/api/v1/workflow/tasks/{id}/delegate", + params(("id" = Uuid, Path, description = "任务ID")), + request_body = DelegateTaskReq, + responses( + (status = 200, description = "成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "任务不存在"), + ), + security(("bearer_auth" = [])), + tag = "流程任务" +)] +/// POST /api/v1/workflow/tasks/{id}/delegate +pub async fn delegate_task( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, + Json(req): Json, +) -> Result>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.delegate")?; + req.validate() + .map_err(|e| AppError::Validation(e.to_string()))?; + + let resp = TaskService::delegate(id, ctx.tenant_id, ctx.user_id, &req, &state.db).await?; + + Ok(Json(ApiResponse::ok(resp))) +} + +#[utoipa::path( + put, + path = "/api/v1/workflow/tasks/{id}/claim", + params(("id" = Uuid, Path, description = "任务ID")), + responses( + (status = 200, description = "认领成功", body = ApiResponse), + (status = 401, description = "未授权"), + (status = 403, description = "权限不足"), + (status = 404, description = "任务不存在"), + ), + security(("bearer_auth" = [])), + tag = "流程任务" +)] +/// PUT /api/v1/workflow/tasks/{id}/claim +pub async fn claim_task( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> Result>, AppError> +where + WorkflowState: FromRef, + S: Clone + Send + Sync + 'static, +{ + require_permission(&ctx, "workflow.approve")?; + + let resp = TaskService::claim(id, ctx.tenant_id, ctx.user_id, &state.db).await?; + + Ok(Json(ApiResponse::ok(resp))) +} diff --git a/crates/erp-workflow/src/lib.rs b/crates/erp-workflow/src/lib.rs new file mode 100644 index 0000000..9196dc5 --- /dev/null +++ b/crates/erp-workflow/src/lib.rs @@ -0,0 +1,16 @@ +// erp-workflow: 工作流引擎模块 (Phase 4) +// +// 提供流程定义、流程实例管理、任务审批、Token 驱动执行引擎 +// 和可视化流程设计器支持。 + +pub mod dto; +pub mod engine; +pub mod entity; +pub mod error; +pub mod handler; +pub mod module; +pub mod service; +pub mod workflow_state; + +pub use module::WorkflowModule; +pub use workflow_state::WorkflowState; diff --git a/crates/erp-workflow/src/module.rs b/crates/erp-workflow/src/module.rs new file mode 100644 index 0000000..eb27452 --- /dev/null +++ b/crates/erp-workflow/src/module.rs @@ -0,0 +1,549 @@ +use axum::Router; +use axum::routing::{get, post, put}; +use std::time::Duration; +use uuid::Uuid; + +use erp_core::error::AppResult; +use erp_core::events::EventBus; +use erp_core::module::{ErpModule, PermissionDescriptor}; + +use crate::handler::{definition_handler, instance_handler, task_handler}; + +/// Workflow module implementing the `ErpModule` trait. +/// +/// Manages workflow definitions, process instances, tasks, +/// and the token-driven execution engine. +pub struct WorkflowModule; + +impl WorkflowModule { + pub fn new() -> Self { + Self + } + + /// Build protected (authenticated) routes for the workflow module. + pub fn protected_routes() -> Router + where + crate::workflow_state::WorkflowState: axum::extract::FromRef, + S: Clone + Send + Sync + 'static, + { + Router::new() + // Definition routes + .route( + "/workflow/definitions", + get(definition_handler::list_definitions) + .post(definition_handler::create_definition), + ) + .route( + "/workflow/definitions/{id}", + get(definition_handler::get_definition).put(definition_handler::update_definition), + ) + .route( + "/workflow/definitions/{id}/publish", + post(definition_handler::publish_definition), + ) + .route( + "/workflow/definitions/{id}/deprecate", + post(definition_handler::deprecate_definition), + ) + // Instance routes + .route( + "/workflow/instances", + post(instance_handler::start_instance).get(instance_handler::list_instances), + ) + .route( + "/workflow/instances/{id}", + get(instance_handler::get_instance), + ) + .route( + "/workflow/instances/{id}/suspend", + post(instance_handler::suspend_instance), + ) + .route( + "/workflow/instances/{id}/resume", + post(instance_handler::resume_instance), + ) + .route( + "/workflow/instances/{id}/terminate", + post(instance_handler::terminate_instance), + ) + // Task routes + .route( + "/workflow/tasks/pending", + get(task_handler::list_pending_tasks), + ) + .route( + "/workflow/tasks/completed", + get(task_handler::list_completed_tasks), + ) + .route( + "/workflow/tasks/{id}/complete", + post(task_handler::complete_task), + ) + .route( + "/workflow/tasks/{id}/delegate", + post(task_handler::delegate_task), + ) + .route("/workflow/tasks/{id}/claim", put(task_handler::claim_task)) + } + + /// 启动超时检查后台任务。 + /// + /// 每 60 秒扫描一次 tasks 表,查找 due_date 已过期但仍处于 pending 状态的任务。 + /// 发现超时任务时发布 `task.timeout` 事件到事件总线,并记录 warning 日志。 + pub fn start_timeout_checker(db: sea_orm::DatabaseConnection, event_bus: EventBus) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + + // 首次跳过,等一个完整间隔再执行 + interval.tick().await; + + loop { + interval.tick().await; + + match crate::engine::timeout::TimeoutChecker::find_all_overdue_tasks_with_details( + &db, + ) + .await + { + Ok(overdue) => { + if !overdue.is_empty() { + tracing::warn!( + count = overdue.len(), + "发现超时未完成的任务,发布 task.timeout 事件" + ); + for (task_id, tenant_id, instance_id, assignee_id) in &overdue { + // 发布超时事件 + let event = erp_core::events::DomainEvent::new( + "task.timeout", + *tenant_id, + erp_core::events::build_event_payload(serde_json::json!({ + "task_id": task_id, + "instance_id": instance_id, + "assignee_id": assignee_id, + })), + ); + event_bus.publish(event, &db).await; + } + } + } + Err(e) => { + tracing::warn!(error = %e, "超时检查任务执行失败"); + } + } + } + }); + } +} + +/// 处理 AI 行动工作流启动请求 +async fn handle_ai_action_start( + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + event: &erp_core::events::DomainEvent, +) { + let workflow_key = match event.payload.get("workflow_key").and_then(|v| v.as_str()) { + Some(k) => k, + None => { + tracing::warn!("AI 行动工作流事件缺少 workflow_key,跳过"); + return; + } + }; + + let tenant_id = event.tenant_id; + + // 查找对应的流程定义 + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + let def = crate::entity::process_definition::Entity::find() + .filter(crate::entity::process_definition::Column::TenantId.eq(tenant_id)) + .filter(crate::entity::process_definition::Column::Key.eq(workflow_key)) + .filter(crate::entity::process_definition::Column::DeletedAt.is_null()) + .filter(crate::entity::process_definition::Column::Status.eq("published")) + .one(db) + .await; + + let def = match def { + Ok(Some(d)) => d, + Ok(None) => { + tracing::warn!( + key = %workflow_key, + tenant_id = %tenant_id, + "AI 行动工作流定义未找到或未发布,跳过" + ); + return; + } + Err(e) => { + tracing::warn!(error = %e, "查询工作流定义失败"); + return; + } + }; + + // 构造启动变量 + let risk_level = event + .payload + .get("risk_level") + .and_then(|v| v.as_str()) + .unwrap_or("medium") + .to_string(); + + let variables = vec![ + crate::dto::SetVariableReq { + name: "risk_level".into(), + var_type: Some("string".into()), + value: serde_json::Value::String(risk_level.clone()), + }, + crate::dto::SetVariableReq { + name: "patient_id".into(), + var_type: Some("string".into()), + value: event + .payload + .get("patient_id") + .cloned() + .unwrap_or(serde_json::Value::Null), + }, + crate::dto::SetVariableReq { + name: "action_type".into(), + var_type: Some("string".into()), + value: event + .payload + .get("action_type") + .and_then(|v| v.as_str()) + .map(|s| serde_json::Value::String(s.to_string())) + .unwrap_or(serde_json::Value::Null), + }, + crate::dto::SetVariableReq { + name: "params".into(), + var_type: Some("string".into()), + value: event + .payload + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null), + }, + ]; + + let req = crate::dto::StartInstanceReq { + definition_id: def.id, + business_key: Some(format!( + "ai_action_{}", + chrono::Utc::now().timestamp_millis() + )), + variables: Some(variables), + }; + + let system_id = Uuid::parse_str("00000000-0000-0000-0000-000000000000").unwrap(); + + match crate::service::instance_service::InstanceService::start( + tenant_id, system_id, &req, db, event_bus, + ) + .await + { + Ok(instance) => { + tracing::info!( + key = %workflow_key, + instance_id = %instance.id, + tenant_id = %tenant_id, + risk_level = %risk_level, + "AI 行动工作流实例已启动" + ); + } + Err(e) => { + tracing::warn!( + key = %workflow_key, + error = %e, + "AI 行动工作流实例启动失败" + ); + } + } +} + +impl Default for WorkflowModule { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl ErpModule for WorkflowModule { + fn name(&self) -> &str { + "workflow" + } + + fn version(&self) -> &str { + env!("CARGO_PKG_VERSION") + } + + fn dependencies(&self) -> Vec<&str> { + vec!["auth"] + } + + fn register_event_handlers(&self, _bus: &EventBus) { + // 事件处理器已迁移到 on_startup(需要 DB 连接),此处保留空实现以兼容 trait 签名 + } + + async fn on_startup( + &self, + ctx: &erp_core::module::ModuleContext, + ) -> erp_core::error::AppResult<()> { + let db = ctx.db.clone(); + let bus = ctx.event_bus.clone(); + + // 订阅 user. 前缀事件,处理 user.deleted + let (mut receiver, _handle) = bus.subscribe_filtered("user.".to_string()); + + tokio::spawn(async move { + loop { + match receiver.recv().await { + Some(event) if event.event_type == "user.deleted" => { + let user_id = match event.payload.get("user_id").and_then(|v| v.as_str()) { + Some(id) => match Uuid::parse_str(id) { + Ok(u) => u, + Err(e) => { + tracing::warn!( + error = %e, + "user.deleted 事件的 user_id 解析失败,跳过" + ); + continue; + } + }, + _ => { + tracing::warn!("user.deleted 事件缺少 user_id 字段,跳过"); + continue; + } + }; + + tracing::info!( + user_id = %user_id, + tenant_id = %event.tenant_id, + "收到 user.deleted 事件,查找并终止相关流程实例" + ); + + // 查找该用户有活跃任务的流程实例 + use chrono::Utc; + use sea_orm::{ + ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, + }; + + // 查找该用户作为 assignee 的 pending 任务 + let active_tasks = crate::entity::task::Entity::find() + .filter(crate::entity::task::Column::TenantId.eq(event.tenant_id)) + .filter(crate::entity::task::Column::AssigneeId.eq(user_id)) + .filter(crate::entity::task::Column::Status.eq("pending")) + .filter(crate::entity::task::Column::DeletedAt.is_null()) + .all(&db) + .await; + + match active_tasks { + Ok(tasks) if tasks.is_empty() => { + tracing::info!( + user_id = %user_id, + "该用户没有活跃的待办任务,无需终止流程" + ); + } + Ok(tasks) => { + // 收集需要终止的实例 ID + let instance_ids: std::collections::HashSet = + tasks.iter().map(|t| t.instance_id).collect(); + + for instance_id in &instance_ids { + // 将实例状态设置为 terminated + let instance = + crate::entity::process_instance::Entity::find_by_id( + *instance_id, + ) + .one(&db) + .await; + + if let Ok(Some(inst)) = instance + && inst.tenant_id == event.tenant_id + && inst.deleted_at.is_none() + && inst.status == "running" + { + let ver = inst.version; + let mut active: crate::entity::process_instance::ActiveModel = inst.into(); + active.status = Set("terminated".to_string()); + active.updated_at = Set(Utc::now()); + active.version = Set(ver + 1); + match active.update(&db).await { + Ok(_) => { + tracing::info!( + instance_id = %instance_id, + "流程实例已终止(用户被删除)" + ); + } + Err(e) => { + tracing::warn!( + instance_id = %instance_id, + error = %e, + "终止流程实例失败" + ); + } + } + } + } + tracing::info!( + user_id = %user_id, + instance_count = instance_ids.len(), + task_count = tasks.len(), + "用户删除事件处理完成" + ); + } + Err(e) => { + tracing::warn!( + error = %e, + "查询用户活跃任务失败" + ); + } + } + } + Some(event) => { + // 其他 user. 前缀事件,忽略 + tracing::debug!( + event_type = %event.event_type, + "忽略非 user.deleted 事件" + ); + } + None => { + // 通道关闭,退出循环 + tracing::info!("Workflow 事件订阅通道已关闭"); + break; + } + } + } + }); + + tracing::info!( + module = "workflow", + "Workflow 事件处理器已注册(监听 user.deleted)" + ); + + // 订阅 AI 行动工作流启动请求 + let (mut ai_rx, _ai_handle) = bus.subscribe_filtered("workflow.ai_action.".to_string()); + let ai_db = ctx.db.clone(); + let ai_bus = bus.clone(); + + tokio::spawn(async move { + loop { + match ai_rx.recv().await { + Some(event) if event.event_type == "workflow.ai_action.start_requested" => { + handle_ai_action_start(&ai_db, &ai_bus, &event).await; + } + Some(_) => {} + None => { + tracing::info!("AI 行动工作流事件订阅通道已关闭"); + break; + } + } + } + }); + + Ok(()) + } + + async fn on_tenant_created( + &self, + _tenant_id: Uuid, + _db: &sea_orm::DatabaseConnection, + _event_bus: &EventBus, + ) -> AppResult<()> { + Ok(()) + } + + async fn on_tenant_deleted( + &self, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> AppResult<()> { + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + + // Delete in dependency order: variables → tasks → tokens → instances → definitions + // process_variables + crate::entity::process_variable::Entity::delete_many() + .filter(crate::entity::process_variable::Column::TenantId.eq(tenant_id)) + .exec(db) + .await?; + + // tasks + crate::entity::task::Entity::delete_many() + .filter(crate::entity::task::Column::TenantId.eq(tenant_id)) + .exec(db) + .await?; + + // tokens + crate::entity::token::Entity::delete_many() + .filter(crate::entity::token::Column::TenantId.eq(tenant_id)) + .exec(db) + .await?; + + // process_instances + crate::entity::process_instance::Entity::delete_many() + .filter(crate::entity::process_instance::Column::TenantId.eq(tenant_id)) + .exec(db) + .await?; + + // process_definitions + crate::entity::process_definition::Entity::delete_many() + .filter(crate::entity::process_definition::Column::TenantId.eq(tenant_id)) + .exec(db) + .await?; + + tracing::info!(%tenant_id, "Workflow data cleaned up for deleted tenant"); + Ok(()) + } + + fn permissions(&self) -> Vec { + vec![ + PermissionDescriptor { + code: "workflow.create".into(), + name: "创建流程".into(), + description: "创建流程定义".into(), + module: "workflow".into(), + }, + PermissionDescriptor { + code: "workflow.list".into(), + name: "查看流程".into(), + description: "查看流程列表".into(), + module: "workflow".into(), + }, + PermissionDescriptor { + code: "workflow.read".into(), + name: "查看流程详情".into(), + description: "查看流程定义详情".into(), + module: "workflow".into(), + }, + PermissionDescriptor { + code: "workflow.update".into(), + name: "编辑流程".into(), + description: "编辑流程定义".into(), + module: "workflow".into(), + }, + PermissionDescriptor { + code: "workflow.publish".into(), + name: "发布流程".into(), + description: "发布流程定义".into(), + module: "workflow".into(), + }, + PermissionDescriptor { + code: "workflow.start".into(), + name: "发起流程".into(), + description: "发起流程实例".into(), + module: "workflow".into(), + }, + PermissionDescriptor { + code: "workflow.approve".into(), + name: "审批任务".into(), + description: "审批流程任务".into(), + module: "workflow".into(), + }, + PermissionDescriptor { + code: "workflow.delegate".into(), + name: "委派任务".into(), + description: "委派流程任务".into(), + module: "workflow".into(), + }, + ] + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} diff --git a/crates/erp-workflow/src/service/ai_workflow_seed.rs b/crates/erp-workflow/src/service/ai_workflow_seed.rs new file mode 100644 index 0000000..4bad7af --- /dev/null +++ b/crates/erp-workflow/src/service/ai_workflow_seed.rs @@ -0,0 +1,205 @@ +//! AI 行动闭环 BPMN 流程定义种子数据 +//! +//! 三条流程: +//! - ai_followup_workflow — AI 随访建议审批 +//! - ai_appointment_workflow — AI 预约建议审批 +//! - ai_alert_workflow — AI 预警确认 + +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use uuid::Uuid; + +use crate::entity::process_definition; + +/// AI 随访审批流程 +/// +/// ```text +/// Start → ExclusiveGateway(风险分级) +/// → [low] → End (自动执行,由分发器直接处理) +/// → [medium] → UserTask(医生审批) → ExclusiveGateway → [approved] → End +/// → [rejected] → End +/// → [high] → UserTask(紧急确认) → ExclusiveGateway → [approved] → End +/// → [rejected] → End +/// ``` +fn followup_nodes() -> Vec { + serde_json::from_value(serde_json::json!([ + {"id": "start", "type": "StartEvent", "name": "AI 随访建议"}, + {"id": "gw_risk", "type": "ExclusiveGateway", "name": "风险分级"}, + {"id": "end_auto", "type": "EndEvent", "name": "自动完成"}, + {"id": "doctor_review", "type": "UserTask", "name": "医生审批随访建议", + "candidate_groups": ["doctor"]}, + {"id": "gw_outcome", "type": "ExclusiveGateway", "name": "审批结果"}, + {"id": "end_approved", "type": "EndEvent", "name": "已批准"}, + {"id": "end_rejected", "type": "EndEvent", "name": "已拒绝"} + ])) + .unwrap() +} + +fn followup_edges() -> Vec { + serde_json::from_value(serde_json::json!([ + {"id": "e1", "source": "start", "target": "gw_risk"}, + {"id": "e2", "source": "gw_risk", "target": "end_auto", + "condition": "risk_level == \"low\"", "label": "低风险"}, + {"id": "e3", "source": "gw_risk", "target": "doctor_review", + "label": "中/高风险"}, + {"id": "e4", "source": "doctor_review", "target": "gw_outcome"}, + {"id": "e5", "source": "gw_outcome", "target": "end_approved", + "condition": "outcome == \"approved\"", "label": "批准"}, + {"id": "e6", "source": "gw_outcome", "target": "end_rejected", + "condition": "outcome == \"rejected\"", "label": "拒绝"} + ])) + .unwrap() +} + +/// AI 预约审批流程 +fn appointment_nodes() -> Vec { + serde_json::from_value(serde_json::json!([ + {"id": "start", "type": "StartEvent", "name": "AI 预约建议"}, + {"id": "gw_risk", "type": "ExclusiveGateway", "name": "风险分级"}, + {"id": "end_auto", "type": "EndEvent", "name": "自动完成"}, + {"id": "doctor_confirm", "type": "UserTask", "name": "医生确认预约建议", + "candidate_groups": ["doctor"]}, + {"id": "gw_outcome", "type": "ExclusiveGateway", "name": "确认结果"}, + {"id": "end_approved", "type": "EndEvent", "name": "已确认"}, + {"id": "end_rejected", "type": "EndEvent", "name": "已拒绝"} + ])) + .unwrap() +} + +fn appointment_edges() -> Vec { + serde_json::from_value(serde_json::json!([ + {"id": "e1", "source": "start", "target": "gw_risk"}, + {"id": "e2", "source": "gw_risk", "target": "end_auto", + "condition": "risk_level == \"low\"", "label": "低风险"}, + {"id": "e3", "source": "gw_risk", "target": "doctor_confirm", + "label": "中/高风险"}, + {"id": "e4", "source": "doctor_confirm", "target": "gw_outcome"}, + {"id": "e5", "source": "gw_outcome", "target": "end_approved", + "condition": "outcome == \"approved\"", "label": "确认"}, + {"id": "e6", "source": "gw_outcome", "target": "end_rejected", + "condition": "outcome == \"rejected\"", "label": "拒绝"} + ])) + .unwrap() +} + +/// AI 预警确认流程 +fn alert_nodes() -> Vec { + serde_json::from_value(serde_json::json!([ + {"id": "start", "type": "StartEvent", "name": "AI 预警"}, + {"id": "gw_risk", "type": "ExclusiveGateway", "name": "风险分级"}, + {"id": "end_auto", "type": "EndEvent", "name": "已发送"}, + {"id": "doctor_ack", "type": "UserTask", "name": "医生确认预警", + "candidate_groups": ["doctor"]}, + {"id": "gw_outcome", "type": "ExclusiveGateway", "name": "确认结果"}, + {"id": "end_acknowledged", "type": "EndEvent", "name": "已确认"}, + {"id": "end_escalated", "type": "EndEvent", "name": "已升级"} + ])) + .unwrap() +} + +fn alert_edges() -> Vec { + serde_json::from_value(serde_json::json!([ + {"id": "e1", "source": "start", "target": "gw_risk"}, + {"id": "e2", "source": "gw_risk", "target": "end_auto", + "condition": "risk_level == \"low\"", "label": "低风险"}, + {"id": "e3", "source": "gw_risk", "target": "doctor_ack", + "label": "中/高风险"}, + {"id": "e4", "source": "doctor_ack", "target": "gw_outcome"}, + {"id": "e5", "source": "gw_outcome", "target": "end_acknowledged", + "condition": "outcome == \"approved\"", "label": "确认"}, + {"id": "e6", "source": "gw_outcome", "target": "end_escalated", + "condition": "outcome == \"rejected\"", "label": "升级"} + ])) + .unwrap() +} + +struct WorkflowTemplate { + key: &'static str, + name: &'static str, + category: &'static str, + description: &'static str, + nodes: Vec, + edges: Vec, +} + +fn all_templates() -> Vec { + vec![ + WorkflowTemplate { + key: "ai_followup_workflow", + name: "AI 随访建议审批", + category: "ai_action", + description: "AI 分析生成的随访建议,按风险等级自动执行或提交医生审批", + nodes: followup_nodes(), + edges: followup_edges(), + }, + WorkflowTemplate { + key: "ai_appointment_workflow", + name: "AI 预约建议审批", + category: "ai_action", + description: "AI 分析生成的预约建议,按风险等级自动执行或提交医生确认", + nodes: appointment_nodes(), + edges: appointment_edges(), + }, + WorkflowTemplate { + key: "ai_alert_workflow", + name: "AI 预警确认", + category: "ai_action", + description: "AI 分析生成的预警通知,按风险等级自动发送或提交医生确认", + nodes: alert_nodes(), + edges: alert_edges(), + }, + ] +} + +/// 确保 AI 行动闭环的工作流定义存在(幂等)。 +/// +/// 对每个 tenant_id 检查 key 是否已存在,不存在则创建并发布。 +pub async fn ensure_ai_workflows( + db: &sea_orm::DatabaseConnection, + tenant_id: Uuid, +) -> Result<(), sea_orm::DbErr> { + let system_id = Uuid::parse_str("00000000-0000-0000-0000-000000000000").unwrap(); + + for tmpl in all_templates() { + let exists = process_definition::Entity::find() + .filter(process_definition::Column::TenantId.eq(tenant_id)) + .filter(process_definition::Column::Key.eq(tmpl.key)) + .filter(process_definition::Column::DeletedAt.is_null()) + .one(db) + .await? + .is_some(); + + if exists { + continue; + } + + let now = Utc::now(); + let id = Uuid::now_v7(); + + let active = process_definition::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + name: Set(tmpl.name.to_string()), + key: Set(tmpl.key.to_string()), + version: Set(1), + category: Set(Some(tmpl.category.to_string())), + description: Set(Some(tmpl.description.to_string())), + nodes: Set(serde_json::json!(tmpl.nodes)), + edges: Set(serde_json::json!(tmpl.edges)), + status: Set("published".to_string()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_id), + updated_by: Set(system_id), + deleted_at: Set(None), + version_field: Set(1), + }; + active.insert(db).await?; + tracing::info!( + key = %tmpl.key, + tenant_id = %tenant_id, + "AI 工作流定义已创建" + ); + } + Ok(()) +} diff --git a/crates/erp-workflow/src/service/definition_service.rs b/crates/erp-workflow/src/service/definition_service.rs new file mode 100644 index 0000000..011a2e4 --- /dev/null +++ b/crates/erp-workflow/src/service/definition_service.rs @@ -0,0 +1,417 @@ +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set}; +use uuid::Uuid; + +use crate::dto::{CreateProcessDefinitionReq, ProcessDefinitionResp, UpdateProcessDefinitionReq}; +use crate::engine::parser; +use crate::entity::process_definition; +use crate::error::{WorkflowError, WorkflowResult}; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::error::check_version; +use erp_core::events::EventBus; +use erp_core::types::Pagination; + +/// 流程定义 CRUD 服务。 +pub struct DefinitionService; + +impl DefinitionService { + /// 分页查询流程定义列表。 + pub async fn list( + tenant_id: Uuid, + pagination: &Pagination, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult<(Vec, u64)> { + let paginator = process_definition::Entity::find() + .filter(process_definition::Column::TenantId.eq(tenant_id)) + .filter(process_definition::Column::DeletedAt.is_null()) + .paginate(db, pagination.limit()); + + let total = paginator + .num_items() + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + let page_index = pagination.page.unwrap_or(1).saturating_sub(1); + let models = paginator + .fetch_page(page_index) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + let resps: Vec = models.iter().map(Self::model_to_resp).collect(); + Ok((resps, total)) + } + + /// 获取单个流程定义。 + pub async fn get_by_id( + id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult { + let model = process_definition::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or_else(|| WorkflowError::NotFound(format!("流程定义不存在: {id}")))?; + + Ok(Self::model_to_resp(&model)) + } + + /// 创建流程定义。 + pub async fn create( + tenant_id: Uuid, + operator_id: Uuid, + req: &CreateProcessDefinitionReq, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> WorkflowResult { + // 验证流程图合法性 + parser::parse_and_validate(&req.nodes, &req.edges)?; + + let now = Utc::now(); + let id = Uuid::now_v7(); + let nodes_json = serde_json::to_value(&req.nodes) + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + let edges_json = serde_json::to_value(&req.edges) + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + let model = process_definition::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + name: Set(req.name.clone()), + key: Set(req.key.clone()), + version: Set(1), + category: Set(req.category.clone()), + description: Set(req.description.clone()), + nodes: Set(nodes_json), + edges: Set(edges_json), + status: Set("draft".to_string()), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id), + updated_by: Set(operator_id), + deleted_at: Set(None), + version_field: Set(1), + }; + model + .insert(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "process_definition.created", + tenant_id, + serde_json::json!({ "definition_id": id, "key": req.key }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "process_definition.create", + "process_definition", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(ProcessDefinitionResp { + id, + name: req.name.clone(), + key: req.key.clone(), + version: 1, + category: req.category.clone(), + description: req.description.clone(), + nodes: serde_json::to_value(&req.nodes).unwrap_or_default(), + edges: serde_json::to_value(&req.edges).unwrap_or_default(), + status: "draft".to_string(), + created_at: now, + updated_at: now, + lock_version: 1, + }) + } + + /// 更新流程定义(仅 draft 状态可编辑)。 + pub async fn update( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &UpdateProcessDefinitionReq, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult { + let model = process_definition::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or_else(|| WorkflowError::NotFound(format!("流程定义不存在: {id}")))?; + + if model.status != "draft" { + return Err(WorkflowError::InvalidState( + "只有 draft 状态的流程定义可以编辑".to_string(), + )); + } + + let current_version = model.version_field; + let mut active: process_definition::ActiveModel = model.into(); + + if let Some(name) = &req.name { + active.name = Set(name.clone()); + } + if let Some(category) = &req.category { + active.category = Set(Some(category.clone())); + } + if let Some(description) = &req.description { + active.description = Set(Some(description.clone())); + } + // 当 nodes 或 edges 任一存在时,取最终值验证流程图完整性 + let _final_nodes = req.nodes.as_ref().or_else(|| { + serde_json::from_value::>(active.nodes.as_ref().clone()) + .ok() + .as_ref() + .map(|_| unreachable!()) + }); + // 简化:如果提供了 nodes 或 edges,将两者合并后验证 + if req.nodes.is_some() || req.edges.is_some() { + let nodes_val = req + .nodes + .as_ref() + .map(|n| serde_json::to_value(n).unwrap_or_default()) + .unwrap_or(active.nodes.as_ref().clone()); + let edges_val = req + .edges + .as_ref() + .map(|e| serde_json::to_value(e).unwrap_or_default()) + .unwrap_or(active.edges.as_ref().clone()); + let nodes: Vec = serde_json::from_value(nodes_val) + .map_err(|e| WorkflowError::Validation(format!("节点数据无效: {e}")))?; + let edges: Vec = serde_json::from_value(edges_val) + .map_err(|e| WorkflowError::Validation(format!("连线数据无效: {e}")))?; + parser::parse_and_validate(&nodes, &edges)?; + } + if let Some(nodes) = &req.nodes { + let nodes_json = serde_json::to_value(nodes) + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + active.nodes = Set(nodes_json); + } + if let Some(edges) = &req.edges { + let edges_json = serde_json::to_value(edges) + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + active.edges = Set(edges_json); + } + + let next_ver = check_version(req.version, current_version) + .map_err(|_| WorkflowError::VersionMismatch)?; + active.version_field = Set(next_ver); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + + let updated = active + .update(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "process_definition.update", + "process_definition", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(Self::model_to_resp(&updated)) + } + + /// 发布流程定义(draft → published)。 + pub async fn publish( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> WorkflowResult { + let model = process_definition::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or_else(|| WorkflowError::NotFound(format!("流程定义不存在: {id}")))?; + + if model.status != "draft" { + return Err(WorkflowError::InvalidState( + "只有 draft 状态的流程定义可以发布".to_string(), + )); + } + + // 验证流程图 + let nodes: Vec = serde_json::from_value(model.nodes.clone()) + .map_err(|e| WorkflowError::InvalidDiagram(format!("节点数据无效: {e}")))?; + let edges: Vec = serde_json::from_value(model.edges.clone()) + .map_err(|e| WorkflowError::InvalidDiagram(format!("连线数据无效: {e}")))?; + parser::parse_and_validate(&nodes, &edges)?; + + let current_version = model.version_field; + let mut active: process_definition::ActiveModel = model.into(); + active.status = Set("published".to_string()); + active.version_field = Set(current_version + 1); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + + let updated = active + .update(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "process_definition.published", + tenant_id, + serde_json::json!({ "definition_id": id }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "process_definition.publish", + "process_definition", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(Self::model_to_resp(&updated)) + } + + /// 将已发布的流程定义标记为 deprecated。 + pub async fn deprecate( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> WorkflowResult { + let model = process_definition::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or_else(|| WorkflowError::NotFound(format!("流程定义不存在: {id}")))?; + + if model.status != "published" { + return Err(WorkflowError::InvalidState( + "只有 published 状态的流程定义可以废弃".to_string(), + )); + } + + let current_version = model.version_field; + let mut active: process_definition::ActiveModel = model.into(); + active.status = Set("deprecated".to_string()); + active.version_field = Set(current_version + 1); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + + let updated = active + .update(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "process_definition.deprecated", + tenant_id, + serde_json::json!({ "definition_id": id }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "process_definition.deprecate", + "process_definition", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(Self::model_to_resp(&updated)) + } + + /// 软删除流程定义。 + pub async fn delete( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult<()> { + let model = process_definition::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .filter(|m| m.tenant_id == tenant_id && m.deleted_at.is_none()) + .ok_or_else(|| WorkflowError::NotFound(format!("流程定义不存在: {id}")))?; + + let current_version = model.version_field; + let mut active: process_definition::ActiveModel = model.into(); + active.version_field = Set(current_version + 1); + active.deleted_at = Set(Some(Utc::now())); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active + .update(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "process_definition.delete", + "process_definition", + ) + .with_resource_id(id), + db, + ) + .await; + + Ok(()) + } + + fn model_to_resp(m: &process_definition::Model) -> ProcessDefinitionResp { + ProcessDefinitionResp { + id: m.id, + name: m.name.clone(), + key: m.key.clone(), + version: m.version, + category: m.category.clone(), + description: m.description.clone(), + nodes: m.nodes.clone(), + edges: m.edges.clone(), + status: m.status.clone(), + created_at: m.created_at, + updated_at: m.updated_at, + lock_version: m.version_field, + } + } +} diff --git a/crates/erp-workflow/src/service/instance_service.rs b/crates/erp-workflow/src/service/instance_service.rs new file mode 100644 index 0000000..34694a0 --- /dev/null +++ b/crates/erp-workflow/src/service/instance_service.rs @@ -0,0 +1,424 @@ +use std::collections::HashMap; + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, ConnectionTrait, EntityTrait, PaginatorTrait, QueryFilter, Set, + TransactionTrait, +}; +use uuid::Uuid; + +use crate::dto::{ProcessInstanceResp, StartInstanceReq, TokenResp}; +use crate::engine::executor::FlowExecutor; +use crate::engine::parser; +use crate::entity::{process_definition, process_instance, process_variable, token}; +use crate::error::{WorkflowError, WorkflowResult}; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::events::EventBus; +use erp_core::types::Pagination; + +/// 流程实例服务。 +pub struct InstanceService; + +impl InstanceService { + /// 启动流程实例。 + pub async fn start( + tenant_id: Uuid, + operator_id: Uuid, + req: &StartInstanceReq, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> WorkflowResult { + // 查找流程定义 + let definition = process_definition::Entity::find_by_id(req.definition_id) + .one(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none()) + .ok_or_else(|| { + WorkflowError::NotFound(format!("流程定义不存在: {}", req.definition_id)) + })?; + + if definition.status != "published" { + return Err(WorkflowError::InvalidState( + "只能启动已发布的流程定义".to_string(), + )); + } + + // 解析流程图 + let nodes: Vec = serde_json::from_value(definition.nodes.clone()) + .map_err(|e| WorkflowError::InvalidDiagram(format!("节点数据无效: {e}")))?; + let edges: Vec = serde_json::from_value(definition.edges.clone()) + .map_err(|e| WorkflowError::InvalidDiagram(format!("连线数据无效: {e}")))?; + let graph = parser::parse_and_validate(&nodes, &edges)?; + + // 准备流程变量 + let mut variables = HashMap::new(); + if let Some(vars) = &req.variables { + for v in vars { + let _var_type = v.var_type.as_deref().unwrap_or("string"); + variables.insert(v.name.clone(), v.value.clone()); + } + } + + let instance_id = Uuid::now_v7(); + let now = Utc::now(); + + // 在事务中创建实例、变量和 token + let instance_id_clone = instance_id; + let tenant_id_clone = tenant_id; + let operator_id_clone = operator_id; + let business_key = req.business_key.clone(); + let definition_id = definition.id; + let definition_name = definition.name.clone(); + let vars_to_save = req.variables.clone(); + + db.transaction::<_, (), WorkflowError>(|txn| { + let graph = graph.clone(); + let variables = variables.clone(); + Box::pin(async move { + // 创建流程实例 + let instance = process_instance::ActiveModel { + id: Set(instance_id_clone), + tenant_id: Set(tenant_id_clone), + definition_id: Set(definition_id), + business_key: Set(business_key), + status: Set("running".to_string()), + started_by: Set(operator_id_clone), + started_at: Set(now), + completed_at: Set(None), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(operator_id_clone), + updated_by: Set(operator_id_clone), + deleted_at: Set(None), + version: Set(1), + }; + instance + .insert(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + // 保存初始变量 + if let Some(vars) = vars_to_save { + for v in vars { + Self::save_variable( + instance_id_clone, + tenant_id_clone, + &v.name, + v.var_type.as_deref().unwrap_or("string"), + &v.value, + txn, + ) + .await?; + } + } + + // 启动执行引擎 + FlowExecutor::start(instance_id_clone, tenant_id_clone, &graph, &variables, txn) + .await?; + + Ok(()) + }) + }) + .await?; + + event_bus.publish(erp_core::events::DomainEvent::new( + "process_instance.started", + tenant_id, + erp_core::events::build_event_payload(serde_json::json!({ "instance_id": instance_id, "definition_id": definition.id, "started_by": operator_id })), + ), db).await; + + audit_service::record( + AuditLog::new( + tenant_id, + Some(operator_id), + "process_instance.start", + "process_instance", + ) + .with_resource_id(instance_id), + db, + ) + .await; + + // 查询创建后的实例(包含 token) + let instance = process_instance::Entity::find_by_id(instance_id) + .one(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .ok_or_else(|| WorkflowError::NotFound(format!("流程实例不存在: {instance_id}")))?; + + let active_tokens = Self::get_active_tokens(instance_id, db).await?; + + Ok(ProcessInstanceResp { + id: instance.id, + definition_id: instance.definition_id, + definition_name: Some(definition_name), + business_key: instance.business_key, + status: instance.status, + started_by: instance.started_by, + started_at: instance.started_at, + completed_at: instance.completed_at, + created_at: instance.created_at, + active_tokens, + version: instance.version, + }) + } + + /// 分页查询流程实例。 + pub async fn list( + tenant_id: Uuid, + pagination: &Pagination, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult<(Vec, u64)> { + let paginator = process_instance::Entity::find() + .filter(process_instance::Column::TenantId.eq(tenant_id)) + .filter(process_instance::Column::DeletedAt.is_null()) + .paginate(db, pagination.limit()); + + let total = paginator + .num_items() + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + let page_index = pagination.page.unwrap_or(1).saturating_sub(1); + let models = paginator + .fetch_page(page_index) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + let mut resps = Vec::new(); + for m in &models { + let active_tokens = Self::get_active_tokens(m.id, db).await.unwrap_or_default(); + let def_name = process_definition::Entity::find_by_id(m.definition_id) + .one(db) + .await + .ok() + .flatten() + .map(|d| d.name); + resps.push(ProcessInstanceResp { + id: m.id, + definition_id: m.definition_id, + definition_name: def_name, + business_key: m.business_key.clone(), + status: m.status.clone(), + started_by: m.started_by, + started_at: m.started_at, + completed_at: m.completed_at, + created_at: m.created_at, + active_tokens, + version: m.version, + }); + } + + Ok((resps, total)) + } + + /// 获取单个流程实例详情。 + pub async fn get_by_id( + id: Uuid, + tenant_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult { + let instance = process_instance::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none()) + .ok_or_else(|| WorkflowError::NotFound(format!("流程实例不存在: {id}")))?; + + let def_name = process_definition::Entity::find_by_id(instance.definition_id) + .one(db) + .await + .ok() + .flatten() + .map(|d| d.name); + + let active_tokens = Self::get_active_tokens(id, db).await?; + + Ok(ProcessInstanceResp { + id: instance.id, + definition_id: instance.definition_id, + definition_name: def_name, + business_key: instance.business_key, + status: instance.status, + started_by: instance.started_by, + started_at: instance.started_at, + completed_at: instance.completed_at, + created_at: instance.created_at, + active_tokens, + version: instance.version, + }) + } + + /// 挂起流程实例。 + pub async fn suspend( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult<()> { + Self::change_status(id, tenant_id, operator_id, "running", "suspended", db).await + } + + /// 终止流程实例。 + pub async fn terminate( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult<()> { + Self::change_status(id, tenant_id, operator_id, "running", "terminated", db).await + } + + /// 恢复已挂起的流程实例。 + pub async fn resume( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult<()> { + Self::change_status(id, tenant_id, operator_id, "suspended", "running", db).await + } + + async fn change_status( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + from_status: &str, + to_status: &str, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult<()> { + let instance = process_instance::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none()) + .ok_or_else(|| WorkflowError::NotFound(format!("流程实例不存在: {id}")))?; + + if instance.status != from_status { + return Err(WorkflowError::InvalidState(format!( + "流程实例状态不是 {},无法变更为 {}", + from_status, to_status + ))); + } + + let current_version = instance.version; + let mut active: process_instance::ActiveModel = instance.into(); + active.status = Set(to_status.to_string()); + active.version = Set(current_version + 1); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + active + .update(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + // 发布状态变更领域事件(通过 outbox 模式,由 relay 广播) + let event_type = format!("process_instance.{}", to_status); + let event_id = Uuid::now_v7(); + let now = Utc::now(); + let outbox_event = erp_core::entity::domain_event::ActiveModel { + id: Set(event_id), + tenant_id: Set(tenant_id), + event_type: Set(event_type), + payload: Set(Some(erp_core::events::build_event_payload( + serde_json::json!({ "instance_id": id, "changed_by": operator_id }), + ))), + correlation_id: Set(Some(Uuid::now_v7())), + status: Set("pending".to_string()), + attempts: Set(0), + last_error: Set(None), + created_at: Set(now), + published_at: Set(None), + }; + match outbox_event.insert(db).await { + Ok(_) => {} + Err(e) => tracing::warn!(error = %e, "领域事件持久化失败"), + } + + let action = format!("process_instance.{}", to_status); + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), action, "process_instance") + .with_resource_id(id), + db, + ) + .await; + + Ok(()) + } + + /// 获取实例的活跃 token 列表。 + pub async fn get_active_tokens( + instance_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult> { + let tokens = token::Entity::find() + .filter(token::Column::InstanceId.eq(instance_id)) + .filter(token::Column::Status.eq("active")) + .all(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + Ok(tokens + .iter() + .map(|t| TokenResp { + id: t.id, + node_id: t.node_id.clone(), + status: t.status.clone(), + created_at: t.created_at, + }) + .collect()) + } + + /// 保存流程变量。 + pub async fn save_variable( + instance_id: Uuid, + tenant_id: Uuid, + name: &str, + var_type: &str, + value: &serde_json::Value, + txn: &impl ConnectionTrait, + ) -> WorkflowResult<()> { + let id = Uuid::now_v7(); + + let (value_string, value_number, value_boolean, _value_date): ( + Option, + Option, + Option, + Option>, + ) = match var_type { + "string" => (value.as_str().map(|s| s.to_string()), None, None, None), + "number" => (None, value.as_f64(), None, None), + "boolean" => (None, None, value.as_bool(), None), + _ => (Some(value.to_string()), None, None, None), + }; + + let now = chrono::Utc::now(); + let system_user = uuid::Uuid::nil(); + + let model = process_variable::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + instance_id: Set(instance_id), + name: Set(name.to_string()), + var_type: Set(var_type.to_string()), + value_string: Set(value_string), + value_number: Set(value_number), + value_boolean: Set(value_boolean), + value_date: Set(None), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user), + updated_by: Set(system_user), + deleted_at: Set(None), + version: Set(1), + }; + model + .insert(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + Ok(()) + } +} diff --git a/crates/erp-workflow/src/service/mod.rs b/crates/erp-workflow/src/service/mod.rs new file mode 100644 index 0000000..838e944 --- /dev/null +++ b/crates/erp-workflow/src/service/mod.rs @@ -0,0 +1,4 @@ +pub mod ai_workflow_seed; +pub mod definition_service; +pub mod instance_service; +pub mod task_service; diff --git a/crates/erp-workflow/src/service/task_service.rs b/crates/erp-workflow/src/service/task_service.rs new file mode 100644 index 0000000..4fcb826 --- /dev/null +++ b/crates/erp-workflow/src/service/task_service.rs @@ -0,0 +1,445 @@ +use std::collections::HashMap; + +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ColumnTrait, ConnectionTrait, DatabaseBackend, EntityTrait, PaginatorTrait, + QueryFilter, Set, Statement, TransactionTrait, +}; +use uuid::Uuid; + +use crate::dto::{CompleteTaskReq, DelegateTaskReq, TaskResp}; +use crate::engine::executor::FlowExecutor; +use crate::engine::parser; +use crate::entity::{process_definition, process_instance, task}; +use crate::error::{WorkflowError, WorkflowResult}; +use erp_core::audit::AuditLog; +use erp_core::audit_service; +use erp_core::events::EventBus; +use erp_core::types::Pagination; + +/// 任务服务。 +pub struct TaskService; + +impl TaskService { + /// 查询当前用户的待办任务。 + pub async fn list_pending( + tenant_id: Uuid, + assignee_id: Uuid, + pagination: &Pagination, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult<(Vec, u64)> { + let paginator = task::Entity::find() + .filter(task::Column::TenantId.eq(tenant_id)) + .filter(task::Column::AssigneeId.eq(assignee_id)) + .filter(task::Column::Status.eq("pending")) + .filter(task::Column::DeletedAt.is_null()) + .paginate(db, pagination.limit()); + + let total = paginator + .num_items() + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + let page_index = pagination.page.unwrap_or(1).saturating_sub(1); + let models = paginator + .fetch_page(page_index) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + let mut resps = Vec::new(); + for m in &models { + let mut resp = Self::model_to_resp(m); + // 附加实例信息 + if let Some(inst) = process_instance::Entity::find_by_id(m.instance_id) + .one(db) + .await + .ok() + .flatten() + { + resp.business_key = inst.business_key; + if let Some(def) = process_definition::Entity::find_by_id(inst.definition_id) + .one(db) + .await + .ok() + .flatten() + { + resp.definition_name = Some(def.name); + } + } + resps.push(resp); + } + + Ok((resps, total)) + } + + /// 查询当前用户的已办任务。 + pub async fn list_completed( + tenant_id: Uuid, + assignee_id: Uuid, + pagination: &Pagination, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult<(Vec, u64)> { + let paginator = task::Entity::find() + .filter(task::Column::TenantId.eq(tenant_id)) + .filter(task::Column::AssigneeId.eq(assignee_id)) + .filter(task::Column::Status.is_in(["completed", "approved", "rejected", "delegated"])) + .filter(task::Column::DeletedAt.is_null()) + .paginate(db, pagination.limit()); + + let total = paginator + .num_items() + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + let page_index = pagination.page.unwrap_or(1).saturating_sub(1); + let models = paginator + .fetch_page(page_index) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + let mut resps = Vec::new(); + for m in &models { + let mut resp = Self::model_to_resp(m); + if let Some(inst) = process_instance::Entity::find_by_id(m.instance_id) + .one(db) + .await + .ok() + .flatten() + { + resp.business_key = inst.business_key; + if let Some(def) = process_definition::Entity::find_by_id(inst.definition_id) + .one(db) + .await + .ok() + .flatten() + { + resp.definition_name = Some(def.name); + } + } + resps.push(resp); + } + + Ok((resps, total)) + } + + /// 完成任务:更新任务状态 + 推进 token。 + pub async fn complete( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &CompleteTaskReq, + db: &sea_orm::DatabaseConnection, + event_bus: &EventBus, + ) -> WorkflowResult { + let task_model = task::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .filter(|t| t.tenant_id == tenant_id && t.deleted_at.is_none()) + .ok_or_else(|| WorkflowError::NotFound(format!("任务不存在: {id}")))?; + + if task_model.status != "pending" { + return Err(WorkflowError::InvalidState( + "任务状态不是 pending,无法完成".to_string(), + )); + } + + // 验证操作者是当前处理人 + if task_model.assignee_id != Some(operator_id) { + return Err(WorkflowError::InvalidState( + "只有当前处理人才能完成任务".to_string(), + )); + } + + let instance_id = task_model.instance_id; + let token_id = task_model.token_id; + + // 获取流程定义和流程图 + let instance = process_instance::Entity::find_by_id(instance_id) + .one(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .filter(|i| i.tenant_id == tenant_id && i.deleted_at.is_none()) + .ok_or_else(|| WorkflowError::NotFound(format!("流程实例不存在: {instance_id}")))?; + + let definition = process_definition::Entity::find_by_id(instance.definition_id) + .one(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .filter(|d| d.tenant_id == tenant_id && d.deleted_at.is_none()) + .ok_or_else(|| { + WorkflowError::NotFound(format!("流程定义不存在: {}", instance.definition_id)) + })?; + + if instance.status != "running" { + return Err(WorkflowError::InvalidState(format!( + "流程实例状态不是 running: {}", + instance.status + ))); + } + + let nodes: Vec = serde_json::from_value(definition.nodes.clone()) + .map_err(|e| WorkflowError::InvalidDiagram(format!("节点数据无效: {e}")))?; + let edges: Vec = serde_json::from_value(definition.edges.clone()) + .map_err(|e| WorkflowError::InvalidDiagram(format!("连线数据无效: {e}")))?; + let graph = parser::parse_and_validate(&nodes, &edges)?; + + // 准备变量(从 req.form_data 中提取) + let mut variables = HashMap::new(); + if let Some(form) = &req.form_data + && let Some(obj) = form.as_object() + { + for (k, v) in obj { + variables.insert(k.clone(), v.clone()); + } + } + + // 在事务中更新任务 + 推进 token + let now = Utc::now(); + let outcome = req.outcome.clone(); + let form_data = req.form_data.clone(); + db.transaction::<_, (), WorkflowError>(|txn| { + let graph = graph.clone(); + let variables = variables.clone(); + let task_model = task_model.clone(); + Box::pin(async move { + // 更新任务状态 + let current_version = task_model.version; + let mut active: task::ActiveModel = task_model.clone().into(); + active.status = Set("completed".to_string()); + active.outcome = Set(Some(outcome)); + active.form_data = Set(form_data); + active.completed_at = Set(Some(now)); + active.version = Set(current_version + 1); + active.updated_at = Set(now); + active.updated_by = Set(operator_id); + active + .update(txn) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + // 推进 token + FlowExecutor::advance(token_id, instance_id, tenant_id, &graph, &variables, txn) + .await?; + + Ok(()) + }) + }) + .await?; + + event_bus + .publish( + erp_core::events::DomainEvent::new( + "task.completed", + tenant_id, + serde_json::json!({ + "task_id": id, + "instance_id": instance_id, + "started_by": instance.started_by, + "outcome": req.outcome, + }), + ), + db, + ) + .await; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "task.complete", "task") + .with_resource_id(id), + db, + ) + .await; + + // 重新查询任务 + let updated = task::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .ok_or_else(|| WorkflowError::NotFound(format!("任务不存在: {id}")))?; + + Ok(Self::model_to_resp(&updated)) + } + + /// 委派任务给其他人。 + pub async fn delegate( + id: Uuid, + tenant_id: Uuid, + operator_id: Uuid, + req: &DelegateTaskReq, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult { + let task_model = task::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .filter(|t| t.tenant_id == tenant_id && t.deleted_at.is_none()) + .ok_or_else(|| WorkflowError::NotFound(format!("任务不存在: {id}")))?; + + if task_model.status != "pending" { + return Err(WorkflowError::InvalidState( + "任务状态不是 pending,无法委派".to_string(), + )); + } + + // 验证操作者是当前处理人 + if task_model.assignee_id != Some(operator_id) { + return Err(WorkflowError::InvalidState( + "只有当前处理人才能委派任务".to_string(), + )); + } + + // 验证目标用户属于同一租户(使用 raw SQL 避免跨模块依赖 erp-auth) + let result = db.query_one(Statement::from_sql_and_values( + DatabaseBackend::Postgres, + "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL AND status = 'active') AS ok", + [req.delegate_to.into(), tenant_id.into()], + )) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + let target_ok = result + .and_then(|r| r.try_get::("", "ok").ok()) + .unwrap_or(false); + + if !target_ok { + return Err(WorkflowError::Validation( + "委派目标用户不存在或不属于当前租户".to_string(), + )); + } + + let current_version = task_model.version; + let mut active: task::ActiveModel = task_model.into(); + active.assignee_id = Set(Some(req.delegate_to)); + active.version = Set(current_version + 1); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(operator_id); + + let updated = active + .update(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + audit_service::record( + AuditLog::new(tenant_id, Some(operator_id), "task.delegate", "task") + .with_resource_id(id), + db, + ) + .await; + + Ok(Self::model_to_resp(&updated)) + } + + /// 创建任务记录(由执行引擎调用)。 + #[allow(clippy::too_many_arguments)] + pub async fn create_task( + instance_id: Uuid, + tenant_id: Uuid, + token_id: Uuid, + node_id: &str, + node_name: Option<&str>, + assignee_id: Option, + candidate_groups: Option>, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult { + let id = Uuid::now_v7(); + let now = Utc::now(); + let system_user = Uuid::nil(); + + let model = task::ActiveModel { + id: Set(id), + tenant_id: Set(tenant_id), + instance_id: Set(instance_id), + token_id: Set(token_id), + node_id: Set(node_id.to_string()), + node_name: Set(node_name.map(|s| s.to_string())), + assignee_id: Set(assignee_id), + candidate_groups: Set( + candidate_groups.map(|g| serde_json::to_value(g).unwrap_or_default()) + ), + status: Set("pending".to_string()), + outcome: Set(None), + form_data: Set(None), + due_date: Set(None), + completed_at: Set(None), + created_at: Set(now), + updated_at: Set(now), + created_by: Set(system_user), + updated_by: Set(system_user), + deleted_at: Set(None), + version: Set(1), + }; + model + .insert(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + Ok(id) + } + + /// 认领任务:将 pending 状态的任务分配给当前用户。 + /// + /// 适用于 candidate_groups 群组任务池中的任务,用户主动认领后 + /// 任务状态变为 in_progress,assignee_id 设置为认领用户。 + pub async fn claim( + id: Uuid, + tenant_id: Uuid, + user_id: Uuid, + db: &sea_orm::DatabaseConnection, + ) -> WorkflowResult { + let task_model = task::Entity::find_by_id(id) + .one(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))? + .filter(|t| t.tenant_id == tenant_id && t.deleted_at.is_none()) + .ok_or_else(|| WorkflowError::NotFound(format!("任务不存在: {id}")))?; + + if task_model.status != "pending" { + return Err(WorkflowError::InvalidState(format!( + "任务状态不是 pending(当前状态: {}),无法认领", + task_model.status + ))); + } + + let current_version = task_model.version; + let mut active: task::ActiveModel = task_model.into(); + active.assignee_id = Set(Some(user_id)); + active.status = Set("in_progress".to_string()); + active.version = Set(current_version + 1); + active.updated_at = Set(Utc::now()); + active.updated_by = Set(user_id); + + let updated = active + .update(db) + .await + .map_err(|e| WorkflowError::Validation(e.to_string()))?; + + audit_service::record( + AuditLog::new(tenant_id, Some(user_id), "task.claim", "task").with_resource_id(id), + db, + ) + .await; + + Ok(Self::model_to_resp(&updated)) + } + + fn model_to_resp(m: &task::Model) -> TaskResp { + TaskResp { + id: m.id, + instance_id: m.instance_id, + token_id: m.token_id, + node_id: m.node_id.clone(), + node_name: m.node_name.clone(), + assignee_id: m.assignee_id, + candidate_groups: m.candidate_groups.clone(), + status: m.status.clone(), + outcome: m.outcome.clone(), + form_data: m.form_data.clone(), + due_date: m.due_date, + completed_at: m.completed_at, + created_at: m.created_at, + definition_name: None, + business_key: None, + version: m.version, + } + } +} diff --git a/crates/erp-workflow/src/workflow_state.rs b/crates/erp-workflow/src/workflow_state.rs new file mode 100644 index 0000000..8c0c5e2 --- /dev/null +++ b/crates/erp-workflow/src/workflow_state.rs @@ -0,0 +1,11 @@ +use erp_core::events::EventBus; +use sea_orm::DatabaseConnection; + +/// Workflow-specific state extracted from the server's AppState via `FromRef`. +/// +/// Contains the database connection and event bus needed by workflow handlers. +#[derive(Clone)] +pub struct WorkflowState { + pub db: DatabaseConnection, + pub event_bus: EventBus, +} diff --git a/dev.ps1 b/dev.ps1 new file mode 100644 index 0000000..188866a --- /dev/null +++ b/dev.ps1 @@ -0,0 +1,226 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + ERP dev environment startup script +.EXAMPLE + .\dev.ps1 # Start backend + frontend + .\dev.ps1 -Stop # Stop all + .\dev.ps1 -Restart # Restart all + .\dev.ps1 -Status # Show port status +#> + +param( + [switch]$Stop, + [switch]$Restart, + [switch]$Status +) + +$BackendPort = 3000 +$FrontendPort = 5174 +$LogDir = ".logs" + +# --- environment variables --- +$env:ERP__DATABASE__URL = "postgres://postgres:123123@localhost:5432/erp" +$env:ERP__JWT__SECRET = "dev-secret-key-change-in-prod" +$env:ERP__AUTH__SUPER_ADMIN_PASSWORD = "Admin@2026" +$env:ERP__REDIS__URL = "redis://localhost:6379" +$env:ERP__WECHAT__APPID = "wx20f4ef9cc2ec66c5" +$env:ERP__WECHAT__SECRET = "52679a563af519590e882c4b8d846f7b" +$env:ERP__WECHAT__DEV_MODE = "false" +$env:ERP__HEALTH__AES_KEY = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2" +$env:ERP__HEALTH__HMAC_KEY = "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5" +$env:ERP__RATE_LIMIT__FAIL_CLOSE = "false" + +# --- find PID using port --- +function Find-PortPid([int]$Port) { + try { + $c = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction Stop + return $c[0].OwningProcess + } catch { return $null } +} + +# --- kill process on port --- +function Stop-PortProcess([int]$Port, [string]$Label) { + $procId = Find-PortPid $Port + if ($null -ne $procId) { + try { $pName = (Get-Process -Id $procId -ErrorAction SilentlyContinue).ProcessName } catch { $pName = "?" } + Write-Host (" {0,-10} port {1} used by PID {2} ({3}), killing..." -f $Label,$Port,$procId,$pName) -ForegroundColor Yellow -NoNewline + try { + Stop-Process -Id $procId -Force -ErrorAction Stop + $w = 0 + while (($w -lt 5) -and (Find-PortPid $Port)) { Start-Sleep -Seconds 1; $w++ } + if (Find-PortPid $Port) { Write-Host " still in use" -ForegroundColor Red } + else { Write-Host " done" -ForegroundColor Green } + } catch { Write-Host " failed" -ForegroundColor Red } + } else { + Write-Host (" {0,-10} port {1} free" -f $Label,$Port) -ForegroundColor Green + } +} + +# --- wait for port --- +function Wait-PortReady([int]$Port, [int]$TimeoutSeconds = 60) { + $sw = [System.Diagnostics.Stopwatch]::StartNew() + while ($sw.ElapsedMilliseconds -lt ($TimeoutSeconds * 1000)) { + if (Find-PortPid $Port) { return $true } + Start-Sleep -Milliseconds 500 + } + return $false +} + +# --- stop all --- +function Stop-Services { + Write-Host "" + Write-Host "Stopping..." -ForegroundColor Cyan + Write-Host "--------------------------------------------------" -ForegroundColor DarkGray + + foreach ($svc in @("backend","frontend")) { + $pidFile = Join-Path $LogDir "$svc.pid" + if (Test-Path $pidFile) { + $svcId = Get-Content $pidFile -ErrorAction SilentlyContinue + if ($svcId -and (Get-Process -Id $svcId -ErrorAction SilentlyContinue)) { + $label = if ($svc -eq "backend") { "Backend" } else { "Frontend" } + Write-Host " Stopping $label (PID $svcId)..." -ForegroundColor Cyan -NoNewline + Stop-Process -Id $svcId -Force -ErrorAction SilentlyContinue + Write-Host " done" -ForegroundColor Green + } + Remove-Item $pidFile -Force -ErrorAction SilentlyContinue + } + } + Stop-PortProcess $BackendPort "Backend" + Stop-PortProcess $FrontendPort "Frontend" + Write-Host "" + Write-Host "Stopped." -ForegroundColor Green +} + +# --- show status --- +function Show-Status { + Write-Host "" + Write-Host "Status:" -ForegroundColor Cyan + Write-Host "--------------------------------------------------" -ForegroundColor DarkGray + + $bp = Find-PortPid $BackendPort + if ($null -ne $bp) { + Write-Host " " -NoNewline; Write-Host "+" -ForegroundColor Green -NoNewline + Write-Host " Backend port $BackendPort PID $bp" + Write-Host " http://localhost:$BackendPort/api/v1/health" -ForegroundColor Cyan + } else { + Write-Host " " -NoNewline; Write-Host "-" -ForegroundColor Red -NoNewline + Write-Host " Backend port $BackendPort stopped" + } + + $fp = Find-PortPid $FrontendPort + if ($null -ne $fp) { + Write-Host " " -NoNewline; Write-Host "+" -ForegroundColor Green -NoNewline + Write-Host " Frontend port $FrontendPort PID $fp" + Write-Host " http://localhost:$FrontendPort" -ForegroundColor Cyan + } else { + Write-Host " " -NoNewline; Write-Host "-" -ForegroundColor Red -NoNewline + Write-Host " Frontend port $FrontendPort stopped" + } + Write-Host "" +} + +# --- start all --- +function Start-Services { + New-Item -ItemType Directory -Path $LogDir -Force | Out-Null + + Write-Host "" + Write-Host "==================================================" -ForegroundColor Cyan + Write-Host " ERP Dev Environment Startup" -ForegroundColor Cyan + Write-Host "==================================================" -ForegroundColor Cyan + Write-Host "" + + # 1. clean ports (包括 Vite 可能递增到的所有端口) + Write-Host "[1/3] Checking ports..." -ForegroundColor Cyan + Write-Host "--------------------------------------------------" -ForegroundColor DarkGray + Stop-PortProcess $BackendPort "Backend" + # 清理所有可能的 Vite 端口 (5174-5189) + foreach ($p in ($FrontendPort..($FrontendPort + 15))) { + Stop-PortProcess $p "Vite:$p" + } + Write-Host "" + + # 2. backend + Write-Host "[2/3] Starting backend (Axum :$BackendPort)..." -ForegroundColor Cyan + Write-Host "--------------------------------------------------" -ForegroundColor DarkGray + + $backendLog = Join-Path $LogDir "backend.log" + $backendErr = Join-Path $LogDir "backend.err" + + $backendDir = Join-Path $PSScriptRoot "crates\erp-server" + $proc = Start-Process -FilePath "cargo" -ArgumentList "run","-p","erp-server" ` + -WorkingDirectory $backendDir ` + -RedirectStandardOutput $backendLog -RedirectStandardError $backendErr ` + -WindowStyle Hidden -PassThru + + Write-Host " PID: $($proc.Id) log: $backendLog" -ForegroundColor DarkGray + Write-Host " Compiling & starting..." -NoNewline + + if (Wait-PortReady $BackendPort 180) { + Write-Host " ready" -ForegroundColor Green + } else { + Write-Host " timeout (check $backendLog)" -ForegroundColor Yellow + } + Write-Host "" + + # 3. frontend + Write-Host "[3/3] Starting frontend (Vite :$FrontendPort)..." -ForegroundColor Cyan + Write-Host "--------------------------------------------------" -ForegroundColor DarkGray + + $webDir = Join-Path $PSScriptRoot "apps\web" + if (-not (Test-Path (Join-Path $webDir "node_modules"))) { + Write-Host " Installing deps..." -ForegroundColor Yellow + Push-Location $webDir + pnpm install 2>&1 | ForEach-Object { Write-Host " $_" } + Pop-Location + } + + $frontendLog = Join-Path $LogDir "frontend.log" + $frontendErr = Join-Path $LogDir "frontend.err" + + $proc = Start-Process -FilePath "cmd.exe" ` + -ArgumentList "/c","cd /d `"$webDir`" && pnpm dev -- --strictPort" ` + -RedirectStandardOutput $frontendLog -RedirectStandardError $frontendErr ` + -WindowStyle Hidden -PassThru + + Write-Host " PID: $($proc.Id) log: $frontendLog" -ForegroundColor DarkGray + Write-Host " Starting..." -NoNewline + + if (Wait-PortReady $FrontendPort 30) { + Write-Host " ready" -ForegroundColor Green + } else { + Write-Host " timeout" -ForegroundColor Red + } + Write-Host "" + + # save PIDs (use port-based PID, not Start-Process PID which may be cmd.exe wrapper) + $bp = Find-PortPid $BackendPort + $fp = Find-PortPid $FrontendPort + if ($bp) { $bp | Set-Content (Join-Path $LogDir "backend.pid") } + if ($fp) { $fp | Set-Content (Join-Path $LogDir "frontend.pid") } + + # done + Write-Host "==================================================" -ForegroundColor Green + Write-Host " All services started!" -ForegroundColor Green + Write-Host "==================================================" -ForegroundColor Green + Write-Host "" + Write-Host " Frontend: http://localhost:$FrontendPort" -ForegroundColor Cyan + Write-Host " Backend: http://localhost:$BackendPort/api/v1" -ForegroundColor Cyan + Write-Host " Health: http://localhost:$BackendPort/api/v1/health" -ForegroundColor Cyan + Write-Host "" + Write-Host " Stop: .\dev.ps1 -Stop" -ForegroundColor DarkGray + Write-Host " Restart: .\dev.ps1 -Restart" -ForegroundColor DarkGray + Write-Host " Status: .\dev.ps1 -Status" -ForegroundColor DarkGray + Write-Host "" +} + +# --- entry --- +if ($Stop) { + Stop-Services +} elseif ($Restart) { + Stop-Services; Start-Sleep -Seconds 1; Start-Services +} elseif ($Status) { + Show-Status +} else { + Start-Services +} diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..a83cbc2 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,6 @@ +POSTGRES_USER=erp +POSTGRES_PASSWORD=erp_dev_2024 +POSTGRES_DB=erp +POSTGRES_PORT=5432 +REDIS_PASSWORD=erp_redis_dev +REDIS_PORT=6379 diff --git a/docker/.env.production.example b/docker/.env.production.example new file mode 100644 index 0000000..33f5637 --- /dev/null +++ b/docker/.env.production.example @@ -0,0 +1,70 @@ +# HMS 云端部署环境变量 +# 复制此文件为 .env.production 并填写实际值 +# cp .env.production.example .env.production + +# ===== 必填 ===== + +# PostgreSQL 连接(host 网络模式,直连宿主机) +ERP__DATABASE__URL=postgres://erp:YOUR_PG_PASSWORD@localhost:5432/erp + +# Redis 连接 +ERP__REDIS__URL=redis://:YOUR_REDIS_PASSWORD@localhost:6379 + +# JWT 密钥(至少 32 字符随机字符串) +ERP__JWT__SECRET=CHANGE_ME_TO_A_RANDOM_STRING_AT_LEAST_32_CHARS + +# 超级管理员初始密码(首次启动时创建 admin 用户) +ERP__AUTH__SUPER_ADMIN_PASSWORD=CHANGE_ME_ADMIN_PASSWORD + +# PII 加密密钥(AES-256 KEK,64 位十六进制) +ERP__CRYPTO__KEK=CHANGE_ME_64_HEX_CHARS_FOR_AES256_KEY + +# 健康数据加密密钥 +ERP__HEALTH__AES_KEY=CHANGE_ME_64_HEX_CHARS +ERP__HEALTH__HMAC_KEY=CHANGE_ME_64_HEX_CHARS + +# ===== 可选 ===== + +# 服务端口(默认 3000) +ERP__SERVER__PORT=3000 + +# Prometheus 指标端口(默认 9090) +ERP__SERVER__METRICS_PORT=9090 + +# CORS 允许的来源(逗号分隔) +ERP__CORS__ALLOWED_ORIGINS=https://your-domain.com,https://www.your-domain.com + +# 上传目录 +ERP__STORAGE__UPLOAD_DIR=/app/uploads + +# 日志级别 +ERP__LOG__LEVEL=info + +# 微信小程序配置(不需要小程序功能可留空) +ERP__WECHAT__APPID= +ERP__WECHAT__SECRET= +ERP__WECHAT__DEV_MODE=false + +# AI 模块配置(不需要 AI 功能可留空) +ERP__AI__DEFAULT_PROVIDER=ollama +ERP__AI__API_KEY= +ERP__AI__BASE_URL=http://localhost:11434 +ERP__AI__MODEL=qwen2.5:7b + +# ===== DevOps ===== + +# 备份加密密码(openssl AES-256-CBC,必填用于生产) +BACKUP_PASSPHRASE=CHANGE_ME_BACKUP_ENCRYPTION_PASSWORD + +# 备份保留天数 +BACKUP_KEEP_DAYS=7 + +# 备份执行时间(cron 格式) +BACKUP_CRON=0 2 * * * + +# uploads 备份时间 +UPLOADS_BACKUP_CRON=0 3 * * * + +# Grafana 管理员密码 +GRAFANA_ADMIN_PASSWORD=CHANGE_ME_GRAFANA_ADMIN +GRAFANA_ROOT_URL=http://localhost:3001 diff --git a/docker/.gitignore b/docker/.gitignore new file mode 100644 index 0000000..69f29bd --- /dev/null +++ b/docker/.gitignore @@ -0,0 +1 @@ +.env.production diff --git a/docker/backup.sh b/docker/backup.sh new file mode 100644 index 0000000..d4f4168 --- /dev/null +++ b/docker/backup.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# PostgreSQL 自动备份脚本(含加密) +# 用法: +# 手动: ./docker/backup.sh +# 自动: 由 docker compose backup 服务每日 02:00 执行 +# +# 加密方式(二选一): +# BACKUP_PASSPHRASE — 使用 openssl AES-256-CBC 对称加密(无额外依赖) +# GPG_RECIPIENT — 使用 GPG 非对称加密(需预置公钥) +set -euo pipefail + +BACKUP_DIR="${BACKUP_DIR:-/backups}" +PG_HOST="${PGHOST:-postgres}" +PG_PORT="${PGPORT:-5432}" +PG_USER="${PGUSER:-erp}" +PG_DB="${PGDATABASE:-erp}" +KEEP_DAYS="${KEEP_DAYS:-7}" +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +FILENAME="${PG_DB}_${TIMESTAMP}.sql.gz" +ENCRYPTED_FILENAME="${FILENAME}.enc" +FILEPATH="${BACKUP_DIR}/${FILENAME}" +ENCRYPTED_FILEPATH="${BACKUP_DIR}/${ENCRYPTED_FILENAME}" + +mkdir -p "${BACKUP_DIR}" + +echo "[$(date -Iseconds)] 开始备份 ${PG_DB} → ${FILEPATH}" + +if pg_dump \ + -h "${PG_HOST}" \ + -p "${PG_PORT}" \ + -U "${PG_USER}" \ + -d "${PG_DB}" \ + --format=plain \ + --no-owner \ + --no-privileges \ + | gzip > "${FILEPATH}"; then + SIZE=$(du -h "${FILEPATH}" | cut -f1) + echo "[$(date -Iseconds)] 备份完成: ${FILENAME} (${SIZE})" +else + echo "[$(date -Iseconds)] 备份失败!" >&2 + rm -f "${FILEPATH}" + exit 1 +fi + +# ── 加密备份 ── +if [ -n "${BACKUP_PASSPHRASE:-}" ]; then + echo "[$(date -Iseconds)] 使用 AES-256-CBC 加密备份..." + if openssl enc -aes-256-cbc -salt -pbkdf2 -pass "pass:${BACKUP_PASSPHRASE}" \ + -in "${FILEPATH}" -out "${ENCRYPTED_FILEPATH}"; then + rm -f "${FILEPATH}" + ENC_SIZE=$(du -h "${ENCRYPTED_FILEPATH}" | cut -f1) + echo "[$(date -Iseconds)] 加密完成: ${ENCRYPTED_FILENAME} (${ENC_SIZE})" + else + echo "[$(date -Iseconds)] 加密失败!保留未加密备份" >&2 + rm -f "${ENCRYPTED_FILEPATH}" + fi +elif [ -n "${GPG_RECIPIENT:-}" ]; then + echo "[$(date -Iseconds)] 使用 GPG 加密备份..." + if gpg --batch --yes --encrypt --recipient "${GPG_RECIPIENT}" "${FILEPATH}"; then + rm -f "${FILEPATH}" + ENC_SIZE=$(du -h "${ENCRYPTED_FILEPATH}" | cut -f1) + echo "[$(date -Iseconds)] 加密完成: ${ENCRYPTED_FILENAME} (${ENC_SIZE})" + else + echo "[$(date -Iseconds)] GPG 加密失败!保留未加密备份" >&2 + rm -f "${FILEPATH}.gpg" + fi +else + echo "[$(date -Iseconds)] 警告: 未设置 BACKUP_PASSPHRASE 或 GPG_RECIPIENT,备份未加密!" >&2 +fi + +# ── 备份完整性校验 ── +LATEST_FILE=$(ls -t "${BACKUP_DIR}/${PG_DB}"_*.sql.gz* 2>/dev/null | head -1) +if [ -n "${LATEST_FILE}" ] && [ -f "${LATEST_FILE}" ]; then + if [[ "${LATEST_FILE}" == *.enc ]]; then + echo "[$(date -Iseconds)] 加密备份文件存在: $(basename "${LATEST_FILE}")" + elif gzip -t "${LATEST_FILE}" 2>/dev/null; then + echo "[$(date -Iseconds)] 备份完整性校验通过" + else + echo "[$(date -Iseconds)] 警告: 备份文件可能损坏: ${LATEST_FILE}" >&2 + fi +fi + +# ── 清理过期备份 ── +DELETED=$(find "${BACKUP_DIR}" -name "${PG_DB}_*.sql.gz*" -mtime +${KEEP_DAYS} -delete -print | wc -l) +if [ "${DELETED}" -gt 0 ]; then + echo "[$(date -Iseconds)] 已清理 ${DELETED} 个过期备份(>${KEEP_DAYS}天)" +fi + +# ── 恢复指引 ── +echo "" +echo "恢复方法:" +echo " # 解密(如加密):" +echo " openssl enc -d -aes-256-cbc -pbkdf2 -pass pass:\$BACKUP_PASSPHRASE -in ${ENCRYPTED_FILEPATH} -out ${FILEPATH}" +echo " # 恢复:" +echo " gunzip -c ${FILEPATH} | psql -h \$PGHOST -U \$PGUSER -d \$PGDB" diff --git a/docker/docker-compose.cloud.yml b/docker/docker-compose.cloud.yml new file mode 100644 index 0000000..efd8486 --- /dev/null +++ b/docker/docker-compose.cloud.yml @@ -0,0 +1,40 @@ +# 云端部署配置 — 仅启动应用容器,PG/Redis 使用宿主机已安装的服务 +# 使用方式: docker compose -f docker/docker-compose.cloud.yml up -d +# +# 前置条件: +# 1. 宿主机已安装 PostgreSQL 16 + Redis 7 +# 2. PostgreSQL 已创建数据库和用户 +# 3. 复制 .env.production.example 为 .env.production 并填写实际值 +# 4. OpenResty 反代配置: +# - /api/* → http://localhost:3000 +# - /uploads/* → http://localhost:3000 +# - / → 前端静态文件 (挂载 /opt/hms/static/) + +services: + app: + build: + context: .. + dockerfile: Dockerfile + container_name: hms-server + restart: unless-stopped + network_mode: host + env_file: + - .env.production + volumes: + - ../uploads:/app/uploads + - ../config:/app/config:ro + - ../static:/app/static + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/v1/health"] + interval: 30s + timeout: 5s + start_period: 60s + retries: 3 + deploy: + resources: + limits: + cpus: "2" + memory: 1024M + reservations: + cpus: "0.5" + memory: 256M diff --git a/docker/docker-compose.production.yml b/docker/docker-compose.production.yml new file mode 100644 index 0000000..71a62ce --- /dev/null +++ b/docker/docker-compose.production.yml @@ -0,0 +1,173 @@ +# 生产环境 Docker Compose 配置 +# 使用方式: docker compose -f docker/docker-compose.yml -f docker/docker-compose.production.yml up -d + +services: + # ── Nginx 反代 + TLS 终端 ── + nginx: + image: nginx:1.27-alpine + container_name: hms-nginx + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - nginx_logs:/var/log/nginx + depends_on: + app: + condition: service_healthy + healthcheck: + test: ["CMD", "wget", "--spider", "-q", "http://localhost:80"] + interval: 30s + timeout: 5s + retries: 3 + deploy: + resources: + limits: + cpus: "0.5" + memory: 128M + networks: + - hms-internal + + # ── HMS 应用服务器 ── + app: + build: + context: .. + dockerfile: Dockerfile + container_name: hms-server + restart: unless-stopped + expose: + - "3000" + - "9090" + env_file: + - .env.production + environment: + ERP__DATABASE__URL: postgres://${POSTGRES_USER:-erp}:${POSTGRES_PASSWORD}@postgres:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-erp} + ERP__REDIS__URL: redis://:${REDIS_PASSWORD}@redis:${REDIS_PORT:-6379} + volumes: + - app-uploads:/app/uploads + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/v1/health"] + interval: 30s + timeout: 5s + start_period: 60s + retries: 3 + deploy: + resources: + limits: + cpus: "2" + memory: 1024M + reservations: + cpus: "0.5" + memory: 256M + networks: + - hms-internal + + # ── 每日自动备份(含加密)── + backup: + image: postgres:16-alpine + container_name: hms-backup + restart: unless-stopped + entrypoint: > + sh -c " + echo '$$BACKUP_CRON /usr/local/bin/backup.sh' > /etc/crontabs/root && + crond -f -l 2 + " + environment: + PGHOST: postgres + PGPORT: "${POSTGRES_PORT:-5432}" + PGUSER: "${POSTGRES_USER:-erp}" + PGDATABASE: "${POSTGRES_DB:-erp}" + BACKUP_DIR: /backups + KEEP_DAYS: "${BACKUP_KEEP_DAYS:-7}" + BACKUP_CRON: "${BACKUP_CRON:-0 2 * * *}" + BACKUP_PASSPHRASE: "${BACKUP_PASSPHRASE:-}" + volumes: + - ./backup.sh:/usr/local/bin/backup.sh:ro + - backup_data:/backups + depends_on: + postgres: + condition: service_healthy + networks: + - hms-internal + + # ── uploads 文件备份(同步到宿主机)── + uploads-backup: + image: alpine:3.20 + container_name: hms-uploads-backup + restart: unless-stopped + entrypoint: > + sh -c " + echo '$$UPLOADS_BACKUP_CRON rsync -a --delete /source/uploads/ /backup/uploads/' > /etc/crontabs/root && + crond -f -l 2 + " + environment: + UPLOADS_BACKUP_CRON: "${UPLOADS_BACKUP_CRON:-0 3 * * *}" + volumes: + - app-uploads:/source/uploads:ro + - uploads_backup_data:/backup/uploads + networks: + - hms-internal + + # ── Prometheus 监控 ── + prometheus: + image: prom/prometheus:v3.1.0 + container_name: hms-prometheus + restart: unless-stopped + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.retention.time=30d" + - "--storage.tsdb.retention.size=2GB" + - "--web.enable-lifecycle" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro + - ./prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro + - prometheus_data:/prometheus + expose: + - "9090" + networks: + - hms-internal + + # ── Grafana 可视化 ── + grafana: + image: grafana/grafana:11.4.0 + container_name: hms-grafana + restart: unless-stopped + environment: + GF_SECURITY_ADMIN_USER: "${GRAFANA_ADMIN_USER:-admin}" + GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD:-}" + GF_USERS_ALLOW_SIGN_UP: "false" + GF_SERVER_ROOT_URL: "${GRAFANA_ROOT_URL:-http://localhost:3001}" + volumes: + - grafana_data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning:ro + expose: + - "3000" + depends_on: + - prometheus + networks: + - hms-internal + +volumes: + app-uploads: + driver: local + backup_data: + driver: local + uploads_backup_data: + driver: local + nginx_logs: + driver: local + prometheus_data: + driver: local + grafana_data: + driver: local + +networks: + hms-internal: + driver: bridge diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..9cd5c0b --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,50 @@ +version: "3.8" + +# WARNING: 生产环境必须通过 .env 文件或环境变量覆盖默认密码 +# 不要在生产环境使用默认密码 + +services: + postgres: + image: postgres:16-alpine + container_name: erp-postgres + environment: + POSTGRES_USER: ${POSTGRES_USER:-erp} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-erp_dev_2024} + POSTGRES_DB: ${POSTGRES_DB:-erp} + expose: + - "5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-erp}"] + interval: 5s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + cpus: "1.0" + memory: 512M + + redis: + image: redis:7-alpine + container_name: erp-redis + command: redis-server --requirepass ${REDIS_PASSWORD:-erp_redis_dev} --appendonly yes + expose: + - "6379" + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:-erp_redis_dev}", "ping"] + interval: 5s + timeout: 5s + retries: 5 + deploy: + resources: + limits: + cpus: "1.0" + memory: 512M + +volumes: + postgres_data: + redis_data: diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf new file mode 100644 index 0000000..e5608a6 --- /dev/null +++ b/docker/nginx/nginx.conf @@ -0,0 +1,96 @@ +upstream hms_backend { + server app:3000; + keepalive 32; +} + +server { + listen 80; + server_name _; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name _; + + # ── TLS ── + ssl_certificate /etc/nginx/ssl/fullchain.pem; + ssl_certificate_key /etc/nginx/ssl/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 1d; + ssl_session_tickets off; + + # ── 安全头 ── + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' wss:; frame-ancestors 'none'" always; + + # ── 日志 ── + access_log /var/log/nginx/hms_access.log; + error_log /var/log/nginx/hms_error.log warn; + + # ── 上传文件(化验单/体检报告)── + location /uploads/ { + proxy_pass http://hms_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # 大文件上传限制 + client_max_body_size 50m; + } + + # ── SSE(消息推送/AI 分析)── + location ~ ^/api/v1/(message|ai)/.*sse { + proxy_pass http://hms_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 86400s; + chunked_transfer_encoding on; + } + + # ── API 反代 ── + location /api/ { + proxy_pass http://hms_backend; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Connection ""; + client_max_body_size 50m; + } + + # ── 健康检查 ── + location /health { + proxy_pass http://hms_backend/api/v1/health; + access_log off; + } + + # ── 指标(仅内网可访问)── + location /metrics { + # 生产环境应限制为 Prometheus 访问 + allow 172.16.0.0/12; + allow 10.0.0.0/8; + deny all; + proxy_pass http://hms_backend:9090/metrics; + access_log off; + } + + location / { + return 404; + } +} diff --git a/docker/nginx/ssl/.gitignore b/docker/nginx/ssl/.gitignore new file mode 100644 index 0000000..c3d07e5 --- /dev/null +++ b/docker/nginx/ssl/.gitignore @@ -0,0 +1,3 @@ +* +!.gitkeep +!.gitignore diff --git a/docker/nginx/ssl/.gitkeep b/docker/nginx/ssl/.gitkeep new file mode 100644 index 0000000..c769dac --- /dev/null +++ b/docker/nginx/ssl/.gitkeep @@ -0,0 +1,8 @@ +# 将 SSL 证书放置在此目录 +# 必需文件: fullchain.pem + privkey.pem +# 生产环境建议使用 Let's Encrypt 或云服务商证书管理 +# +# Let's Encrypt 示例: +# certbot certonly --standalone -d your-domain.com +# cp /etc/letsencrypt/live/your-domain.com/fullchain.pem . +# cp /etc/letsencrypt/live/your-domain.com/privkey.pem . diff --git a/docker/prometheus/alerts.yml b/docker/prometheus/alerts.yml new file mode 100644 index 0000000..2af17f1 --- /dev/null +++ b/docker/prometheus/alerts.yml @@ -0,0 +1,103 @@ +groups: + # ── 系统级告警 ── + - name: system + rules: + - alert: HMSHighMemoryUsage + expr: process_resident_memory_bytes > 800000000 + for: 5m + labels: + severity: warning + annotations: + summary: "HMS 内存使用超过 800MB" + description: "当前值: {{ $value | humanize }}B" + + - alert: HMSHighMemoryCritical + expr: process_resident_memory_bytes > 1000000000 + for: 2m + labels: + severity: critical + annotations: + summary: "HMS 内存使用超过 1GB(危险)" + description: "当前值: {{ $value | humanize }}B" + + - alert: HMSHighCPU + expr: rate(process_cpu_seconds_total[5m]) > 0.8 + for: 10m + labels: + severity: warning + annotations: + summary: "HMS CPU 使用率超过 80%" + + # ── 应用级告警 ── + - name: application + rules: + - alert: HMSHighErrorRate + expr: rate(http_requests_total{status=~"5.."}[5m]) / rate(http_requests_total[5m]) > 0.05 + for: 5m + labels: + severity: critical + annotations: + summary: "API 5xx 错误率超过 5%" + description: "当前错误率: {{ $value | humanizePercentage }}" + + - alert: HMSSlowResponses + expr: histogram_quantile(0.95, rate(http_request_duration_seconds_bucket[5m])) > 2 + for: 10m + labels: + severity: warning + annotations: + summary: "95% 请求响应时间超过 2 秒" + + - alert: HMSInstanceDown + expr: up{job="hms"} == 0 + for: 2m + labels: + severity: critical + annotations: + summary: "HMS 服务不可达" + + # ── 数据库告警 ── + - name: database + rules: + - alert: HMSPostgresConnectionsHigh + expr: pg_stat_activity_count > 80 + for: 5m + labels: + severity: warning + annotations: + summary: "PostgreSQL 活跃连接数超过 80" + + - alert: HMSPostgresReplicationLag + expr: pg_replication_lag > 30 + for: 5m + labels: + severity: critical + annotations: + summary: "PostgreSQL 复制延迟超过 30 秒" + + - alert: HMSBackupMissing + expr: time() - hms_last_backup_timestamp > 86400 * 2 + for: 1h + labels: + severity: critical + annotations: + summary: "数据库备份超过 48 小时未执行" + + # ── Redis 告警 ── + - name: redis + rules: + - alert: HMSRedisMemoryHigh + expr: redis_memory_used_bytes / redis_memory_max_bytes > 0.9 + for: 5m + labels: + severity: warning + annotations: + summary: "Redis 内存使用超过 90%" + + - alert: HMSRedisDown + expr: redis_up == 0 + for: 2m + labels: + severity: critical + annotations: + summary: "Redis 服务不可达" diff --git a/docker/prometheus/prometheus.yml b/docker/prometheus/prometheus.yml new file mode 100644 index 0000000..2a4762b --- /dev/null +++ b/docker/prometheus/prometheus.yml @@ -0,0 +1,32 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +rule_files: + - "alerts.yml" + +scrape_configs: + - job_name: "hms" + metrics_path: /metrics + static_configs: + - targets: ["app:9090"] + labels: + service: "hms-server" + + - job_name: "postgres" + static_configs: + - targets: ["postgres-exporter:9187"] + labels: + service: "postgresql" + + - job_name: "redis" + static_configs: + - targets: ["redis-exporter:9121"] + labels: + service: "redis" + + - job_name: "nginx" + static_configs: + - targets: ["nginx-exporter:9113"] + labels: + service: "nginx" diff --git a/docker/restore.sh b/docker/restore.sh new file mode 100644 index 0000000..62c300e --- /dev/null +++ b/docker/restore.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +# PostgreSQL 备份恢复脚本 +# 用法: BACKUP_PASSPHRASE=xxx ./docker/restore.sh /backups/erp_20260521_020000.sql.gz.enc +set -euo pipefail + +BACKUP_FILE="${1:?用法: restore.sh <备份文件路径>}" +PG_HOST="${PGHOST:-postgres}" +PG_PORT="${PGPORT:-5432}" +PG_USER="${PGUSER:-erp}" +PG_DB="${PGDATABASE:-erp}" + +if [ ! -f "${BACKUP_FILE}" ]; then + echo "错误: 文件不存在: ${BACKUP_FILE}" >&2 + exit 1 +fi + +echo "[$(date -Iseconds)] 恢复目标: ${PG_HOST}:${PG_PORT}/${PG_DB}" +echo "[$(date -Iseconds)] 备份文件: ${BACKUP_FILE}" + +# 解密(如果是加密文件) +if [[ "${BACKUP_FILE}" == *.enc ]]; then + if [ -z "${BACKUP_PASSPHRASE:-}" ]; then + echo "错误: 加密备份需要设置 BACKUP_PASSPHRASE 环境变量" >&2 + exit 1 + fi + DECRYPTED="${BACKUP_FILE%.enc}" + echo "[$(date -Iseconds)] 解密中..." + openssl enc -d -aes-256-cbc -pbkdf2 -pass "pass:${BACKUP_PASSPHRASE}" \ + -in "${BACKUP_FILE}" -out "${DECRYPTED}" + BACKUP_FILE="${DECRYPTED}" +fi + +# 解压并恢复 +echo "[$(date -Iseconds)] 恢复中..." +gunzip -c "${BACKUP_FILE}" | psql -h "${PG_HOST}" -p "${PG_PORT}" -U "${PG_USER}" -d "${PG_DB}" + +echo "[$(date -Iseconds)] 恢复完成" + +# 清理解密文件 +if [ -n "${DECRYPTED:-}" ] && [ -f "${DECRYPTED}" ]; then + rm -f "${DECRYPTED}" + echo "[$(date -Iseconds)] 已清理解密临时文件" +fi diff --git a/docs/superpowers/specs/2026-05-31-nuanji-warm-notes-design.md b/docs/superpowers/specs/2026-05-31-nuanji-warm-notes-design.md new file mode 100644 index 0000000..4078661 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-nuanji-warm-notes-design.md @@ -0,0 +1,779 @@ +# 暖记 (Warm Notes) — 产品设计规格 + +> **版本**: 1.2 +> **日期**: 2026-05-31 +> **状态**: Phase 1 待确认 +> **平台**: Android / iOS / macOS / Windows / HarmonyOS +> **后端**: HMS ERP 基座 (Rust/Axum) + erp-diary 业务模块 + +--- + +## 1. 产品定位 + +**暖记** 是一款温暖治愈风格的手账日记 app,以手写/涂鸦为核心输入方式,覆盖小学到大学全学段用户。 + +### 1.1 目标用户 + +| 阶段 | 用户群 | 核心场景 | 优先级 | +|------|--------|----------|--------| +| **Phase 1** | 小学生 (8-12岁, 3-6年级) | 平板+触控笔手写日记、班级分享、老师点评 | ⭐ 核心 | +| **Phase 2** | 中学生 (13-15岁) | 独立创作、心情追踪、好友互动 | 扩展 | +| **Phase 3** | 高中/大学生/年轻人 (16+) | 完整社交、高级模板、数据分析 | 扩展 | + +### 1.2 核心价值主张 + +- **笔迹保真**:保留用户真实手写笔迹,适度去抖但不过度平滑,让日记有温度和个人辨识度 +- **手账体验**:贴纸、涂鸦、照片、和纸胶带——不只是在手机上打字,而是一本可以装饰的手账 +- **班级连接**:老师布置主题 → 学生写作 → 老师点评,形成写作闭环,培养写作习惯 +- **成长记录**:心情追踪、成就徽章、日历回顾,让用户看见自己的变化 + +### 1.3 双模式设计 + +App 同时支持两类用户,互不排斥: + +- **班级用户**:通过班级码加入,享受班级分享、老师点评功能。以小学生为主。 +- **独立用户**:手机号/第三方注册,自由使用全部日记功能,无需加入班级。覆盖中学生到成人。 + +--- + +## 2. 角色与权限 + +### 2.1 三种角色 + +#### 👩‍🏫 老师 +- 创建/管理班级,生成班级码 +- 布置日记主题/作业 +- 查看班级所有已分享的日记 +- 点评 + 文字评语 +- 统计班级完成情况 +- **不可**查看学生标记为"私密"的日记 + +#### 👧 学生 +- 手写/打字/拍照创建日记 +- 装饰日记(贴纸/涂鸦/胶带/照片) +- 选择性分享到班级(每篇日记自主选择公开或私密) +- 查看同学分享的日记 +- 收到老师评语通知 +- 收集成就徽章 + +#### 👨‍👩‍👧 家长 +- 绑定孩子账号(扫码/短信验证) +- 查看自己孩子的日记(只读) +- 查看老师的评语 +- 设置使用时间限制(可选) +- 接收周报/月报 +- **不可**查看其他学生日记 +- **不可**修改孩子日记内容 + +### 2.2 权限矩阵 + +| 操作 | 老师 | 学生 | 家长 | +|------|------|------|------| +| 创建日记 | — | ✅ | — | +| 查看自己孩子的日记 | — | ✅ | ✅ | +| 查看其他学生分享的日记 | ✅ | ✅ | — | +| 查看学生私密日记 | — | 仅自己 | — | +| 布置日记主题 | ✅ | — | — | +| 点评日记 | ✅ | — | — | +| 管理班级成员 | ✅ | — | — | +| 设置使用时间 | — | — | ✅ | + +--- + +## 3. 功能模块 + +### 3.1 手账编辑器(核心) + +**架构**:Flutter Stack 层叠 +- **Layer 1 — Canvas 手写层**:CustomPainter + perfect_freehand +- **Layer 2 — 元素层**:Positioned Widgets(贴纸/照片/文字/胶带),Draggable + 手势缩放旋转 +- **Layer 3 — 工具栏**:贴纸面板、模板面板、画笔面板、格式栏、标签面板 + +**手写引擎**: +- perfect_freehand 库实现速度→线宽映射 +- 触控笔压感直传(PointerEvent.pressure → 线宽/透明度) +- 轻量去抖(仅消除 ±1-2px 设备采样噪声,保留个人笔迹特征) +- Palm rejection(触控笔书写时忽略手掌触碰) +- 笔画收尾处理保留(起笔/收笔轻重变化是个人风格关键) + +**画笔工具**: +- 钢笔(细线、压感敏感) +- 铅笔(中等粗细、轻微纹理感) +- 马克笔(宽笔触、半透明叠加) +- 橡皮(区域擦除) + +**工具面板**: +- 贴纸面板:分类浏览(热门/可爱/植物/天气/节日/手绘/校园)、搜索、拖拽放置 +- 模板面板:日/周/月视图模板、学生专属模板 +- 画笔面板:工具选择、粗细滑块、颜色选择、透明度(马克笔专属) +- 格式栏:加粗/斜体/下划线、文字颜色、对齐方式 +- 标签面板:已选标签、输入新标签、推荐标签 + +**自动保存**:每次笔画结束或元素变更时增量保存。 + +### 3.2 日记内容数据模型 + +``` +JournalEntry { + id: String + title: String + date: DateTime + mood: Enum (happy, calm, sad, angry, thinking) + weather: Enum (sunny, cloudy, rainy, snowy, windy) + elements: List // 有序排列 + tags: List + isSharedToClass: Boolean + teacherComment: String? + createdAt: DateTime + updatedAt: DateTime +} + +JournalElement { + type: Enum (handwriting, text, sticker, photo, washiTape) + position: Offset + size: Size + rotation: Double + zIndex: Int + // type-specific data: + handwritingData: HandwritingStroke[]? // 矢量点序列 + textContent: String? + stickerId: String? + photoPath: String? + washiTapeStyle: String? +} + +HandwritingStroke { + points: List // 原始坐标 + pressures: List // 压感数据 + timestamps: List // 时间戳(可回放) + color: String + width: Double + toolType: Enum (pen, pencil, marker, eraser) + opacity: Double +} +``` + +### 3.3 日历系统 + +- **月视图**:7×N 网格,心情色彩标记(心情点),日记标记点 +- **周视图**:7 天概览,每日卡片展示日记缩略 +- **时间轴**:按时间线展示当日日记条目 +- **心情概览**:条形图统计本月各心情天数 + +### 3.4 心情追踪 + +- 5 种标准化心情:😊开心、😐平静、😢难过、😡生气、🤔思考 +- 天气标记:☀️晴、⛅多云、🌧雨、❄️雪、🍃风 +- 心情趋势图:近7天/30天/3月 +- 统计卡片:好心情占比、连续记录天数、天气分布、日记篇数 +- 心情洞察:最佳心情日、开心触发因素、月度变化趋势 + +### 3.5 班级系统(Phase 1 新增) + +#### 班级功能 +- 老师创建班级 → 生成 6 位班级码 → 学生输入加入 +- 班级成员列表(老师可管理) +- 班级日记墙(展示所有已分享的日记) +- 老师布置日记主题/作业(带截止时间) +- 老师点评 + 评语通知 + +#### 分享机制 +- 学生写完日记后自主选择:分享到班级 / 保持私密 +- 分享后的日记出现在班级日记墙 +- 老师可点评任意已分享日记 +- 学生收到点评通知 + +### 3.6 贴纸/模板系统 + +- 预装基础贴纸包(免费) +- 扩展贴纸包下载(免费 + 付费) +- 分类:热门/可爱/植物/天气/节日/手绘/校园/文字/和纸胶带 +- 收藏夹功能 +- 模板画廊:日/周/月视图模板、学生专属模板(考试复习/课程表/读书笔记/校园生活) + +### 3.7 成就系统 + +- 连续记录徽章(7天/30天/100天) +- 百篇日记徽章 +- 贴纸达人徽章 +- 年度记录徽章 +- 解锁条件明确,视觉反馈清晰 + +### 3.8 搜索系统 + +- 全文搜索日记内容 +- 按标签搜索 +- 按心情/天气筛选 +- 模板搜索 +- 搜索历史 + 热门搜索 + +### 3.9 个人中心 + +- 头像 + 昵称 + 签名 +- 统计:总日记数、连续天数、本月日记、使用贴纸数 +- 成就徽章展示 +- 设置:日记提醒、隐私锁、云同步、主题外观、数据导出、反馈、关于 + +--- + +## 4. 视觉设计系统 + +### 4.1 色彩 + +| Token | 浅色模式 | 深色模式 | 用途 | +|-------|---------|---------|------| +| bg | #FFF8F0 | #1A1614 | 页面背景(奶油白) | +| surface | #FFFFFF | #2A2520 | 卡片/面板背景 | +| fg | #2D2420 | #F0E8DF | 主文字 | +| accent | #E07A5F | #E8907A | 主色调(珊瑚色) | +| secondary | #81B29A | #8FBF9E | 辅助色(鼠尾草绿) | +| tertiary | #F2CC8F | #D4B878 | 第三色(暖金) | +| rose | #D4A5A5 | #C4A0A0 | 玫瑰粉 | + +### 4.2 字体 + +- 显示字体:Quicksand / Nunito / SF Pro Rounded +- 正文字体:Nunito +- 手写体:Caveat / Kalam +- 等宽字体:JetBrains Mono + +### 4.3 设计要素 + +- 大圆角体系:10px / 16px / 22px / 28px / pill +- 柔和阴影:soft / medium / float 三级 +- 触摸目标最小 44px(WCAG 2.5.8) +- 弹性动画曲线:cubic-bezier(0.34, 1.56, 0.64, 1) +- 深色模式完整支持 + +--- + +## 5. 技术架构 + +### 5.1 技术栈 + +| 层级 | 技术方案 | 选型理由 | +|------|---------|---------| +| UI 框架 | Flutter 3.x | 跨平台、高性能自绘引擎 | +| 状态管理 | flutter_bloc (BLoC) | 复杂交互场景、可测试性强 | +| 本地存储 | Isar | 查询能力 + 全文搜索 + 内置加密 | +| 手写绘制 | CustomPainter + perfect_freehand | 笔迹保真、压感支持、速度→线宽 | +| 图表 | fl_chart | 功能完善、高度自定义 | +| 主题 | ThemeData + ColorScheme | 官方支持、深色模式 | +| 图片处理 | flutter_image_compress | 压缩存储 | +| 路由 | go_router | 声明式路由、深链接支持 | +| 网络 | dio + connectivity_plus | 云同步 + 离线检测 | +| 数据模型 | freezed + json_serializable | 不可变、类型安全 | +| 通知 | flutter_local_notifications | 日记提醒 | +| 权限 | permission_handler | 相机/存储/通知 | +| 导出 | pdf + screenshot + share_plus | 日记导出与分享 | +| 日志 | logger | 调试与错误追踪 | + +### 5.2 跨平台策略 + +- **Android / iOS**:Flutter 直接构建(Phase 1 首发) +- **macOS / Windows**:Flutter 桌面端适配(响应式布局) +- **HarmonyOS**:通过 ohos_flutter 社区方案适配 +- **优先级**:Android/iOS → macOS/Windows → HarmonyOS + +### 5.3 响应式布局 + +- 手机端(< 600px):单列布局,底部 TabBar +- 平板端(600-1024px):双栏布局(列表+详情),侧边导航 +- 桌面端(> 1024px):三栏布局(导航+列表+详情/编辑器) + +### 5.4 数据架构 + +**离线优先 + 定时云同步**: +- 所有数据优先写入 Isar 本地数据库,离线完全可用 +- WiFi 环境下自动增量同步到云端(避免消耗学生流量) +- 同步冲突策略:本地优先 → 云端合并 → 人工提示解决 +- 班级分享数据通过云端推送(需网络),本地缓存离线可读 +- 素材包:预装基础贴纸 + WiFi 下载扩展包 + 本地缓存管理 + +--- + +## 6. 注册与安全 + +### 6.1 注册流程 + +**班级用户(小学生)**: +1. 方式 A:输入老师提供的 6 位班级码 → 设置昵称+头像 → 加入班级 → 首次使用需家长授权 +2. 方式 B:家长注册 → 为孩子创建子账号 → 绑定班级码 + +**独立用户(中学/成人)**: +1. 手机号 + 验证码注册 +2. 第三方登录(微信/Apple/Google) +3. 选择年龄段标签 +4. 直接使用全部功能 + +### 6.2 儿童安全 + +**PIPL 合规(中国个人信息保护法)**: +- 未满 14 岁必须取得父母/监护人同意(家长授权弹窗 + 短信/扫码确认) +- 制定专门的儿童个人信息处理规则(单独隐私政策文档) +- 收集信息最小必要(昵称+年级即可,无需真实姓名/身份证) +- 家长有权查阅/更正/删除孩子的个人信息 +- 账号注销后 30 天内删除所有数据 + +**数据安全**: +- Isar 内置加密存储(日记内容 AES 加密) +- 云同步 TLS 传输加密 +- 照片加密本地存储 + +**内容安全**: +- 敏感词本地词库过滤 +- 分享前自动检查 +- 老师可审核班级内容 +- 家长可查看孩子日记(只读) + +--- + +## 7. Phase 1 页面清单 + +基于原型稿,Phase 1 需实现的屏幕: + +### 7.1 直接复用原型稿(需适配小学生内容) + +| 页面 | 原型文件 | 调整说明 | +|------|---------|---------| +| 启动页 | splash.html | 保持不变 | +| 引导页 | onboarding.html | 内容调整为小学生向 | +| 首页日记流 | home-daily.html | 移除"考研"等成人内容 | +| 手账编辑器 | editor.html | 强化手写优先 | +| 日历视图 | calendar.html | 保持不变 | +| 周概览 | weekly.html | 保持不变 | +| 月度概览 | monthly.html | 保持不变 | +| 心情追踪 | mood-tracker.html | 保持不变 | +| 贴纸素材库 | stickers.html | 增加校园主题贴纸 | +| 模板画廊 | templates.html | 内容调整为小学生向 | +| 搜索 | search.html | 保持不变 | +| 个人中心 | profile.html | 增加家长关联/班级入口 | + +### 7.2 Phase 1 新增页面 + +| 页面 | 说明 | +|------|------| +| 班级码加入 | 输入班级码 → 设置昵称头像 → 加入班级 | +| 家长授权 | 弹窗 → 家长扫码/短信确认 → 授权完成 | +| 班级主页 | 班级信息、成员列表、日记墙、老师布置的主题 | +| 班级日记墙 | 瀑布流展示同学分享的日记 | +| 老师布置主题 | 老师发布日记主题/作业(带截止时间) | +| 老师点评 | 查看学生分享日记 → 写评语 | +| 家长仪表板 | 查看孩子日记、使用报告、时间设置 | +| 家长关联 | 扫码/短信绑定孩子账号 | + +### 7.3 推迟到 Phase 2+ + +| 页面 | 原型文件 | 说明 | +|------|---------|------| +| 发现页 | discover.html | 社交功能,Phase 2 开放给中学生 | + +--- + +## 8. 项目范围总结 + +### Phase 1 MVP 范围(小学生版) +- ✅ 完整手账编辑器(手写优先) +- ✅ 日历/周/月视图 +- ✅ 心情追踪系统 +- ✅ 贴纸/模板系统(预装 + 扩展下载) +- ✅ 班级系统(创建/加入/分享/点评) +- ✅ 三角色(老师/学生/家长) +- ✅ 成就系统 +- ✅ 搜索系统 +- ✅ 儿童安全与家长控制 +- ✅ 深色模式 +- ✅ Android + iOS 首发 +- ❌ 发现页/社交功能(Phase 2) +- ❌ 达人日记/热门话题(Phase 2+) +- ❌ 高级数据分析(Phase 3) +- ❌ macOS/Windows/HarmonyOS(Phase 1 后续) + +--- + +## 9. 后端架构 — 基于 HMS ERP 基座 + +> **核心决策:复用 HMS 健康管理平台的 ERP 基座,以 Feature Flag 工作区模式加载暖记业务模块。** + +### 9.1 架构策略:Feature Flag 工作区 + +采用 **单一仓库 + Cargo Workspace + Feature Flag** 模式: + +``` +hms/ # 一个仓库 +├── crates/ +│ ├── erp-core/ # L1: 基座 — 事件·错误·trait +│ ├── erp-auth/ # L2: 基座 — 用户·角色·权限·JWT +│ ├── erp-config/ # L2: 基座 — 字典·菜单·设置 +│ ├── erp-message/ # L2: 基座 — 消息·通知·推送 +│ ├── erp-workflow/ # L2: 基座 — 工作流(可选) +│ ├── erp-plugin/ # L2: 基座 — 插件运行时 +│ │ +│ ├── erp-diary/ # L2: 暖记业务 — 日记·班级·贴纸·心情 +│ │ ├── src/entity/ # ~15 Entity(日记·班级·贴纸等) +│ │ ├── src/service/ # ~12 Service(日记CRUD·同步·班级管理) +│ │ ├── src/handler/ # ~10 Handler(REST API) +│ │ └── src/event.rs # diary.created / diary.shared 等 +│ │ +│ ├── erp-health/ # L2: HMS 业务(feature flag 控制) +│ ├── erp-ai/ # L2: HMS 业务(feature flag 控制) +│ ├── erp-dialysis/ # L2: HMS 业务(feature flag 控制) +│ │ +│ └── erp-server/ # L3: Axum 入口,按 feature 组装模块 +│ └── migration/ # SeaORM 迁移(基座 + 业务) +├── apps/ +│ ├── web/ # HMS 管理后台(React SPA) +│ └── nuanji-api/ # 暖记专用 API 网关(可选) +└── Cargo.toml # Workspace root +``` + +**Feature Flag 控制**: + +```toml +# Cargo.toml (workspace) +[workspace] +members = [ + "crates/erp-core", + "crates/erp-auth", + "crates/erp-config", + "crates/erp-message", + "crates/erp-workflow", + "crates/erp-plugin", + "crates/erp-diary", # 暖记业务模块 + "crates/erp-server", +] + +# erp-server/Cargo.toml +[features] +default = ["diary"] +diary = ["erp-diary"] +health = ["erp-health"] +ai = ["erp-ai"] +dialysis = ["erp-dialysis"] +full = ["diary", "health", "ai", "dialysis"] +``` + +**启动时按 feature 组装模块**: + +```rust +// erp-server/src/main.rs +fn register_modules() -> Vec> { + let mut modules: Vec> = vec![ + Box::new(AuthModule), + Box::new(ConfigModule), + Box::new(MessageModule), + ]; + + #[cfg(feature = "diary")] + modules.push(Box::new(DiaryModule)); + + #[cfg(feature = "health")] + modules.push(Box::new(HealthModule)); + + modules +} +``` + +**部署实例**: +- `cargo build --features diary` → 暖记后端(不含医疗模块) +- `cargo build --features health,ai` → HMS 后端(不含日记模块) +- `cargo build --features full` → 全功能实例 + +### 9.2 技术栈(继承 HMS 基座) + +| 层级 | 技术方案 | 来源 | +|------|---------|------| +| 后端框架 | Axum 0.8 (Rust) | HMS 基座继承 | +| ORM | SeaORM 1.1 | HMS 基座继承 | +| 数据库 | PostgreSQL 16 | HMS 基座继承 | +| 缓存 | Redis 7 | HMS 基座继承 | +| 认证 | JWT + Argon2 + RBAC | HMS 基座继承(扩展角色) | +| 加密 | AES-256-GCM + KEK/DEK | HMS 基座继承 | +| 事件 | EventBus + Outbox | HMS 基座继承 | +| 文件存储 | 阿里云 OSS / 腾讯云 COS | 新增(贴纸/照片) | +| 推送 | 极光推送 / 个推 | 新增(老师点评通知) | +| 短信 | 阿里云短信 | HMS 基座继承 | +| CDN | 阿里云 CDN | 新增(贴纸包分发) | +| API 文档 | utoipa (OpenAPI) | HMS 基座继承 | + +> **数据本地化**:儿童个人信息必须存储在中国境内服务器。HMS 已支持多租户 + 租户独立加密密钥。 + +### 9.3 基座继承能力 vs 新增开发 + +| 能力 | 来源 | 工作量 | +|------|------|--------| +| 用户/角色/权限 CRUD | erp-auth 继承 | ✅ 零开发 | +| JWT 认证 + Token 轮换 | erp-auth 继承 | ✅ 零开发 | +| RBAC 权限码守卫 | erp-auth 继承 | ✅ 零开发 | +| 组织/部门/岗位 | erp-auth 继承 | ✅ 零开发 | +| 事件总线 + Outbox | erp-core 继承 | ✅ 零开发 | +| 字典/菜单/设置 | erp-config 继承 | ✅ 零开发 | +| 消息/通知/模板 | erp-message 继承 | ✅ 零开发 | +| PII 加密 + 盲索引 | erp-core 继承 | ✅ 零开发 | +| 审计日志 | erp-core 继承 | ✅ 零开发 | +| 多租户隔离 | erp-core 继承 | ✅ 零开发 | +| SeaORM 迁移框架 | erp-server 继承 | ✅ 零开发 | +| OpenAPI 文档生成 | utoipa 继承 | ✅ 零开发 | +| 测试框架 | HMS 基座继承 | ✅ 零开发 | +| student/teacher/parent 角色 | erp-auth 扩展 | 🆕 ~200 行 | +| 班级码认证 | erp-auth 扩展 | 🆕 ~500 行 | +| 家长扫码绑定 | 新增 service | 🆕 ~400 行 | +| 日记 CRUD + 同步 | erp-diary 新增 | 🆕 ~2000 行 | +| 班级管理 | erp-diary 新增 | 🆕 ~800 行 | +| 贴纸/模板管理 | erp-diary 新增 | 🆕 ~600 行 | +| 心情/统计 API | erp-diary 新增 | 🆕 ~500 行 | +| 内容安全过滤 | erp-diary 新增 | 🆕 ~300 行 | +| 文件上传(照片/贴纸) | 参考健康模块 | 🆕 ~500 行 | +| **合计新增** | | **~5800 行 Rust** | + +### 9.4 核心后端模块(erp-diary) + +1. **日记服务**:日记 CRUD、元素管理、增量同步、版本号冲突检测、软删除 +2. **手写数据服务**:笔画矢量存储、按需加载(大字段独立表)、导出为图片 +3. **班级服务**:创建班级、生成班级码、成员管理、班级码安全(有效期+次数限制+锁定) +4. **主题布置服务**:老师发布/管理日记主题、学生提交关联、完成度统计 +5. **点评服务**:老师评语、通知推送(继承 erp-message) +6. **家长绑定服务**:扫码/短信验证、绑定关系管理、只读权限控制 +7. **素材服务**:贴纸包管理、下载统计、Phase 1 全免费 +8. **心情统计服务**:心情趋势、统计卡片、洞察分析 +9. **内容安全服务**:敏感词过滤(含谐音/拼音变体检测)、图片安全标注 +10. **成就服务**:成就规则引擎、徽章解锁、通知 + +### 9.5 基座优化同步策略 + +由于所有项目共享一个仓库: +- 基座层的 Bug 修复或优化只提交一次 +- 所有 feature 配置的构建自动受益 +- CI 按矩阵运行:`--features diary` + `--features health` 等组合 +- 迁移文件统一管理,按序号递增(暖记迁移从 m000166 开始) + +--- + +## 10. 完整数据模型(评审补充) + +### 10.1 用户与角色 + +``` +User { + id: String (UUID) + role: Enum (teacher, student, parent, independent) + nickname: String + avatarUrl: String? + phone: String? // 独立用户/家长/老师必须有 + gradeLevel: Int? // 学生年级 (1-6) + ageGroup: String? // 独立用户年龄段标签 + parentAuthorized: Boolean // 未满14岁需家长授权 + createdAt: DateTime + settings: UserSettings +} + +UserSettings { + theme: Enum (light, dark, system) + reminderEnabled: Boolean + reminderTime: String // "21:00" + privacyLockEnabled: Boolean + syncOnWifiOnly: Boolean + language: String // "zh-CN" +} + +TeacherProfile { + userId: String + schoolName: String? + verificationStatus: Enum (pending, verified, rejected) + // 老师身份验证:教师证上传 或 学校邮箱验证 +} + +ParentChildBinding { + id: String + parentId: String + childId: String + verificationMethod: Enum (qrcode, sms) + status: Enum (active, revoked) + createdAt: DateTime +} +``` + +### 10.2 班级 + +``` +Class { + id: String + name: String // "三年级2班" + schoolName: String? + teacherId: String // 创建者/班主任 + classCode: String // 6位字母数字混合(安全性优于纯数字) + codeExpiresAt: DateTime? // 班级码有效期(学期结束自动失效) + codeMaxUses: Int? // 班级码最大使用次数 + memberCount: Int + createdAt: DateTime + isActive: Boolean // 学期结束后可归档 +} + +ClassMember { + classId: String + userId: String + role: Enum (teacher, student) + joinedAt: DateTime + isActive: Boolean +} +``` + +### 10.3 日记(完整模型) + +``` +JournalEntry { + id: String (UUID) + authorId: String // 明确谁写的 + classId: String? // 所属班级(可选) + title: String + date: DateTime + mood: Enum (happy, calm, sad, angry, thinking) + weather: Enum (sunny, cloudy, rainy, snowy, windy) + elements: List + tags: List + // 分享状态 + isPrivate: Boolean // true = 仅自己可见 + sharedToClass: Boolean // 是否已分享到班级 + sharedAt: DateTime? // 分享时间 + assignedTopicId: String? // 关联老师布置的主题 + // 元数据 + version: Int // 版本号(同步冲突检测) + createdAt: DateTime + updatedAt: DateTime + deletedAt: DateTime? // 软删除 +} +``` + +### 10.4 主题布置与点评 + +``` +TopicAssignment { + id: String + classId: String + teacherId: String + title: String // "今天我观察了..." + description: String? + deadline: DateTime? + createdAt: DateTime + isActive: Boolean +} + +Comment { + id: String + journalEntryId: String + authorId: String // 点评者(老师) + content: String + createdAt: DateTime +} +``` + +### 10.5 素材与成就 + +``` +StickerPack { + id: String + name: String + category: String + stickerCount: Int + price: Decimal // 0 = 免费 + downloadUrl: String + version: Int +} + +Achievement { + id: String + name: String + description: String + iconEmoji: String + condition: String // "streak_days >= 7" +} + +UserAchievement { + userId: String + achievementId: String + earnedAt: DateTime +} +``` + +--- + +## 11. 安全与合规补充(评审补充) + +### 11.1 老师注册流程 + +1. 下载 app → 选择"我是老师" +2. 注册:手机号 + 验证码 +3. 身份验证(二选一): + - **方式 A**:上传教师证照片 → 人工审核(1-3工作日) + - **方式 B**:学校教育邮箱验证(.edu.cn 后缀) +4. 审核通过后可创建班级 + +### 11.2 PIPL 合规补充 + +- **监护人可验证性**:家长需提供手机号(运营商实名)+ 短信验证码确认身份 +- **儿童个人信息影响评估**:上线前完成《个人信息保护影响评估报告》 +- **数据本地化**:所有服务器部署在中国境内(阿里云/腾讯云) +- **家长数据管理权**:家长仪表板提供导出/更正/删除孩子数据功能 +- **儿童隐私政策**:使用通俗易懂的语言编写,配有图示说明 +- **账号注销**:注销后 30 天内删除所有关联数据 + +### 11.3 班级码安全机制 + +- 6 位字母数字混合(62^6 ≈ 568 亿种组合) +- 班级码有效期(默认学期结束自动失效) +- 老师可随时重置班级码 +- 连续 5 次输入错误后锁定 30 分钟 +- 可选开启"加入需老师审批"模式 + +### 11.4 Phase 1 贴纸付费策略 + +- **Phase 1 全部免费**:避免儿童支付合规问题 +- 预装 3-5 个基础贴纸包 +- 限时活动赠送主题贴纸包 +- **Phase 2** 再引入付费机制(家长代付) + +### 11.5 内容安全方案 + +- **文字**:本地敏感词库 + 服务端 AI 内容检测(含谐音/拼音变体) +- **图片**:服务端 AI 安全标注(检测不当图片) +- **分享审核**:可配置"老师审核后才能分享到班级" +- **举报机制**:学生/老师可举报不当内容 + +--- + +## 12. 字体与中文化补充(评审补充) + +### 12.1 中文字体方案 + +| 用途 | 浅色字体 | 回退方案 | +|------|---------|---------| +| 显示字体 | 思源黑体 (Noto Sans SC) / 阿里巴巴普惠体 | PingFang SC (iOS) / 系统默认 | +| 正文 | Noto Sans SC / 系统默认 | 各平台系统字体 | +| 手写风格 | 漫漫字 / 手写体 | Caveat (英文手写部分) | + +> Flutter 可通过 Google Fonts 加载 Noto Sans SC,确保中文排版美观一致。 + +### 12.2 perfect_freehand 技术确认 + +`perfect_freehand` 有 Dart/Flutter 原生移植版本(`perfect_freehand` pub.dev 包),可直接在 Flutter 中使用,无需 JS interop。 + +--- + +## 13. 性能目标(评审补充) + +| 指标 | 目标值 | +|------|--------| +| App 冷启动 | < 2 秒 | +| 日记列表滚动 | 60fps 稳定 | +| 手写延迟 | < 16ms(触控到渲染) | +| 照片压缩后质量 | ≥ 85% SSIM | +| 单篇日记本地存储 | < 5MB(含照片) | +| 本地数据库上限 | 500MB(超限提示清理) | +| 云同步单次数据量 | < 10MB | +| 贴纸包大小 | < 5MB/包 | + +--- + +*本文档为暖记 (Warm Notes) Phase 1 产品设计规格,基于原型稿分析与头脑风暴讨论整理。* +*评审版本:v1.1 — 补充后端架构、完整数据模型、安全合规、性能目标* +*v1.2 — 后端架构从 NestJS 方案替换为 HMS ERP 基座 (Rust/Axum) + Feature Flag 工作区模式* diff --git a/permissions.yaml b/permissions.yaml new file mode 100644 index 0000000..5a36f50 --- /dev/null +++ b/permissions.yaml @@ -0,0 +1,317 @@ +# HMS 权限注册表 — 单一真相源 +# +# 此文件是权限码的权威来源。所有模块的权限必须在此声明。 +# CI 脚本 check-permissions.sh 从此文件验证一致性。 +# +# 用法: +# - 新增权限: 在对应模块下添加条目 +# - 生成 seed: node scripts/gen-permissions.js --seed +# - 验证一致: bash scripts/check-permissions.sh + +auth: + module: erp-auth + description: 用户/角色/权限/组织/部门/岗位 + permissions: + - code: user.list + name: 查看用户列表 + - code: user.create + name: 创建用户 + - code: user.read + name: 查看用户详情 + - code: user.update + name: 编辑用户 + - code: user.delete + name: 删除用户 + - code: role.list + name: 查看角色列表 + - code: role.create + name: 创建角色 + - code: role.read + name: 查看角色详情 + - code: role.update + name: 编辑角色 + - code: role.delete + name: 删除角色 + - code: permission.list + name: 查看权限 + - code: organization.list + name: 查看组织列表 + - code: organization.create + name: 创建组织 + - code: organization.update + name: 编辑组织 + - code: organization.delete + name: 删除组织 + - code: department.list + name: 查看部门列表 + - code: department.create + name: 创建部门 + - code: department.update + name: 编辑部门 + - code: department.delete + name: 删除部门 + - code: position.list + name: 查看岗位列表 + - code: position.create + name: 创建岗位 + - code: position.update + name: 编辑岗位 + - code: position.delete + name: 删除岗位 + +config: + module: erp-config + description: 字典/菜单/配置/编号/主题/语言 + permissions: + - code: dictionary.list + name: 查看字典 + - code: dictionary.create + name: 创建字典 + - code: dictionary.update + name: 编辑字典 + - code: dictionary.delete + name: 删除字典 + - code: menu.list + name: 查看菜单 + - code: menu.update + name: 编辑菜单 + - code: setting.read + name: 查看配置 + - code: setting.update + name: 编辑配置 + - code: setting.delete + name: 删除配置 + - code: numbering.list + name: 查看编号规则 + - code: numbering.create + name: 创建编号规则 + - code: numbering.update + name: 编辑编号规则 + - code: numbering.delete + name: 删除编号规则 + - code: numbering.generate + name: 生成编号 + - code: theme.read + name: 查看主题 + - code: theme.update + name: 编辑主题 + - code: language.list + name: 查看语言 + - code: language.update + name: 编辑语言 + +workflow: + module: erp-workflow + description: 流程定义/审批/委派 + permissions: + - code: workflow.create + name: 创建流程 + - code: workflow.list + name: 查看流程 + - code: workflow.read + name: 查看流程详情 + - code: workflow.update + name: 编辑流程 + - code: workflow.publish + name: 发布流程 + - code: workflow.start + name: 发起流程 + - code: workflow.approve + name: 审批任务 + - code: workflow.delegate + name: 委派任务 + +message: + module: erp-message + description: 消息/模板 + permissions: + - code: message.list + name: 查看消息 + - code: message.send + name: 发送消息 + - code: message.template.list + name: 查看消息模板 + - code: message.template.create + name: 创建消息模板 + - code: message.template.manage + name: 管理消息模板 + +plugin: + module: erp-plugin + description: 插件管理 + permissions: + - code: plugin.admin + name: 插件管理 + - code: plugin.list + name: 查看插件 + +health: + module: erp-health + description: 患者管理/健康数据/预约排班/随访/咨询/告警/设备/积分/内容/媒体 + permissions: + - code: health.patient.list + name: 查看患者列表 + - code: health.patient.manage + name: 管理患者 + - code: health.health-data.list + name: 查看健康数据 + - code: health.health-data.manage + name: 管理健康数据 + - code: health.appointment.list + name: 查看预约 + - code: health.appointment.manage + name: 管理预约 + - code: health.follow-up.list + name: 查看随访 + - code: health.follow-up.manage + name: 管理随访 + - code: health.consultation.list + name: 查看咨询 + - code: health.consultation.manage + name: 管理咨询 + - code: health.doctor.list + name: 查看医护 + - code: health.doctor.manage + name: 管理医护 + - code: health.articles.list + name: 查看资讯 + - code: health.articles.manage + name: 管理资讯 + - code: health.articles.review + name: 审核资讯 + - code: health.points.list + name: 查看积分 + - code: health.points.manage + name: 管理积分 + - code: health.device-readings.list + name: 查看设备数据 + - code: health.device-readings.manage + name: 管理设备数据 + - code: health.devices.list + name: 查看设备绑定 + - code: health.devices.manage + name: 管理设备绑定 + - code: health.alerts.list + name: 查看告警 + - code: health.alerts.manage + name: 管理告警 + - code: health.alert-rules.list + name: 查看告警规则 + - code: health.alert-rules.manage + name: 管理告警规则 + - code: health.critical-alerts.list + name: 查看危急值告警 + - code: health.critical-alerts.manage + name: 处理危急值告警 + - code: health.critical-value-thresholds.list + name: 查看危急值阈值 + - code: health.critical-value-thresholds.manage + name: 管理危急值阈值 + - code: health.follow-up-templates.list + name: 查看随访模板 + - code: health.follow-up-templates.manage + name: 管理随访模板 + - code: health.daily-monitoring.list + name: 查看日常监测 + - code: health.daily-monitoring.manage + name: 管理日常监测 + - code: health.consent.list + name: 查看知情同意 + - code: health.consent.manage + name: 管理知情同意 + - code: health.medication-records.list + name: 查看用药记录 + - code: health.medication-records.manage + name: 管理用药记录 + - code: health.medication-reminders.list + name: 查看药物提醒 + - code: health.medication-reminders.manage + name: 管理药物提醒 + - code: health.action-inbox.list + name: 查看行动收件箱 + - code: health.action-inbox.manage + name: 管理行动项 + - code: health.action-inbox.team + name: 查看团队概览 + - code: health.dashboard.manage + name: 工作台管理 + - code: health.oauth.list + name: 查看合作方 + - code: health.oauth.manage + name: 管理合作方 + - code: health.care-plan.list + name: 查看护理计划 + frozen: true + - code: health.care-plan.manage + name: 管理护理计划 + frozen: true + - code: health.shifts.list + name: 查看班次 + frozen: true + - code: health.shifts.manage + name: 管理班次 + frozen: true + - code: health.ble-gateways.list + name: 查看 BLE 网关 + - code: health.ble-gateways.manage + name: 管理 BLE 网关 + - code: health.family-proxy.list + name: 查看家庭健康代理 + frozen: true + - code: health.family-proxy.manage + name: 管理家庭健康代理 + frozen: true + - code: health.media.list + name: 查看媒体库 + - code: health.media.manage + name: 管理媒体库 + - code: health.banners.list + name: 查看轮播图 + - code: health.banners.manage + name: 管理轮播图 + +ai: + module: erp-ai + description: AI 分析/Prompt/Copilot + permissions: + - code: ai.analysis.list + name: 查看分析历史 + - code: ai.analysis.manage + name: 请求分析 + - code: ai.prompt.list + name: 查看 Prompt + - code: ai.prompt.manage + name: 管理 Prompt + - code: ai.usage.list + name: 查看用量 + - code: ai.provider.manage + name: 管理提供商 + - code: ai.suggestion.list + name: 查看 AI 建议 + - code: ai.suggestion.manage + name: 审批 AI 建议 + - code: copilot.insights.list + name: 查看 Copilot 洞察 + - code: copilot.insights.manage + name: 管理 Copilot 洞察 + - code: copilot.risk.view + name: 查看风险评分 + - code: copilot.rules.list + name: 查看 Copilot 规则 + - code: copilot.rules.manage + name: 管理 Copilot 规则 + +dialysis: + module: erp-dialysis + description: 透析管理 + permissions: + - code: health.dialysis.list + name: 查看透析记录 + - code: health.dialysis.manage + name: 管理透析记录 + - code: health.dialysis-prescription.list + name: 查看透析处方 + - code: health.dialysis-prescription.manage + name: 管理透析处方 + - code: health.dialysis.stats + name: 查看透析统计 diff --git a/scripts/api_test.sh b/scripts/api_test.sh new file mode 100644 index 0000000..1167f66 --- /dev/null +++ b/scripts/api_test.sh @@ -0,0 +1,394 @@ +#!/bin/bash +BASE="http://localhost:3000/api/v1" +RESULTS_FILE="/tmp/hms_test_results.txt" +> "$RESULTS_FILE" + +# Login first +LOGIN_RESP=$(curl -s "$BASE/auth/login" -H "Content-Type: application/json" -d '{"username":"admin","password":"Admin@2026"}') +TOKEN=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['access_token'])" 2>/dev/null) +REFRESH_TOKEN=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['refresh_token'])" 2>/dev/null) + +if [ -z "$TOKEN" ]; then + echo "LOGIN FAILED - cannot continue" + exit 1 +fi + +echo "Login successful, TOKEN length: ${#TOKEN}" + +# Helper function +test_endpoint() { + local method=$1 + local url=$2 + local data="$3" + local label=$4 + local expected="$5" + + local resp_body="" + local http_code="" + + if [ "$method" = "GET" ]; then + RESP=$(curl -s -w "\nHTTP_CODE:%{http_code}" "$BASE$url" \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" 2>/dev/null) + elif [ "$method" = "DELETE" ]; then + RESP=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X DELETE "$BASE$url" \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" 2>/dev/null) + else + RESP=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X "$method" "$BASE$url" \ + -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \ + -d "$data" 2>/dev/null) + fi + + http_code=$(echo "$RESP" | grep "HTTP_CODE:" | sed 's/HTTP_CODE://') + resp_body=$(echo "$RESP" | grep -v "HTTP_CODE:") + success=$(echo "$resp_body" | python3 -c "import sys,json; d=json.load(sys.stdin); print('true' if d.get('success') else 'false')" 2>/dev/null || echo "parse_error") + + local status="PASS" + if [ -n "$expected" ]; then + if [ "$http_code" != "$expected" ]; then + status="FAIL" + fi + else + if [ "$http_code" -ge 400 ] 2>/dev/null; then + status="FAIL" + fi + fi + + echo "$status | $method $url | HTTP $http_code | success=$success | $label" >> "$RESULTS_FILE" + + # Return the body for chaining + echo "$resp_body" +} + +# ========================================== +# AUTH MODULE (24 endpoints) +# ========================================== +echo "=== AUTH MODULE TESTS ===" >> "$RESULTS_FILE" + +# 1. POST /auth/login +echo "PASS | POST /auth/login | HTTP 200 | success=true | Login" >> "$RESULTS_FILE" + +# 2. POST /auth/refresh +test_endpoint POST "/auth/refresh" "{\"refresh_token\":\"$REFRESH_TOKEN\"}" "Token refresh" + +# Re-login since refresh may invalidate old token +LOGIN_RESP=$(curl -s "$BASE/auth/login" -H "Content-Type: application/json" -d '{"username":"admin","password":"Admin@2026"}') +TOKEN=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['access_token'])" 2>/dev/null) +REFRESH_TOKEN=$(echo "$LOGIN_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['refresh_token'])" 2>/dev/null) + +# 3. POST /auth/logout - test last to keep token alive +echo "PENDING | POST /auth/logout | - | - | Logout (tested last)" >> "$RESULTS_FILE" + +# 4. POST /auth/change-password (wrong password - expect 400 or error) +test_endpoint POST "/auth/change-password" '{"current_password":"wrong_password","new_password":"NewPass123!"}' "Change password wrong current" "400" + +# 5. GET /users +USERS_RESP=$(test_endpoint GET "/users?page=1&page_size=10" "" "User list") + +# 6. POST /users +RAND=$(date +%s) +CREATE_USER_RESP=$(test_endpoint POST "/users" "{\"username\":\"apitest_${RAND}\",\"password\":\"Test@2026pwd\",\"display_name\":\"API Test User\",\"email\":\"apitest_${RAND}@test.com\"}" "Create user") +USER_ID=$(echo "$CREATE_USER_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null) +echo " -> Created user: $USER_ID" + +# 7. GET /users/{id} +if [ -n "$USER_ID" ]; then + test_endpoint GET "/users/$USER_ID" "" "Get user detail" +fi + +# 8. PUT /users/{id} +if [ -n "$USER_ID" ]; then + test_endpoint PUT "/users/$USER_ID" '{"display_name":"API Test User Updated","email":"updated@test.com"}' "Update user" +fi + +# 9. DELETE /users/{id} +if [ -n "$USER_ID" ]; then + test_endpoint DELETE "/users/$USER_ID" "" "Delete user (soft)" +fi + +# 10. POST /users/{id}/roles - need user id and role id +echo "PENDING | POST /users/{id}/roles | - | - | Assign roles (need user+role)" >> "$RESULTS_FILE" + +# 11. POST /users/{id}/reset-password +echo "SKIP | POST /users/{id}/reset-password | - | - | Reset password (skip safety)" >> "$RESULTS_FILE" + +# 12. GET /roles +ROLES_RESP=$(test_endpoint GET "/roles?page=1&page_size=10" "" "Role list") +ROLE_ID=$(echo "$ROLES_RESP" | python3 -c "import sys,json; items=json.load(sys.stdin).get('data',{}).get('items',[]); print(items[0]['id'] if items else '')" 2>/dev/null) +echo " -> First role: $ROLE_ID" + +# 13. POST /roles +CREATE_ROLE_RESP=$(test_endpoint POST "/roles" "{\"name\":\"API Test Role ${RAND}\",\"code\":\"api_test_${RAND}\",\"description\":\"Test role by API test\"}" "Create role") +NEW_ROLE_ID=$(echo "$CREATE_ROLE_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null) +echo " -> Created role: $NEW_ROLE_ID" + +# 14. GET /roles/permissions => same as GET /permissions (tested later as #20) + +# 15. GET /roles/{id} +if [ -n "$NEW_ROLE_ID" ]; then + test_endpoint GET "/roles/$NEW_ROLE_ID" "" "Get role detail" +fi + +# 16. PUT /roles/{id} +if [ -n "$NEW_ROLE_ID" ]; then + test_endpoint PUT "/roles/$NEW_ROLE_ID" '{"name":"Updated API Test Role","description":"Updated"}' "Update role" +fi + +# 17. DELETE /roles/{id} +if [ -n "$NEW_ROLE_ID" ]; then + test_endpoint DELETE "/roles/$NEW_ROLE_ID" "" "Delete role" +fi + +# 18. GET /roles/{id}/permissions +if [ -n "$ROLE_ID" ]; then + test_endpoint GET "/roles/$ROLE_ID/permissions" "" "Get role permissions" +fi + +# 19. POST /roles/{id}/permissions +if [ -n "$ROLE_ID" ]; then + test_endpoint POST "/roles/$ROLE_ID/permissions" '{"permission_ids":["user.list","user.create"]}' "Assign permissions to role" +fi + +# 20. GET /permissions +test_endpoint GET "/permissions" "" "Permission list" + +# 21. GET /organizations +ORGS_RESP=$(test_endpoint GET "/organizations?page=1&page_size=10" "" "Organization list") + +# 22. POST /organizations +CREATE_ORG_RESP=$(test_endpoint POST "/organizations" "{\"name\":\"API Test Org ${RAND}\",\"code\":\"TEST_ORG_${RAND}\",\"description\":\"Test org\"}" "Create organization") +ORG_ID=$(echo "$CREATE_ORG_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null) +echo " -> Created org: $ORG_ID" + +# 23. PUT /organizations/{id} +if [ -n "$ORG_ID" ]; then + test_endpoint PUT "/organizations/$ORG_ID" '{"name":"Updated Org","description":"Updated"}' "Update organization" +fi + +# 24. DELETE /organizations/{id} +if [ -n "$ORG_ID" ]; then + test_endpoint DELETE "/organizations/$ORG_ID" "" "Delete organization" +fi + +# ========================================== +# CONFIG MODULE (19 endpoints) +# ========================================== +echo "" >> "$RESULTS_FILE" +echo "=== CONFIG MODULE TESTS ===" >> "$RESULTS_FILE" + +# 1. GET /config/dictionaries +DICTS_RESP=$(test_endpoint GET "/config/dictionaries?page=1&page_size=10" "" "Dictionary list") + +# 2. POST /config/dictionaries +CREATE_DICT_RESP=$(test_endpoint POST "/config/dictionaries" "{\"name\":\"API Test Dict ${RAND}\",\"code\":\"api_test_dict_${RAND}\",\"description\":\"Test dictionary\"}" "Create dictionary") +DICT_ID=$(echo "$CREATE_DICT_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null) + +# 3. PUT /config/dictionaries/{id} +if [ -n "$DICT_ID" ]; then + test_endpoint PUT "/config/dictionaries/$DICT_ID" '{"name":"Updated Dict","description":"Updated"}' "Update dictionary" +fi + +# 4. DELETE /config/dictionaries/{id} +if [ -n "$DICT_ID" ]; then + test_endpoint DELETE "/config/dictionaries/$DICT_ID" "" "Delete dictionary" +fi + +# 5. GET /config/dictionaries/items +test_endpoint GET "/config/dictionaries/items?page=1&page_size=10" "" "Dictionary items list" + +# Create another dict for item tests +CREATE_DICT_RESP2=$(test_endpoint POST "/config/dictionaries" "{\"name\":\"API Test Dict Items ${RAND}\",\"code\":\"api_test_items_${RAND}\"}" "Create dict for item test") +DICT_ID2=$(echo "$CREATE_DICT_RESP2" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null) + +# 6. POST /config/dictionaries/{dict_id}/items +if [ -n "$DICT_ID2" ]; then + CREATE_ITEM_RESP=$(test_endpoint POST "/config/dictionaries/$DICT_ID2/items" '{"label":"Test Item","value":"test_value","sort_order":1}' "Create dictionary item") + ITEM_ID=$(echo "$CREATE_ITEM_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null) + + # 7. PUT /config/dictionaries/{dict_id}/items/{item_id} + if [ -n "$ITEM_ID" ]; then + test_endpoint PUT "/config/dictionaries/$DICT_ID2/items/$ITEM_ID" '{"label":"Updated Item","value":"updated_value"}' "Update dictionary item" + + # 8. DELETE /config/dictionaries/{dict_id}/items/{item_id} + test_endpoint DELETE "/config/dictionaries/$DICT_ID2/items/$ITEM_ID" "" "Delete dictionary item" + fi +fi + +# 9. GET /config/menus +test_endpoint GET "/config/menus" "" "Menu list" + +# 10. POST /config/menus +CREATE_MENU_RESP=$(test_endpoint POST "/config/menus" '{"name":"API Test Menu","path":"/test","icon":"test","sort_order":999,"type":"menu"}' "Create menu") +MENU_ID=$(echo "$CREATE_MENU_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null) + +# 11. PUT /config/menus/{id} +if [ -n "$MENU_ID" ]; then + test_endpoint PUT "/config/menus/$MENU_ID" '{"name":"Updated Menu","path":"/test-updated"}' "Update menu" +fi + +# 12. DELETE /config/menus/{id} +if [ -n "$MENU_ID" ]; then + test_endpoint DELETE "/config/menus/$MENU_ID" "" "Delete menu" +fi + +# 13. GET /menus/user +test_endpoint GET "/menus/user" "" "User menu tree" + +# 14. GET /config/settings/{key} +test_endpoint GET "/config/settings/system.title" "" "Get setting by key" + +# 15. PUT /config/settings/{key} +test_endpoint PUT "/config/settings/system.title" '{"value":"HMS Health Platform"}' "Update setting" + +# 16. GET /config/numbering-rules +test_endpoint GET "/config/numbering-rules?page=1&page_size=10" "" "Numbering rules list" + +# 17. POST /config/numbering-rules +CREATE_NUM_RESP=$(test_endpoint POST "/config/numbering-rules" "{\"name\":\"API Test Rule ${RAND}\",\"code\":\"TEST_NUM_${RAND}\",\"prefix\":\"TST\",\"pattern\":\"{YYYY}{MM}-{SEQ}\",\"seq_length\":4}" "Create numbering rule") +NUM_RULE_ID=$(echo "$CREATE_NUM_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null) + +# 18. POST /config/numbering-rules/{id}/generate +if [ -n "$NUM_RULE_ID" ]; then + test_endpoint POST "/config/numbering-rules/$NUM_RULE_ID/generate" "{}" "Generate number" +fi + +# 19. GET /config/languages +test_endpoint GET "/config/languages" "" "Language list" + +# ========================================== +# WORKFLOW MODULE (15 endpoints) +# ========================================== +echo "" >> "$RESULTS_FILE" +echo "=== WORKFLOW MODULE TESTS ===" >> "$RESULTS_FILE" + +# 1. GET /workflow/definitions +test_endpoint GET "/workflow/definitions?page=1&page_size=10" "" "Workflow definitions list" + +# 2. POST /workflow/definitions +BPMN='' +CREATE_WF_RESP=$(test_endpoint POST "/workflow/definitions" "{\"name\":\"API Test Workflow ${RAND}\",\"description\":\"Test workflow\",\"bpmn_xml\":\"${BPMN}\"}" "Create workflow definition") +WF_DEF_ID=$(echo "$CREATE_WF_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null) + +# 3. GET /workflow/definitions/{id} +if [ -n "$WF_DEF_ID" ]; then + test_endpoint GET "/workflow/definitions/$WF_DEF_ID" "" "Get workflow definition detail" + + # 4. PUT /workflow/definitions/{id} + test_endpoint PUT "/workflow/definitions/$WF_DEF_ID" '{"name":"Updated Workflow","description":"Updated"}' "Update workflow definition" + + # 5. POST /workflow/definitions/{id}/publish + test_endpoint POST "/workflow/definitions/$WF_DEF_ID/publish" "{}" "Publish workflow" +fi + +# 6. POST /workflow/definitions/{id}/deprecate - create and publish first +# Re-create for deprecate test +WF_DEF2_RESP=$(test_endpoint POST "/workflow/definitions" "{\"name\":\"API Test Workflow Dep ${RAND}\",\"description\":\"For deprecate\",\"bpmn_xml\":\"${BPMN}\"}" "Create workflow for deprecate") +WF_DEF2_ID=$(echo "$WF_DEF2_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null) +if [ -n "$WF_DEF2_ID" ]; then + test_endpoint POST "/workflow/definitions/$WF_DEF2_ID/publish" "{}" "Publish for deprecate" + test_endpoint POST "/workflow/definitions/$WF_DEF2_ID/deprecate" "{}" "Deprecate workflow" +fi + +# 7. POST /workflow/instances +WF_INST_RESP=$(test_endpoint POST "/workflow/instances" "{\"definition_id\":\"${WF_DEF_ID}\",\"variables\":{}}" "Start workflow instance") +WF_INST_ID=$(echo "$WF_INST_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null) + +# 8. GET /workflow/instances +test_endpoint GET "/workflow/instances?page=1&page_size=10" "" "Workflow instances list" + +# 9. GET /workflow/instances/{id} +if [ -n "$WF_INST_ID" ]; then + test_endpoint GET "/workflow/instances/$WF_INST_ID" "" "Get instance detail" + + # 10. POST /workflow/instances/{id}/suspend + test_endpoint POST "/workflow/instances/$WF_INST_ID/suspend" "{}" "Suspend instance" + + # 11. POST /workflow/instances/{id}/resume + test_endpoint POST "/workflow/instances/$WF_INST_ID/resume" "{}" "Resume instance" + + # 12. POST /workflow/instances/{id}/terminate + test_endpoint POST "/workflow/instances/$WF_INST_ID/terminate" "{}" "Terminate instance" +fi + +# 13. GET /workflow/tasks/pending +test_endpoint GET "/workflow/tasks/pending?page=1&page_size=10" "" "Pending tasks" + +# 14. GET /workflow/tasks/completed +test_endpoint GET "/workflow/tasks/completed?page=1&page_size=10" "" "Completed tasks" + +# 15. POST /workflow/tasks/{id}/complete +PENDING_TASK_RESP=$(test_endpoint GET "/workflow/tasks/pending?page=1&page_size=1" "" "Get pending task for complete test") +TASK_ID=$(echo "$PENDING_TASK_RESP" | python3 -c "import sys,json; items=json.load(sys.stdin).get('data',{}).get('items',[]); print(items[0]['id'] if items else '')" 2>/dev/null) +if [ -n "$TASK_ID" ]; then + test_endpoint POST "/workflow/tasks/$TASK_ID/complete" '{"variables":{}}' "Complete task" +else + echo "SKIP | POST /workflow/tasks/{id}/complete | - | - | No pending task available" >> "$RESULTS_FILE" +fi + +# ========================================== +# MESSAGE MODULE (10 endpoints) +# ========================================== +echo "" >> "$RESULTS_FILE" +echo "=== MESSAGE MODULE TESTS ===" >> "$RESULTS_FILE" + +# 1. GET /messages +test_endpoint GET "/messages?page=1&page_size=10" "" "Message list" + +# 2. POST /messages +CREATE_MSG_RESP=$(test_endpoint POST "/messages" '{"title":"API Test Message","content":"Test message content","type":"system","recipient_type":"user"}' "Send message") +MSG_ID=$(echo "$CREATE_MSG_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null) + +# 3. GET /messages/unread-count +test_endpoint GET "/messages/unread-count" "" "Unread count" + +# 4. PUT /messages/{id}/read +if [ -n "$MSG_ID" ]; then + test_endpoint PUT "/messages/$MSG_ID/read" "{}" "Mark message read" +fi + +# 5. PUT /messages/read-all +test_endpoint PUT "/messages/read-all" "{}" "Mark all read" + +# 6. DELETE /messages/{id} +if [ -n "$MSG_ID" ]; then + test_endpoint DELETE "/messages/$MSG_ID" "" "Delete message" +fi + +# 7. GET /message-templates +test_endpoint GET "/message-templates?page=1&page_size=10" "" "Message templates list" + +# 8. POST /message-templates +CREATE_TPL_RESP=$(test_endpoint POST "/message-templates" "{\"name\":\"API Test Template ${RAND}\",\"code\":\"test_tpl_${RAND}\",\"content\":\"Hello {{name}}\",\"channel\":\"system\"}" "Create template") +TPL_ID=$(echo "$CREATE_TPL_RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('data',{}).get('id',''))" 2>/dev/null) + +# 9. PUT /message-templates/{id} +if [ -n "$TPL_ID" ]; then + test_endpoint PUT "/message-templates/$TPL_ID" '{"name":"Updated Template","content":"Hello {{name}}, updated"}' "Update template" +fi + +# 10. GET /message-subscriptions +test_endpoint GET "/message-subscriptions?page=1&page_size=10" "" "Subscriptions list" + +# ========================================== +# Final: Test logout +# ========================================== +test_endpoint POST "/auth/logout" "{}" "Logout" + +# ========================================== +# SUMMARY +# ========================================== +echo "" >> "$RESULTS_FILE" +echo "=== SUMMARY ===" >> "$RESULTS_FILE" + +TOTAL=$(grep -c "^PASS\|^FAIL\|^SKIP\|^PENDING" "$RESULTS_FILE") +PASSED=$(grep -c "^PASS" "$RESULTS_FILE") +FAILED=$(grep -c "^FAIL" "$RESULTS_FILE") +SKIPPED=$(grep -c "^SKIP" "$RESULTS_FILE") +PENDING=$(grep -c "^PENDING" "$RESULTS_FILE") + +echo "Total: $TOTAL | Passed: $PASSED | Failed: $FAILED | Skipped: $SKIPPED | Pending: $PENDING" >> "$RESULTS_FILE" + +echo "" +echo "===============================" +echo "Test completed." +echo "===============================" +cat "$RESULTS_FILE" diff --git a/scripts/api_test_health_alert.py b/scripts/api_test_health_alert.py new file mode 100644 index 0000000..58696a8 --- /dev/null +++ b/scripts/api_test_health_alert.py @@ -0,0 +1,477 @@ +#!/usr/bin/env python3 +""" +HMS Health Management Platform -- Vital Signs to Alert Pipeline E2E API Test + +Correct route structure (derived from actual source code): + - Patients: GET/POST /health/patients + - Vital Signs: GET/POST /health/patients/{id}/vital-signs + - Vital Signs DTO: { record_date, systolic_bp_morning, diastolic_bp_morning, heart_rate, ... } + - Trends: GET /health/patients/{id}/trends/{indicator} + - Lab Reports: GET/POST /health/patients/{id}/lab-reports + - Lab DTO: { report_date, report_type, items: [{name, value, unit, reference_low, reference_high, is_abnormal}] } + - Alerts: GET /health/alerts + - Alert Rules: GET/POST /health/alert-rules + - Critical Value Thresholds: GET /health/critical-value-thresholds + - Critical Alerts: GET /health/critical-alerts +""" +import json +import sys +import time +from datetime import datetime, timezone, date +from urllib.request import Request, urlopen +from urllib.error import HTTPError + +BASE_URL = "http://localhost:3000/api/v1" +results = [] + + +def log_test(category, test_name, passed, detail="", response_code=None, response_time_ms=None): + status = "PASS" if passed else "FAIL" + entry = {"category": category, "test_name": test_name, "status": status, "detail": detail} + if response_code is not None: + entry["http_code"] = response_code + if response_time_ms is not None: + entry["response_time_ms"] = round(response_time_ms, 1) + results.append(entry) + icon = "[PASS]" if passed else "[FAIL]" + rt = f" ({response_time_ms:.0f}ms)" if response_time_ms else "" + code = f" HTTP {response_code}" if response_code else "" + print(f" {icon} {test_name}{rt}{code} -- {detail}") + return passed + + +def api_call(method, path, data=None, token=None): + url = f"{BASE_URL}{path}" + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + body = json.dumps(data).encode("utf-8") if data else None + req = Request(url, data=body, headers=headers, method=method) + start = time.time() + try: + resp = urlopen(req, timeout=30) + elapsed = (time.time() - start) * 1000 + status_code = resp.status + resp_body = json.loads(resp.read().decode("utf-8")) + except HTTPError as e: + elapsed = (time.time() - start) * 1000 + status_code = e.code + try: + resp_body = json.loads(e.read().decode("utf-8")) + except Exception: + resp_body = {"error": str(e)} + except Exception as e: + elapsed = (time.time() - start) * 1000 + status_code = 0 + resp_body = {"error": str(e)} + return status_code, resp_body, elapsed + + +def extract_items(data_node): + """Extract items from paginated response (supports both data.data and data.items patterns)""" + if isinstance(data_node, list): + return data_node + items = data_node.get("data", data_node.get("items", [])) + return items if isinstance(items, list) else [] + + +def extract_total(data_node): + """Extract total from paginated response""" + if isinstance(data_node, list): + return len(data_node) + return data_node.get("total", len(extract_items(data_node))) + + +# ============================================================ +# STEP 1: Authentication +# ============================================================ +print("\n" + "=" * 70) +print("STEP 1: Authentication") +print("=" * 70) + +code, resp, rt = api_call("POST", "/auth/login", {"username": "admin", "password": "Admin@2026"}) +if code == 200 and resp.get("success") and resp.get("data", {}).get("access_token"): + TOKEN = resp["data"]["access_token"] + log_test("Auth", "Admin login", True, f"Token acquired ({len(TOKEN)} chars)", code, rt) +else: + log_test("Auth", "Admin login", False, f"Failed: {json.dumps(resp, ensure_ascii=False)[:200]}", code, rt) + sys.exit(1) + + +# ============================================================ +# STEP 2: Get Patient ID +# ============================================================ +print("\n" + "=" * 70) +print("STEP 2: Get test Patient ID") +print("=" * 70) + +code, resp, rt = api_call("GET", "/health/patients?page=1&page_size=3", token=TOKEN) +if code == 200 and resp.get("success"): + items = extract_items(resp["data"]) + total = extract_total(resp["data"]) + PATIENT_ID = items[0]["id"] if items else None + log_test("Patient", "Get patient list", True, f"Total {total}, using ID: {PATIENT_ID}", code, rt) +else: + log_test("Patient", "Get patient list", False, f"Failed: {json.dumps(resp, ensure_ascii=False)[:200]}", code, rt) + PATIENT_ID = None + +if not PATIENT_ID: + print("\nFATAL: No patient ID available, aborting") + sys.exit(1) + + +# ============================================================ +# STEP 3: Vital Signs Recording +# ============================================================ +print("\n" + "=" * 70) +print("STEP 3: Vital Signs Recording") +print("=" * 70) + +today = date.today().isoformat() +vs_path = f"/health/patients/{PATIENT_ID}/vital-signs" + +# 3.1 Normal BP 120/80 + HR 72 +code1, resp1, rt1 = api_call("POST", vs_path, { + "record_date": today, + "systolic_bp_morning": 120, + "diastolic_bp_morning": 80, + "heart_rate": 72, + "source": "manual" +}, token=TOKEN) + +if code1 in (200, 201): + s1 = resp1.get("success", False) + log_test("VitalSigns", "Normal BP 120/80 + HR 72", s1, + f"success={s1}", code1, rt1) +else: + log_test("VitalSigns", "Normal BP 120/80 + HR 72", False, + f"HTTP {code1}: {json.dumps(resp1, ensure_ascii=False)[:200]}", code1, rt1) + +time.sleep(0.5) + +# 3.2 Abnormal BP 200/130 (should trigger alert) +code2, resp2, rt2 = api_call("POST", vs_path, { + "record_date": today, + "systolic_bp_morning": 200, + "diastolic_bp_morning": 130, + "heart_rate": 85, + "source": "manual" +}, token=TOKEN) + +if code2 in (200, 201): + s2 = resp2.get("success", False) + log_test("VitalSigns", "Abnormal BP 200/130", s2, + f"success={s2}", code2, rt2) +else: + log_test("VitalSigns", "Abnormal BP 200/130", False, + f"HTTP {code2}: {json.dumps(resp2, ensure_ascii=False)[:200]}", code2, rt2) + +time.sleep(0.5) + +# 3.3 Abnormal HR 150 (should trigger alert) +code3, resp3, rt3 = api_call("POST", vs_path, { + "record_date": today, + "systolic_bp_morning": 125, + "diastolic_bp_morning": 82, + "heart_rate": 150, + "source": "manual" +}, token=TOKEN) + +if code3 in (200, 201): + s3 = resp3.get("success", False) + log_test("VitalSigns", "Abnormal HR 150", s3, + f"success={s3}", code3, rt3) +else: + log_test("VitalSigns", "Abnormal HR 150", False, + f"HTTP {code3}: {json.dumps(resp3, ensure_ascii=False)[:200]}", code3, rt3) + +time.sleep(0.5) + +# 3.4 Abnormal blood sugar 20.0 +code4, resp4, rt4 = api_call("POST", vs_path, { + "record_date": today, + "blood_sugar": 20.0, + "blood_sugar_type": "fasting", + "source": "manual" +}, token=TOKEN) + +if code4 in (200, 201): + s4 = resp4.get("success", False) + log_test("VitalSigns", "Abnormal blood sugar 20.0", s4, + f"success={s4}", code4, rt4) +else: + log_test("VitalSigns", "Abnormal blood sugar 20.0", False, + f"HTTP {code4}: {json.dumps(resp4, ensure_ascii=False)[:200]}", code4, rt4) + +time.sleep(0.5) + +# 3.5 Validation -- missing required field (record_date) +code5, resp5, rt5 = api_call("POST", vs_path, { + "systolic_bp_morning": 120, + "heart_rate": 72, +}, token=TOKEN) + +validation_fail = (code5 == 400 or code5 == 422) +log_test("VitalSigns", "Missing record_date rejected", validation_fail, + f"HTTP {code5}", code5, rt5) + +time.sleep(0.5) + +# 3.6 Security -- no token +code6, resp6, rt6 = api_call("POST", vs_path, { + "record_date": today, + "heart_rate": 80, +}, token=None) + +auth_fail = (code6 == 401) +log_test("VitalSigns", "No token returns 401", auth_fail, + f"HTTP {code6} (expected 401)", code6, rt6) + + +# ============================================================ +# STEP 4: Query Vital Signs +# ============================================================ +print("\n" + "=" * 70) +print("STEP 4: Query Vital Signs") +print("=" * 70) + +time.sleep(1) + +# 4.1 List by patient +code_q1, resp_q1, rt_q1 = api_call("GET", f"/health/patients/{PATIENT_ID}/vital-signs", token=TOKEN) +if code_q1 == 200 and resp_q1.get("success"): + total_vs = extract_total(resp_q1["data"]) + items_vs = extract_items(resp_q1["data"]) + log_test("VitalSigns Query", "List by patient", True, + f"total={total_vs}, returned {len(items_vs)} records", code_q1, rt_q1) +else: + log_test("VitalSigns Query", "List by patient", False, + f"HTTP {code_q1}: {json.dumps(resp_q1, ensure_ascii=False)[:200]}", code_q1, rt_q1) + +time.sleep(0.5) + +# 4.2 Trend data (management path) +code_q2, resp_q2, rt_q2 = api_call("GET", + f"/health/patients/{PATIENT_ID}/trends", token=TOKEN) + +if code_q2 == 200 and resp_q2.get("success"): + trend_data = resp_q2.get("data", {}) + log_test("VitalSigns Query", "Trend list", True, + f"data type: {type(trend_data).__name__}", code_q2, rt_q2) +else: + log_test("VitalSigns Query", "Trend list", False, + f"HTTP {code_q2}: {json.dumps(resp_q2, ensure_ascii=False)[:200]}", code_q2, rt_q2) + +time.sleep(0.5) + +# 4.3 Specific indicator timeseries +code_q3, resp_q3, rt_q3 = api_call("GET", + f"/health/patients/{PATIENT_ID}/trends/systolic_bp_morning", token=TOKEN) + +if code_q3 == 200 and resp_q3.get("success"): + log_test("VitalSigns Query", "BP timeseries", True, + f"success", code_q3, rt_q3) +else: + log_test("VitalSigns Query", "BP timeseries", False, + f"HTTP {code_q3}: {json.dumps(resp_q3, ensure_ascii=False)[:200]}", code_q3, rt_q3) + + +# ============================================================ +# STEP 5: Lab Reports +# ============================================================ +print("\n" + "=" * 70) +print("STEP 5: Lab Reports") +print("=" * 70) + +time.sleep(0.5) + +# 5.1 Create lab report (with abnormal items) +lab_path = f"/health/patients/{PATIENT_ID}/lab-reports" +code_l1, resp_l1, rt_l1 = api_call("POST", lab_path, { + "report_date": today, + "report_type": "blood_routine", + "items": [ + {"name": "WBC", "value": "12.5", "unit": "10^9/L", "reference_low": "4.0", "reference_high": "10.0", "is_abnormal": True}, + {"name": "Hemoglobin", "value": "135", "unit": "g/L", "reference_low": "120", "reference_high": "160", "is_abnormal": False} + ] +}, token=TOKEN) + +if code_l1 in (200, 201) and resp_l1.get("success"): + lab_id = resp_l1.get("data", {}).get("id", "N/A") + log_test("LabReport", "Create blood routine (with abnormal)", True, + f"id={lab_id}", code_l1, rt_l1) +else: + log_test("LabReport", "Create blood routine (with abnormal)", False, + f"HTTP {code_l1}: {json.dumps(resp_l1, ensure_ascii=False)[:200]}", code_l1, rt_l1) + +time.sleep(0.5) + +# 5.2 List lab reports +code_l2, resp_l2, rt_l2 = api_call("GET", f"{lab_path}?page=1&page_size=10", token=TOKEN) +if code_l2 == 200 and resp_l2.get("success"): + total_lab = extract_total(resp_l2["data"]) + log_test("LabReport", "List patient lab reports", True, + f"total={total_lab}", code_l2, rt_l2) +else: + log_test("LabReport", "List patient lab reports", False, + f"HTTP {code_l2}: {json.dumps(resp_l2, ensure_ascii=False)[:200]}", code_l2, rt_l2) + + +# ============================================================ +# STEP 6: Alert Management +# ============================================================ +print("\n" + "=" * 70) +print("STEP 6: Alert Management") +print("=" * 70) + +time.sleep(2) # Wait for alert engine processing + +# 6.1 Alert list +code_a1, resp_a1, rt_a1 = api_call("GET", "/health/alerts?page=1&page_size=20", token=TOKEN) +if code_a1 == 200 and resp_a1.get("success"): + total_alerts = extract_total(resp_a1["data"]) + alert_items = extract_items(resp_a1["data"]) + log_test("Alerts", "Alert list query", True, + f"total={total_alerts}, returned {len(alert_items)} records", code_a1, rt_a1) + if total_alerts > 0: + for a in alert_items[:3]: + level = a.get("severity", a.get("level", "N/A")) + status_a = a.get("status", "N/A") + msg = a.get("message", a.get("title", "N/A")) + print(f" -> Alert: [{level}] {status_a} -- {str(msg)[:60]}") + else: + print(" -> Note: No alerts generated. Check alert rules configuration.") +else: + log_test("Alerts", "Alert list query", False, + f"HTTP {code_a1}: {json.dumps(resp_a1, ensure_ascii=False)[:200]}", code_a1, rt_a1) + +time.sleep(0.5) + +# 6.2 Alert rules list +code_a2, resp_a2, rt_a2 = api_call("GET", "/health/alert-rules?page=1&page_size=20", token=TOKEN) +if code_a2 == 200 and resp_a2.get("success"): + total_rules = extract_total(resp_a2["data"]) + rule_items = extract_items(resp_a2["data"]) + log_test("Alerts", "Alert rules list", True, + f"total={total_rules}, returned {len(rule_items)} records", code_a2, rt_a2) + if total_rules > 0: + for r in rule_items[:3]: + rname = r.get("name", "N/A") + renabled = r.get("is_enabled", r.get("enabled", "N/A")) + rtype = r.get("indicator_type", r.get("type", "N/A")) + print(f" -> Rule: {rname} | type: {rtype} | enabled: {renabled}") +else: + log_test("Alerts", "Alert rules list", False, + f"HTTP {code_a2}: {json.dumps(resp_a2, ensure_ascii=False)[:200]}", code_a2, rt_a2) + +time.sleep(0.5) + +# 6.3 Critical value thresholds (may return list directly) +code_a3, resp_a3, rt_a3 = api_call("GET", "/health/critical-value-thresholds?page=1&page_size=20", token=TOKEN) +if code_a3 == 200 and resp_a3.get("success"): + data_a3 = resp_a3.get("data", {}) + if isinstance(data_a3, list): + total_thresholds = len(data_a3) + threshold_items = data_a3 + else: + total_thresholds = extract_total(data_a3) + threshold_items = extract_items(data_a3) + log_test("Alerts", "Critical value thresholds list", True, + f"total={total_thresholds}, returned {len(threshold_items)} records", code_a3, rt_a3) + if total_thresholds > 0: + for t in threshold_items[:3]: + tname = t.get("name", t.get("indicator_name", "N/A")) + ttype = t.get("indicator_type", "N/A") + thigh = t.get("high_threshold", t.get("threshold_high", "N/A")) + tlow = t.get("low_threshold", t.get("threshold_low", "N/A")) + print(f" -> Threshold: {tname} | type: {ttype} | high: {thigh} | low: {tlow}") +else: + log_test("Alerts", "Critical value thresholds list", False, + f"HTTP {code_a3}: {json.dumps(resp_a3, ensure_ascii=False)[:200]}", code_a3, rt_a3) + +time.sleep(0.5) + +# 6.4 Critical alerts list +code_a4, resp_a4, rt_a4 = api_call("GET", "/health/critical-alerts?page=1&page_size=20", token=TOKEN) +if code_a4 == 200 and resp_a4.get("success"): + total_ca = extract_total(resp_a4["data"]) + ca_items = extract_items(resp_a4["data"]) + log_test("Alerts", "Critical alerts list", True, + f"total={total_ca}, returned {len(ca_items)} records", code_a4, rt_a4) +else: + log_test("Alerts", "Critical alerts list", False, + f"HTTP {code_a4}: {json.dumps(resp_a4, ensure_ascii=False)[:200]}", code_a4, rt_a4) + +time.sleep(0.5) + +# 6.5 Security -- alerts without auth +code_a5, resp_a5, rt_a5 = api_call("GET", "/health/alerts", token=None) +auth_fail_alerts = (code_a5 == 401) +log_test("Alerts", "No token on alerts returns 401", auth_fail_alerts, + f"HTTP {code_a5} (expected 401)", code_a5, rt_a5) + + +# ============================================================ +# STEP 7: Performance Statistics +# ============================================================ +print("\n" + "=" * 70) +print("STEP 7: Performance Statistics") +print("=" * 70) + +response_times = [r["response_time_ms"] for r in results if r.get("response_time_ms")] +if response_times: + avg_rt = sum(response_times) / len(response_times) + max_rt = max(response_times) + min_rt = min(response_times) + under_200ms = sum(1 for v in response_times if v < 200) + pct = (under_200ms / len(response_times)) * 100 + print(f" Average response time: {avg_rt:.0f}ms") + print(f" Max response time: {max_rt:.0f}ms") + print(f" Min response time: {min_rt:.0f}ms") + print(f" Under 200ms: {pct:.0f}% ({under_200ms}/{len(response_times)})") + print(f" SLA (< 200ms 95%): {'PASS' if pct >= 95 else 'FAIL'}") + + +# ============================================================ +# FINAL REPORT +# ============================================================ +print("\n" + "=" * 70) +print("FINAL TEST REPORT") +print("=" * 70) + +total_tests = len(results) +passed = sum(1 for r in results if r["status"] == "PASS") +failed = total_tests - passed +pass_rate = (passed / total_tests * 100) if total_tests > 0 else 0 + +print(f"\n Total tests: {total_tests}") +print(f" Passed: {passed}") +print(f" Failed: {failed}") +print(f" Pass rate: {pass_rate:.1f}%") + +print("\n Category summary:") +categories = {} +for r in results: + cat = r["category"] + if cat not in categories: + categories[cat] = {"pass": 0, "fail": 0} + if r["status"] == "PASS": + categories[cat]["pass"] += 1 + else: + categories[cat]["fail"] += 1 + +for cat, counts in categories.items(): + t = counts["pass"] + counts["fail"] + print(f" {cat}: {counts['pass']}/{t} PASS ({counts['fail']} FAIL)") + +if failed > 0: + print("\n Failed test details:") + for r in results: + if r["status"] == "FAIL": + print(f" [FAIL] {r['category']} / {r['test_name']}") + print(f" {r['detail'][:150]}") + +print(f"\n Overall: {'ALL PASS' if failed == 0 else 'HAS FAILURES'}") +print("=" * 70) + +sys.exit(0 if failed == 0 else 1) diff --git a/scripts/api_test_mp.py b/scripts/api_test_mp.py new file mode 100644 index 0000000..b727c72 --- /dev/null +++ b/scripts/api_test_mp.py @@ -0,0 +1,255 @@ +#!/usr/bin/env python3 +"""Miniprogram API comprehensive test - 6 endpoints.""" +import json, urllib.request, urllib.error, sys + +BASE = 'http://localhost:3000/api/v1' +# Read fresh token from file +with open('g:/hms/.test_token_fresh.txt') as f: + TOKEN = f.read().strip() +PATIENT_ID = '019dcd34-bc4d-72c1-8c19-77ce1f4839d6' +TENANT_ID = '019d80da-7a2c-7820-b0a3-3d5266a3a324' + +headers = { + 'Authorization': f'Bearer {TOKEN}', + 'X-Tenant-Id': TENANT_ID, + 'X-Patient-Id': PATIENT_ID, + 'Content-Type': 'application/json' +} + + +def api_call(method, path, data=None): + url = f'{BASE}{path}' + if data: + req = urllib.request.Request(url, data=json.dumps(data).encode(), headers=headers, method=method) + else: + req = urllib.request.Request(url, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + body = json.loads(resp.read().decode()) + return resp.status, body + except urllib.error.HTTPError as e: + body = e.read().decode() + try: + return e.code, json.loads(body) + except Exception: + return e.code, body + except Exception as e: + return 0, str(e) + + +def get_keys(obj, prefix=''): + """Recursively extract field names from a dict.""" + fields = [] + if isinstance(obj, dict): + for k, v in obj.items(): + full = f'{prefix}{k}' if not prefix else f'{prefix}.{k}' + if isinstance(v, dict): + fields.extend(get_keys(v, full)) + else: + fields.append(full) + return fields + + +def print_header(title, api_path): + print() + print('=' * 70) + print(f'{title}') + print(f'Endpoint: {api_path}') + print('=' * 70) + + +def analyze_fields(name, data, expected_fields): + """Compare actual vs expected fields.""" + actual_keys = set(data.keys()) if isinstance(data, dict) else set() + expected_set = set(expected_fields) + matched = actual_keys & expected_set + missing = expected_set - actual_keys + extra = actual_keys - expected_set + + match_status = "MATCH" if not missing else "MISMATCH" + print(f'[{name}] status=200 | {match_status}') + print(f' Actual fields: {sorted(actual_keys)}') + print(f' Expected fields: {sorted(expected_set)}') + if matched: + print(f' Matched: {sorted(matched)}') + if missing: + print(f' ** MISSING: {sorted(missing)} **') + if extra: + print(f' Extra: {sorted(extra)}') + return missing + + +# ===================================================== +# API 1: GET /health/vital-signs/today +# ===================================================== +print_header('API 1: Today Summary', f'GET /health/vital-signs/today?patient_id={PATIENT_ID}') +status, body = api_call('GET', f'/health/vital-signs/today?patient_id={PATIENT_ID}') +print(f'status={status}') +print(f'Response: {json.dumps(body, indent=2, ensure_ascii=False)}') + +if status == 200: + data = body.get('data', body) + expected = ['blood_pressure', 'heart_rate', 'blood_sugar', 'weight'] + missing = analyze_fields('Today Summary', data, expected) + # Check nested structure + for key in ['blood_pressure', 'heart_rate', 'blood_sugar', 'weight']: + if key in data and isinstance(data[key], dict): + print(f' {key} sub-fields: {sorted(data[key].keys())}') +else: + print(f' FAILED: {status}') + # Try without patient_id + print(' Retrying without patient_id param...') + status2, body2 = api_call('GET', '/health/vital-signs/today') + print(f' status={status2}') + print(f' Response: {json.dumps(body2, indent=2, ensure_ascii=False)}') + +# ===================================================== +# API 2: POST /health/patients/{id}/vital-signs +# ===================================================== +print_header('API 2: Create Vital Signs', f'POST /health/patients/{PATIENT_ID}/vital-signs') +create_data = { + 'record_date': '2026-04-27', + 'systolic_bp_morning': 130, + 'diastolic_bp_morning': 85, + 'heart_rate': 75, + 'weight': 69.0, + 'blood_sugar': 5.5, +} +status, body = api_call('POST', f'/health/patients/{PATIENT_ID}/vital-signs', create_data) +print(f'status={status}') +print(f'Response: {json.dumps(body, indent=2, ensure_ascii=False)}') + +if status in (200, 201): + data = body.get('data', body) + actual_keys = set(data.keys()) if isinstance(data, dict) else set() + expected_keys = {'id', 'patient_id', 'record_date', 'systolic_bp_morning', 'diastolic_bp_morning', + 'heart_rate', 'weight', 'blood_sugar', 'created_at', 'updated_at', 'version', 'tenant_id'} + analyze_fields('Create Vital Signs', data, expected_keys) + +# ===================================================== +# API 3: GET /health/patients/{id}/vital-signs (paginated) +# ===================================================== +print_header('API 3: Vital Signs History', f'GET /health/patients/{PATIENT_ID}/vital-signs?page=1&page_size=5') +status, body = api_call('GET', f'/health/patients/{PATIENT_ID}/vital-signs?page=1&page_size=5') +print(f'status={status}') +print(f'Response: {json.dumps(body, indent=2, ensure_ascii=False)}') + +if status == 200: + data = body.get('data', body) + # Check pagination structure + if isinstance(body.get('data'), dict) and 'data' in body['data']: + # wrapped: {data: {data: [...], total: N}} + paginated = body['data'] + print(f' Pagination structure: data={type(paginated.get("data")).__name__}, total={paginated.get("total")}') + items = paginated.get('data', []) + elif isinstance(body.get('data'), list): + items = body['data'] + print(f' Response is direct array, len={len(items)}') + elif isinstance(body.get('data'), dict) and 'items' in body.get('data', {}): + paginated = body['data'] + items = paginated.get('items', []) + print(f' Pagination via items: total={paginated.get("total")}') + else: + items = [] + print(f' Unknown pagination structure') + + if items and isinstance(items[0], dict): + actual_keys = set(items[0].keys()) + expected_keys = {'id', 'record_date', 'systolic_bp_morning', 'diastolic_bp_morning', + 'heart_rate', 'weight', 'blood_sugar', 'created_at', 'updated_at', 'patient_id'} + analyze_fields('Vital Signs Item', items[0], expected_keys) + +# ===================================================== +# API 4: GET /health/vital-signs/trend (mini trend) +# ===================================================== +print_header('API 4: Vital Signs Trend', 'GET /health/vital-signs/trend?indicator=blood_pressure&range=7d') +status, body = api_call('GET', '/health/vital-signs/trend?indicator=blood_pressure&range=7d') +print(f'status={status}') +print(f'Response: {json.dumps(body, indent=2, ensure_ascii=False)}') + +# Also try the task-specified path +print() +print(' Also testing: GET /health/patients/{id}/vital-signs/trend') +status2, body2 = api_call('GET', f'/health/patients/{PATIENT_ID}/vital-signs/trend?start_date=2026-04-20&end_date=2026-04-27') +print(f' status={status2}') +print(f' Response: {json.dumps(body2, indent=2, ensure_ascii=False)}') + +if status == 200: + data = body.get('data', body) + print(f' Top-level type: {type(data).__name__}') + if isinstance(data, dict): + print(f' Keys: {sorted(data.keys())}') + elif isinstance(data, list): + print(f' Array length: {len(data)}') + if data: + print(f' First item keys: {sorted(data[0].keys()) if isinstance(data[0], dict) else data[0]}') + +# ===================================================== +# API 5: GET /health/appointments +# ===================================================== +print_header('API 5: Appointments List', f'GET /health/appointments?patient_id={PATIENT_ID}&page=1&page_size=5') +status, body = api_call('GET', f'/health/appointments?patient_id={PATIENT_ID}&page=1&page_size=5') +print(f'status={status}') +print(f'Response: {json.dumps(body, indent=2, ensure_ascii=False)}') + +if status == 200: + data = body.get('data', body) + if isinstance(data, dict) and 'data' in data: + items = data.get('data', []) + total = data.get('total', 'N/A') + print(f' Pagination: total={total}, items={len(items)}') + elif isinstance(data, list): + items = data + else: + items = [] + + if items and isinstance(items[0], dict): + actual_keys = set(items[0].keys()) + # From miniprogram appointment.ts Appointment interface + expected_keys = {'id', 'patient_name', 'doctor_name', 'department', + 'appointment_date', 'start_time', 'end_time', 'status', 'version'} + analyze_fields('Appointment', items[0], expected_keys) + +# ===================================================== +# API 6: GET /health/follow-up-tasks +# ===================================================== +print_header('API 6: Follow-Up Tasks', f'GET /health/follow-up-tasks?patient_id={PATIENT_ID}&status=pending&page=1&page_size=5') +status, body = api_call('GET', f'/health/follow-up-tasks?patient_id={PATIENT_ID}&status=pending&page=1&page_size=5') +print(f'status={status}') +print(f'Response: {json.dumps(body, indent=2, ensure_ascii=False)}') + +if status == 200: + data = body.get('data', body) + if isinstance(data, dict) and 'data' in data: + items = data.get('data', []) + total = data.get('total', 'N/A') + print(f' Pagination: total={total}, items={len(items)}') + elif isinstance(data, list): + items = data + else: + items = [] + + if items and isinstance(items[0], dict): + actual_keys = set(items[0].keys()) + # From miniprogram followup.ts FollowUpTask interface + expected_keys = {'id', 'patient_id', 'patient_name', 'follow_up_type', + 'content_template', 'status', 'planned_date', 'version'} + analyze_fields('FollowUpTask', items[0], expected_keys) + elif not items: + print(' No pending tasks. Retrying without status filter...') + status2, body2 = api_call('GET', f'/health/follow-up-tasks?patient_id={PATIENT_ID}&page=1&page_size=5') + print(f' status={status2}') + data2 = body2.get('data', body2) if status2 == 200 else {} + if isinstance(data2, dict) and 'data' in data2: + items2 = data2.get('data', []) + print(f' Items without status filter: {len(items2)}') + if items2 and isinstance(items2[0], dict): + actual_keys = set(items2[0].keys()) + expected_keys = {'id', 'patient_id', 'patient_name', 'follow_up_type', + 'content_template', 'status', 'planned_date', 'version'} + analyze_fields('FollowUpTask (all)', items2[0], expected_keys) + +print() +print('=' * 70) +print('TEST COMPLETE') +print('=' * 70) diff --git a/scripts/api_test_patient.py b/scripts/api_test_patient.py new file mode 100644 index 0000000..dff8b06 --- /dev/null +++ b/scripts/api_test_patient.py @@ -0,0 +1,512 @@ +#!/usr/bin/env python3 +"""HMS 患者建档链路端到端 API 测试""" +import json +import sys +import time +import urllib.request +import urllib.error + +BASE_URL = "http://localhost:3000/api/v1" +TOKEN = None +RESULTS = [] + +def log(test_id, name, status, detail): + """记录测试结果""" + RESULTS.append({"id": test_id, "name": name, "status": status, "detail": detail}) + icon = "PASS" if status == "PASS" else ("FAIL" if status == "FAIL" else "WARN") + print(f" [{icon}] {test_id}: {name} -- {detail}") + +def api_call(method, path, data=None, token=None, expect_status=None): + """发送 API 请求""" + url = f"{BASE_URL}{path}" + headers = {"Content-Type": "application/json"} + if token: + headers["Authorization"] = f"Bearer {token}" + + body = json.dumps(data).encode("utf-8") if data else None + req = urllib.request.Request(url, data=body, headers=headers, method=method) + + try: + with urllib.request.urlopen(req, timeout=30) as resp: + status = resp.status + resp_data = json.loads(resp.read().decode("utf-8")) + if expect_status and status != expect_status: + return status, resp_data, f"Expected {expect_status}, got {status}" + return status, resp_data, None + except urllib.error.HTTPError as e: + resp_body = e.read().decode("utf-8", errors="replace") + try: + resp_data = json.loads(resp_body) + except: + resp_data = {"raw": resp_body} + if expect_status and e.code == expect_status: + return e.code, resp_data, None + return e.code, resp_data, f"HTTP {e.code}: {resp_body[:200]}" + except Exception as e: + return 0, None, str(e) + +# ============================================================ +# Step 0: Login +# ============================================================ +print("\n" + "="*60) +print("Step 0: 登录获取 Token") +print("="*60) + +status, resp, err = api_call("POST", "/auth/login", + {"username": "admin", "password": "Admin@2026"}, expect_status=200) + +if err: + # 可能限流,等一下重试 + print(f" 首次登录失败: {err}") + print(" 等待 20 秒后重试...") + time.sleep(20) + status, resp, err = api_call("POST", "/auth/login", + {"username": "admin", "password": "Admin@2026"}, expect_status=200) + +if err or not resp or not resp.get("success"): + log("T0", "登录", "FAIL", f"登录失败: {err or resp}") + sys.exit(1) + +TOKEN = resp["data"]["access_token"] +user = resp["data"]["user"] +log("T0", "登录", "PASS", f"用户: {user['display_name']}, 角色: {[r['name'] for r in user['roles']]}") + +# ============================================================ +# Test 1.1: Patient List +# ============================================================ +print("\n" + "="*60) +print("Test 1.1: 患者列表") +print("="*60) + +status, resp, err = api_call("GET", "/health/patients?page=1&page_size=10", token=TOKEN) +if err: + log("T1.1", "患者列表", "FAIL", err) +else: + total = resp.get("data", {}).get("total", 0) + items = resp.get("data", {}).get("items", []) + log("T1.1", "患者列表", "PASS", f"success={resp['success']}, total={total}, items={len(items)}") + if items: + p = items[0] + print(f" 首条: id={p.get('id')}, name={p.get('name')}, gender={p.get('gender')}") + +# ============================================================ +# Test 1.2: Create Patient (Valid) +# ============================================================ +print("\n" + "="*60) +print("Test 1.2: 创建患者 - 完整有效数据") +print("="*60) + +import random +_ts = str(int(time.time() * 1000))[-6:] + +patient_data = { + "name": f"API测试患者_{_ts}", + "gender": "male", + "birth_date": "1990-05-15", + "phone": f"138{random.randint(10000000, 99999999)}", + "blood_type": "A", + "emergency_contact_name": "紧急联系人", + "emergency_contact_phone": f"139{random.randint(10000000, 99999999)}" +} + +patient_id = None # 预定义,防止后续 NameError + +status, resp, err = api_call("POST", "/health/patients", patient_data, token=TOKEN) +if err: + log("T1.2", "创建患者(有效)", "FAIL", err) +elif resp and resp.get("success"): + patient = resp["data"] + patient_id = patient.get("id") + log("T1.2", "创建患者(有效)", "PASS", + f"id={patient_id}, name={patient.get('name')}, gender={patient.get('gender')}, version={patient.get('version')}") + print(f" birth_date={patient.get('birth_date')}, phone={patient.get('phone')}") + print(f" blood_type={patient.get('blood_type')}, tenant_id={patient.get('tenant_id')}") +else: + log("T1.2", "创建患者(有效)", "FAIL", f"success={resp.get('success')}, error={resp.get('error')}") + +# ============================================================ +# Test 1.3: Create Patient - Empty Name (should fail) +# ============================================================ +print("\n" + "="*60) +print("Test 1.3: 创建患者 - 空名称(应失败)") +print("="*60) + +status, resp, err = api_call("POST", "/health/patients", + {"name": "", "gender": "male", "birth_date": "1990-01-01"}, token=TOKEN) + +if status == 400 or (resp and not resp.get("success")): + log("T1.3", "创建患者(空名称)", "PASS", + f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}") +elif status == 201 or (resp and resp.get("success")): + log("T1.3", "创建患者(空名称)", "FAIL", "空名称被接受,应该被拒绝") +else: + log("T1.3", "创建患者(空名称)", "FAIL", f"status={status}, resp={resp}") + +# ============================================================ +# Test 1.4: Create Patient - Future Birth Date (should fail) +# ============================================================ +print("\n" + "="*60) +print("Test 1.4: 创建患者 - 未来出生日期(应失败)") +print("="*60) + +status, resp, err = api_call("POST", "/health/patients", + {"name": "未来患者", "gender": "male", "birth_date": "2099-01-01"}, token=TOKEN) + +if status == 400 or (resp and not resp.get("success")): + log("T1.4", "创建患者(未来日期)", "PASS", + f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}") +elif status == 201 or (resp and resp.get("success")): + log("T1.4", "创建患者(未来日期)", "FAIL", "未来出生日期被接受,应该被拒绝") +else: + log("T1.4", "创建患者(未来日期)", "FAIL", f"status={status}, resp={resp}") + +# ============================================================ +# Test 2.1: Patient Detail + PII Check +# ============================================================ +print("\n" + "="*60) +print("Test 2.1: 患者详情 + PII 脱敏验证") +print("="*60) + +if patient_id: + status, resp, err = api_call("GET", f"/health/patients/{patient_id}", token=TOKEN) + if err: + log("T2.1", "患者详情", "FAIL", err) + else: + p = resp.get("data", {}) + log("T2.1", "患者详情", "PASS", f"success={resp['success']}, name={p.get('name')}") + # PII 检查: phone 是否为明文或脱敏 + phone = p.get("phone", "N/A") + emergency_phone = p.get("emergency_contact_phone", "N/A") + print(f" phone={phone}") + print(f" emergency_contact_phone={emergency_phone}") + print(f" id_card_number={p.get('id_card_number', 'N/A')}") + # 检查标准字段 + has_tenant_id = "tenant_id" in p + has_created_at = "created_at" in p + has_version = "version" in p + print(f" tenant_id={'存在' if has_tenant_id else '缺失'}, " + f"created_at={'存在' if has_created_at else '缺失'}, " + f"version={'存在' if has_version else '缺失'}") + if not (has_tenant_id and has_created_at and has_version): + log("T2.1b", "标准字段检查", "WARN", "部分标准字段缺失") + else: + print(f" 标准字段检查: 通过") +else: + log("T2.1", "患者详情", "SKIP", "无 patient_id (创建患者失败)") + +# ============================================================ +# Test 2.2: Patient Detail - Non-existent ID (should 404) +# ============================================================ +print("\n" + "="*60) +print("Test 2.2: 患者详情 - 不存在的ID(应404)") +print("="*60) + +status, resp, err = api_call("GET", "/health/patients/00000000-0000-0000-0000-000000000000", token=TOKEN) +if status == 404: + log("T2.2", "患者详情(不存在)", "PASS", f"正确返回 404") +elif err: + log("T2.2", "患者详情(不存在)", "WARN", f"status={status}, err={err}") +else: + log("T2.2", "患者详情(不存在)", "FAIL", f"status={status}, 应为 404") + +# ============================================================ +# Test 3.1: Patient Tags - Create Tag +# ============================================================ +print("\n" + "="*60) +print("Test 3.1: 患者标签 - 创建标签") +print("="*60) + +tag_id = None +status, resp, err = api_call("POST", "/health/patient-tags", + {"name": f"API测试标签_{_ts}", "color": "#FF5500"}, token=TOKEN) + +if err: + log("T3.1", "创建标签", "FAIL", err) +elif resp and resp.get("success"): + tag = resp["data"] + tag_id = tag.get("id") + log("T3.1", "创建标签", "PASS", f"id={tag_id}, name={tag.get('name')}, color={tag.get('color')}") +else: + log("T3.1", "创建标签", "FAIL", f"status={status}, error={resp}") + +# ============================================================ +# Test 3.2: Patient Tags - List +# ============================================================ +print("\n" + "="*60) +print("Test 3.2: 患者标签 - 列表") +print("="*60) + +status, resp, err = api_call("GET", "/health/patient-tags", token=TOKEN) +if err: + log("T3.2", "标签列表", "FAIL", err) +else: + raw_data = resp.get("data") + if isinstance(raw_data, list): + total = len(raw_data) + log("T3.2", "标签列表", "PASS", f"success={resp['success']}, count={total}") + elif isinstance(raw_data, dict): + items = raw_data.get("items", []) + total = raw_data.get("total", len(items)) + log("T3.2", "标签列表", "PASS", f"success={resp['success']}, total={total}") + else: + log("T3.2", "标签列表", "PASS", f"success={resp['success']}, data_type={type(raw_data)}") + +# ============================================================ +# Test 3.3: Patient Tags - Assign to Patient +# ============================================================ +print("\n" + "="*60) +print("Test 3.3: 患者标签 - 关联标签到患者") +print("="*60) + +if patient_id and tag_id: + # 尝试关联标签 - 可能的 API 路径 + status, resp, err = api_call("POST", f"/health/patients/{patient_id}/tags", + {"tag_ids": [tag_id]}, token=TOKEN) + + if err and status == 404: + # 尝试替代路径 + status2, resp2, err2 = api_call("POST", "/health/patient-tag-relations", + {"patient_id": patient_id, "tag_id": tag_id}, token=TOKEN) + if err2: + log("T3.3", "关联标签", "WARN", f"标签关联路径未确认: {err[:100]}") + else: + log("T3.3", "关联标签", "PASS", f"通过 /patient-tag-relations: {resp2}") + elif err: + log("T3.3", "关联标签", "WARN", f"status={status}, err={err[:150]}") + else: + log("T3.3", "关联标签", "PASS", f"success={resp.get('success')}") +else: + log("T3.3", "关联标签", "SKIP", "缺少 patient_id 或 tag_id") + +# ============================================================ +# Test 4.1: Patient Update - Normal +# ============================================================ +print("\n" + "="*60) +print("Test 4.1: 患者更新 - 正常更新") +print("="*60) + +updated_version = None +if patient_id: + # 先获取当前 version + status, resp, err = api_call("GET", f"/health/patients/{patient_id}", token=TOKEN) + if not err and resp.get("success"): + current_version = resp["data"].get("version") + print(f" 当前 version: {current_version}") + + update_data = { + "name": "API测试患者-已更新", + "phone": "13800003333", + "version": current_version + } + + status, resp, err = api_call("PUT", f"/health/patients/{patient_id}", update_data, token=TOKEN) + if err: + log("T4.1", "患者更新", "FAIL", err) + elif resp and resp.get("success"): + updated = resp["data"] + updated_version = updated.get("version") + log("T4.1", "患者更新", "PASS", + f"name={updated.get('name')}, version={current_version}->{updated_version}") + else: + log("T4.1", "患者更新", "FAIL", f"success={resp.get('success')}, error={resp}") + else: + log("T4.1", "患者更新", "FAIL", f"获取患者失败: {err}") +else: + log("T4.1", "患者更新", "SKIP", "无 patient_id") + +# ============================================================ +# Test 4.2: Patient Update - Optimistic Lock (should fail) +# ============================================================ +print("\n" + "="*60) +print("Test 4.2: 患者更新 - 乐观锁冲突(应失败)") +print("="*60) + +if patient_id: + # 使用旧 version 触发乐观锁冲突 + stale_update = { + "name": "API测试患者-冲突更新", + "version": 1 # 旧版本号 + } + + status, resp, err = api_call("PUT", f"/health/patients/{patient_id}", stale_update, token=TOKEN) + if status == 409 or (resp and not resp.get("success")): + log("T4.2", "乐观锁冲突", "PASS", + f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}") + elif resp and resp.get("success"): + log("T4.2", "乐观锁冲突", "FAIL", "旧版本号更新被接受,乐观锁未生效") + else: + log("T4.2", "乐观锁冲突", "WARN", f"status={status}, resp={str(resp)[:200]}") +else: + log("T4.2", "乐观锁冲突", "SKIP", "无 patient_id") + +# ============================================================ +# Test 5.1: Security - No Auth (should 401) +# ============================================================ +print("\n" + "="*60) +print("Test 5.1: 安全测试 - 无认证访问(应401)") +print("="*60) + +status, resp, err = api_call("GET", "/health/patients") +if status == 401: + log("T5.1", "无认证访问", "PASS", "正确返回 401") +elif err: + log("T5.1", "无认证访问", "FAIL", f"status={status}, 应为 401") +else: + log("T5.1", "无认证访问", "FAIL", f"status={status}, 成功访问但应该被拒绝") + +# ============================================================ +# Test 5.2: Security - SQL Injection Attempt +# ============================================================ +print("\n" + "="*60) +print("Test 5.2: 安全测试 - SQL 注入尝试") +print("="*60) + +status, resp, err = api_call("GET", "/health/patients?search=%27%3B%20DROP%20TABLE%20patients%3B%20--", token=TOKEN) +if status == 500: + log("T5.2", "SQL注入防护", "FAIL", "服务器返回 500,可能存在注入风险") +elif resp and resp.get("success"): + # 正常返回搜索结果 = 注入被参数化查询防住了 + log("T5.2", "SQL注入防护", "PASS", f"注入被参数化查询拦截,正常返回数据") +else: + log("T5.2", "SQL注入防护", "PASS", f"status={status}, 注入未导致服务异常") + +# ============================================================ +# Test 5.3: Security - Invalid Token (should 401) +# ============================================================ +print("\n" + "="*60) +print("Test 5.3: 安全测试 - 无效 Token(应401)") +print("="*60) + +status, resp, err = api_call("GET", "/health/patients", token="invalid_token_here") +if status == 401: + log("T5.3", "无效Token", "PASS", "正确返回 401") +else: + log("T5.3", "无效Token", "FAIL", f"status={status}, 应为 401") + +# ============================================================ +# Test 6.1: Pagination +# ============================================================ +print("\n" + "="*60) +print("Test 6.1: 分页查询") +print("="*60) + +status, resp, err = api_call("GET", "/health/patients?page=1&page_size=2", token=TOKEN) +if err: + log("T6.1", "分页查询", "FAIL", err) +else: + total = resp.get("data", {}).get("total", 0) + items = resp.get("data", {}).get("items", []) + page = resp.get("data", {}).get("page", "N/A") + page_size = resp.get("data", {}).get("page_size", resp.get("data", {}).get("per_page", "N/A")) + log("T6.1", "分页查询", "PASS", + f"total={total}, page={page}, page_size={page_size}, returned={len(items)}") + +# ============================================================ +# Test 7.1: Create patient with minimal data +# ============================================================ +print("\n" + "="*60) +print("Test 7.1: 创建患者 - 最小必填数据") +print("="*60) + +status, resp, err = api_call("POST", "/health/patients", + {"name": f"最小数据患者_{_ts}", "gender": "female", "birth_date": "2000-01-01"}, token=TOKEN) + +if err: + log("T7.1", "创建患者(最小数据)", "FAIL", err) +elif resp and resp.get("success"): + p = resp["data"] + log("T7.1", "创建患者(最小数据)", "PASS", + f"id={p.get('id')}, name={p.get('name')}, optional fields: phone={p.get('phone')}, blood_type={p.get('blood_type')}") +else: + log("T7.1", "创建患者(最小数据)", "FAIL", f"success={resp.get('success')}, error={resp}") + +# ============================================================ +# Test 7.2: Create patient with whitespace-only name (should fail) +# ============================================================ +print("\n" + "="*60) +print("Test 7.2: 创建患者 - 纯空格名称(应失败)") +print("="*60) + +status, resp, err = api_call("POST", "/health/patients", + {"name": " ", "gender": "male", "birth_date": "1990-01-01"}, token=TOKEN) + +if status == 400 or (resp and not resp.get("success")): + log("T7.2", "创建患者(空格名称)", "PASS", + f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}") +else: + log("T7.2", "创建患者(空格名称)", "FAIL", f"纯空格名称被接受: status={status}") + +# ============================================================ +# Test 8.1: Invalid gender +# ============================================================ +print("\n" + "="*60) +print("Test 8.1: 创建患者 - 无效性别值") +print("="*60) + +status, resp, err = api_call("POST", "/health/patients", + {"name": "无效性别", "gender": "invalid_gender", "birth_date": "1990-01-01"}, token=TOKEN) + +if status == 400 or (resp and not resp.get("success")): + log("T8.1", "创建患者(无效性别)", "PASS", + f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}") +elif resp and resp.get("success"): + log("T8.1", "创建患者(无效性别)", "WARN", f"无效性别被接受(可能是开放枚举): gender={resp['data'].get('gender')}") +else: + log("T8.1", "创建患者(无效性别)", "WARN", f"status={status}") + +# ============================================================ +# Test 9.1: Invalid birth_date format +# ============================================================ +print("\n" + "="*60) +print("Test 9.1: 创建患者 - 无效日期格式") +print("="*60) + +status, resp, err = api_call("POST", "/health/patients", + {"name": "无效日期", "gender": "male", "birth_date": "not-a-date"}, token=TOKEN) + +if status == 400 or (resp and not resp.get("success")): + log("T9.1", "创建患者(无效日期格式)", "PASS", + f"正确拒绝: status={status}, error={resp.get('error', resp.get('message', 'N/A'))}") +else: + log("T9.1", "创建患者(无效日期格式)", "FAIL", f"无效日期格式被接受: status={status}") + +# ============================================================ +# Summary +# ============================================================ +print("\n" + "="*60) +print("测试汇总") +print("="*60) + +passed = sum(1 for r in RESULTS if r["status"] == "PASS") +failed = sum(1 for r in RESULTS if r["status"] == "FAIL") +warned = sum(1 for r in RESULTS if r["status"] == "WARN") +skipped = sum(1 for r in RESULTS if r["status"] == "SKIP") +total = len(RESULTS) + +print(f"\n 总计: {total} 项测试") +print(f" PASS: {passed}") +print(f" FAIL: {failed}") +print(f" WARN: {warned}") +print(f" SKIP: {skipped}") +print(f" 通过率: {passed/total*100:.1f}%") + +if failed > 0: + print("\n 失败项:") + for r in RESULTS: + if r["status"] == "FAIL": + print(f" [{r['id']}] {r['name']}: {r['detail']}") + +if warned > 0: + print("\n 警告项:") + for r in RESULTS: + if r["status"] == "WARN": + print(f" [{r['id']}] {r['name']}: {r['detail']}") + +print("\n" + "="*60) +if failed == 0: + print("结论: 所有测试通过") +elif failed <= 2: + print(f"结论: 基本通过,有 {failed} 项失败需要关注") +else: + print(f"结论: 有 {failed} 项失败,需要修复") +print("="*60) diff --git a/scripts/check-api-paths.sh b/scripts/check-api-paths.sh new file mode 100644 index 0000000..95ed8d5 --- /dev/null +++ b/scripts/check-api-paths.sh @@ -0,0 +1,136 @@ +#!/usr/bin/env bash +# check-api-paths.sh - Frontend API paths vs Backend routes consistency check +# +# Usage: bash scripts/check-api-paths.sh +# Returns: 0=pass, 1=mismatch found + +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +FRONTEND_PATHS=$(mktemp) +BACKEND_ROUTES=$(mktemp) +KNOWN_PREFIXES=$(mktemp) +trap 'rm -f "$FRONTEND_PATHS" "$BACKEND_ROUTES" "$KNOWN_PREFIXES"' EXIT + +echo "==========================================" +echo " Frontend-Backend API Path Consistency" +echo "==========================================" + +# --- Extract frontend API paths --- +# Single-quoted paths from api/ modules +grep -rohE "'\/[^']+'" apps/web/src/api/ --include="*.ts" | tr -d "'" > "$FRONTEND_PATHS" +# Template literal paths +grep -rohE '`/[^`]+`' apps/web/src/api/ --include="*.ts" | tr -d '`"' >> "$FRONTEND_PATHS" +# Normalize: ${var} -> {param}, UUIDs -> {param}, hardcoded IDs (xxx-001) -> {param}, sort+dedup +perl -pe 's/\$\{[^}]*\}/\{param\}/g; s/\/[0-9a-f]{8}-[0-9a-f]{4}[^\/]*//g; s/\/[a-z]+-\d+(\/|$)/\/\{param\}$1/g; s/\/ana-\d+//g; s/\/dept-\d+//g; s/\/org-\d+//g; s/\/pos-\d+//g; s/\{param\}\/\{param\}/\{param\}/g' \ + "$FRONTEND_PATHS" > "${FRONTEND_PATHS}.tmp" +sort -u "${FRONTEND_PATHS}.tmp" > "$FRONTEND_PATHS" +rm -f "${FRONTEND_PATHS}.tmp" + +# --- Extract backend Axum routes --- +# From .route() calls in Rust — capture all API paths +grep -rohE '"/(health|ai|auth|config|workflow|message|plugin|admin|fhir|public|dashboard|copilot|market|upload)[^"]*"' \ + crates/ --include="*.rs" \ + | tr -d '"' \ + | sed 's/:[a-z_][a-z0-9_]*/\{param\}/g' \ + | sed 's/{[^}]*}/{param}/g' \ + | sort -u > "$BACKEND_ROUTES" +# Also capture base module routes (users, roles, etc.) from module.rs files +grep -rohE '"/(users|roles|departments|organizations|positions|permissions|menus|settings|audit-logs|dashboard|numbering|themes|languages|dictionaries)[^"]*"' \ + crates/ --include="*.rs" \ + | tr -d '"' \ + | sed 's/:[a-z_][a-z0-9_]*/\{param\}/g' \ + | sed 's/{[^}]*}/{param}/g' \ + | sort -u >> "$BACKEND_ROUTES" +cat "$BACKEND_ROUTES" | sort -u > "${BACKEND_ROUTES}.tmp" && mv "${BACKEND_ROUTES}.tmp" "$BACKEND_ROUTES" + +# --- Known prefixes that have dynamic/different routing --- +# Plugin dynamic table routes: /plugins/crm/{table} - registered at runtime +cat > "$KNOWN_PREFIXES" <<'EOF' +/admin/plugins +/plugins/crm +/plugins/{param} +/market +/api/v1/public/brand +/api/v1 +/dashboard +/new +/config/settings/ +EOF + +FE_COUNT=$(wc -l < "$FRONTEND_PATHS") +BE_COUNT=$(wc -l < "$BACKEND_ROUTES") +echo "" +echo "Stats: Frontend ${FE_COUNT} paths | Backend ${BE_COUNT} routes" +echo "" + +ERRORS=0 + +# --- Check 1: Frontend paths that have no backend route --- +echo "--- Check 1: Frontend paths missing from backend ---" +while IFS= read -r fpath; do + [ -z "$fpath" ] && continue + + # Skip known dynamic prefixes (plugin routes registered at runtime) + skip=false + while IFS= read -r prefix; do + [ -z "$prefix" ] && continue + case "$fpath" in + "$prefix"*) skip=true; break ;; + esac + done < "$KNOWN_PREFIXES" + [ "$skip" = true ] && continue + + # Remove trailing /{param} for loose matching + clean_path="${fpath%/{param}}" + found=false + while IFS= read -r bpath; do + [ -z "$bpath" ] && continue + clean_bpath="${bpath%/{param}}" + # Exact match or prefix match + if [ "$fpath" = "$bpath" ] || [ "$clean_path" = "$clean_bpath" ]; then + found=true + break + fi + case "$fpath" in + "$bpath"/*|"$clean_bpath"/*) found=true; break ;; + "$bpath"|"$clean_bpath") found=true; break ;; + esac + done < "$BACKEND_ROUTES" + + if [ "$found" = false ]; then + echo -e " ${RED}MISSING${NC} Frontend '${fpath}' not found in backend routes" + ERRORS=$((ERRORS + 1)) + fi +done < "$FRONTEND_PATHS" + +if [ $ERRORS -eq 0 ]; then + echo -e " ${GREEN}OK${NC} All frontend paths have backend routes" +fi + +echo "" + +# --- Check 2: Backend route parameter format --- +echo "--- Check 2: Backend route param format ---" +bad_format=$(grep -E ':[a-z_]+[/"]' "$BACKEND_ROUTES" || true) +if [ -n "$bad_format" ]; then + echo -e " ${YELLOW}WARN${NC} Routes using old :param syntax:" + echo "$bad_format" | while IFS= read -r line; do echo " $line"; done +else + echo -e " ${GREEN}OK${NC} All routes use {param} syntax" +fi + +echo "" +echo "==========================================" +if [ $ERRORS -gt 0 ]; then + echo -e " ${RED}FAIL${NC} ${ERRORS} mismatches" + exit 1 +else + echo -e " ${GREEN}PASS${NC} All paths consistent" + exit 0 +fi diff --git a/scripts/check-permissions.sh b/scripts/check-permissions.sh new file mode 100644 index 0000000..a458caa --- /dev/null +++ b/scripts/check-permissions.sh @@ -0,0 +1,117 @@ +#!/usr/bin/env bash +# check-permissions.sh — 权限注册完整性 CI 检查 +# +# 检查三处权限定义的一致性: +# 1. 后端 handler 中的 require_permission 调用 +# 2. 前端 routeConfig.ts 中的路由权限声明 +# 3. 数据库迁移中的权限 seed 数据 +# +# 用法: bash scripts/check-permissions.sh +# 返回: 0=通过, 1=发现不一致 + +set -uo pipefail +cd "$(git rev-parse --show-toplevel)" + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +# 临时文件 +BACKEND_PERMS=$(mktemp) +FRONTEND_PERMS=$(mktemp) +SEED_PERMS=$(mktemp) +trap 'rm -f "$BACKEND_PERMS" "$FRONTEND_PERMS" "$SEED_PERMS"' EXIT + +echo "==========================================" +echo " 权限注册完整性检查" +echo "==========================================" + +# --- 提取后端 handler 权限码 --- +# 1) require_permission 调用 +grep -roh 'require_permission.*"[^"]*"' crates/ --include="*.rs" \ + | grep -oE '"[^"]*"' | tr -d '"' | sort -u > "$BACKEND_PERMS" +# 2) module.rs 中 PermissionDescriptor 声明的 code 字段 +grep -roh 'code: *"[^"]*"' crates/ --include="*.rs" \ + | grep -oE '"[^"]*\.[^"]*\.[^"]*"' | tr -d '"' | sort -u >> "$BACKEND_PERMS" +# 去重 +cat "$BACKEND_PERMS" | sort -u > "${BACKEND_PERMS}.tmp" && mv "${BACKEND_PERMS}.tmp" "$BACKEND_PERMS" + +# --- 提取前端 routeConfig 权限码 --- +grep -oE '"[a-z][-a-z0-9]*\.[a-z][-a-z0-9]*\.[a-z][-a-z0-9]*"' \ + apps/web/src/routeConfig.ts | tr -d '"' | sort -u > "$FRONTEND_PERMS" + +# --- 提取 seed 迁移权限码 --- +# 匹配三段式(health.patient.list)和两段式(plugin.admin)权限码 +grep -rohE '[a-z][-a-z0-9]*\.[a-z][-a-z0-9]*(\.[a-z][-a-z0-9]*)?' \ + crates/erp-server/migration/src/ --include="*.rs" \ + | grep -vE 'fn |mod |use |struct |impl |async |let |pub |self|super|crate' \ + | grep -E '^(user|role|workflow|message|setting|plugin|department|organization|position|dictionary|menu|numbering|theme|language|tenant|ai|copilot|health)' \ + | grep -v '\.(rs|sql|md|toml)$' \ + | sort -u > "$SEED_PERMS" +# 提取 handler 中的非 health 权限码也加入 seed 对比 +grep -roh 'require_permission.*"[^"]*"' crates/erp-auth/ crates/erp-config/ crates/erp-workflow/ crates/erp-message/ --include="*.rs" \ + | grep -oE '"[^"]*"' | tr -d '"' | sort -u >> "$SEED_PERMS" +# 去重 +cat "$SEED_PERMS" | sort -u > "${SEED_PERMS}.tmp" && mv "${SEED_PERMS}.tmp" "$SEED_PERMS" + +echo "" +echo "统计: 后端 $(wc -l < "$BACKEND_PERMS") 个 | 前端 $(wc -l < "$FRONTEND_PERMS") 个 | Seed $(wc -l < "$SEED_PERMS") 个" +echo "" + +ERRORS=0 + +# --- 检查 1: 前端引用了但后端不存在的权限码 --- +echo "--- 检查 1: 前端权限码是否在后端 handler 中存在 ---" +while IFS= read -r perm; do + if ! grep -q "^${perm}$" "$BACKEND_PERMS"; then + echo -e " ${RED}MISSING${NC} 前端声明 '$perm' 但后端 handler 未使用" + ERRORS=$((ERRORS + 1)) + fi +done < "$FRONTEND_PERMS" + +if [ $ERRORS -eq 0 ]; then + echo -e " ${GREEN}OK${NC} 前端所有权限码在后端都有对应" +fi + +echo "" + +# --- 检查 2: 后端 handler 有但 seed 迁移缺失的权限码 --- +echo "--- 检查 2: 后端权限码是否在 seed 迁移中注册 ---" +SEED_MISSING=0 +while IFS= read -r perm; do + if ! grep -q "^${perm}$" "$SEED_PERMS"; then + echo -e " ${RED}MISSING${NC} 后端使用 '$perm' 但 seed 迁移未注册" + SEED_MISSING=$((SEED_MISSING + 1)) + ERRORS=$((ERRORS + 1)) + fi +done < "$BACKEND_PERMS" + +if [ $SEED_MISSING -eq 0 ]; then + echo -e " ${GREEN}OK${NC} 后端所有权限码在 seed 中都已注册" +fi + +echo "" + +# --- 检查 3: 每个 .list 权限是否配有 .manage --- +echo "--- 检查 3: 每个实体是否同时有 .list 和 .manage ---" +LIST_PERMS=$(grep -E '\.list$' "$BACKEND_PERMS" || true) +while IFS= read -r list_perm; do + [ -z "$list_perm" ] && continue + manage_perm="${list_perm%.list}.manage" + if ! grep -q "^${manage_perm}$" "$BACKEND_PERMS"; then + echo -e " ${YELLOW}WARN${NC} '$list_perm' 缺少对应的 '$manage_perm'" + fi +done <<< "$LIST_PERMS" + +echo "" + +# --- 总结 --- +echo "==========================================" +if [ $ERRORS -gt 0 ]; then + echo -e " ${RED}FAIL${NC} 发现 $ERRORS 个不一致" + exit 1 +else + echo -e " ${GREEN}PASS${NC} 权限注册完整性检查通过" + exit 0 +fi diff --git a/scripts/demo-seed.sql b/scripts/demo-seed.sql new file mode 100644 index 0000000..975beca --- /dev/null +++ b/scripts/demo-seed.sql @@ -0,0 +1,271 @@ +-- HMS V1 演示数据预置脚本 +-- 用法: docker exec -i erp-postgres psql -U erp -d erp < scripts/demo-seed.sql +-- 幂等:使用 ON CONFLICT (id) DO NOTHING +-- 说明:预置张建国患者 + 化验单 + 背景患者 + 随访/告警 + 科普文章 + +-- 获取租户 ID(变量) +WITH t AS (SELECT id AS tid FROM tenants WHERE deleted_at IS NULL LIMIT 1) +SELECT 'tenant_id: ' || tid FROM t; + +\set ON_ERROR_STOP on + +BEGIN; + +-- ============================================================ +-- 1. 张建国患者档案 +-- ============================================================ +INSERT INTO patient (id, tenant_id, name, gender, birth_date, phone, + allergy_history, medical_history_summary, + emergency_contact_name, emergency_contact_phone, + status, verification_status, source, + created_at, updated_at, version) +SELECT + 'a0000001-0001-7000-8000-000000000001'::uuid, + t.id, + '张建国', 'male', '1961-03-15', '13800138001', + '青霉素过敏', '慢性肾病3期,高血压病史5年', + '张小明', '13900139001', + 'active', 'verified', 'manual', + NOW(), NOW(), 1 +FROM tenants t WHERE t.deleted_at IS NULL +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 2. 张建国历史体征数据(3 条,覆盖 3 个月) +-- ============================================================ +-- 3 个月前:血压 132/82,心率 70 +INSERT INTO vital_signs (id, tenant_id, patient_id, record_date, + systolic_bp_morning, diastolic_bp_morning, heart_rate, + source, created_at, updated_at, version) +SELECT + 'b0000001-0001-7000-8000-000000000001'::uuid, + t.id, + 'a0000001-0001-7000-8000-000000000001'::uuid, + CURRENT_DATE - INTERVAL '90 days', + 132, 82, 70, + 'manual', NOW(), NOW(), 1 +FROM tenants t WHERE t.deleted_at IS NULL +ON CONFLICT (id) DO NOTHING; + +-- 1 个月前:血压 138/86,心率 74,空腹血糖 5.6 +INSERT INTO vital_signs (id, tenant_id, patient_id, record_date, + systolic_bp_morning, diastolic_bp_morning, heart_rate, blood_sugar, + blood_sugar_type, source, created_at, updated_at, version) +SELECT + 'b0000001-0001-7000-8000-000000000002'::uuid, + t.id, + 'a0000001-0001-7000-8000-000000000001'::uuid, + CURRENT_DATE - INTERVAL '30 days', + 138, 86, 74, 5.6, + 'fasting', 'manual', NOW(), NOW(), 1 +FROM tenants t WHERE t.deleted_at IS NULL +ON CONFLICT (id) DO NOTHING; + +-- 今天:血压 142/88,心率 72,空腹血糖 5.8 +INSERT INTO vital_signs (id, tenant_id, patient_id, record_date, + systolic_bp_morning, diastolic_bp_morning, heart_rate, blood_sugar, + blood_sugar_type, source, created_at, updated_at, version) +SELECT + 'b0000001-0001-7000-8000-000000000003'::uuid, + t.id, + 'a0000001-0001-7000-8000-000000000001'::uuid, + CURRENT_DATE, + 142, 88, 72, 5.8, + 'fasting', 'manual', NOW(), NOW(), 1 +FROM tenants t WHERE t.deleted_at IS NULL +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 3. 化验报告(2 份,肌酐趋势 88→102) +-- ============================================================ +-- 化验单 1:3 个月前,肌酐 88 +INSERT INTO lab_report (id, tenant_id, patient_id, report_date, report_type, + source, items, status, + created_at, updated_at, version) +SELECT + 'c0000001-0001-7000-8000-000000000001'::uuid, + t.id, + 'a0000001-0001-7000-8000-000000000001'::uuid, + CURRENT_DATE - INTERVAL '90 days', + 'kidney_function', 'manual_input', + '[{"name":"肌酐","value":"88","unit":"μmol/L","reference_low":"44","reference_high":"133","is_abnormal":false}, + {"name":"尿素氮","value":"6.1","unit":"mmol/L","reference_low":"2.6","reference_high":"7.5","is_abnormal":false}, + {"name":"eGFR","value":"75","unit":"mL/min/1.73m2","reference_low":"60","reference_high":"","is_abnormal":false}]'::jsonb, + 'reviewed', + NOW(), NOW(), 1 +FROM tenants t WHERE t.deleted_at IS NULL +ON CONFLICT (id) DO NOTHING; + +-- 化验单 2:1 个月前,肌酐 102(偏高趋势) +INSERT INTO lab_report (id, tenant_id, patient_id, report_date, report_type, + source, items, status, + created_at, updated_at, version) +SELECT + 'c0000001-0001-7000-8000-000000000002'::uuid, + t.id, + 'a0000001-0001-7000-8000-000000000001'::uuid, + CURRENT_DATE - INTERVAL '30 days', + 'kidney_function', 'manual_input', + '[{"name":"肌酐","value":"102","unit":"μmol/L","reference_low":"44","reference_high":"133","is_abnormal":false}, + {"name":"尿素氮","value":"6.8","unit":"mmol/L","reference_low":"2.6","reference_high":"7.5","is_abnormal":false}, + {"name":"eGFR","value":"72","unit":"mL/min/1.73m2","reference_low":"60","reference_high":"","is_abnormal":false}]'::jsonb, + 'reviewed', + NOW(), NOW(), 1 +FROM tenants t WHERE t.deleted_at IS NULL +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 4. 背景患者(25 个,让仪表盘有数据) +-- ============================================================ +INSERT INTO patient (id, tenant_id, name, gender, birth_date, phone, + status, verification_status, source, + created_at, updated_at, version) +SELECT + ('d0000001-0001-7000-8000-0000000' || lpad(i::text, 6, '0'))::uuid, + t.id, + '测试患者' || i, + CASE WHEN i % 2 = 0 THEN 'male' ELSE 'female' END, + CURRENT_DATE - (30 + (i * 37) % 50) * INTERVAL '1 year', + '138' || lpad((13800000 + i)::text, 8, '0'), + 'active', 'verified', 'manual', + NOW() - (i * INTERVAL '1 day'), NOW(), 1 +FROM tenants t, generate_series(1, 25) AS i +WHERE t.deleted_at IS NULL +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 5. 随访模板(慢性肾病定期随访) +-- ============================================================ +INSERT INTO follow_up_template (id, tenant_id, name, description, + follow_up_type, applicable_scope, status, + created_at, updated_at, version) +SELECT + 'e0000001-0001-7000-8000-000000000001'::uuid, + t.id, + '慢性肾病定期随访', 'CKD 3-4期患者标准随访计划', + 'phone', 'chronic_kidney_disease', 'active', + NOW(), NOW(), 1 +FROM tenants t WHERE t.deleted_at IS NULL +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 6. 随访任务(张建国 + 若干背景患者) +-- ============================================================ +-- 张建国的随访任务(pending 状态,演示场景 6 用) +INSERT INTO follow_up_task (id, tenant_id, patient_id, + follow_up_type, planned_date, status, content_template, + created_at, updated_at, version) +SELECT + 'f0000001-0001-7000-8000-000000000001'::uuid, + t.id, + 'a0000001-0001-7000-8000-000000000001'::uuid, + 'phone', CURRENT_DATE + INTERVAL '7 days', 'pending', + '肾功能复查随访:询问近期症状、饮食依从性、用药情况', + NOW(), NOW(), 1 +FROM tenants t WHERE t.deleted_at IS NULL +ON CONFLICT (id) DO NOTHING; + +-- 背景患者的随访任务(混合状态) +INSERT INTO follow_up_task (id, tenant_id, patient_id, + follow_up_type, planned_date, status, content_template, + created_at, updated_at, version) +SELECT + ('f0000002-0001-7000-8000-0000000' || lpad(i::text, 6, '0'))::uuid, + t.id, + ('d0000001-0001-7000-8000-0000000' || lpad(i::text, 6, '0'))::uuid, + CASE WHEN i % 3 = 0 THEN 'phone' WHEN i % 3 = 1 THEN 'outpatient' ELSE 'wechat' END, + CURRENT_DATE - (i % 10) * INTERVAL '1 day', + CASE WHEN i <= 15 THEN 'completed' ELSE 'pending' END, + '定期健康随访', + NOW() - (i * INTERVAL '1 day'), NOW(), 1 +FROM tenants t, generate_series(1, 20) AS i +WHERE t.deleted_at IS NULL +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 7. 告警规则(收缩压 >=160,场景 5 用) +-- ============================================================ +-- 注意:此规则用于演示场景 5,张大爷录入血压 168 时触发 +-- seed 中的收缩压危急规则是 >=180,这里补充 >=160 的中等严重性规则 +INSERT INTO alert_rules (id, tenant_id, name, description, + device_type, condition_type, condition_params, severity, + is_active, notify_roles, cooldown_minutes, + created_at, updated_at, version) +SELECT + 'g0000001-0001-7000-8000-000000000001'::uuid, + t.id, + '收缩压偏高(演示用)', '收缩压 >= 160mmHg 触发中等严重性告警', + 'blood_pressure', 'threshold', + '{"metric":"systolic_bp","operator":">=","threshold":160}'::jsonb, + 'medium', + true, + '["nurse","health_manager","doctor"]'::jsonb, + 30, + NOW(), NOW(), 1 +FROM tenants t WHERE t.deleted_at IS NULL +ON CONFLICT (id) DO NOTHING; + +-- ============================================================ +-- 8. 科普文章(3 篇 CKD 相关) +-- ============================================================ +INSERT INTO article (id, tenant_id, title, summary, content, + category, author, status, content_type, + view_count, sort_order, + published_at, created_at, updated_at, version) +SELECT + 'h0000001-0001-7000-8000-000000000001'::uuid, + t.id, + '慢性肾病患者的饮食指南', '科学饮食延缓 CKD 进展', + '

低盐低蛋白饮食原则

CKD 3期患者每日蛋白质摄入控制在0.6-0.8g/kg,食盐不超过5g。

推荐食物:鸡蛋清、鱼肉、瘦肉(限量)、新鲜蔬菜。

避免:高钾食物(香蕉、土豆)、高磷食物(坚果、可乐)、加工食品。

', + 'nutrition', 'HMS 健康管理团队', 'published', 'rich_text', + 128, 1, + NOW() - INTERVAL '10 days', NOW() - INTERVAL '10 days', NOW(), 1 +FROM tenants t WHERE t.deleted_at IS NULL +ON CONFLICT (id) DO NOTHING; + +INSERT INTO article (id, tenant_id, title, summary, content, + category, author, status, content_type, + view_count, sort_order, + published_at, created_at, updated_at, version) +SELECT + 'h0000001-0001-7000-8000-000000000002'::uuid, + t.id, + 'CKD 患者运动建议', '安全运动改善生活质量', + '

适度运动有益 CKD 管理

推荐运动:散步(30分钟/天)、太极拳、瑜伽、游泳(低强度)。

运动频率:每周3-5次,每次20-40分钟。

注意:避免剧烈运动,运动前后监测血压,感觉不适立即停止。

', + 'exercise', 'HMS 健康管理团队', 'published', 'rich_text', + 85, 2, + NOW() - INTERVAL '7 days', NOW() - INTERVAL '7 days', NOW(), 1 +FROM tenants t WHERE t.deleted_at IS NULL +ON CONFLICT (id) DO NOTHING; + +INSERT INTO article (id, tenant_id, title, summary, content, + category, author, status, content_type, + view_count, sort_order, + published_at, created_at, updated_at, version) +SELECT + 'h0000001-0001-7000-8000-000000000003'::uuid, + t.id, + '慢性肾病常用药物说明', '了解您正在服用的药物', + '

常见 CKD 药物

降压药(ACEI/ARB):保护肾功能,降低蛋白尿。需定期监测血钾。

碳酸氢钠:纠正代谢性酸中毒。

铁剂/促红素:改善肾性贫血。

重要提示:请勿自行停药或调整剂量,如有不适请及时联系医生。

', + 'medication', 'HMS 健康管理团队', 'published', 'rich_text', + 96, 3, + NOW() - INTERVAL '3 days', NOW() - INTERVAL '3 days', NOW(), 1 +FROM tenants t WHERE t.deleted_at IS NULL +ON CONFLICT (id) DO NOTHING; + +COMMIT; + +-- ============================================================ +-- 验证查询 +-- ============================================================ +SELECT 'patients' AS tbl, count(*) FROM patient WHERE deleted_at IS NULL +UNION ALL +SELECT 'vital_signs', count(*) FROM vital_signs WHERE deleted_at IS NULL +UNION ALL +SELECT 'lab_reports', count(*) FROM lab_report WHERE deleted_at IS NULL +UNION ALL +SELECT 'follow_up_tasks', count(*) FROM follow_up_task WHERE deleted_at IS NULL +UNION ALL +SELECT 'alert_rules', count(*) FROM alert_rules WHERE deleted_at IS NULL AND is_active = true +UNION ALL +SELECT 'articles', count(*) FROM article WHERE deleted_at IS NULL AND status = 'published'; diff --git a/scripts/e2e_appointment_test.py b/scripts/e2e_appointment_test.py new file mode 100644 index 0000000..2945d38 --- /dev/null +++ b/scripts/e2e_appointment_test.py @@ -0,0 +1,488 @@ +#!/usr/bin/env python3 +"""HMS 预约排班链路端到端 API 测试""" +import urllib.request, json, os, sys, time +from datetime import datetime, timedelta +from urllib.error import HTTPError + +BASE = 'http://localhost:3000/api/v1' + +def log(msg): + print(msg, flush=True) + +def api(method, path, body=None, token=None): + url = f'{BASE}{path}' + data_bytes = json.dumps(body).encode('utf-8') if body else None + headers = {'Content-Type': 'application/json'} + if token: + headers['Authorization'] = f'Bearer {token}' + req = urllib.request.Request(url, data=data_bytes, method=method, headers=headers) + try: + resp = urllib.request.urlopen(req, timeout=15) + raw = resp.read().decode('utf-8') + return json.loads(raw), resp.status + except HTTPError as e: + raw = e.read().decode('utf-8') + try: + return json.loads(raw), e.code + except: + return {'raw_error': raw[:300]}, e.code + +def extract_items(data_obj): + """Extract items from various response structures""" + if isinstance(data_obj, list): + return data_obj + if isinstance(data_obj, dict): + # Try common field names + for key in ['data', 'items', 'records', 'rows']: + if key in data_obj: + val = data_obj[key] + if isinstance(val, list): + return val + # If data_obj has total but no list field, check all values + for v in data_obj.values(): + if isinstance(v, list) and len(v) > 0: + return v + return [] + +results = [] +TOKEN = None +DOCTOR_ID = None +SCHEDULE_ID = None +PATIENT_ID = None +APPOINTMENT_ID = None + +# ================================================================ +# STEP 0: 登录 +# ================================================================ +log('=' * 60) +log('[STEP 0] 登录') +d, code = api('POST', '/auth/login', {'username': 'admin', 'password': 'Admin@2026'}) +if d.get('success') and code == 200: + TOKEN = d['data']['access_token'] + log(f' PASS | Token length: {len(TOKEN)}') + results.append(('登录', 'PASS', code)) +else: + log(f' FAIL | HTTP {code} | {str(d)[:200]}') + results.append(('登录', 'FAIL', code)) + sys.exit(1) + +# ================================================================ +# PHASE 1: 医护管理 +# ================================================================ +log('') +log('=' * 60) +log('PHASE 1: 医护管理') + +# 1.1 医护列表 +log('[T1.1] GET /health/doctors') +d, code = api('GET', '/health/doctors', token=TOKEN) +ok = d.get('success', False) and code == 200 +if ok: + data_obj = d.get('data', {}) + total = data_obj.get('total', 0) if isinstance(data_obj, dict) else 0 + items = extract_items(data_obj) + log(f' PASS | HTTP {code} | total={total} | items_count={len(items)}') + if items: + DOCTOR_ID = items[0].get('id') + for it in items[:3]: + log(f' - id={it.get("id","?")[:20]} | name={it.get("name","?")} | dept={it.get("department","?")}') + else: + log(f' Data structure keys: {list(data_obj.keys()) if isinstance(data_obj, dict) else type(data_obj).__name__}') + # Debug: print full structure + log(f' Full data (truncated): {json.dumps(data_obj, ensure_ascii=False)[:500]}') +else: + log(f' FAIL | HTTP {code} | {str(d)[:200]}') +results.append(('T1.1 医护列表', 'PASS' if ok else 'FAIL', code)) + +# 1.2 创建医护(如果没有现成的) +if not DOCTOR_ID: + log('') + log('[T1.2] 创建测试医生 (因为没有现成的)') + new_doc = { + 'name': f'API测试医生_{int(time.time())}', + 'department': '测试科', + 'title': '主治医师', + 'speciality': '自动化测试', + 'phone': '13800000001', + 'status': 'active' + } + d, code = api('POST', '/health/doctors', new_doc, token=TOKEN) + if d.get('success') and code in [200, 201]: + DOCTOR_ID = d['data'].get('id') + log(f' PASS | HTTP {code} | doctor_id={DOCTOR_ID}') + else: + log(f' Result | HTTP {code} | {str(d)[:300]}') + results.append(('T1.2 创建医护', 'PASS' if d.get('success') else 'FAIL', code)) +else: + # Already have a doctor, create a test one anyway for isolation + log('') + log('[T1.2] 创建隔离测试医生') + new_doc = { + 'name': f'E2E测试医生_{int(time.time())}', + 'department': '测试科', + 'title': '主治医师', + 'speciality': 'E2E自动化测试', + 'phone': '13800000999', + 'status': 'active' + } + d, code = api('POST', '/health/doctors', new_doc, token=TOKEN) + if d.get('success') and code in [200, 201]: + DOCTOR_ID = d['data'].get('id') # Use the new one + log(f' PASS | HTTP {code} | doctor_id={DOCTOR_ID}') + else: + log(f' Result | HTTP {code} | {str(d)[:300]}') + results.append(('T1.2 创建医护', 'PASS' if d.get('success') else 'FAIL', code)) + +# 1.3 医护详情 +if DOCTOR_ID: + log('') + log(f'[T1.3] GET /health/doctors/{{id}}') + d, code = api('GET', f'/health/doctors/{DOCTOR_ID}', token=TOKEN) + ok = d.get('success', False) and code == 200 + if ok: + doc = d.get('data', {}) + log(f' PASS | HTTP {code} | name={doc.get("name")} | dept={doc.get("department")}') + else: + log(f' FAIL | HTTP {code} | {str(d)[:200]}') + results.append(('T1.3 医护详情', 'PASS' if ok else 'FAIL', code)) + +# 1.4 安全: 无认证 +log('') +log('[T1.4] 无认证访问 /health/doctors (应 401)') +try: + req = urllib.request.Request(f'{BASE}/health/doctors') + resp = urllib.request.urlopen(req, timeout=10) + log(f' FAIL | HTTP {resp.status} (应 401)') + results.append(('T1.4 无认证拦截', 'FAIL', resp.status)) +except HTTPError as e: + ok = e.code == 401 + log(f' {"PASS" if ok else "FAIL"} | HTTP {e.code}') + results.append(('T1.4 无认证拦截', 'PASS' if ok else 'FAIL', e.code)) + +# ================================================================ +# PHASE 2: 排班管理 +# ================================================================ +log('') +log('=' * 60) +log('PHASE 2: 排班管理') + +# 2.1 排班列表 +log('[T2.1] GET /health/doctor-schedules') +d, code = api('GET', '/health/doctor-schedules', token=TOKEN) +ok = d.get('success', False) and code == 200 +if ok: + data_obj = d.get('data', {}) + total = data_obj.get('total', 0) if isinstance(data_obj, dict) else 0 + items = extract_items(data_obj) + log(f' PASS | HTTP {code} | total={total} | items_count={len(items)}') + if items: + for it in items[:3]: + log(f' - id={it.get("id","?")[:20]} | date={it.get("schedule_date","?")} | doctor_id={str(it.get("doctor_id","?"))[:20]}') +else: + log(f' FAIL | HTTP {code} | {str(d)[:200]}') +results.append(('T2.1 排班列表', 'PASS' if ok else 'FAIL', code)) + +# 2.2 创建排班 +if DOCTOR_ID: + TOMORROW = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d') + log('') + log(f'[T2.2] POST /health/doctor-schedules (创建明天排班)') + schedule_data = { + 'doctor_id': DOCTOR_ID, + 'schedule_date': TOMORROW, + 'start_time': '09:00', + 'end_time': '17:00', + 'max_appointments': 10, + 'time_slot_duration': 30 + } + d, code = api('POST', '/health/doctor-schedules', schedule_data, token=TOKEN) + ok = d.get('success', False) and code in [200, 201] + if ok: + SCHEDULE_ID = d['data'].get('id') + log(f' PASS | HTTP {code} | schedule_id={SCHEDULE_ID}') + else: + log(f' Result | HTTP {code} | {str(d)[:300]}') + # Schedule might already exist, search for it + d2, code2 = api('GET', '/health/doctor-schedules', token=TOKEN) + if d2.get('success'): + items2 = extract_items(d2.get('data', {})) + for item in items2: + if item.get('doctor_id') == DOCTOR_ID: + SCHEDULE_ID = item.get('id') + log(f' Found existing schedule: {SCHEDULE_ID}') + break + results.append(('T2.2 创建排班', 'PASS' if ok else 'FAIL', code)) +else: + results.append(('T2.2 创建排班', 'SKIP', 0)) + +# ================================================================ +# PHASE 3: 患者准备 + 预约管理 +# ================================================================ +log('') +log('=' * 60) +log('PHASE 3: 预约管理') + +# 3.1 获取患者列表 +log('[T3.1] GET /health/patients') +d, code = api('GET', '/health/patients', token=TOKEN) +ok = d.get('success', False) and code == 200 +if ok: + data_obj = d.get('data', {}) + total = data_obj.get('total', 0) if isinstance(data_obj, dict) else 0 + items = extract_items(data_obj) + log(f' PASS | HTTP {code} | total={total} | items_count={len(items)}') + if items: + PATIENT_ID = items[0].get('id') + for it in items[:3]: + log(f' - id={it.get("id","?")[:20]} | name={it.get("name","?")}') + else: + log(f' Data structure keys: {list(data_obj.keys()) if isinstance(data_obj, dict) else type(data_obj).__name__}') +else: + log(f' FAIL | HTTP {code} | {str(d)[:200]}') +results.append(('T3.1 患者列表', 'PASS' if ok else 'FAIL', code)) + +# 3.2 创建患者(如果没有现成的) +if not PATIENT_ID: + log('') + log('[T3.2] 创建测试患者') + new_patient = { + 'name': f'E2E测试患者_{int(time.time())}', + 'gender': 'male', + 'phone': '13900000999', + 'status': 'active' + } + d, code = api('POST', '/health/patients', new_patient, token=TOKEN) + if d.get('success') and code in [200, 201]: + PATIENT_ID = d['data'].get('id') + log(f' PASS | HTTP {code} | patient_id={PATIENT_ID}') + else: + log(f' Result | HTTP {code} | {str(d)[:300]}') + results.append(('T3.2 创建患者', 'PASS' if d.get('success') else 'FAIL', code)) +else: + results.append(('T3.2 创建患者', 'SKIP (已有)', 0)) + +# 3.3 预约列表 +log('') +log('[T3.3] GET /health/appointments') +d, code = api('GET', '/health/appointments', token=TOKEN) +ok = d.get('success', False) and code == 200 +if ok: + data_obj = d.get('data', {}) + total = data_obj.get('total', 0) if isinstance(data_obj, dict) else 0 + log(f' PASS | HTTP {code} | total={total}') +else: + log(f' FAIL | HTTP {code} | {str(d)[:200]}') +results.append(('T3.3 预约列表', 'PASS' if ok else 'FAIL', code)) + +# 3.4 创建预约 +log('') +log(f'[T3.4] POST /health/appointments (创建预约)') +log(f' DOCTOR_ID={DOCTOR_ID}, PATIENT_ID={PATIENT_ID}, SCHEDULE_ID={SCHEDULE_ID}') + +if DOCTOR_ID and PATIENT_ID and SCHEDULE_ID: + TOMORROW = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d') + appt_data = { + 'patient_id': PATIENT_ID, + 'doctor_id': DOCTOR_ID, + 'schedule_id': SCHEDULE_ID, + 'appointment_date': TOMORROW, + 'start_time': '09:00', + 'end_time': '09:30', + 'type': 'initial_consultation', + 'reason': 'API E2E 测试预约' + } + d, code = api('POST', '/health/appointments', appt_data, token=TOKEN) + ok = d.get('success', False) and code in [200, 201] + if ok: + APPOINTMENT_ID = d['data'].get('id') + log(f' PASS | HTTP {code} | appointment_id={APPOINTMENT_ID}') + else: + log(f' Result | HTTP {code} | {str(d)[:300]}') + results.append(('T3.4 创建预约', 'PASS' if ok else 'FAIL', code)) +elif DOCTOR_ID and PATIENT_ID: + # Try without schedule_id + TOMORROW = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d') + appt_data = { + 'patient_id': PATIENT_ID, + 'doctor_id': DOCTOR_ID, + 'appointment_date': TOMORROW, + 'start_time': '09:00', + 'end_time': '09:30', + 'type': 'initial_consultation', + 'reason': 'API E2E 测试预约(无排班)' + } + d, code = api('POST', '/health/appointments', appt_data, token=TOKEN) + ok = d.get('success', False) and code in [200, 201] + if ok: + APPOINTMENT_ID = d['data'].get('id') + log(f' PASS | HTTP {code} | appointment_id={APPOINTMENT_ID}') + else: + log(f' Result | HTTP {code} | {str(d)[:300]}') + results.append(('T3.4 创建预约(无排班)', 'PASS' if ok else 'FAIL', code)) +else: + log(' SKIP - 缺少必要 ID') + results.append(('T3.4 创建预约', 'SKIP', 0)) + +# ================================================================ +# PHASE 4: 预约状态流转 +# ================================================================ +log('') +log('=' * 60) +log('PHASE 4: 预约状态流转') + +if APPOINTMENT_ID: + # 4.1 确认预约 (pending -> confirmed) + log(f'[T4.1] PUT /health/appointments/{{id}}/status -> confirmed') + d, code = api('PUT', f'/health/appointments/{APPOINTMENT_ID}/status', + {'status': 'confirmed'}, token=TOKEN) + ok = d.get('success', False) and code == 200 + if ok: + log(f' PASS | HTTP {code}') + else: + log(f' FAIL | HTTP {code} | {str(d)[:200]}') + results.append(('T4.1 确认预约', 'PASS' if ok else 'FAIL', code)) + + # 4.2 完成预约 (confirmed -> completed) + log('') + log(f'[T4.2] PUT /health/appointments/{{id}}/status -> completed') + d, code = api('PUT', f'/health/appointments/{APPOINTMENT_ID}/status', + {'status': 'completed'}, token=TOKEN) + ok = d.get('success', False) and code == 200 + if ok: + log(f' PASS | HTTP {code}') + else: + log(f' FAIL | HTTP {code} | {str(d)[:200]}') + results.append(('T4.2 完成预约', 'PASS' if ok else 'FAIL', code)) + + # 4.3 获取预约详情验证最终状态 + log('') + log(f'[T4.3] GET /health/appointments/{{id}} (验证最终状态)') + d, code = api('GET', f'/health/appointments/{APPOINTMENT_ID}', token=TOKEN) + ok = d.get('success', False) and code == 200 + if ok: + appt = d.get('data', {}) + final_status = appt.get('status', 'N/A') + ok = final_status == 'completed' + log(f' {"PASS" if ok else "FAIL"} | HTTP {code} | final_status={final_status}') + else: + log(f' FAIL | HTTP {code} | {str(d)[:200]}') + results.append(('T4.3 最终状态验证', 'PASS' if ok else 'FAIL', code)) + + # 4.4 尝试非法状态流转 (completed -> confirmed, 应失败) + log('') + log(f'[T4.4] PUT status=confirmed from completed (应失败)') + d, code = api('PUT', f'/health/appointments/{APPOINTMENT_ID}/status', + {'status': 'confirmed'}, token=TOKEN) + ok = code in [400, 409, 422] + log(f' {"PASS" if ok else "FAIL"} | HTTP {code} (预期 4xx)') + results.append(('T4.4 非法状态流转拦截', 'PASS' if ok else 'FAIL', code)) +else: + log(' SKIP - 无预约 ID') + for name in ['T4.1 确认预约', 'T4.2 完成预约', 'T4.3 最终状态验证', 'T4.4 非法状态流转拦截']: + results.append((name, 'SKIP', 0)) + +# ================================================================ +# PHASE 5: 边界条件和安全测试 +# ================================================================ +log('') +log('=' * 60) +log('PHASE 5: 边界条件和安全测试') + +# 5.1 创建预约 - 缺少必填字段 +log('[T5.1] POST /health/appointments (缺少 doctor_id)') +dummy_id = '00000000-0000-0000-0000-000000000000' +d, code = api('POST', '/health/appointments', { + 'patient_id': PATIENT_ID or dummy_id, + 'appointment_date': '2026-12-01', + 'start_time': '09:00', + 'end_time': '09:30' +}, token=TOKEN) +ok = code in [400, 404, 422] +log(f' {"PASS" if ok else "FAIL"} | HTTP {code} (预期 4xx)') +results.append(('T5.1 缺少必填字段', 'PASS' if ok else 'FAIL', code)) + +# 5.2 创建预约 - 无效 UUID +log('') +log('[T5.2] POST /health/appointments (无效 doctor_id)') +d, code = api('POST', '/health/appointments', { + 'patient_id': PATIENT_ID or dummy_id, + 'doctor_id': 'not-a-uuid', + 'appointment_date': '2026-12-01', + 'start_time': '09:00', + 'end_time': '09:30', + 'type': 'initial_consultation' +}, token=TOKEN) +ok = code in [400, 404, 422] +log(f' {"PASS" if ok else "FAIL"} | HTTP {code} (预期 4xx)') +results.append(('T5.2 无效 UUID', 'PASS' if ok else 'FAIL', code)) + +# 5.3 排班日历视图 +log('') +log('[T5.3] GET /health/doctor-schedules/calendar') +d, code = api('GET', '/health/doctor-schedules/calendar', token=TOKEN) +ok = d.get('success', False) and code == 200 +log(f' {"PASS" if ok else "FAIL"} | HTTP {code}') +results.append(('T5.3 排班日历视图', 'PASS' if ok else 'FAIL', code)) + +# 5.4 无效 Token +log('') +log('[T5.4] GET /health/doctors (无效 Token)') +d, code = api('GET', '/health/doctors', token='invalid-token-xxx') +ok = code == 401 +log(f' {"PASS" if ok else "FAIL"} | HTTP {code} (预期 401)') +results.append(('T5.4 无效 Token', 'PASS' if ok else 'FAIL', code)) + +# 5.5 SQL 注入测试 +log('') +log('[T5.5] GET /health/doctors?search=; DROP TABLE') +d, code = api('GET', '/health/doctors?search=;%20DROP%20TABLE%20users;%20--', token=TOKEN) +ok = code == 200 # Should not crash +log(f' {"PASS" if ok else "FAIL"} | HTTP {code} (不应崩溃)') +results.append(('T5.5 SQL注入防护', 'PASS' if ok else 'FAIL', code)) + +# 5.6 XSS 注入测试 +log('') +log('[T5.6] POST /health/doctors (XSS name)') +d, code = api('POST', '/health/doctors', { + 'name': '', + 'department': '测试科', + 'phone': '13800000002', + 'status': 'active' +}, token=TOKEN) +ok = d.get('success', False) and code in [200, 201] +if ok: + name = d.get('data', {}).get('name', '') + has_script = '