From 815aa04fe897fb571fa1e689ca149d6fa86154dc Mon Sep 17 00:00:00 2001 From: haomingming Date: Wed, 20 May 2026 18:21:39 +0800 Subject: [PATCH] first --- .gitignore | 40 + README.md | 94 + backend/pom.xml | 107 + .../com/writeoff/WriteOffApplication.java | 14 + .../writeoff/common/api/ApiErrorResponse.java | 45 + .../com/writeoff/common/api/ApiResponse.java | 44 + .../com/writeoff/common/api/PageResult.java | 33 + .../common/exception/BusinessException.java | 14 + .../writeoff/common/exception/ErrorCodes.java | 30 + .../exception/GlobalExceptionHandler.java | 45 + .../writeoff/common/model/ImportResult.java | 74 + .../writeoff/common/model/ImportRowError.java | 40 + .../common/util/ImportValidationUtils.java | 86 + .../writeoff/common/web/RequestIdContext.java | 21 + .../java/com/writeoff/config/WebConfig.java | 29 + .../audit/controller/AuditController.java | 123 + .../audit/controller/AuditFlowController.java | 84 + .../module/audit/dto/AuditActionRequest.java | 26 + .../audit/dto/AuditFlowNodeRequest.java | 55 + .../dto/AuditMaterialItemRejectRequest.java | 56 + .../AuditMaterialModuleApproveRequest.java | 26 + .../audit/dto/BatchAuditActionRequest.java | 40 + .../module/audit/dto/BatchRemindRequest.java | 26 + .../audit/dto/CreateAuditFlowRequest.java | 58 + .../audit/dto/TransferAuditTaskRequest.java | 37 + .../audit/dto/UpdateAuditFlowRequest.java | 4 + .../module/audit/model/AuditFlowInfo.java | 57 + .../module/audit/model/AuditFlowNodeInfo.java | 49 + .../module/audit/model/AuditNode.java | 7 + .../module/audit/model/AuditTask.java | 198 + .../module/audit/model/AuditTaskStatus.java | 7 + .../audit/repository/AuditTaskRepository.java | 25 + .../InMemoryAuditTaskRepository.java | 106 + .../repository/JdbcAuditTaskRepository.java | 240 + .../audit/service/AuditFlowConfigService.java | 89 + .../audit/service/AuditFlowManageService.java | 272 + .../module/audit/service/AuditService.java | 708 +++ .../auth/controller/AuthController.java | 749 +++ .../auth/controller/CaptchaController.java | 29 + .../PlatformAuthSessionController.java | 59 + .../module/auth/dto/LoginRequest.java | 29 + .../dto/PasswordSetupCompleteRequest.java | 26 + ...PlatformSessionRevokePrincipalRequest.java | 36 + .../module/auth/dto/SwitchTenantRequest.java | 16 + .../module/auth/dto/TenantLoginRequest.java | 35 + .../auth/model/PlatformAuthSessionInfo.java | 97 + .../module/auth/model/TenantSwitchOption.java | 37 + .../service/PlatformAuthSessionService.java | 130 + .../auth/service/RefreshTokenService.java | 273 + .../OperationsDashboardController.java | 27 + .../controller/TenantDashboardController.java | 60 + .../service/OperationsDashboardService.java | 116 + .../expert/controller/ExpertController.java | 105 + .../controller/PlatformExpertController.java | 103 + .../module/expert/dto/AddBankCardRequest.java | 117 + .../expert/dto/CreateExpertRequest.java | 153 + .../dto/ExpertAssetUploadSignRequest.java | 26 + .../expert/dto/ImportExpertsRequest.java | 19 + .../module/expert/dto/MergeExpertRequest.java | 27 + .../expert/model/ExpertBankCardInfo.java | 91 + .../module/expert/model/ExpertInfo.java | 119 + .../module/expert/service/ExpertService.java | 682 +++ .../expert/service/ExpertSnapshotService.java | 131 + .../expert/service/PlatformExpertService.java | 653 ++ .../controller/ExportTaskController.java | 48 + .../export/dto/CreateExportTaskRequest.java | 63 + .../module/export/model/ExportTaskInfo.java | 85 + .../export/service/ExportTaskService.java | 579 ++ .../file/controller/FileController.java | 53 + .../module/file/service/OssService.java | 129 + .../finance/controller/FinanceController.java | 80 + .../finance/dto/ConfirmPaymentRequest.java | 59 + .../finance/dto/FinanceLockRequest.java | 37 + .../dto/FinanceReconciliationRequest.java | 37 + .../dto/UpsertFinanceMeetingBillRequest.java | 95 + .../finance/model/FinanceMeetingBillInfo.java | 88 + .../module/finance/model/Payment.java | 37 + .../module/finance/model/PaymentStatus.java | 8 + .../repository/InMemoryPaymentRepository.java | 112 + .../repository/JdbcPaymentRepository.java | 187 + .../finance/repository/PaymentRepository.java | 24 + .../finance/service/FinanceService.java | 267 + .../meeting/controller/MeetingController.java | 294 + .../dto/BindMeetingExpertsRequest.java | 18 + .../CreateMeetingMaterialsExportRequest.java | 25 + .../meeting/dto/CreateMeetingRequest.java | 125 + .../dto/GenerateMeetingSummaryRequest.java | 25 + .../dto/MeetingInvoiceConfigRequest.java | 19 + ...tingLaborAgreementExtractApplyRequest.java | 52 + ...tingLaborAgreementExtractQueryRequest.java | 16 + ...ingLaborAgreementExtractSubmitRequest.java | 26 + .../dto/MeetingMaterialUploadSignRequest.java | 25 + .../meeting/dto/MeetingQueryRequest.java | 127 + .../dto/SaveMeetingMaterialRequest.java | 25 + .../dto/SubmitMeetingMaterialRequest.java | 25 + .../meeting/dto/SubmitMeetingRequest.java | 25 + .../meeting/dto/WithdrawMeetingRequest.java | 26 + .../module/meeting/model/Meeting.java | 413 ++ .../meeting/model/MeetingAuditStatus.java | 8 + .../meeting/model/MeetingExpertBinding.java | 49 + .../MeetingLaborAgreementExtractResult.java | 253 + .../module/meeting/model/MeetingMaterial.java | 104 + .../meeting/model/MeetingMaterialHistory.java | 55 + .../module/meeting/model/MeetingStatus.java | 10 + .../repository/InMemoryMeetingRepository.java | 82 + .../repository/JdbcMeetingRepository.java | 252 + .../meeting/repository/MeetingRepository.java | 16 + .../service/MeetingExpertBindingService.java | 460 ++ .../MeetingLaborAgreementExtractService.java | 541 ++ .../service/MeetingMaterialExportService.java | 643 ++ .../service/MeetingMaterialService.java | 1479 +++++ .../meeting/service/MeetingService.java | 744 +++ .../service/MeetingSummaryExportService.java | 996 +++ .../InAppNotificationController.java | 44 + .../NotificationDispatchController.java | 133 + .../NotificationPolicyController.java | 72 + .../NotificationTextTemplateController.java | 63 + .../PlatformNotifyGatewayController.java | 55 + .../dto/AliyunSmsReceiptRequest.java | 67 + .../BindNotificationPolicyEventsRequest.java | 16 + .../dto/CreateNotificationPolicyRequest.java | 75 + ...CreateNotificationTextTemplateRequest.java | 53 + .../dto/DispatchNotificationRequest.java | 62 + .../dto/NotificationReceiptRequest.java | 55 + .../dto/SavePlatformNotifyGatewayRequest.java | 51 + .../dto/TestPlatformNotifyGatewayRequest.java | 34 + .../dto/UpdateNotificationPolicyRequest.java | 4 + ...UpdateNotificationTextTemplateRequest.java | 4 + .../model/InAppNotificationInfo.java | 43 + .../model/NotificationPolicyInfo.java | 55 + .../model/NotificationTaskInfo.java | 103 + .../model/NotificationTextTemplateInfo.java | 43 + .../model/PlatformNotifyGatewayInfo.java | 71 + .../PlatformNotifyGatewayResolvedConfig.java | 57 + .../provider/EmailNotificationProvider.java | 141 + .../provider/InAppNotificationProvider.java | 118 + .../provider/NotificationChannelProvider.java | 9 + .../provider/NotificationSendResult.java | 31 + .../provider/SmsNotificationProvider.java | 267 + .../service/InAppNotificationService.java | 95 + ...NotificationDeliveryProtectionService.java | 266 + .../service/NotificationDispatchService.java | 888 +++ .../NotificationGatewayCryptoService.java | 59 + .../service/NotificationPolicyService.java | 239 + .../NotificationTextTemplateService.java | 212 + .../service/PlatformNotifyGatewayService.java | 314 + .../PlatformNotifyGatewayTestService.java | 212 + .../ws/NotificationWebSocketConfig.java | 22 + .../ws/NotificationWebSocketHandler.java | 100 + .../ws/NotificationWebSocketPushService.java | 155 + .../controller/ObservabilityController.java | 76 + .../dto/CreateAlertRuleRequest.java | 75 + .../dto/UpdateAlertRuleRequest.java | 4 + .../job/AlertRuleAutoEvaluateJob.java | 47 + .../observability/model/AlertEventInfo.java | 61 + .../observability/model/AlertRuleInfo.java | 55 + .../model/ObservabilityMetricPoint.java | 25 + .../service/ObservabilityService.java | 429 ++ .../module/ocr/config/BaiduOcrProperties.java | 117 + .../module/ocr/controller/OcrController.java | 75 + .../ocr/controller/PlatformOcrController.java | 52 + .../module/ocr/dto/BankCardOcrRequest.java | 17 + .../module/ocr/dto/BankCardOcrResponse.java | 73 + .../dto/DocumentExtractTaskQueryRequest.java | 16 + .../dto/DocumentExtractTaskQueryResponse.java | 87 + .../dto/DocumentExtractTaskSubmitRequest.java | 133 + .../DocumentExtractTaskSubmitResponse.java | 33 + .../module/ocr/dto/IdCardOcrRequest.java | 30 + .../module/ocr/dto/IdCardOcrResponse.java | 118 + .../ocr/dto/MultipleInvoiceOcrRequest.java | 17 + .../ocr/dto/MultipleInvoiceOcrResponse.java | 91 + .../ocr/service/BaiduBankCardOcrService.java | 178 + .../service/BaiduDocumentExtractService.java | 366 ++ .../ocr/service/BaiduIdCardOcrService.java | 182 + .../BaiduMultipleInvoiceOcrService.java | 462 ++ .../ocr/service/BaiduOcrTokenService.java | 111 + .../project/controller/ProjectController.java | 124 + .../project/dto/CreateProjectRequest.java | 153 + .../dto/SaveProjectBindingsRequest.java | 33 + .../module/project/model/Project.java | 434 ++ .../module/project/model/ProjectStatus.java | 10 + .../repository/InMemoryProjectRepository.java | 97 + .../repository/JdbcProjectRepository.java | 302 + .../project/repository/ProjectRepository.java | 16 + .../project/service/ProjectService.java | 793 +++ .../scheduler/job/AsyncJobScheduler.java | 77 + .../module/scheduler/model/AsyncJob.java | 95 + .../scheduler/model/AsyncJobStatus.java | 8 + .../repository/AsyncJobRepository.java | 17 + .../InMemoryAsyncJobRepository.java | 69 + .../repository/JdbcAsyncJobRepository.java | 109 + .../scheduler/service/AsyncJobService.java | 92 + .../controller/InvoiceProfileController.java | 54 + .../dto/CreateInvoiceProfileRequest.java | 82 + .../dto/UpdateInvoiceProfileRequest.java | 4 + .../model/InvoiceProfileInfo.java | 61 + .../service/InvoiceProfileService.java | 170 + .../system/controller/AuditLogController.java | 78 + .../controller/DataPermissionController.java | 82 + .../controller/DictionaryController.java | 86 + .../controller/EnterpriseController.java | 78 + .../controller/GlobalSearchController.java | 25 + .../system/controller/MenuController.java | 72 + .../controller/PermissionController.java | 27 + .../PlatformAuditLogController.java | 35 + .../PlatformDictionaryController.java | 95 + .../controller/PlatformMenuController.java | 83 + .../PlatformPermissionController.java | 28 + .../controller/PlatformRoleController.java | 93 + .../controller/PlatformTenantController.java | 97 + .../controller/PlatformUserController.java | 86 + .../system/controller/ProfileController.java | 71 + .../system/controller/RoleController.java | 102 + .../system/controller/SystemController.java | 56 + .../system/controller/TenantController.java | 70 + .../system/controller/UserController.java | 133 + .../controller/UserDelegationController.java | 31 + .../dto/AssignRoleDataPermissionRequest.java | 26 + .../system/dto/AssignUserRoleRequest.java | 18 + .../dto/BindPlatformMenuRolesRequest.java | 13 + .../system/dto/BindRoleMenusRequest.java | 13 + .../dto/BindRolePermissionsRequest.java | 17 + .../system/dto/ChangePasswordRequest.java | 29 + .../CreateDataPermissionPolicyRequest.java | 110 + .../system/dto/CreateEnterpriseRequest.java | 22 + .../module/system/dto/CreateMenuRequest.java | 37 + .../CreatePlatformDictionaryItemRequest.java | 56 + .../CreatePlatformDictionaryTypeRequest.java | 46 + .../module/system/dto/CreateRoleRequest.java | 26 + .../system/dto/CreateTenantAdminRequest.java | 32 + .../system/dto/CreateTenantRequest.java | 35 + .../dto/CreateUserDelegationRequest.java | 30 + .../module/system/dto/CreateUserRequest.java | 67 + .../system/dto/DisableDelegationRequest.java | 12 + .../dto/EnterpriseLogoUploadSignRequest.java | 17 + .../system/dto/ImportUserItemRequest.java | 67 + .../module/system/dto/ImportUsersRequest.java | 19 + .../system/dto/ReorderMenusRequest.java | 28 + .../system/dto/ResetPasswordRequest.java | 16 + .../UpdateDataPermissionPolicyRequest.java | 4 + .../system/dto/UpdateEnterpriseRequest.java | 22 + .../module/system/dto/UpdateMenuRequest.java | 37 + .../UpdatePlatformDictionaryItemRequest.java | 46 + .../dto/UpdateProfilePreferencesRequest.java | 31 + .../module/system/dto/UpdateRoleRequest.java | 16 + .../system/dto/UpdateTenantRequest.java | 17 + .../job/UserDelegationExpireScheduler.java | 19 + .../module/system/model/BizChangeLogInfo.java | 111 + .../system/model/DataPermissionPolicy.java | 97 + .../module/system/model/EnterpriseInfo.java | 43 + .../system/model/GlobalSearchGroup.java | 27 + .../module/system/model/GlobalSearchItem.java | 37 + .../system/model/GlobalSearchResult.java | 27 + .../module/system/model/MenuInfo.java | 49 + .../system/model/OperationAuditLogInfo.java | 99 + .../module/system/model/PermissionInfo.java | 31 + .../system/model/PlatformDictionaryItem.java | 52 + .../system/model/PlatformDictionaryType.java | 46 + .../module/system/model/PlatformRoleInfo.java | 31 + .../system/model/ProfilePreferencesInfo.java | 25 + .../module/system/model/RoleInfo.java | 31 + .../module/system/model/SystemUser.java | 75 + .../module/system/model/TenantInfo.java | 43 + .../system/model/UserDelegationInfo.java | 61 + .../module/system/model/UserRoleHistory.java | 43 + .../system/service/BizChangeLogService.java | 268 + .../system/service/DataPermissionService.java | 681 +++ .../system/service/EnterpriseService.java | 239 + .../system/service/GlobalSearchService.java | 365 ++ .../module/system/service/MenuService.java | 238 + .../service/OperationAuditLogService.java | 214 + .../service/PlatformDictionaryService.java | 267 + .../system/service/PlatformIamService.java | 573 ++ .../system/service/PlatformMenuService.java | 239 + .../system/service/PlatformRoleService.java | 33 + .../system/service/SystemUserService.java | 962 +++ .../module/system/service/TenantService.java | 655 ++ .../system/service/UserDelegationService.java | 183 + .../controller/TemplateController.java | 195 + .../template/dto/BindFlowTemplateRequest.java | 16 + .../template/dto/CreateTemplateRequest.java | 127 + .../dto/CreateTemplateVersionRequest.java | 25 + .../template/dto/RollbackTemplateRequest.java | 27 + .../dto/TemplateUploadSignRequest.java | 34 + .../model/TemplateDownloadLogInfo.java | 99 + .../template/model/TemplateFlowLinkInfo.java | 43 + .../module/template/model/TemplateInfo.java | 111 + .../template/model/TemplateTypeOption.java | 31 + .../template/model/TemplateVersionInfo.java | 61 + .../template/service/TemplateService.java | 1056 ++++ .../com/writeoff/security/AuthContext.java | 47 + .../writeoff/security/AuthInterceptor.java | 379 ++ .../java/com/writeoff/security/AuthScope.java | 19 + .../com/writeoff/security/CaptchaService.java | 144 + .../com/writeoff/security/DataScopeType.java | 9 + .../writeoff/security/JwtTokenService.java | 87 + .../security/LoginAttemptService.java | 195 + .../security/LoginPasswordCryptoService.java | 71 + .../security/PasswordCodecService.java | 90 + .../security/PasswordPolicyService.java | 60 + .../security/PasswordSetupService.java | 277 + .../PasswordStorageMigrationRunner.java | 74 + .../writeoff/security/PermissionDomain.java | 6 + .../security/PermissionMetadataGuard.java | 96 + .../writeoff/security/PermissionService.java | 126 + .../writeoff/security/RateLimitFilter.java | 86 + .../writeoff/security/RateLimitService.java | 91 + .../writeoff/security/RequirePermission.java | 13 + backend/src/main/resources/application.yml | 71 + backend/src/main/resources/db/data.sql | 40 + ...project_meeting_change_log_permissions.sql | 56 + .../migration/V10__data_permission_policy.sql | 52 + .../migration/V11__export_permission_seed.sql | 13 + .../V12__meeting_material_module.sql | 31 + .../V13__meeting_material_permission_seed.sql | 15 + .../V14__audit_material_read_permission.sql | 10 + .../db/migration/V15__template_module.sql | 46 + .../V16__template_permission_seed.sql | 19 + .../V17__template_type_standardize.sql | 11 + .../migration/V18__template_type_option.sql | 20 + .../db/migration/V19__tenant_table.sql | 59 + .../db/migration/V1__init_schema.sql | 104 + .../migration/V20__tenant_permission_seed.sql | 9 + .../db/migration/V21__operation_audit_log.sql | 30 + .../V22__audit_sla_transfer_enhance.sql | 41 + .../V23__finance_reconciliation_lock.sql | 44 + .../V24__template_archive_diff_permission.sql | 9 + .../migration/V25__template_flow_linkage.sql | 34 + .../db/migration/V26__expert_module.sql | 78 + .../db/migration/V27__notification_policy.sql | 44 + .../migration/V28__observability_alerting.sql | 63 + .../V29__observability_recovery_suppress.sql | 10 + .../resources/db/migration/V2__seed_data.sql | 7 + .../V30__meeting_withdraw_field_invoice.sql | 56 + ...ication_dispatch_export_task_auto_eval.sql | 68 + ...epen_receipt_export_download_dashboard.sql | 35 + .../migration/V33__user_account_validity.sql | 36 + ..._enterprise_and_project_enterprise_ref.sql | 47 + .../db/migration/V35__menu_management.sql | 67 + .../migration/V36__menu_permission_code.sql | 37 + .../db/migration/V37__user_delegation.sql | 29 + .../migration/V38__v2a_field_dictionary.sql | 372 ++ .../V39__operation_audit_log_scope.sql | 36 + .../db/migration/V3__auth_rbac_seed.sql | 94 + .../db/migration/V40__platform_admin_rbac.sql | 77 + .../V41__platform_menu_management.sql | 44 + .../V42__platform_menu_permission_seed.sql | 11 + .../V43__platform_menu_manage_seed.sql | 16 + .../migration/V44__platform_iam_menu_seed.sql | 32 + ...5__migrate_auditlog_expert_to_platform.sql | 32 + ...init_tenant_admin_role_menu_permission.sql | 57 + .../V47__tenant_permission_menu_seed.sql | 41 + .../migration/V48__project_user_binding.sql | 24 + .../V49__data_permission_user_scope_owner.sql | 7 + .../db/migration/V4__audit_flow_config.sql | 34 + .../V50__project_bind_executor_permission.sql | 9 + .../V51__meeting_expert_binding_table.sql | 15 + .../V52__project_fields_and_comments.sql | 55 + .../V53__project_more_fields_from_prd.sql | 79 + .../V54__project_key_change_log_table.sql | 20 + ...project_subproject_count_and_host_name.sql | 9 + .../V56__project_remove_user_id_columns.sql | 12 + .../V57__project_hierarchy_parent_id.sql | 8 + .../V58__project_drop_enterprise_id.sql | 4 + ..._meeting_field_attributes_and_comments.sql | 93 + .../V5__user_role_permission_seed.sql | 15 + .../V60__remove_meeting_field_module.sql | 38 + ...g_remove_subproject_and_comment_update.sql | 19 + ...latform_dictionary_and_expert_dict_ref.sql | 98 + ...t_identity_and_bank_card_image_columns.sql | 25 + .../db/migration/V64__tenant_logo_url.sql | 20 + ...V65__platform_file_download_permission.sql | 19 + .../V66__operation_audit_log_request_id.sql | 35 + ...V67__audit_material_item_review_record.sql | 18 + .../V68__in_app_notification_center.sql | 56 + .../V69__in_app_notification_menu_seed.sql | 30 + .../V6__audit_flow_manage_enhance.sql | 18 + ...V70__notification_text_template_module.sql | 82 + .../db/migration/V71__auth_refresh_token.sql | 25 + .../V72__platform_auth_session_permission.sql | 54 + .../db/migration/V73__project_fee_json.sql | 14 + .../V74__ocr_id_card_permission_seed.sql | 49 + .../V75__ocr_bank_card_permission_seed.sql | 49 + ...6__platform_dictionary_type_management.sql | 39 + ..._tenant_expert_menu_permission_restore.sql | 77 + .../V78__data_permission_expert_scope.sql | 3 + .../migration/V79__meeting_invoice_config.sql | 28 + .../V7__user_role_manage_enhance.sql | 16 + .../V80__meeting_read_permission.sql | 26 + .../V81__meeting_read_permission.sql | 1 + .../V82__project_remove_status_fields.sql | 2 + .../migration/V83__audit_flow_soft_delete.sql | 2 + .../migration/V84__security_state_mysql.sql | 28 + .../migration/V85__user_import_permission.sql | 9 + .../V86__tenant_switch_permission.sql | 22 + .../V87__platform_notify_gateway.sql | 113 + .../V88__notification_delivery_protection.sql | 35 + .../db/migration/V89__user_ui_preferences.sql | 73 + .../migration/V8__permission_seed_iter_a.sql | 27 + .../V90__tenant_switch_account_key.sql | 37 + .../V91__auth_password_setup_token.sql | 17 + .../V92__template_download_log_enhance.sql | 83 + .../V93__notification_user_created_policy.sql | 101 + .../V94__template_menu_permission_split.sql | 72 + ...95__meeting_material_export_permission.sql | 28 + ...96__meeting_location_comment_free_text.sql | 2 + .../V97__user_theme_scheme_preferences.sql | 39 + .../V98__enterprise_permission_cleanup.sql | 131 + .../db/migration/V99__biz_change_log.sql | 22 + ...task_assignee_and_role_permission_bind.sql | 14 + backend/src/main/resources/db/schema.sql | 211 + backend/src/main/resources/logback-spring.xml | 28 + .../templates/meeting-summary-template.docx | Bin 0 -> 110148 bytes .../com/writeoff/AsyncJobServiceTest.java | 18 + .../com/writeoff/MvpFlowIntegrationTest.java | 180 + .../SystemUserServicePasswordTest.java | 63 + .../UserDelegationServiceValidationTest.java | 85 + .../LoginPasswordCryptoServiceTest.java | 49 + .../security/PasswordCodecServiceTest.java | 32 + .../PermissionServiceDelegationTest.java | 102 + docs/MVP_试运行与发布回滚预案.md | 35 + docs/平台超级管理员双域鉴权开发文档.md | 103 + docs/新租户初始化清单SQL.sql | 165 + docs/未开发项开发迭代清单.md | 558 ++ docs/未开发项开发迭代清单V2.md | 131 + docs/租户域模板管理模块优化方案清单.md | 420 ++ fix_btns.js | 6 + frontend/index.html | 158 + frontend/package-lock.json | 1873 ++++++ frontend/package.json | 24 + frontend/src/App.vue | 13 + frontend/src/api/http.ts | 148 + frontend/src/api/modules.ts | 1079 ++++ frontend/src/components/BreadcrumbNav.vue | 62 + .../src/components/GlobalSearchLauncher.vue | 364 ++ frontend/src/components/PageContainer.vue | 64 + .../src/components/PasswordStrengthBar.vue | 110 + frontend/src/components/QueryToolbar.vue | 15 + frontend/src/components/SectionTitle.vue | 23 + frontend/src/constants/permissions.ts | 155 + frontend/src/constants/ui.ts | 50 + frontend/src/main.ts | 124 + frontend/src/router/index.ts | 151 + frontend/src/static/专题授课.png | Bin 0 -> 5362 bytes frontend/src/static/会场发票.png | Bin 0 -> 30772 bytes frontend/src/static/会场合同.png | Bin 0 -> 22382 bytes frontend/src/static/会场小票.png | Bin 0 -> 37213 bytes frontend/src/static/会场明细单.png | Bin 0 -> 32061 bytes frontend/src/static/会议串场.png | Bin 0 -> 34905 bytes frontend/src/static/会议主持.png | Bin 0 -> 11945 bytes frontend/src/static/会议主题.png | Bin 0 -> 60389 bytes frontend/src/static/会议总结.png | Bin 0 -> 20981 bytes frontend/src/static/会议总结2.png | Bin 0 -> 53237 bytes frontend/src/static/会议日程 copy.png | Bin 0 -> 41183 bytes frontend/src/static/会议日程.png | Bin 0 -> 548005 bytes frontend/src/static/会议结算单.png | Bin 0 -> 50494 bytes frontend/src/static/会议讨论.png | Bin 0 -> 14921 bytes frontend/src/static/劳务费协议.png | Bin 0 -> 30749 bytes frontend/src/static/劳务费发票.png | Bin 0 -> 38331 bytes frontend/src/static/大交通发票.png | Bin 0 -> 63827 bytes frontend/src/static/大交通发票2.png | Bin 0 -> 22613 bytes frontend/src/static/大交通明细2.png | Bin 0 -> 45498 bytes frontend/src/static/大交通明细单.png | Bin 0 -> 107402 bytes frontend/src/static/大会主持.png | Bin 0 -> 8270 bytes frontend/src/static/小交通发票.jpg | Bin 0 -> 72070 bytes frontend/src/static/小交通发票.png | Bin 0 -> 22253 bytes frontend/src/static/小交通明细.png | Bin 0 -> 34439 bytes frontend/src/static/桌牌.png | Bin 0 -> 3533 bytes frontend/src/static/物料.png | Bin 0 -> 12164 bytes frontend/src/static/物料2.png | Bin 0 -> 34896 bytes frontend/src/static/物料3.png | Bin 0 -> 68822 bytes frontend/src/static/物料4.png | Bin 0 -> 16300 bytes frontend/src/static/物料明细.png | Bin 0 -> 19397 bytes frontend/src/static/签到表.png | Bin 0 -> 32807 bytes frontend/src/static/设备.png | Bin 0 -> 53294 bytes frontend/src/static/设备2.png | Bin 0 -> 54825 bytes frontend/src/static/设计稿.png | Bin 0 -> 30435 bytes frontend/src/static/邀请函.png | Bin 0 -> 40311 bytes frontend/src/static/餐饮发票.png | Bin 0 -> 30774 bytes frontend/src/stores/appearance.ts | 241 + frontend/src/stores/auth.ts | 273 + frontend/src/stores/index.ts | 4 + frontend/src/stores/menu.ts | 139 + frontend/src/stores/notification.ts | 212 + frontend/src/styles/theme.css | 677 ++ frontend/src/styles/utilities.css | 135 + frontend/src/styles/variables.css | 58 + frontend/src/utils/authCrypto.ts | 58 + frontend/src/utils/authNavigation.ts | 11 + frontend/src/utils/batchImport.ts | 94 + frontend/src/utils/compress.ts | 42 + frontend/src/utils/status.ts | 111 + frontend/src/views/layout/AppLayout.vue | 826 +++ frontend/src/views/modules/AuditFlowPage.vue | 329 + frontend/src/views/modules/AuditLogPage.vue | 197 + frontend/src/views/modules/AuditPage.vue | 1632 +++++ .../src/views/modules/DataPermissionPage.vue | 346 ++ frontend/src/views/modules/EnterprisePage.vue | 350 ++ frontend/src/views/modules/ExpertPage.vue | 1009 +++ frontend/src/views/modules/ExportTaskPage.vue | 170 + frontend/src/views/modules/FinancePage.vue | 144 + .../views/modules/InAppNotificationPage.vue | 116 + .../src/views/modules/InvoiceProfilePage.vue | 187 + frontend/src/views/modules/MeetingPage.vue | 5417 +++++++++++++++++ frontend/src/views/modules/MenuPage.vue | 291 + frontend/src/views/modules/NotFoundPage.vue | 89 + .../views/modules/NotificationPolicyPage.vue | 774 +++ .../modules/NotificationTextTemplatePage.vue | 209 + .../src/views/modules/ObservabilityPage.vue | 232 + .../views/modules/OperationsDashboardPage.vue | 129 + frontend/src/views/modules/PermissionPage.vue | 38 + .../views/modules/PlatformDictionaryPage.vue | 300 + .../src/views/modules/PlatformLoginPage.vue | 589 ++ .../src/views/modules/PlatformMenuPage.vue | 283 + .../modules/PlatformNotifyGatewayPage.vue | 536 ++ .../views/modules/PlatformPermissionPage.vue | 38 + .../src/views/modules/PlatformRolePage.vue | 247 + .../src/views/modules/PlatformSessionPage.vue | 137 + .../src/views/modules/PlatformUserPage.vue | 530 ++ frontend/src/views/modules/ProfilePage.vue | 678 +++ frontend/src/views/modules/ProjectPage.vue | 516 ++ frontend/src/views/modules/RolePage.vue | 300 + .../views/modules/TemplateDownloadLogPage.vue | 625 ++ frontend/src/views/modules/TemplatePage.vue | 1131 ++++ .../src/views/modules/TenantDashboardPage.vue | 283 + .../src/views/modules/TenantLoginPage.vue | 628 ++ frontend/src/views/modules/TenantPage.vue | 505 ++ .../views/modules/TenantPasswordSetupPage.vue | 384 ++ frontend/src/views/modules/UserPage.vue | 837 +++ .../audit-page/AuditBasicInfoReviewPanel.vue | 311 + .../AuditExpertProfileReviewPanel.vue | 262 + .../audit-page/AuditExpertReviewPanel.vue | 1202 ++++ .../modules/audit-page/AuditListTable.vue | 91 + .../audit-page/AuditMaterialDrawer.vue | 598 ++ .../modules/audit-page/AuditQueryToolbar.vue | 52 + .../AuditWriteOffDocsReviewPanel.vue | 367 ++ .../MaterialPictureCardFileItem.vue | 120 + .../MeetingAuditProgressDialog.vue | 62 + .../meeting-page/MeetingBindExpertDialog.vue | 239 + .../MeetingCreateExpertBankCardOcrDialog.vue | 33 + .../MeetingCreateExpertIdOcrDialog.vue | 43 + .../MeetingCreatePlatformExpertDialog.vue | 577 ++ .../meeting-page/MeetingDetailDrawer.vue | 57 + .../meeting-page/MeetingDocPreviewDialog.vue | 61 + .../meeting-page/MeetingEditDrawer.vue | 87 + .../MeetingInvoiceConfigDialog.vue | 129 + ...tingLaborAgreementExtractConfirmDialog.vue | 108 + .../MeetingLaborInvoiceOcrConfirmDialog.vue | 38 + .../modules/meeting-page/MeetingListTable.vue | 163 + .../meeting-page/MeetingMaterialDrawer.vue | 1710 ++++++ .../MeetingMaterialHistoryDialog.vue | 53 + .../MeetingMeetingInvoiceOcrConfirmDialog.vue | 33 + .../meeting-page/MeetingOcrRawDialog.vue | 16 + .../meeting-page/MeetingQueryToolbar.vue | 81 + .../project-page/ProjectEditDrawer.vue | 354 ++ frontend/tsconfig.json | 15 + frontend/vite.config.ts | 21 + frontend_pinia_adoption_plan.md | 361 ++ frontend_style_analysis.md | 338 + refactor.py | 346 ++ system_enhancement_prd_v2.md | 515 ++ system_enhancement_prd_v2_gap_analysis.md | 320 + 会议核销SaaS系统_技术开发文档.md | 854 +++ 基金会、协会、北京欣欣会议核销系统_完整版.md | 1301 ++++ 564 files changed, 82601 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/pom.xml create mode 100644 backend/src/main/java/com/writeoff/WriteOffApplication.java create mode 100644 backend/src/main/java/com/writeoff/common/api/ApiErrorResponse.java create mode 100644 backend/src/main/java/com/writeoff/common/api/ApiResponse.java create mode 100644 backend/src/main/java/com/writeoff/common/api/PageResult.java create mode 100644 backend/src/main/java/com/writeoff/common/exception/BusinessException.java create mode 100644 backend/src/main/java/com/writeoff/common/exception/ErrorCodes.java create mode 100644 backend/src/main/java/com/writeoff/common/exception/GlobalExceptionHandler.java create mode 100644 backend/src/main/java/com/writeoff/common/model/ImportResult.java create mode 100644 backend/src/main/java/com/writeoff/common/model/ImportRowError.java create mode 100644 backend/src/main/java/com/writeoff/common/util/ImportValidationUtils.java create mode 100644 backend/src/main/java/com/writeoff/common/web/RequestIdContext.java create mode 100644 backend/src/main/java/com/writeoff/config/WebConfig.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/controller/AuditController.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/controller/AuditFlowController.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/dto/AuditActionRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/dto/AuditFlowNodeRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/dto/AuditMaterialItemRejectRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/dto/AuditMaterialModuleApproveRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/dto/BatchAuditActionRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/dto/BatchRemindRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/dto/CreateAuditFlowRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/dto/TransferAuditTaskRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/dto/UpdateAuditFlowRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/model/AuditFlowInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/model/AuditFlowNodeInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/model/AuditNode.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/model/AuditTask.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/model/AuditTaskStatus.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/repository/AuditTaskRepository.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/repository/InMemoryAuditTaskRepository.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/repository/JdbcAuditTaskRepository.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/service/AuditFlowConfigService.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/service/AuditFlowManageService.java create mode 100644 backend/src/main/java/com/writeoff/module/audit/service/AuditService.java create mode 100644 backend/src/main/java/com/writeoff/module/auth/controller/AuthController.java create mode 100644 backend/src/main/java/com/writeoff/module/auth/controller/CaptchaController.java create mode 100644 backend/src/main/java/com/writeoff/module/auth/controller/PlatformAuthSessionController.java create mode 100644 backend/src/main/java/com/writeoff/module/auth/dto/LoginRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/auth/dto/PasswordSetupCompleteRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/auth/dto/PlatformSessionRevokePrincipalRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/auth/dto/SwitchTenantRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/auth/dto/TenantLoginRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/auth/model/PlatformAuthSessionInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/auth/model/TenantSwitchOption.java create mode 100644 backend/src/main/java/com/writeoff/module/auth/service/PlatformAuthSessionService.java create mode 100644 backend/src/main/java/com/writeoff/module/auth/service/RefreshTokenService.java create mode 100644 backend/src/main/java/com/writeoff/module/dashboard/controller/OperationsDashboardController.java create mode 100644 backend/src/main/java/com/writeoff/module/dashboard/controller/TenantDashboardController.java create mode 100644 backend/src/main/java/com/writeoff/module/dashboard/service/OperationsDashboardService.java create mode 100644 backend/src/main/java/com/writeoff/module/expert/controller/ExpertController.java create mode 100644 backend/src/main/java/com/writeoff/module/expert/controller/PlatformExpertController.java create mode 100644 backend/src/main/java/com/writeoff/module/expert/dto/AddBankCardRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/expert/dto/CreateExpertRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/expert/dto/ExpertAssetUploadSignRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/expert/dto/ImportExpertsRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/expert/dto/MergeExpertRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/expert/model/ExpertBankCardInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/expert/model/ExpertInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/expert/service/ExpertService.java create mode 100644 backend/src/main/java/com/writeoff/module/expert/service/ExpertSnapshotService.java create mode 100644 backend/src/main/java/com/writeoff/module/expert/service/PlatformExpertService.java create mode 100644 backend/src/main/java/com/writeoff/module/export/controller/ExportTaskController.java create mode 100644 backend/src/main/java/com/writeoff/module/export/dto/CreateExportTaskRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/export/model/ExportTaskInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/export/service/ExportTaskService.java create mode 100644 backend/src/main/java/com/writeoff/module/file/controller/FileController.java create mode 100644 backend/src/main/java/com/writeoff/module/file/service/OssService.java create mode 100644 backend/src/main/java/com/writeoff/module/finance/controller/FinanceController.java create mode 100644 backend/src/main/java/com/writeoff/module/finance/dto/ConfirmPaymentRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/finance/dto/FinanceLockRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/finance/dto/FinanceReconciliationRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/finance/dto/UpsertFinanceMeetingBillRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/finance/model/FinanceMeetingBillInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/finance/model/Payment.java create mode 100644 backend/src/main/java/com/writeoff/module/finance/model/PaymentStatus.java create mode 100644 backend/src/main/java/com/writeoff/module/finance/repository/InMemoryPaymentRepository.java create mode 100644 backend/src/main/java/com/writeoff/module/finance/repository/JdbcPaymentRepository.java create mode 100644 backend/src/main/java/com/writeoff/module/finance/repository/PaymentRepository.java create mode 100644 backend/src/main/java/com/writeoff/module/finance/service/FinanceService.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/controller/MeetingController.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/dto/BindMeetingExpertsRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/dto/CreateMeetingMaterialsExportRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/dto/CreateMeetingRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/dto/GenerateMeetingSummaryRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/dto/MeetingInvoiceConfigRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractApplyRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractQueryRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractSubmitRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/dto/MeetingMaterialUploadSignRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/dto/MeetingQueryRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/dto/SaveMeetingMaterialRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/dto/SubmitMeetingMaterialRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/dto/SubmitMeetingRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/dto/WithdrawMeetingRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/model/Meeting.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/model/MeetingAuditStatus.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/model/MeetingExpertBinding.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/model/MeetingLaborAgreementExtractResult.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/model/MeetingMaterial.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/model/MeetingMaterialHistory.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/model/MeetingStatus.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/repository/InMemoryMeetingRepository.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/repository/JdbcMeetingRepository.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/repository/MeetingRepository.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/service/MeetingExpertBindingService.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/service/MeetingLaborAgreementExtractService.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/service/MeetingMaterialExportService.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/service/MeetingMaterialService.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/service/MeetingService.java create mode 100644 backend/src/main/java/com/writeoff/module/meeting/service/MeetingSummaryExportService.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/controller/InAppNotificationController.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/controller/NotificationDispatchController.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/controller/NotificationPolicyController.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/controller/NotificationTextTemplateController.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/controller/PlatformNotifyGatewayController.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/dto/AliyunSmsReceiptRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/dto/BindNotificationPolicyEventsRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/dto/CreateNotificationPolicyRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/dto/CreateNotificationTextTemplateRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/dto/DispatchNotificationRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/dto/NotificationReceiptRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/dto/SavePlatformNotifyGatewayRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/dto/TestPlatformNotifyGatewayRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/dto/UpdateNotificationPolicyRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/dto/UpdateNotificationTextTemplateRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/model/InAppNotificationInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/model/NotificationPolicyInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/model/NotificationTaskInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/model/NotificationTextTemplateInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/model/PlatformNotifyGatewayInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/model/PlatformNotifyGatewayResolvedConfig.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/provider/EmailNotificationProvider.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/provider/InAppNotificationProvider.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/provider/NotificationChannelProvider.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/provider/NotificationSendResult.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/provider/SmsNotificationProvider.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/service/InAppNotificationService.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/service/NotificationDeliveryProtectionService.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/service/NotificationDispatchService.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/service/NotificationGatewayCryptoService.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/service/NotificationPolicyService.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/service/NotificationTextTemplateService.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/service/PlatformNotifyGatewayService.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/service/PlatformNotifyGatewayTestService.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketConfig.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketHandler.java create mode 100644 backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketPushService.java create mode 100644 backend/src/main/java/com/writeoff/module/observability/controller/ObservabilityController.java create mode 100644 backend/src/main/java/com/writeoff/module/observability/dto/CreateAlertRuleRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/observability/dto/UpdateAlertRuleRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/observability/job/AlertRuleAutoEvaluateJob.java create mode 100644 backend/src/main/java/com/writeoff/module/observability/model/AlertEventInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/observability/model/AlertRuleInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/observability/model/ObservabilityMetricPoint.java create mode 100644 backend/src/main/java/com/writeoff/module/observability/service/ObservabilityService.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/config/BaiduOcrProperties.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/controller/OcrController.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/controller/PlatformOcrController.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/dto/BankCardOcrRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/dto/BankCardOcrResponse.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskQueryRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskQueryResponse.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskSubmitRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskSubmitResponse.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/dto/IdCardOcrRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/dto/IdCardOcrResponse.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/dto/MultipleInvoiceOcrRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/dto/MultipleInvoiceOcrResponse.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/service/BaiduBankCardOcrService.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/service/BaiduDocumentExtractService.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/service/BaiduIdCardOcrService.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/service/BaiduMultipleInvoiceOcrService.java create mode 100644 backend/src/main/java/com/writeoff/module/ocr/service/BaiduOcrTokenService.java create mode 100644 backend/src/main/java/com/writeoff/module/project/controller/ProjectController.java create mode 100644 backend/src/main/java/com/writeoff/module/project/dto/CreateProjectRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/project/dto/SaveProjectBindingsRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/project/model/Project.java create mode 100644 backend/src/main/java/com/writeoff/module/project/model/ProjectStatus.java create mode 100644 backend/src/main/java/com/writeoff/module/project/repository/InMemoryProjectRepository.java create mode 100644 backend/src/main/java/com/writeoff/module/project/repository/JdbcProjectRepository.java create mode 100644 backend/src/main/java/com/writeoff/module/project/repository/ProjectRepository.java create mode 100644 backend/src/main/java/com/writeoff/module/project/service/ProjectService.java create mode 100644 backend/src/main/java/com/writeoff/module/scheduler/job/AsyncJobScheduler.java create mode 100644 backend/src/main/java/com/writeoff/module/scheduler/model/AsyncJob.java create mode 100644 backend/src/main/java/com/writeoff/module/scheduler/model/AsyncJobStatus.java create mode 100644 backend/src/main/java/com/writeoff/module/scheduler/repository/AsyncJobRepository.java create mode 100644 backend/src/main/java/com/writeoff/module/scheduler/repository/InMemoryAsyncJobRepository.java create mode 100644 backend/src/main/java/com/writeoff/module/scheduler/repository/JdbcAsyncJobRepository.java create mode 100644 backend/src/main/java/com/writeoff/module/scheduler/service/AsyncJobService.java create mode 100644 backend/src/main/java/com/writeoff/module/setting/invoiceprofile/controller/InvoiceProfileController.java create mode 100644 backend/src/main/java/com/writeoff/module/setting/invoiceprofile/dto/CreateInvoiceProfileRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/setting/invoiceprofile/dto/UpdateInvoiceProfileRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/setting/invoiceprofile/model/InvoiceProfileInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/setting/invoiceprofile/service/InvoiceProfileService.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/AuditLogController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/DataPermissionController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/DictionaryController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/EnterpriseController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/GlobalSearchController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/MenuController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/PermissionController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/PlatformAuditLogController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/PlatformDictionaryController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/PlatformMenuController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/PlatformPermissionController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/PlatformRoleController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/PlatformTenantController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/PlatformUserController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/ProfileController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/RoleController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/SystemController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/TenantController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/UserController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/controller/UserDelegationController.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/AssignRoleDataPermissionRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/AssignUserRoleRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/BindPlatformMenuRolesRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/BindRoleMenusRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/BindRolePermissionsRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/ChangePasswordRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/CreateDataPermissionPolicyRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/CreateEnterpriseRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/CreateMenuRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/CreatePlatformDictionaryItemRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/CreatePlatformDictionaryTypeRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/CreateRoleRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/CreateTenantAdminRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/CreateTenantRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/CreateUserDelegationRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/CreateUserRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/DisableDelegationRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/EnterpriseLogoUploadSignRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/ImportUserItemRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/ImportUsersRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/ReorderMenusRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/ResetPasswordRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/UpdateDataPermissionPolicyRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/UpdateEnterpriseRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/UpdateMenuRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/UpdatePlatformDictionaryItemRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/UpdateProfilePreferencesRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/UpdateRoleRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/dto/UpdateTenantRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/system/job/UserDelegationExpireScheduler.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/BizChangeLogInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/DataPermissionPolicy.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/EnterpriseInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/GlobalSearchGroup.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/GlobalSearchItem.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/GlobalSearchResult.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/MenuInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/OperationAuditLogInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/PermissionInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/PlatformDictionaryItem.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/PlatformDictionaryType.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/PlatformRoleInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/ProfilePreferencesInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/RoleInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/SystemUser.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/TenantInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/UserDelegationInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/system/model/UserRoleHistory.java create mode 100644 backend/src/main/java/com/writeoff/module/system/service/BizChangeLogService.java create mode 100644 backend/src/main/java/com/writeoff/module/system/service/DataPermissionService.java create mode 100644 backend/src/main/java/com/writeoff/module/system/service/EnterpriseService.java create mode 100644 backend/src/main/java/com/writeoff/module/system/service/GlobalSearchService.java create mode 100644 backend/src/main/java/com/writeoff/module/system/service/MenuService.java create mode 100644 backend/src/main/java/com/writeoff/module/system/service/OperationAuditLogService.java create mode 100644 backend/src/main/java/com/writeoff/module/system/service/PlatformDictionaryService.java create mode 100644 backend/src/main/java/com/writeoff/module/system/service/PlatformIamService.java create mode 100644 backend/src/main/java/com/writeoff/module/system/service/PlatformMenuService.java create mode 100644 backend/src/main/java/com/writeoff/module/system/service/PlatformRoleService.java create mode 100644 backend/src/main/java/com/writeoff/module/system/service/SystemUserService.java create mode 100644 backend/src/main/java/com/writeoff/module/system/service/TenantService.java create mode 100644 backend/src/main/java/com/writeoff/module/system/service/UserDelegationService.java create mode 100644 backend/src/main/java/com/writeoff/module/template/controller/TemplateController.java create mode 100644 backend/src/main/java/com/writeoff/module/template/dto/BindFlowTemplateRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/template/dto/CreateTemplateRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/template/dto/CreateTemplateVersionRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/template/dto/RollbackTemplateRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/template/dto/TemplateUploadSignRequest.java create mode 100644 backend/src/main/java/com/writeoff/module/template/model/TemplateDownloadLogInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/template/model/TemplateFlowLinkInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/template/model/TemplateInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/template/model/TemplateTypeOption.java create mode 100644 backend/src/main/java/com/writeoff/module/template/model/TemplateVersionInfo.java create mode 100644 backend/src/main/java/com/writeoff/module/template/service/TemplateService.java create mode 100644 backend/src/main/java/com/writeoff/security/AuthContext.java create mode 100644 backend/src/main/java/com/writeoff/security/AuthInterceptor.java create mode 100644 backend/src/main/java/com/writeoff/security/AuthScope.java create mode 100644 backend/src/main/java/com/writeoff/security/CaptchaService.java create mode 100644 backend/src/main/java/com/writeoff/security/DataScopeType.java create mode 100644 backend/src/main/java/com/writeoff/security/JwtTokenService.java create mode 100644 backend/src/main/java/com/writeoff/security/LoginAttemptService.java create mode 100644 backend/src/main/java/com/writeoff/security/LoginPasswordCryptoService.java create mode 100644 backend/src/main/java/com/writeoff/security/PasswordCodecService.java create mode 100644 backend/src/main/java/com/writeoff/security/PasswordPolicyService.java create mode 100644 backend/src/main/java/com/writeoff/security/PasswordSetupService.java create mode 100644 backend/src/main/java/com/writeoff/security/PasswordStorageMigrationRunner.java create mode 100644 backend/src/main/java/com/writeoff/security/PermissionDomain.java create mode 100644 backend/src/main/java/com/writeoff/security/PermissionMetadataGuard.java create mode 100644 backend/src/main/java/com/writeoff/security/PermissionService.java create mode 100644 backend/src/main/java/com/writeoff/security/RateLimitFilter.java create mode 100644 backend/src/main/java/com/writeoff/security/RateLimitService.java create mode 100644 backend/src/main/java/com/writeoff/security/RequirePermission.java create mode 100644 backend/src/main/resources/application.yml create mode 100644 backend/src/main/resources/db/data.sql create mode 100644 backend/src/main/resources/db/migration/V100__project_meeting_change_log_permissions.sql create mode 100644 backend/src/main/resources/db/migration/V10__data_permission_policy.sql create mode 100644 backend/src/main/resources/db/migration/V11__export_permission_seed.sql create mode 100644 backend/src/main/resources/db/migration/V12__meeting_material_module.sql create mode 100644 backend/src/main/resources/db/migration/V13__meeting_material_permission_seed.sql create mode 100644 backend/src/main/resources/db/migration/V14__audit_material_read_permission.sql create mode 100644 backend/src/main/resources/db/migration/V15__template_module.sql create mode 100644 backend/src/main/resources/db/migration/V16__template_permission_seed.sql create mode 100644 backend/src/main/resources/db/migration/V17__template_type_standardize.sql create mode 100644 backend/src/main/resources/db/migration/V18__template_type_option.sql create mode 100644 backend/src/main/resources/db/migration/V19__tenant_table.sql create mode 100644 backend/src/main/resources/db/migration/V1__init_schema.sql create mode 100644 backend/src/main/resources/db/migration/V20__tenant_permission_seed.sql create mode 100644 backend/src/main/resources/db/migration/V21__operation_audit_log.sql create mode 100644 backend/src/main/resources/db/migration/V22__audit_sla_transfer_enhance.sql create mode 100644 backend/src/main/resources/db/migration/V23__finance_reconciliation_lock.sql create mode 100644 backend/src/main/resources/db/migration/V24__template_archive_diff_permission.sql create mode 100644 backend/src/main/resources/db/migration/V25__template_flow_linkage.sql create mode 100644 backend/src/main/resources/db/migration/V26__expert_module.sql create mode 100644 backend/src/main/resources/db/migration/V27__notification_policy.sql create mode 100644 backend/src/main/resources/db/migration/V28__observability_alerting.sql create mode 100644 backend/src/main/resources/db/migration/V29__observability_recovery_suppress.sql create mode 100644 backend/src/main/resources/db/migration/V2__seed_data.sql create mode 100644 backend/src/main/resources/db/migration/V30__meeting_withdraw_field_invoice.sql create mode 100644 backend/src/main/resources/db/migration/V31__notification_dispatch_export_task_auto_eval.sql create mode 100644 backend/src/main/resources/db/migration/V32__deepen_receipt_export_download_dashboard.sql create mode 100644 backend/src/main/resources/db/migration/V33__user_account_validity.sql create mode 100644 backend/src/main/resources/db/migration/V34__enterprise_and_project_enterprise_ref.sql create mode 100644 backend/src/main/resources/db/migration/V35__menu_management.sql create mode 100644 backend/src/main/resources/db/migration/V36__menu_permission_code.sql create mode 100644 backend/src/main/resources/db/migration/V37__user_delegation.sql create mode 100644 backend/src/main/resources/db/migration/V38__v2a_field_dictionary.sql create mode 100644 backend/src/main/resources/db/migration/V39__operation_audit_log_scope.sql create mode 100644 backend/src/main/resources/db/migration/V3__auth_rbac_seed.sql create mode 100644 backend/src/main/resources/db/migration/V40__platform_admin_rbac.sql create mode 100644 backend/src/main/resources/db/migration/V41__platform_menu_management.sql create mode 100644 backend/src/main/resources/db/migration/V42__platform_menu_permission_seed.sql create mode 100644 backend/src/main/resources/db/migration/V43__platform_menu_manage_seed.sql create mode 100644 backend/src/main/resources/db/migration/V44__platform_iam_menu_seed.sql create mode 100644 backend/src/main/resources/db/migration/V45__migrate_auditlog_expert_to_platform.sql create mode 100644 backend/src/main/resources/db/migration/V46__init_tenant_admin_role_menu_permission.sql create mode 100644 backend/src/main/resources/db/migration/V47__tenant_permission_menu_seed.sql create mode 100644 backend/src/main/resources/db/migration/V48__project_user_binding.sql create mode 100644 backend/src/main/resources/db/migration/V49__data_permission_user_scope_owner.sql create mode 100644 backend/src/main/resources/db/migration/V4__audit_flow_config.sql create mode 100644 backend/src/main/resources/db/migration/V50__project_bind_executor_permission.sql create mode 100644 backend/src/main/resources/db/migration/V51__meeting_expert_binding_table.sql create mode 100644 backend/src/main/resources/db/migration/V52__project_fields_and_comments.sql create mode 100644 backend/src/main/resources/db/migration/V53__project_more_fields_from_prd.sql create mode 100644 backend/src/main/resources/db/migration/V54__project_key_change_log_table.sql create mode 100644 backend/src/main/resources/db/migration/V55__project_subproject_count_and_host_name.sql create mode 100644 backend/src/main/resources/db/migration/V56__project_remove_user_id_columns.sql create mode 100644 backend/src/main/resources/db/migration/V57__project_hierarchy_parent_id.sql create mode 100644 backend/src/main/resources/db/migration/V58__project_drop_enterprise_id.sql create mode 100644 backend/src/main/resources/db/migration/V59__meeting_field_attributes_and_comments.sql create mode 100644 backend/src/main/resources/db/migration/V5__user_role_permission_seed.sql create mode 100644 backend/src/main/resources/db/migration/V60__remove_meeting_field_module.sql create mode 100644 backend/src/main/resources/db/migration/V61__meeting_remove_subproject_and_comment_update.sql create mode 100644 backend/src/main/resources/db/migration/V62__platform_dictionary_and_expert_dict_ref.sql create mode 100644 backend/src/main/resources/db/migration/V63__expert_identity_and_bank_card_image_columns.sql create mode 100644 backend/src/main/resources/db/migration/V64__tenant_logo_url.sql create mode 100644 backend/src/main/resources/db/migration/V65__platform_file_download_permission.sql create mode 100644 backend/src/main/resources/db/migration/V66__operation_audit_log_request_id.sql create mode 100644 backend/src/main/resources/db/migration/V67__audit_material_item_review_record.sql create mode 100644 backend/src/main/resources/db/migration/V68__in_app_notification_center.sql create mode 100644 backend/src/main/resources/db/migration/V69__in_app_notification_menu_seed.sql create mode 100644 backend/src/main/resources/db/migration/V6__audit_flow_manage_enhance.sql create mode 100644 backend/src/main/resources/db/migration/V70__notification_text_template_module.sql create mode 100644 backend/src/main/resources/db/migration/V71__auth_refresh_token.sql create mode 100644 backend/src/main/resources/db/migration/V72__platform_auth_session_permission.sql create mode 100644 backend/src/main/resources/db/migration/V73__project_fee_json.sql create mode 100644 backend/src/main/resources/db/migration/V74__ocr_id_card_permission_seed.sql create mode 100644 backend/src/main/resources/db/migration/V75__ocr_bank_card_permission_seed.sql create mode 100644 backend/src/main/resources/db/migration/V76__platform_dictionary_type_management.sql create mode 100644 backend/src/main/resources/db/migration/V77__tenant_expert_menu_permission_restore.sql create mode 100644 backend/src/main/resources/db/migration/V78__data_permission_expert_scope.sql create mode 100644 backend/src/main/resources/db/migration/V79__meeting_invoice_config.sql create mode 100644 backend/src/main/resources/db/migration/V7__user_role_manage_enhance.sql create mode 100644 backend/src/main/resources/db/migration/V80__meeting_read_permission.sql create mode 100644 backend/src/main/resources/db/migration/V81__meeting_read_permission.sql create mode 100644 backend/src/main/resources/db/migration/V82__project_remove_status_fields.sql create mode 100644 backend/src/main/resources/db/migration/V83__audit_flow_soft_delete.sql create mode 100644 backend/src/main/resources/db/migration/V84__security_state_mysql.sql create mode 100644 backend/src/main/resources/db/migration/V85__user_import_permission.sql create mode 100644 backend/src/main/resources/db/migration/V86__tenant_switch_permission.sql create mode 100644 backend/src/main/resources/db/migration/V87__platform_notify_gateway.sql create mode 100644 backend/src/main/resources/db/migration/V88__notification_delivery_protection.sql create mode 100644 backend/src/main/resources/db/migration/V89__user_ui_preferences.sql create mode 100644 backend/src/main/resources/db/migration/V8__permission_seed_iter_a.sql create mode 100644 backend/src/main/resources/db/migration/V90__tenant_switch_account_key.sql create mode 100644 backend/src/main/resources/db/migration/V91__auth_password_setup_token.sql create mode 100644 backend/src/main/resources/db/migration/V92__template_download_log_enhance.sql create mode 100644 backend/src/main/resources/db/migration/V93__notification_user_created_policy.sql create mode 100644 backend/src/main/resources/db/migration/V94__template_menu_permission_split.sql create mode 100644 backend/src/main/resources/db/migration/V95__meeting_material_export_permission.sql create mode 100644 backend/src/main/resources/db/migration/V96__meeting_location_comment_free_text.sql create mode 100644 backend/src/main/resources/db/migration/V97__user_theme_scheme_preferences.sql create mode 100644 backend/src/main/resources/db/migration/V98__enterprise_permission_cleanup.sql create mode 100644 backend/src/main/resources/db/migration/V99__biz_change_log.sql create mode 100644 backend/src/main/resources/db/migration/V9__audit_task_assignee_and_role_permission_bind.sql create mode 100644 backend/src/main/resources/db/schema.sql create mode 100644 backend/src/main/resources/logback-spring.xml create mode 100644 backend/src/main/resources/templates/meeting-summary-template.docx create mode 100644 backend/src/test/java/com/writeoff/AsyncJobServiceTest.java create mode 100644 backend/src/test/java/com/writeoff/MvpFlowIntegrationTest.java create mode 100644 backend/src/test/java/com/writeoff/module/system/service/SystemUserServicePasswordTest.java create mode 100644 backend/src/test/java/com/writeoff/module/system/service/UserDelegationServiceValidationTest.java create mode 100644 backend/src/test/java/com/writeoff/security/LoginPasswordCryptoServiceTest.java create mode 100644 backend/src/test/java/com/writeoff/security/PasswordCodecServiceTest.java create mode 100644 backend/src/test/java/com/writeoff/security/PermissionServiceDelegationTest.java create mode 100644 docs/MVP_试运行与发布回滚预案.md create mode 100644 docs/平台超级管理员双域鉴权开发文档.md create mode 100644 docs/新租户初始化清单SQL.sql create mode 100644 docs/未开发项开发迭代清单.md create mode 100644 docs/未开发项开发迭代清单V2.md create mode 100644 docs/租户域模板管理模块优化方案清单.md create mode 100644 fix_btns.js create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/http.ts create mode 100644 frontend/src/api/modules.ts create mode 100644 frontend/src/components/BreadcrumbNav.vue create mode 100644 frontend/src/components/GlobalSearchLauncher.vue create mode 100644 frontend/src/components/PageContainer.vue create mode 100644 frontend/src/components/PasswordStrengthBar.vue create mode 100644 frontend/src/components/QueryToolbar.vue create mode 100644 frontend/src/components/SectionTitle.vue create mode 100644 frontend/src/constants/permissions.ts create mode 100644 frontend/src/constants/ui.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/static/专题授课.png create mode 100644 frontend/src/static/会场发票.png create mode 100644 frontend/src/static/会场合同.png create mode 100644 frontend/src/static/会场小票.png create mode 100644 frontend/src/static/会场明细单.png create mode 100644 frontend/src/static/会议串场.png create mode 100644 frontend/src/static/会议主持.png create mode 100644 frontend/src/static/会议主题.png create mode 100644 frontend/src/static/会议总结.png create mode 100644 frontend/src/static/会议总结2.png create mode 100644 frontend/src/static/会议日程 copy.png create mode 100644 frontend/src/static/会议日程.png create mode 100644 frontend/src/static/会议结算单.png create mode 100644 frontend/src/static/会议讨论.png create mode 100644 frontend/src/static/劳务费协议.png create mode 100644 frontend/src/static/劳务费发票.png create mode 100644 frontend/src/static/大交通发票.png create mode 100644 frontend/src/static/大交通发票2.png create mode 100644 frontend/src/static/大交通明细2.png create mode 100644 frontend/src/static/大交通明细单.png create mode 100644 frontend/src/static/大会主持.png create mode 100644 frontend/src/static/小交通发票.jpg create mode 100644 frontend/src/static/小交通发票.png create mode 100644 frontend/src/static/小交通明细.png create mode 100644 frontend/src/static/桌牌.png create mode 100644 frontend/src/static/物料.png create mode 100644 frontend/src/static/物料2.png create mode 100644 frontend/src/static/物料3.png create mode 100644 frontend/src/static/物料4.png create mode 100644 frontend/src/static/物料明细.png create mode 100644 frontend/src/static/签到表.png create mode 100644 frontend/src/static/设备.png create mode 100644 frontend/src/static/设备2.png create mode 100644 frontend/src/static/设计稿.png create mode 100644 frontend/src/static/邀请函.png create mode 100644 frontend/src/static/餐饮发票.png create mode 100644 frontend/src/stores/appearance.ts create mode 100644 frontend/src/stores/auth.ts create mode 100644 frontend/src/stores/index.ts create mode 100644 frontend/src/stores/menu.ts create mode 100644 frontend/src/stores/notification.ts create mode 100644 frontend/src/styles/theme.css create mode 100644 frontend/src/styles/utilities.css create mode 100644 frontend/src/styles/variables.css create mode 100644 frontend/src/utils/authCrypto.ts create mode 100644 frontend/src/utils/authNavigation.ts create mode 100644 frontend/src/utils/batchImport.ts create mode 100644 frontend/src/utils/compress.ts create mode 100644 frontend/src/utils/status.ts create mode 100644 frontend/src/views/layout/AppLayout.vue create mode 100644 frontend/src/views/modules/AuditFlowPage.vue create mode 100644 frontend/src/views/modules/AuditLogPage.vue create mode 100644 frontend/src/views/modules/AuditPage.vue create mode 100644 frontend/src/views/modules/DataPermissionPage.vue create mode 100644 frontend/src/views/modules/EnterprisePage.vue create mode 100644 frontend/src/views/modules/ExpertPage.vue create mode 100644 frontend/src/views/modules/ExportTaskPage.vue create mode 100644 frontend/src/views/modules/FinancePage.vue create mode 100644 frontend/src/views/modules/InAppNotificationPage.vue create mode 100644 frontend/src/views/modules/InvoiceProfilePage.vue create mode 100644 frontend/src/views/modules/MeetingPage.vue create mode 100644 frontend/src/views/modules/MenuPage.vue create mode 100644 frontend/src/views/modules/NotFoundPage.vue create mode 100644 frontend/src/views/modules/NotificationPolicyPage.vue create mode 100644 frontend/src/views/modules/NotificationTextTemplatePage.vue create mode 100644 frontend/src/views/modules/ObservabilityPage.vue create mode 100644 frontend/src/views/modules/OperationsDashboardPage.vue create mode 100644 frontend/src/views/modules/PermissionPage.vue create mode 100644 frontend/src/views/modules/PlatformDictionaryPage.vue create mode 100644 frontend/src/views/modules/PlatformLoginPage.vue create mode 100644 frontend/src/views/modules/PlatformMenuPage.vue create mode 100644 frontend/src/views/modules/PlatformNotifyGatewayPage.vue create mode 100644 frontend/src/views/modules/PlatformPermissionPage.vue create mode 100644 frontend/src/views/modules/PlatformRolePage.vue create mode 100644 frontend/src/views/modules/PlatformSessionPage.vue create mode 100644 frontend/src/views/modules/PlatformUserPage.vue create mode 100644 frontend/src/views/modules/ProfilePage.vue create mode 100644 frontend/src/views/modules/ProjectPage.vue create mode 100644 frontend/src/views/modules/RolePage.vue create mode 100644 frontend/src/views/modules/TemplateDownloadLogPage.vue create mode 100644 frontend/src/views/modules/TemplatePage.vue create mode 100644 frontend/src/views/modules/TenantDashboardPage.vue create mode 100644 frontend/src/views/modules/TenantLoginPage.vue create mode 100644 frontend/src/views/modules/TenantPage.vue create mode 100644 frontend/src/views/modules/TenantPasswordSetupPage.vue create mode 100644 frontend/src/views/modules/UserPage.vue create mode 100644 frontend/src/views/modules/audit-page/AuditBasicInfoReviewPanel.vue create mode 100644 frontend/src/views/modules/audit-page/AuditExpertProfileReviewPanel.vue create mode 100644 frontend/src/views/modules/audit-page/AuditExpertReviewPanel.vue create mode 100644 frontend/src/views/modules/audit-page/AuditListTable.vue create mode 100644 frontend/src/views/modules/audit-page/AuditMaterialDrawer.vue create mode 100644 frontend/src/views/modules/audit-page/AuditQueryToolbar.vue create mode 100644 frontend/src/views/modules/audit-page/AuditWriteOffDocsReviewPanel.vue create mode 100644 frontend/src/views/modules/meeting-page/MaterialPictureCardFileItem.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingAuditProgressDialog.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingBindExpertDialog.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingCreateExpertBankCardOcrDialog.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingCreateExpertIdOcrDialog.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingCreatePlatformExpertDialog.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingDetailDrawer.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingDocPreviewDialog.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingEditDrawer.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingInvoiceConfigDialog.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingLaborAgreementExtractConfirmDialog.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingLaborInvoiceOcrConfirmDialog.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingListTable.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingMaterialDrawer.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingMaterialHistoryDialog.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingMeetingInvoiceOcrConfirmDialog.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingOcrRawDialog.vue create mode 100644 frontend/src/views/modules/meeting-page/MeetingQueryToolbar.vue create mode 100644 frontend/src/views/modules/project-page/ProjectEditDrawer.vue create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 frontend_pinia_adoption_plan.md create mode 100644 frontend_style_analysis.md create mode 100644 refactor.py create mode 100644 system_enhancement_prd_v2.md create mode 100644 system_enhancement_prd_v2_gap_analysis.md create mode 100644 会议核销SaaS系统_技术开发文档.md create mode 100644 基金会、协会、北京欣欣会议核销系统_完整版.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0900de --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# OS files +.DS_Store +Thumbs.db + +# Editor / IDE +.idea/ +.vscode/ +*.iml + +# Local agent / tool state +.agents/ +.npm-cache/ + +# Environment files +.env +.env.* +!.env.example +!.env.*.example + +# Logs +*.log +logs/ +backend/logs/ + +# Frontend +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ +frontend/coverage/ +frontend/tmp-*.cjs + +# Backend / Java +backend/target/ +backend/.mvn/ +*.class + +# Build artifacts +coverage/ +tmp/ +temp/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..9358f6c --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# 会议核销SaaS 开发启动说明 + +## 目录 +- `backend/`:Spring Boot 后端工程(按模块分包)。 +- `frontend/`:Vue3 + Element Plus 前端工程(按模块路由)。 +- `基金会、协会、北京欣欣会议核销系统_完整版.md`:业务需求文档。 +- `会议核销SaaS系统_技术开发文档.md`:技术开发文档。 + +## 当前已落地MVP模块 +- 后端核心接口 + - `GET /api/system/health` + - `GET/POST /api/projects` + - `POST /api/projects/{id}/freeze` + - `GET/POST /api/meetings` + - `POST /api/meetings/{id}/submit` + - `GET /api/audits/tasks` + - `POST /api/audits/tasks/{id}/approve` + - `POST /api/audits/tasks/{id}/reject` + - `POST /api/audits/tasks/{id}/return` + - `GET /api/finance/projects` + - `POST /api/finance/payments` +- 后端分层结构 + - 已按 `controller/service/repository/model/dto` 分层。 + - 已实现业务异常、参数校验、统一响应结构、错误码处理。 +- 数据库与初始化 + - `backend/src/main/resources/db/schema.sql` + - `backend/src/main/resources/db/data.sql` +- 调度任务(无MQ) + - 已实现 `Scheduler + async_job` 内存版执行机制、重试与幂等防重。 +- 前端模块页面 + - 项目管理(新建/冻结/列表) + - 会议管理(新建/会议级提交) + - 审核管理(通过/拒绝/退回) + - 财务管理(支付确认/列表) + +## 第二阶段已落地 +- 持久化升级 + - 新增 JDBC 仓储实现(项目/会议/审核/支付/任务)。 + - 支持通过 `APP_REPOSITORY_MODE` 切换 `jdbc` 与 `in-memory`。 +- 数据库迁移 + - 新增 Flyway 迁移脚本: + - `backend/src/main/resources/db/migration/V1__init_schema.sql` + - `backend/src/main/resources/db/migration/V2__seed_data.sql` +- 系统设置基础模块 + - 后端新增用户与角色接口:`/api/users`、`/api/roles` + - 前端新增用户管理、角色管理页面与菜单。 +- 文件能力 + - 新增 OSS 预签名下载接口:`GET /api/files/presign-download` + +## 第三阶段当前进展(已完成) +- 认证与鉴权 + - 新增 JWT 登录接口:`POST /api/auth/login` + - 新增鉴权拦截器:统一校验 `Authorization: Bearer ` + - 新增 RBAC 权限注解:`@RequirePermission` +- 权限落地 + - 项目、会议、审核、财务关键写操作已接入权限码校验。 +- Flyway 迁移增强 + - 新增 `V3__auth_rbac_seed.sql`,补齐用户、角色、权限与映射初始化数据。 +- 前端登录与路由守卫 + - 新增登录页 `/login` + - 未登录自动跳转登录,token 过期自动清理并回登录页。 + - 新增退出登录按钮。 + +## 启动方式 + +### 后端 +1. 配置 `backend/src/main/resources/application.yml` 中的 MySQL 与 OSS 参数。 +2. 在 `backend` 目录执行: + - `mvn spring-boot:run` + +### 前端 +1. 在 `frontend` 目录执行: + - `npm install` + - `npm run dev` + +## 测试与构建 + +### 后端 +- 在 `backend` 目录执行: + - `mvn clean test` + +### 前端 +- 在 `frontend` 目录执行: + - `npm run build` + +## 试运行与发布 +- 试运行与回滚预案见: + - `docs/MVP_试运行与发布回滚预案.md` + +## 下一阶段建议 +1. 接入真实 MySQL 持久化仓储(替换内存仓储)。 +2. 完成租户/用户/角色/权限模块。 +3. 完善审核流配置化与任务告警通知通道。 +4. 落地 OSS 上传、模板治理、专家模块。 diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..b11abed --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,107 @@ + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 2.7.18 + + + + com.writeoff + writeoff-backend + 0.0.1-SNAPSHOT + writeoff-backend + Meeting Write-off SaaS Backend + + + 1.8 + UTF-8 + 7.15.0 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-jdbc + + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.boot + spring-boot-starter-websocket + + + org.flywaydb + flyway-core + + + org.springdoc + springdoc-openapi-ui + 1.7.0 + + + com.aliyun.oss + aliyun-sdk-oss + 3.17.4 + + + org.apache.poi + poi-ooxml + 5.2.5 + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + + + com.mysql + mysql-connector-j + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/backend/src/main/java/com/writeoff/WriteOffApplication.java b/backend/src/main/java/com/writeoff/WriteOffApplication.java new file mode 100644 index 0000000..7592a44 --- /dev/null +++ b/backend/src/main/java/com/writeoff/WriteOffApplication.java @@ -0,0 +1,14 @@ +package com.writeoff; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class WriteOffApplication { + + public static void main(String[] args) { + SpringApplication.run(WriteOffApplication.class, args); + } +} diff --git a/backend/src/main/java/com/writeoff/common/api/ApiErrorResponse.java b/backend/src/main/java/com/writeoff/common/api/ApiErrorResponse.java new file mode 100644 index 0000000..c1ee771 --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/api/ApiErrorResponse.java @@ -0,0 +1,45 @@ +package com.writeoff.common.api; + +import com.writeoff.common.web.RequestIdContext; +import java.time.Instant; +import java.util.Map; + +public class ApiErrorResponse { + private int code; + private String message; + private Map errors; + private String requestId; + private String timestamp; + + public ApiErrorResponse(int code, String message, Map errors, String requestId, String timestamp) { + this.code = code; + this.message = message; + this.errors = errors; + this.requestId = requestId; + this.timestamp = timestamp; + } + + public static ApiErrorResponse of(int code, String message, Map errors) { + return new ApiErrorResponse(code, message, errors, RequestIdContext.get(), Instant.now().toString()); + } + + public int getCode() { + return code; + } + + public String getMessage() { + return message; + } + + public Map getErrors() { + return errors; + } + + public String getRequestId() { + return requestId; + } + + public String getTimestamp() { + return timestamp; + } +} diff --git a/backend/src/main/java/com/writeoff/common/api/ApiResponse.java b/backend/src/main/java/com/writeoff/common/api/ApiResponse.java new file mode 100644 index 0000000..2362ffc --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/api/ApiResponse.java @@ -0,0 +1,44 @@ +package com.writeoff.common.api; + +import com.writeoff.common.web.RequestIdContext; +import java.time.Instant; + +public class ApiResponse { + private int code; + private String message; + private T data; + private String requestId; + private String timestamp; + + public ApiResponse(int code, String message, T data, String requestId, String timestamp) { + this.code = code; + this.message = message; + this.data = data; + this.requestId = requestId; + this.timestamp = timestamp; + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(0, "success", data, RequestIdContext.get(), Instant.now().toString()); + } + + public int getCode() { + return code; + } + + public String getMessage() { + return message; + } + + public T getData() { + return data; + } + + public String getRequestId() { + return requestId; + } + + public String getTimestamp() { + return timestamp; + } +} diff --git a/backend/src/main/java/com/writeoff/common/api/PageResult.java b/backend/src/main/java/com/writeoff/common/api/PageResult.java new file mode 100644 index 0000000..23cd181 --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/api/PageResult.java @@ -0,0 +1,33 @@ +package com.writeoff.common.api; + +import java.util.List; + +public class PageResult { + private List list; + private long total; + private int pageNo; + private int pageSize; + + public PageResult(List list, long total, int pageNo, int pageSize) { + this.list = list; + this.total = total; + this.pageNo = pageNo; + this.pageSize = pageSize; + } + + public List getList() { + return list; + } + + public long getTotal() { + return total; + } + + public int getPageNo() { + return pageNo; + } + + public int getPageSize() { + return pageSize; + } +} diff --git a/backend/src/main/java/com/writeoff/common/exception/BusinessException.java b/backend/src/main/java/com/writeoff/common/exception/BusinessException.java new file mode 100644 index 0000000..3e11882 --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/exception/BusinessException.java @@ -0,0 +1,14 @@ +package com.writeoff.common.exception; + +public class BusinessException extends RuntimeException { + private final int code; + + public BusinessException(int code, String message) { + super(message); + this.code = code; + } + + public int getCode() { + return code; + } +} diff --git a/backend/src/main/java/com/writeoff/common/exception/ErrorCodes.java b/backend/src/main/java/com/writeoff/common/exception/ErrorCodes.java new file mode 100644 index 0000000..edb6441 --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/exception/ErrorCodes.java @@ -0,0 +1,30 @@ +package com.writeoff.common.exception; + +public final class ErrorCodes { + private ErrorCodes() { + } + + public static final int VALIDATION_ERROR = 10001; + public static final int IDEMPOTENCY_CONFLICT = 10002; + public static final int RESOURCE_NOT_FOUND = 10003; + public static final int RATE_LIMITED = 10005; + + public static final int UNAUTHORIZED = 11001; + public static final int TOKEN_EXPIRED = 11002; + public static final int SESSION_INVALID = 11003; + public static final int ACCOUNT_EXPIRED = 11004; + public static final int REFRESH_TOKEN_INVALID = 11005; + public static final int REFRESH_TOKEN_EXPIRED = 11006; + public static final int REFRESH_RISK_REJECTED = 11007; + + public static final int NO_PERMISSION = 20001; + public static final int NO_DATA_PERMISSION = 20002; + + public static final int INVALID_STATE = 30001; + public static final int TASK_ALREADY_PROCESSED = 30003; + + public static final int PAYMENT_STATE_INVALID = 40003; + public static final int PAYMENT_LOCKED = 40004; + + public static final int INTERNAL_ERROR = 90001; +} diff --git a/backend/src/main/java/com/writeoff/common/exception/GlobalExceptionHandler.java b/backend/src/main/java/com/writeoff/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..cd855e2 --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,45 @@ +package com.writeoff.common.exception; + +import com.writeoff.common.api.ApiErrorResponse; +import javax.validation.ConstraintViolationException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BusinessException.class) + public ResponseEntity handleBusiness(BusinessException ex) { + HttpStatus status = ex.getCode() >= 90000 ? HttpStatus.INTERNAL_SERVER_ERROR : HttpStatus.UNPROCESSABLE_ENTITY; + return ResponseEntity.status(status) + .body(ApiErrorResponse.of(ex.getCode(), ex.getMessage(), Collections.emptyMap())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidation(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + ex.getBindingResult().getFieldErrors() + .forEach(fieldError -> errors.put(fieldError.getField(), fieldError.getDefaultMessage())); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiErrorResponse.of(10001, "参数校验失败", errors)); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraint(ConstraintViolationException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(ApiErrorResponse.of(10001, "参数校验失败", Collections.singletonMap("message", ex.getMessage()))); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleUnknown(Exception ex) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiErrorResponse.of(90001, "系统内部异常", Collections.singletonMap("message", ex.getMessage()))); + } +} diff --git a/backend/src/main/java/com/writeoff/common/model/ImportResult.java b/backend/src/main/java/com/writeoff/common/model/ImportResult.java new file mode 100644 index 0000000..c224651 --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/model/ImportResult.java @@ -0,0 +1,74 @@ +package com.writeoff.common.model; + +import java.util.ArrayList; +import java.util.List; + +public class ImportResult { + private int total; + private int success; + private int failed; + private boolean partialSuccess; + private List errors = new ArrayList(); + + public int getTotal() { + return total; + } + + public void setTotal(int total) { + this.total = total; + refreshFlags(); + } + + public int getSuccess() { + return success; + } + + public void setSuccess(int success) { + this.success = success; + refreshFlags(); + } + + public int getFailed() { + return failed; + } + + public void setFailed(int failed) { + this.failed = failed; + refreshFlags(); + } + + public boolean isPartialSuccess() { + return partialSuccess; + } + + public void setPartialSuccess(boolean partialSuccess) { + this.partialSuccess = partialSuccess; + } + + public List getErrors() { + return errors; + } + + public void setErrors(List errors) { + this.errors = errors == null ? new ArrayList() : errors; + refreshFlags(); + } + + public void markSuccess() { + this.success++; + refreshFlags(); + } + + public void addError(int rowNo, String identifier, String message) { + this.errors.add(new ImportRowError(rowNo, identifier, message)); + this.failed++; + refreshFlags(); + } + + private void refreshFlags() { + if (this.total <= 0) { + this.total = this.success + this.failed; + } + this.partialSuccess = this.success > 0 && this.failed > 0; + } +} diff --git a/backend/src/main/java/com/writeoff/common/model/ImportRowError.java b/backend/src/main/java/com/writeoff/common/model/ImportRowError.java new file mode 100644 index 0000000..b5f2fc0 --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/model/ImportRowError.java @@ -0,0 +1,40 @@ +package com.writeoff.common.model; + +public class ImportRowError { + private int rowNo; + private String identifier; + private String message; + + public ImportRowError() { + } + + public ImportRowError(int rowNo, String identifier, String message) { + this.rowNo = rowNo; + this.identifier = identifier; + this.message = message; + } + + public int getRowNo() { + return rowNo; + } + + public void setRowNo(int rowNo) { + this.rowNo = rowNo; + } + + public String getIdentifier() { + return identifier; + } + + public void setIdentifier(String identifier) { + this.identifier = identifier; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/backend/src/main/java/com/writeoff/common/util/ImportValidationUtils.java b/backend/src/main/java/com/writeoff/common/util/ImportValidationUtils.java new file mode 100644 index 0000000..8da6e91 --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/util/ImportValidationUtils.java @@ -0,0 +1,86 @@ +package com.writeoff.common.util; + +import com.writeoff.common.exception.BusinessException; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.regex.Pattern; + +public final class ImportValidationUtils { + private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); + private static final Pattern ID_NO_PATTERN = Pattern.compile("(^\\d{15}$)|(^\\d{17}[\\dXx]$)"); + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private ImportValidationUtils() { + } + + public static void validatePhone(String phone) { + String value = trim(phone); + if (value.isEmpty()) { + throw new BusinessException(10001, "\u624b\u673a\u53f7\u4e0d\u80fd\u4e3a\u7a7a"); + } + if (!PHONE_PATTERN.matcher(value).matches()) { + throw new BusinessException(10001, "\u624b\u673a\u53f7\u683c\u5f0f\u4e0d\u6b63\u786e"); + } + } + + public static void validateOptionalEmail(String email) { + String value = trim(email); + if (value.isEmpty()) { + return; + } + if (!EMAIL_PATTERN.matcher(value).matches()) { + throw new BusinessException(10001, "\u90ae\u7bb1\u683c\u5f0f\u4e0d\u6b63\u786e"); + } + } + + public static void validateRequiredEmail(String email) { + String value = trim(email); + if (value.isEmpty()) { + throw new BusinessException(10001, "\u90ae\u7bb1\u4e0d\u80fd\u4e3a\u7a7a"); + } + if (!EMAIL_PATTERN.matcher(value).matches()) { + throw new BusinessException(10001, "\u90ae\u7bb1\u683c\u5f0f\u4e0d\u6b63\u786e"); + } + } + + public static void validateIdNo(String idNo) { + String value = trim(idNo); + if (value.isEmpty()) { + throw new BusinessException(10001, "\u8eab\u4efd\u8bc1\u53f7\u4e0d\u80fd\u4e3a\u7a7a"); + } + if (!ID_NO_PATTERN.matcher(value).matches()) { + throw new BusinessException(10001, "\u8eab\u4efd\u8bc1\u53f7\u683c\u5f0f\u4e0d\u6b63\u786e"); + } + } + + public static LocalDateTime parseOptionalDateTime(String raw, String fieldName) { + String value = trim(raw); + if (value.isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(normalizeDateTime(value), DATE_TIME_FORMATTER); + } catch (Exception ex) { + throw new BusinessException(10001, fieldName + "\u683c\u5f0f\u4e0d\u6b63\u786e\uff0c\u5e94\u4e3a yyyy-MM-dd HH:mm:ss"); + } + } + + public static void validateDateRange(String validFrom, String validTo) { + LocalDateTime from = parseOptionalDateTime(validFrom, "\u751f\u6548\u65f6\u95f4"); + LocalDateTime to = parseOptionalDateTime(validTo, "\u5931\u6548\u65f6\u95f4"); + if (from != null && to != null && to.isBefore(from)) { + throw new BusinessException(10001, "\u5931\u6548\u65f6\u95f4\u4e0d\u80fd\u65e9\u4e8e\u751f\u6548\u65f6\u95f4"); + } + } + + public static String trim(String value) { + return value == null ? "" : value.trim(); + } + + private static String normalizeDateTime(String value) { + String normalized = value.replace("T", " "); + return normalized.length() == 16 ? normalized + ":00" : normalized; + } +} diff --git a/backend/src/main/java/com/writeoff/common/web/RequestIdContext.java b/backend/src/main/java/com/writeoff/common/web/RequestIdContext.java new file mode 100644 index 0000000..8e797bc --- /dev/null +++ b/backend/src/main/java/com/writeoff/common/web/RequestIdContext.java @@ -0,0 +1,21 @@ +package com.writeoff.common.web; + +public final class RequestIdContext { + private static final ThreadLocal HOLDER = new ThreadLocal(); + + private RequestIdContext() { + } + + public static void set(String requestId) { + HOLDER.set(requestId); + } + + public static String get() { + String requestId = HOLDER.get(); + return requestId == null ? "" : requestId; + } + + public static void clear() { + HOLDER.remove(); + } +} diff --git a/backend/src/main/java/com/writeoff/config/WebConfig.java b/backend/src/main/java/com/writeoff/config/WebConfig.java new file mode 100644 index 0000000..2e50e03 --- /dev/null +++ b/backend/src/main/java/com/writeoff/config/WebConfig.java @@ -0,0 +1,29 @@ +package com.writeoff.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import com.writeoff.security.AuthInterceptor; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + private final AuthInterceptor authInterceptor; + + public WebConfig(AuthInterceptor authInterceptor) { + this.authInterceptor = authInterceptor; + } + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/api/**") + .allowedOrigins("*") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*"); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(authInterceptor).addPathPatterns("/api/**"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/controller/AuditController.java b/backend/src/main/java/com/writeoff/module/audit/controller/AuditController.java new file mode 100644 index 0000000..cc3586a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/controller/AuditController.java @@ -0,0 +1,123 @@ +package com.writeoff.module.audit.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.audit.dto.AuditActionRequest; +import com.writeoff.module.audit.dto.AuditMaterialItemRejectRequest; +import com.writeoff.module.audit.dto.AuditMaterialModuleApproveRequest; +import com.writeoff.module.audit.dto.BatchAuditActionRequest; +import com.writeoff.module.audit.dto.BatchRemindRequest; +import com.writeoff.module.audit.dto.TransferAuditTaskRequest; +import com.writeoff.module.audit.model.AuditTask; +import com.writeoff.security.DataScopeType; +import com.writeoff.module.audit.service.AuditService; +import com.writeoff.security.RequirePermission; +import javax.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/audits") +public class AuditController { + private final AuditService auditService; + + public AuditController(AuditService auditService) { + this.auditService = auditService; + } + + @GetMapping("/tasks") + public ApiResponse> tasks( + @RequestParam(value = "mine", required = false, defaultValue = "false") boolean mine, + @RequestParam(value = "scope", required = false) String scope, + @RequestParam(value = "meetingId", required = false) Long meetingId, + @RequestParam(value = "pageNo", required = false) Integer pageNo, + @RequestParam(value = "pageSize", required = false) Integer pageSize, + @RequestParam(value = "sortBy", required = false) String sortBy, + @RequestParam(value = "order", required = false) String order + ) { + if (meetingId == null && pageNo == null && pageSize == null && sortBy == null && order == null) { + return ApiResponse.success(auditService.listTasks(mine, scope)); + } + return ApiResponse.success(auditService.listTasks(mine, scope, meetingId, pageNo, pageSize, sortBy, order)); + } + + @PostMapping("/tasks/{id}/approve") + @RequirePermission(value = "audit.approve", dataScope = DataScopeType.MEETING, auditAction = "AUDIT_APPROVE") + public ApiResponse> approve(@PathVariable("id") Long taskId, + @RequestBody @Valid AuditActionRequest request) { + return ApiResponse.success(auditService.approve(taskId, request)); + } + + @PostMapping("/tasks/{id}/reject") + @RequirePermission(value = "audit.reject", dataScope = DataScopeType.MEETING, auditAction = "AUDIT_REJECT") + public ApiResponse> reject(@PathVariable("id") Long taskId, + @RequestBody @Valid AuditActionRequest request) { + return ApiResponse.success(auditService.reject(taskId, request)); + } + + @PostMapping("/tasks/{id}/return") + @RequirePermission(value = "audit.return", dataScope = DataScopeType.MEETING, auditAction = "AUDIT_RETURN") + public ApiResponse> back(@PathVariable("id") Long taskId, + @RequestBody @Valid AuditActionRequest request) { + return ApiResponse.success(auditService.back(taskId, request)); + } + + @GetMapping("/export-opinions") + @RequirePermission(value = "audit.export.opinions", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_EXPORT_OPINIONS") + public ApiResponse> exportOpinions() { + return ApiResponse.success(auditService.exportOpinions()); + } + + @GetMapping("/tasks/{id}/material") + @RequirePermission(value = "audit.material.read", dataScope = DataScopeType.MEETING_MODULE, auditAction = "AUDIT_MATERIAL_READ") + public ApiResponse> readTaskMaterial(@PathVariable("id") Long id, + @RequestParam("moduleCode") String moduleCode) { + return ApiResponse.success(auditService.readTaskMaterial(id, moduleCode)); + } + + @PostMapping("/tasks/{id}/material/approve-module") + @RequirePermission(value = "audit.approve", dataScope = DataScopeType.MEETING_MODULE, auditAction = "AUDIT_MATERIAL_APPROVE_MODULE") + public ApiResponse> approveMaterialModule(@PathVariable("id") Long id, + @RequestBody @Valid AuditMaterialModuleApproveRequest request) { + return ApiResponse.success(auditService.approveMaterialModule(id, request)); + } + + @PostMapping("/tasks/{id}/material/reject-item") + @RequirePermission(value = "audit.reject", dataScope = DataScopeType.MEETING_MODULE, auditAction = "AUDIT_MATERIAL_REJECT_ITEM") + public ApiResponse> rejectMaterialItem(@PathVariable("id") Long id, + @RequestBody @Valid AuditMaterialItemRejectRequest request) { + return ApiResponse.success(auditService.rejectMaterialItem(id, request)); + } + + @PostMapping("/tasks/{id}/transfer") + @RequirePermission(value = "audit.transfer", dataScope = DataScopeType.MEETING, auditAction = "AUDIT_TRANSFER") + public ApiResponse> transfer(@PathVariable("id") Long id, + @RequestBody @Valid TransferAuditTaskRequest request) { + return ApiResponse.success(auditService.transfer(id, request)); + } + + @PostMapping("/tasks/batch-remind") + @RequirePermission(value = "audit.remind", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_BATCH_REMIND") + public ApiResponse> batchRemind(@RequestBody @Valid BatchRemindRequest request) { + return ApiResponse.success(auditService.batchRemind(request)); + } + + @GetMapping("/tasks/sla-stat") + @RequirePermission(value = "audit.sla.read", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_SLA_STAT") + public ApiResponse> slaStat() { + return ApiResponse.success(auditService.slaStat()); + } + + @PostMapping("/tasks/batch-approve") + @RequirePermission(value = "audit.approve", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_BATCH_APPROVE") + public ApiResponse> batchApprove(@RequestBody @Valid BatchAuditActionRequest request) { + return ApiResponse.success(auditService.batchApprove(request)); + } + + @PostMapping("/tasks/batch-reject") + @RequirePermission(value = "audit.reject", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_BATCH_REJECT") + public ApiResponse> batchReject(@RequestBody @Valid BatchAuditActionRequest request) { + return ApiResponse.success(auditService.batchReject(request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/controller/AuditFlowController.java b/backend/src/main/java/com/writeoff/module/audit/controller/AuditFlowController.java new file mode 100644 index 0000000..24e69db --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/controller/AuditFlowController.java @@ -0,0 +1,84 @@ +package com.writeoff.module.audit.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.audit.dto.CreateAuditFlowRequest; +import com.writeoff.module.audit.dto.UpdateAuditFlowRequest; +import com.writeoff.module.audit.model.AuditFlowInfo; +import com.writeoff.module.audit.service.AuditFlowManageService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/audit-flows") +public class AuditFlowController { + private final AuditFlowManageService auditFlowManageService; + + public AuditFlowController(AuditFlowManageService auditFlowManageService) { + this.auditFlowManageService = auditFlowManageService; + } + + @GetMapping + @RequirePermission(value = "audit.flow.read", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_FLOW_LIST") + public ApiResponse> list( + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return ApiResponse.success(auditFlowManageService.list(pageNo, pageSize)); + } + + @PostMapping + @RequirePermission(value = "audit.flow.manage", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_FLOW_CREATE") + public ApiResponse create(@RequestBody @Valid CreateAuditFlowRequest request) { + return ApiResponse.success(auditFlowManageService.create(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "audit.flow.manage", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_FLOW_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid UpdateAuditFlowRequest request) { + return ApiResponse.success(auditFlowManageService.update(id, request)); + } + + @PostMapping("/{id}/copy") + @RequirePermission(value = "audit.flow.manage", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_FLOW_COPY") + public ApiResponse copy(@PathVariable("id") Long id) { + return ApiResponse.success(auditFlowManageService.copy(id)); + } + + @PostMapping("/{id}/default") + @RequirePermission(value = "audit.flow.manage", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_FLOW_SET_DEFAULT") + public ApiResponse setDefault(@PathVariable("id") Long id) { + auditFlowManageService.setDefault(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "audit.flow.manage", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_FLOW_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + auditFlowManageService.enable(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "audit.flow.manage", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_FLOW_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + auditFlowManageService.disable(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/delete") + @RequirePermission(value = "audit.flow.manage", dataScope = DataScopeType.TENANT, auditAction = "AUDIT_FLOW_DELETE") + public ApiResponse delete(@PathVariable("id") Long id) { + auditFlowManageService.softDelete(id); + return ApiResponse.success("OK"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/AuditActionRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/AuditActionRequest.java new file mode 100644 index 0000000..6020191 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/AuditActionRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.audit.dto; + +import javax.validation.constraints.NotBlank; + +public class AuditActionRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotBlank(message = "审核意见不能为空") + private String opinion; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getOpinion() { + return opinion; + } + + public void setOpinion(String opinion) { + this.opinion = opinion; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/AuditFlowNodeRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/AuditFlowNodeRequest.java new file mode 100644 index 0000000..05217f1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/AuditFlowNodeRequest.java @@ -0,0 +1,55 @@ +package com.writeoff.module.audit.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class AuditFlowNodeRequest { + @NotBlank(message = "节点编码不能为空") + private String nodeCode; + @NotBlank(message = "节点名称不能为空") + private String nodeName; + @NotNull(message = "节点排序不能为空") + private Integer sortNo; + private String assigneeType; + private Long assigneeRefId; + + public String getNodeCode() { + return nodeCode; + } + + public void setNodeCode(String nodeCode) { + this.nodeCode = nodeCode; + } + + public String getNodeName() { + return nodeName; + } + + public void setNodeName(String nodeName) { + this.nodeName = nodeName; + } + + public Integer getSortNo() { + return sortNo; + } + + public void setSortNo(Integer sortNo) { + this.sortNo = sortNo; + } + + public String getAssigneeType() { + return assigneeType; + } + + public void setAssigneeType(String assigneeType) { + this.assigneeType = assigneeType; + } + + public Long getAssigneeRefId() { + return assigneeRefId; + } + + public void setAssigneeRefId(Long assigneeRefId) { + this.assigneeRefId = assigneeRefId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/AuditMaterialItemRejectRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/AuditMaterialItemRejectRequest.java new file mode 100644 index 0000000..4f514b4 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/AuditMaterialItemRejectRequest.java @@ -0,0 +1,56 @@ +package com.writeoff.module.audit.dto; + +import javax.validation.constraints.NotBlank; + +public class AuditMaterialItemRejectRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotBlank(message = "模块编码不能为空") + private String moduleCode; + @NotBlank(message = "条目编码不能为空") + private String itemKey; + @NotBlank(message = "条目名称不能为空") + private String itemLabel; + @NotBlank(message = "不通过原因不能为空") + private String reason; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getModuleCode() { + return moduleCode; + } + + public void setModuleCode(String moduleCode) { + this.moduleCode = moduleCode; + } + + public String getItemKey() { + return itemKey; + } + + public void setItemKey(String itemKey) { + this.itemKey = itemKey; + } + + public String getItemLabel() { + return itemLabel; + } + + public void setItemLabel(String itemLabel) { + this.itemLabel = itemLabel; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/AuditMaterialModuleApproveRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/AuditMaterialModuleApproveRequest.java new file mode 100644 index 0000000..31c9ae5 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/AuditMaterialModuleApproveRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.audit.dto; + +import javax.validation.constraints.NotBlank; + +public class AuditMaterialModuleApproveRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotBlank(message = "模块编码不能为空") + private String moduleCode; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getModuleCode() { + return moduleCode; + } + + public void setModuleCode(String moduleCode) { + this.moduleCode = moduleCode; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/BatchAuditActionRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/BatchAuditActionRequest.java new file mode 100644 index 0000000..3180244 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/BatchAuditActionRequest.java @@ -0,0 +1,40 @@ +package com.writeoff.module.audit.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotEmpty; +import javax.validation.constraints.Size; +import java.util.List; + +public class BatchAuditActionRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotEmpty(message = "任务ID列表不能为空") + @Size(max = 50, message = "单次批量操作最多50条") + private List taskIds; + @NotBlank(message = "审核意见不能为空") + private String opinion; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public List getTaskIds() { + return taskIds; + } + + public void setTaskIds(List taskIds) { + this.taskIds = taskIds; + } + + public String getOpinion() { + return opinion; + } + + public void setOpinion(String opinion) { + this.opinion = opinion; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/BatchRemindRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/BatchRemindRequest.java new file mode 100644 index 0000000..1d047ce --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/BatchRemindRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.audit.dto; + +import javax.validation.constraints.NotBlank; +import java.util.List; + +public class BatchRemindRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + private List taskIds; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public List getTaskIds() { + return taskIds; + } + + public void setTaskIds(List taskIds) { + this.taskIds = taskIds; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/CreateAuditFlowRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/CreateAuditFlowRequest.java new file mode 100644 index 0000000..431cea2 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/CreateAuditFlowRequest.java @@ -0,0 +1,58 @@ +package com.writeoff.module.audit.dto; + +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotEmpty; +import java.util.List; + +public class CreateAuditFlowRequest { + @NotBlank(message = "流程编码不能为空") + private String flowCode; + @NotBlank(message = "流程名称不能为空") + private String flowName; + private String effectiveStartAt; + private String effectiveEndAt; + @NotEmpty(message = "至少配置一个节点") + @Valid + private List nodes; + + public String getFlowCode() { + return flowCode; + } + + public void setFlowCode(String flowCode) { + this.flowCode = flowCode; + } + + public String getFlowName() { + return flowName; + } + + public void setFlowName(String flowName) { + this.flowName = flowName; + } + + public String getEffectiveStartAt() { + return effectiveStartAt; + } + + public void setEffectiveStartAt(String effectiveStartAt) { + this.effectiveStartAt = effectiveStartAt; + } + + public String getEffectiveEndAt() { + return effectiveEndAt; + } + + public void setEffectiveEndAt(String effectiveEndAt) { + this.effectiveEndAt = effectiveEndAt; + } + + public List getNodes() { + return nodes; + } + + public void setNodes(List nodes) { + this.nodes = nodes; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/TransferAuditTaskRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/TransferAuditTaskRequest.java new file mode 100644 index 0000000..3958b1e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/TransferAuditTaskRequest.java @@ -0,0 +1,37 @@ +package com.writeoff.module.audit.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class TransferAuditTaskRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotNull(message = "转交目标用户不能为空") + private Long toUserId; + @NotBlank(message = "转审原因不能为空") + private String reason; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public Long getToUserId() { + return toUserId; + } + + public void setToUserId(Long toUserId) { + this.toUserId = toUserId; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/dto/UpdateAuditFlowRequest.java b/backend/src/main/java/com/writeoff/module/audit/dto/UpdateAuditFlowRequest.java new file mode 100644 index 0000000..a5e8f42 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/dto/UpdateAuditFlowRequest.java @@ -0,0 +1,4 @@ +package com.writeoff.module.audit.dto; + +public class UpdateAuditFlowRequest extends CreateAuditFlowRequest { +} diff --git a/backend/src/main/java/com/writeoff/module/audit/model/AuditFlowInfo.java b/backend/src/main/java/com/writeoff/module/audit/model/AuditFlowInfo.java new file mode 100644 index 0000000..5f51e4d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/model/AuditFlowInfo.java @@ -0,0 +1,57 @@ +package com.writeoff.module.audit.model; + +import java.util.List; + +public class AuditFlowInfo { + private Long id; + private String flowCode; + private String flowName; + private String status; + private Boolean isDefault; + private String effectiveStartAt; + private String effectiveEndAt; + private List nodes; + + public AuditFlowInfo(Long id, String flowCode, String flowName, String status, Boolean isDefault, String effectiveStartAt, String effectiveEndAt, List nodes) { + this.id = id; + this.flowCode = flowCode; + this.flowName = flowName; + this.status = status; + this.isDefault = isDefault; + this.effectiveStartAt = effectiveStartAt; + this.effectiveEndAt = effectiveEndAt; + this.nodes = nodes; + } + + public Long getId() { + return id; + } + + public String getFlowCode() { + return flowCode; + } + + public String getFlowName() { + return flowName; + } + + public String getStatus() { + return status; + } + + public Boolean getIsDefault() { + return isDefault; + } + + public String getEffectiveStartAt() { + return effectiveStartAt; + } + + public String getEffectiveEndAt() { + return effectiveEndAt; + } + + public List getNodes() { + return nodes; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/model/AuditFlowNodeInfo.java b/backend/src/main/java/com/writeoff/module/audit/model/AuditFlowNodeInfo.java new file mode 100644 index 0000000..1be151d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/model/AuditFlowNodeInfo.java @@ -0,0 +1,49 @@ +package com.writeoff.module.audit.model; + +public class AuditFlowNodeInfo { + private Long id; + private String nodeCode; + private String nodeName; + private Integer sortNo; + private String status; + private String assigneeType; + private Long assigneeRefId; + + public AuditFlowNodeInfo(Long id, String nodeCode, String nodeName, Integer sortNo, String status, String assigneeType, Long assigneeRefId) { + this.id = id; + this.nodeCode = nodeCode; + this.nodeName = nodeName; + this.sortNo = sortNo; + this.status = status; + this.assigneeType = assigneeType; + this.assigneeRefId = assigneeRefId; + } + + public Long getId() { + return id; + } + + public String getNodeCode() { + return nodeCode; + } + + public String getNodeName() { + return nodeName; + } + + public Integer getSortNo() { + return sortNo; + } + + public String getStatus() { + return status; + } + + public String getAssigneeType() { + return assigneeType; + } + + public Long getAssigneeRefId() { + return assigneeRefId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/model/AuditNode.java b/backend/src/main/java/com/writeoff/module/audit/model/AuditNode.java new file mode 100644 index 0000000..0a49983 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/model/AuditNode.java @@ -0,0 +1,7 @@ +package com.writeoff.module.audit.model; + +public enum AuditNode { + INIT_REVIEW, + RE_REVIEW, + FINAL_REVIEW +} diff --git a/backend/src/main/java/com/writeoff/module/audit/model/AuditTask.java b/backend/src/main/java/com/writeoff/module/audit/model/AuditTask.java new file mode 100644 index 0000000..f790d9c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/model/AuditTask.java @@ -0,0 +1,198 @@ +package com.writeoff.module.audit.model; + +import java.util.List; + +public class AuditTask { + private Long id; + private Long meetingId; + private AuditNode node; + private Long assigneeUserId; + private String assigneeUserName; + private AuditTaskStatus status; + private String opinion; + private String slaDeadlineAt; + private Integer timeoutLevel; + private Integer overtimeHours; + private Boolean overtime; + private Long transferFromUserId; + private String transferReason; + private String returnReason; + private Integer rejectCount; + private String lastRejectReason; + private String lastActionAt; + private List flowNodes; + + public AuditTask(Long id, Long meetingId, AuditNode node, Long assigneeUserId, AuditTaskStatus status, String opinion) { + this(id, meetingId, node, assigneeUserId, status, opinion, null, 0, 0, false, null, null, null, 0, null, null); + } + + public AuditTask(Long id, Long meetingId, AuditNode node, Long assigneeUserId, AuditTaskStatus status, String opinion, String slaDeadlineAt, Integer timeoutLevel) { + this(id, meetingId, node, assigneeUserId, status, opinion, slaDeadlineAt, timeoutLevel, 0, false, null, null, null, 0, null, null); + } + + public AuditTask(Long id, + Long meetingId, + AuditNode node, + Long assigneeUserId, + AuditTaskStatus status, + String opinion, + String slaDeadlineAt, + Integer timeoutLevel, + Integer overtimeHours, + Boolean overtime, + Long transferFromUserId, + String transferReason, + String returnReason, + Integer rejectCount, + String lastRejectReason, + String lastActionAt) { + this.id = id; + this.meetingId = meetingId; + this.node = node; + this.assigneeUserId = assigneeUserId; + this.status = status; + this.opinion = opinion; + this.slaDeadlineAt = slaDeadlineAt; + this.timeoutLevel = timeoutLevel; + this.overtimeHours = overtimeHours; + this.overtime = overtime; + this.transferFromUserId = transferFromUserId; + this.transferReason = transferReason; + this.returnReason = returnReason; + this.rejectCount = rejectCount; + this.lastRejectReason = lastRejectReason; + this.lastActionAt = lastActionAt; + } + + public Long getId() { + return id; + } + + public Long getMeetingId() { + return meetingId; + } + + public AuditNode getNode() { + return node; + } + + public Long getAssigneeUserId() { + return assigneeUserId; + } + + public String getAssigneeUserName() { + return assigneeUserName; + } + + public AuditTaskStatus getStatus() { + return status; + } + + public String getOpinion() { + return opinion; + } + + public String getSlaDeadlineAt() { + return slaDeadlineAt; + } + + public Integer getTimeoutLevel() { + return timeoutLevel; + } + + public Integer getOvertimeHours() { + return overtimeHours; + } + + public Boolean getOvertime() { + return overtime; + } + + public Long getTransferFromUserId() { + return transferFromUserId; + } + + public String getTransferReason() { + return transferReason; + } + + public String getReturnReason() { + return returnReason; + } + + public Integer getRejectCount() { + return rejectCount; + } + + public String getLastRejectReason() { + return lastRejectReason; + } + + public String getLastActionAt() { + return lastActionAt; + } + + public List getFlowNodes() { + return flowNodes; + } + + public void setStatus(AuditTaskStatus status) { + this.status = status; + } + + public void setOpinion(String opinion) { + this.opinion = opinion; + } + + public void setAssigneeUserId(Long assigneeUserId) { + this.assigneeUserId = assigneeUserId; + } + + public void setAssigneeUserName(String assigneeUserName) { + this.assigneeUserName = assigneeUserName; + } + + public void setSlaDeadlineAt(String slaDeadlineAt) { + this.slaDeadlineAt = slaDeadlineAt; + } + + public void setTimeoutLevel(Integer timeoutLevel) { + this.timeoutLevel = timeoutLevel; + } + + public void setOvertimeHours(Integer overtimeHours) { + this.overtimeHours = overtimeHours; + } + + public void setOvertime(Boolean overtime) { + this.overtime = overtime; + } + + public void setTransferFromUserId(Long transferFromUserId) { + this.transferFromUserId = transferFromUserId; + } + + public void setTransferReason(String transferReason) { + this.transferReason = transferReason; + } + + public void setReturnReason(String returnReason) { + this.returnReason = returnReason; + } + + public void setRejectCount(Integer rejectCount) { + this.rejectCount = rejectCount; + } + + public void setLastRejectReason(String lastRejectReason) { + this.lastRejectReason = lastRejectReason; + } + + public void setLastActionAt(String lastActionAt) { + this.lastActionAt = lastActionAt; + } + + public void setFlowNodes(List flowNodes) { + this.flowNodes = flowNodes; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/model/AuditTaskStatus.java b/backend/src/main/java/com/writeoff/module/audit/model/AuditTaskStatus.java new file mode 100644 index 0000000..1af9daa --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/model/AuditTaskStatus.java @@ -0,0 +1,7 @@ +package com.writeoff.module.audit.model; + +public enum AuditTaskStatus { + PENDING, + APPROVED, + REJECTED +} diff --git a/backend/src/main/java/com/writeoff/module/audit/repository/AuditTaskRepository.java b/backend/src/main/java/com/writeoff/module/audit/repository/AuditTaskRepository.java new file mode 100644 index 0000000..476431c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/repository/AuditTaskRepository.java @@ -0,0 +1,25 @@ +package com.writeoff.module.audit.repository; + +import com.writeoff.module.audit.model.AuditTask; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +public interface AuditTaskRepository { + AuditTask save(AuditTask task); + + Optional findById(Long id); + + List findAll(); + + Optional findLatestByMeetingId(Long meetingId); + + int withdrawPendingByMeetingId(Long meetingId, String reason, Long operatorUserId); + + void transfer(Long taskId, Long toUserId, String reason, Long operatorUserId); + + int batchRemind(List taskIds, Long operatorUserId); + + Map slaStat(); +} diff --git a/backend/src/main/java/com/writeoff/module/audit/repository/InMemoryAuditTaskRepository.java b/backend/src/main/java/com/writeoff/module/audit/repository/InMemoryAuditTaskRepository.java new file mode 100644 index 0000000..f760e8c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/repository/InMemoryAuditTaskRepository.java @@ -0,0 +1,106 @@ +package com.writeoff.module.audit.repository; + +import com.writeoff.module.audit.model.AuditTask; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "in-memory") +public class InMemoryAuditTaskRepository implements AuditTaskRepository { + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(3000); + + @Override + public AuditTask save(AuditTask task) { + if (task.getId() == null) { + AuditTask newTask = new AuditTask( + idGenerator.incrementAndGet(), + task.getMeetingId(), + task.getNode(), + task.getAssigneeUserId(), + task.getStatus(), + task.getOpinion() + ); + store.put(newTask.getId(), newTask); + return newTask; + } + store.put(task.getId(), task); + return task; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAll() { + return new ArrayList<>(store.values()); + } + + @Override + public Optional findLatestByMeetingId(Long meetingId) { + return store.values().stream() + .filter(t -> t.getMeetingId().equals(meetingId)) + .max(Comparator.comparingLong(AuditTask::getId)); + } + + @Override + public int withdrawPendingByMeetingId(Long meetingId, String reason, Long operatorUserId) { + int count = 0; + List toRemove = new ArrayList(); + for (Map.Entry e : store.entrySet()) { + AuditTask task = e.getValue(); + if (task.getMeetingId().equals(meetingId) && "PENDING".equals(task.getStatus().name())) { + toRemove.add(e.getKey()); + } + } + for (Long id : toRemove) { + store.remove(id); + count++; + } + return count; + } + + @Override + public void transfer(Long taskId, Long toUserId, String reason, Long operatorUserId) { + AuditTask task = store.get(taskId); + if (task == null) { + throw new IllegalArgumentException("task not found"); + } + task.setAssigneeUserId(toUserId); + task.setOpinion(reason == null ? task.getOpinion() : ("转审:" + reason)); + task.setTimeoutLevel(0); + store.put(taskId, task); + } + + @Override + public int batchRemind(List taskIds, Long operatorUserId) { + int count = 0; + for (Long taskId : taskIds) { + if (store.containsKey(taskId)) { + count++; + } + } + return count; + } + + @Override + public Map slaStat() { + Map data = new LinkedHashMap<>(); + data.put("pendingTotal", store.size()); + data.put("timeout4h", 0); + data.put("timeout12h", 0); + data.put("timeout24h", 0); + return data; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/repository/JdbcAuditTaskRepository.java b/backend/src/main/java/com/writeoff/module/audit/repository/JdbcAuditTaskRepository.java new file mode 100644 index 0000000..5d47eb1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/repository/JdbcAuditTaskRepository.java @@ -0,0 +1,240 @@ +package com.writeoff.module.audit.repository; + +import com.writeoff.module.audit.model.AuditNode; +import com.writeoff.module.audit.model.AuditTask; +import com.writeoff.module.audit.model.AuditTaskStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import com.writeoff.security.AuthContext; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "jdbc", matchIfMissing = true) +public class JdbcAuditTaskRepository implements AuditTaskRepository { + private final JdbcTemplate jdbcTemplate; + private static final RowMapper ROW_MAPPER = (rs, n) -> new AuditTask( + rs.getLong("id"), + rs.getLong("meeting_id"), + AuditNode.valueOf(rs.getString("audit_node")), + rs.getObject("assignee_user_id") == null ? null : rs.getLong("assignee_user_id"), + AuditTaskStatus.valueOf(rs.getString("status")), + rs.getString("opinion"), + rs.getString("sla_deadline_at"), + rs.getInt("timeout_level"), + rs.getInt("overtime_hours"), + rs.getInt("is_overtime") == 1, + rs.getObject("transfer_from_user_id") == null ? null : rs.getLong("transfer_from_user_id"), + rs.getString("transfer_reason"), + rs.getString("return_reason"), + rs.getInt("reject_count"), + rs.getString("last_reject_reason"), + rs.getString("last_action_at") + ); + + public JdbcAuditTaskRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public AuditTask save(AuditTask task) { + if (task.getId() == null) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO audit_task (tenant_id, meeting_id, audit_node, assignee_user_id, status, opinion, sla_deadline_at, timeout_level, overtime_hours, is_overtime, " + + "transfer_from_user_id, transfer_reason, return_reason, reject_count, last_reject_reason, last_action_at, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, DATE_ADD(NOW(), INTERVAL 24 HOUR), 0, 0, 0, NULL, NULL, NULL, 0, NULL, NOW(), 0, 0)", + Statement.RETURN_GENERATED_KEYS + ); + ps.setLong(1, tenantId()); + ps.setLong(2, task.getMeetingId()); + ps.setString(3, task.getNode().name()); + ps.setObject(4, task.getAssigneeUserId()); + ps.setString(5, task.getStatus().name()); + ps.setString(6, task.getOpinion()); + return ps; + }, keyHolder); + Number key = keyHolder.getKey(); + Long id = key == null ? null : key.longValue(); + return new AuditTask(id, task.getMeetingId(), task.getNode(), task.getAssigneeUserId(), task.getStatus(), task.getOpinion()); + } + jdbcTemplate.update( + "UPDATE audit_task SET status=?, opinion=?, assignee_user_id=?, transfer_from_user_id=?, transfer_reason=?, return_reason=?, reject_count=?, " + + "last_reject_reason=?, last_action_at=NOW(), updated_by=0 WHERE tenant_id=? AND id=?", + task.getStatus().name(), + task.getOpinion(), + task.getAssigneeUserId(), + task.getTransferFromUserId(), + task.getTransferReason(), + task.getReturnReason(), + task.getRejectCount() == null ? 0 : task.getRejectCount(), + task.getLastRejectReason(), + tenantId(), + task.getId() + ); + return task; + } + + @Override + public Optional findById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, meeting_id, audit_node, assignee_user_id, status, opinion, " + + "DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " + + "IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " + + "transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " + + "DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at " + + "FROM audit_task WHERE tenant_id=? AND id=? AND is_deleted=0", + ROW_MAPPER, tenantId(), id + ); + return list.stream().findFirst(); + } + + @Override + public List findAll() { + refreshTimeoutLevels(); + return jdbcTemplate.query( + "SELECT id, meeting_id, audit_node, assignee_user_id, status, opinion, " + + "DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " + + "IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " + + "transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " + + "DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at " + + "FROM audit_task WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC", + ROW_MAPPER, tenantId() + ); + } + + @Override + public Optional findLatestByMeetingId(Long meetingId) { + List list = jdbcTemplate.query( + "SELECT id, meeting_id, audit_node, assignee_user_id, status, opinion, " + + "DATE_FORMAT(sla_deadline_at, '%Y-%m-%d %H:%i:%s') AS sla_deadline_at, IFNULL(timeout_level, 0) AS timeout_level, " + + "IFNULL(overtime_hours, 0) AS overtime_hours, IFNULL(is_overtime, 0) AS is_overtime, " + + "transfer_from_user_id, transfer_reason, return_reason, IFNULL(reject_count, 0) AS reject_count, last_reject_reason, " + + "DATE_FORMAT(last_action_at, '%Y-%m-%d %H:%i:%s') AS last_action_at " + + "FROM audit_task WHERE tenant_id=? AND meeting_id=? AND is_deleted=0 ORDER BY id DESC LIMIT 1", + ROW_MAPPER, tenantId(), meetingId + ); + return list.stream().findFirst(); + } + + @Override + public int withdrawPendingByMeetingId(Long meetingId, String reason, Long operatorUserId) { + return jdbcTemplate.update( + "UPDATE audit_task SET is_deleted=1, opinion=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND meeting_id=? AND status='PENDING' AND is_deleted=0", + reason == null ? "会议撤回提交" : ("会议撤回提交:" + reason), + operatorUserId == null ? 0L : operatorUserId, + tenantId(), + meetingId + ); + } + + @Override + public void transfer(Long taskId, Long toUserId, String reason, Long operatorUserId) { + AuditTask task = findById(taskId).orElseThrow(() -> new IllegalArgumentException("task not found")); + if (task.getStatus() != AuditTaskStatus.PENDING) { + throw new IllegalStateException("task is not pending"); + } + jdbcTemplate.update( + "UPDATE audit_task SET assignee_user_id=?, opinion=?, sla_deadline_at=DATE_ADD(NOW(), INTERVAL 24 HOUR), timeout_level=0, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + ", overtime_hours=0, is_overtime=0, transfer_from_user_id=?, transfer_reason=?, last_action_at=NOW() " + + "WHERE tenant_id=? AND id=?", + toUserId, + reason == null ? task.getOpinion() : ("转审:" + reason), + operatorUserId == null ? 0L : operatorUserId, + task.getAssigneeUserId(), + reason, + tenantId(), + taskId + ); + jdbcTemplate.update( + "INSERT INTO audit_transfer_log (tenant_id, task_id, from_user_id, to_user_id, reason, created_by) VALUES (?, ?, ?, ?, ?, ?)", + tenantId(), + taskId, + task.getAssigneeUserId(), + toUserId, + reason, + operatorUserId == null ? 0L : operatorUserId + ); + } + + @Override + public int batchRemind(List taskIds, Long operatorUserId) { + if (taskIds == null || taskIds.isEmpty()) { + return 0; + } + int count = 0; + for (Long taskId : taskIds) { + Integer updated = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_task WHERE tenant_id=? AND id=? AND status='PENDING' AND is_deleted=0", + Integer.class, + tenantId(), + taskId + ); + if (updated != null && updated > 0) { + count++; + } + } + return count; + } + + @Override + public Map slaStat() { + refreshTimeoutLevels(); + Integer pending = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_task WHERE tenant_id=? AND status='PENDING' AND is_deleted=0", + Integer.class, + tenantId() + ); + Integer timeout4h = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_task WHERE tenant_id=? AND status='PENDING' AND timeout_level>=1 AND is_deleted=0", + Integer.class, + tenantId() + ); + Integer timeout12h = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_task WHERE tenant_id=? AND status='PENDING' AND timeout_level>=2 AND is_deleted=0", + Integer.class, + tenantId() + ); + Integer timeout24h = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_task WHERE tenant_id=? AND status='PENDING' AND timeout_level>=3 AND is_deleted=0", + Integer.class, + tenantId() + ); + Map data = new LinkedHashMap<>(); + data.put("pendingTotal", pending == null ? 0 : pending); + data.put("timeout4h", timeout4h == null ? 0 : timeout4h); + data.put("timeout12h", timeout12h == null ? 0 : timeout12h); + data.put("timeout24h", timeout24h == null ? 0 : timeout24h); + return data; + } + + private void refreshTimeoutLevels() { + jdbcTemplate.update( + "UPDATE audit_task SET timeout_level = CASE " + + "WHEN status='PENDING' AND sla_deadline_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) >= 24 THEN 3 " + + "WHEN status='PENDING' AND sla_deadline_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) >= 12 THEN 2 " + + "WHEN status='PENDING' AND sla_deadline_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) >= 4 THEN 1 " + + "ELSE 0 END, " + + "overtime_hours = CASE WHEN status='PENDING' AND sla_deadline_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) > 0 " + + "THEN TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) ELSE 0 END, " + + "is_overtime = CASE WHEN status='PENDING' AND sla_deadline_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) > 0 THEN 1 ELSE 0 END " + + "WHERE tenant_id=? AND is_deleted=0", + tenantId() + ); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/service/AuditFlowConfigService.java b/backend/src/main/java/com/writeoff/module/audit/service/AuditFlowConfigService.java new file mode 100644 index 0000000..8e56673 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/service/AuditFlowConfigService.java @@ -0,0 +1,89 @@ +package com.writeoff.module.audit.service; + +import com.writeoff.module.audit.model.AuditNode; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Service +public class AuditFlowConfigService { + private final JdbcTemplate jdbcTemplate; + + public AuditFlowConfigService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public List getEnabledNodes(Long tenantId) { + List codes = jdbcTemplate.queryForList( + "SELECT n.node_code FROM audit_flow f " + + "JOIN audit_flow_node n ON f.id=n.flow_id " + + "WHERE f.tenant_id=? AND f.is_default=1 AND f.status='ENABLED' AND n.status='ENABLED' " + + "ORDER BY n.sort_no ASC", + String.class, + tenantId + ); + List result = new ArrayList<>(); + for (String code : codes) { + result.add(AuditNode.valueOf(code)); + } + if (result.isEmpty()) { + result.add(AuditNode.INIT_REVIEW); + result.add(AuditNode.RE_REVIEW); + result.add(AuditNode.FINAL_REVIEW); + } + return result; + } + + public AuditNode firstNode(Long tenantId) { + return getEnabledNodes(tenantId).get(0); + } + + public AuditNode nextNode(Long tenantId, AuditNode current) { + List nodes = getEnabledNodes(tenantId); + for (int i = 0; i < nodes.size(); i++) { + if (nodes.get(i) == current) { + if (i + 1 < nodes.size()) { + return nodes.get(i + 1); + } + return null; + } + } + return null; + } + + public Long resolveAssigneeUserId(Long tenantId, AuditNode node) { + List> rows = jdbcTemplate.queryForList( + "SELECT a.assignee_type, a.assignee_ref_id FROM audit_flow f " + + "JOIN audit_flow_node n ON f.id=n.flow_id " + + "LEFT JOIN audit_flow_node_assignee a ON n.id=a.flow_node_id " + + "WHERE f.tenant_id=? AND f.is_default=1 AND f.status='ENABLED' AND n.status='ENABLED' AND n.node_code=? " + + "ORDER BY a.id ASC LIMIT 1", + tenantId, + node.name() + ); + if (rows.isEmpty()) { + return null; + } + String assigneeType = rows.get(0).get("assignee_type") == null ? null : String.valueOf(rows.get(0).get("assignee_type")); + Object ref = rows.get(0).get("assignee_ref_id"); + if ("USER".equals(assigneeType) && ref != null) { + return ((Number) ref).longValue(); + } + if ("ROLE".equals(assigneeType) && ref != null) { + List userIds = jdbcTemplate.queryForList( + "SELECT ur.user_id FROM user_role ur " + + "JOIN sys_user u ON ur.user_id=u.id " + + "WHERE ur.tenant_id=? AND ur.role_id=? AND u.status='ENABLED' AND u.is_deleted=0 " + + "ORDER BY ur.user_id ASC LIMIT 1", + Long.class, + tenantId, + ((Number) ref).longValue() + ); + return userIds.isEmpty() ? null : userIds.get(0); + } + return null; + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/service/AuditFlowManageService.java b/backend/src/main/java/com/writeoff/module/audit/service/AuditFlowManageService.java new file mode 100644 index 0000000..a804f13 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/service/AuditFlowManageService.java @@ -0,0 +1,272 @@ +package com.writeoff.module.audit.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.audit.dto.AuditFlowNodeRequest; +import com.writeoff.module.audit.dto.CreateAuditFlowRequest; +import com.writeoff.module.audit.dto.UpdateAuditFlowRequest; +import com.writeoff.module.audit.model.AuditFlowInfo; +import com.writeoff.module.audit.model.AuditFlowNodeInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.List; + +@Service +public class AuditFlowManageService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper NODE_ROW_MAPPER = (rs, n) -> new AuditFlowNodeInfo( + rs.getLong("id"), + rs.getString("node_code"), + rs.getString("node_name"), + rs.getInt("sort_no"), + rs.getString("status"), + rs.getString("assignee_type"), + rs.getObject("assignee_ref_id") == null ? null : rs.getLong("assignee_ref_id") + ); + + public AuditFlowManageService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult list(int pageNo, int pageSize) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + int offset = (safePage - 1) * safeSize; + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_flow WHERE tenant_id=? AND is_deleted=0", + Integer.class, + tenantId() + ); + long totalCount = total == null ? 0 : total; + + List list = jdbcTemplate.query( + "SELECT id, flow_code, flow_name, status, is_default, " + + "DATE_FORMAT(effective_start_at, '%Y-%m-%d %H:%i:%s') AS effective_start_at, " + + "DATE_FORMAT(effective_end_at, '%Y-%m-%d %H:%i:%s') AS effective_end_at " + + "FROM audit_flow WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC LIMIT ? OFFSET ?", + (rs, n) -> { + Long id = rs.getLong("id"); + return new AuditFlowInfo( + id, + rs.getString("flow_code"), + rs.getString("flow_name"), + rs.getString("status"), + rs.getInt("is_default") == 1, + rs.getString("effective_start_at"), + rs.getString("effective_end_at"), + loadNodes(id) + ); + }, + tenantId(), + safeSize, + offset + ); + return new PageResult<>(list, totalCount, safePage, safeSize); + } + + @Transactional + public AuditFlowInfo create(CreateAuditFlowRequest request) { + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_flow WHERE tenant_id=? AND flow_code=?", + Integer.class, + tenantId(), + request.getFlowCode() + ); + if (exists != null && exists > 0) { + throw new BusinessException(10001, "流程编码已存在"); + } + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO audit_flow (tenant_id, flow_code, flow_name, status, is_default, effective_start_at, effective_end_at) " + + "VALUES (?, ?, ?, 'ENABLED', 0, ?, ?)", + Statement.RETURN_GENERATED_KEYS + ); + ps.setLong(1, tenantId()); + ps.setString(2, request.getFlowCode()); + ps.setString(3, request.getFlowName()); + ps.setString(4, request.getEffectiveStartAt()); + ps.setString(5, request.getEffectiveEndAt()); + return ps; + }, keyHolder); + Long flowId = keyHolder.getKey() == null ? null : keyHolder.getKey().longValue(); + if (flowId == null) { + throw new BusinessException(10001, "流程创建失败"); + } + saveNodes(flowId, request.getNodes()); + return findById(flowId); + } + + @Transactional + public AuditFlowInfo update(Long flowId, UpdateAuditFlowRequest request) { + assertFlowExists(flowId); + jdbcTemplate.update( + "UPDATE audit_flow SET flow_name=?, effective_start_at=?, effective_end_at=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND tenant_id=?", + request.getFlowName(), + request.getEffectiveStartAt(), + request.getEffectiveEndAt(), + flowId, + tenantId() + ); + jdbcTemplate.update( + "DELETE a FROM audit_flow_node_assignee a JOIN audit_flow_node n ON a.flow_node_id=n.id WHERE n.flow_id=?", + flowId + ); + jdbcTemplate.update("DELETE FROM audit_flow_node WHERE flow_id=?", flowId); + saveNodes(flowId, request.getNodes()); + return findById(flowId); + } + + @Transactional + public AuditFlowInfo copy(Long flowId) { + AuditFlowInfo source = findById(flowId); + String newCode = source.getFlowCode() + "_COPY_" + System.currentTimeMillis(); + CreateAuditFlowRequest request = new CreateAuditFlowRequest(); + request.setFlowCode(newCode); + request.setFlowName(source.getFlowName() + " - 复制"); + request.setEffectiveStartAt(source.getEffectiveStartAt()); + request.setEffectiveEndAt(source.getEffectiveEndAt()); + List nodes = loadNodes(flowId).stream().map(node -> { + AuditFlowNodeRequest item = new AuditFlowNodeRequest(); + item.setNodeCode(node.getNodeCode()); + item.setNodeName(node.getNodeName()); + item.setSortNo(node.getSortNo()); + item.setAssigneeType(node.getAssigneeType()); + item.setAssigneeRefId(node.getAssigneeRefId()); + return item; + }).collect(java.util.stream.Collectors.toList()); + request.setNodes(nodes); + return create(request); + } + + @Transactional + public void setDefault(Long flowId) { + assertFlowExists(flowId); + jdbcTemplate.update("UPDATE audit_flow SET is_default=0 WHERE tenant_id=?", tenantId()); + jdbcTemplate.update("UPDATE audit_flow SET is_default=1 WHERE tenant_id=? AND id=?", tenantId(), flowId); + } + + public void enable(Long flowId) { + updateStatus(flowId, "ENABLED"); + } + + public void disable(Long flowId) { + updateStatus(flowId, "DISABLED"); + } + + @Transactional + public void softDelete(Long flowId) { + assertFlowExists(flowId); + // 不允许删除默认流程 + Integer isDefault = jdbcTemplate.queryForObject( + "SELECT is_default FROM audit_flow WHERE id=? AND tenant_id=? AND is_deleted=0", + Integer.class, flowId, tenantId()); + if (isDefault != null && isDefault == 1) { + throw new BusinessException(10001, "默认审核流不能删除"); + } + // 不允许删除启用状态的流程 + String status = jdbcTemplate.queryForObject( + "SELECT status FROM audit_flow WHERE id=? AND tenant_id=? AND is_deleted=0", + String.class, flowId, tenantId()); + if ("ENABLED".equals(status)) { + throw new BusinessException(10001, "请先停用审核流再删除"); + } + jdbcTemplate.update( + "UPDATE audit_flow SET is_deleted=1, updated_at=CURRENT_TIMESTAMP WHERE id=? AND tenant_id=?", + flowId, tenantId()); + } + + private void updateStatus(Long flowId, String status) { + assertFlowExists(flowId); + jdbcTemplate.update("UPDATE audit_flow SET status=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND tenant_id=?", status, flowId, tenantId()); + } + + private void assertFlowExists(Long flowId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_flow WHERE id=? AND tenant_id=? AND is_deleted=0", + Integer.class, + flowId, + tenantId() + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "审核流不存在"); + } + } + + private AuditFlowInfo findById(Long flowId) { + List list = jdbcTemplate.query( + "SELECT id, flow_code, flow_name, status, is_default, " + + "DATE_FORMAT(effective_start_at, '%Y-%m-%d %H:%i:%s') AS effective_start_at, " + + "DATE_FORMAT(effective_end_at, '%Y-%m-%d %H:%i:%s') AS effective_end_at " + + "FROM audit_flow WHERE tenant_id=? AND id=? AND is_deleted=0", + (rs, n) -> new AuditFlowInfo( + rs.getLong("id"), + rs.getString("flow_code"), + rs.getString("flow_name"), + rs.getString("status"), + rs.getInt("is_default") == 1, + rs.getString("effective_start_at"), + rs.getString("effective_end_at"), + loadNodes(rs.getLong("id")) + ), + tenantId(), + flowId + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "审核流不存在"); + } + return list.get(0); + } + + private List loadNodes(Long flowId) { + return jdbcTemplate.query( + "SELECT n.id, n.node_code, n.node_name, n.sort_no, n.status, a.assignee_type, a.assignee_ref_id " + + "FROM audit_flow_node n " + + "LEFT JOIN audit_flow_node_assignee a ON n.id=a.flow_node_id " + + "WHERE n.flow_id=? ORDER BY n.sort_no ASC", + NODE_ROW_MAPPER, + flowId + ); + } + + private void saveNodes(Long flowId, List nodes) { + for (AuditFlowNodeRequest node : nodes) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO audit_flow_node (flow_id, node_code, node_name, sort_no, status) VALUES (?, ?, ?, ?, 'ENABLED')", + Statement.RETURN_GENERATED_KEYS + ); + ps.setLong(1, flowId); + ps.setString(2, node.getNodeCode()); + ps.setString(3, node.getNodeName()); + ps.setInt(4, node.getSortNo()); + return ps; + }, keyHolder); + Number key = keyHolder.getKey(); + Long flowNodeId = key == null ? null : key.longValue(); + if (flowNodeId != null && node.getAssigneeType() != null && !node.getAssigneeType().trim().isEmpty()) { + jdbcTemplate.update( + "INSERT INTO audit_flow_node_assignee (flow_node_id, assignee_type, assignee_ref_id) VALUES (?, ?, ?)", + flowNodeId, + node.getAssigneeType(), + node.getAssigneeRefId() + ); + } + } + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/audit/service/AuditService.java b/backend/src/main/java/com/writeoff/module/audit/service/AuditService.java new file mode 100644 index 0000000..bc270a7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/audit/service/AuditService.java @@ -0,0 +1,708 @@ +package com.writeoff.module.audit.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.audit.dto.AuditActionRequest; +import com.writeoff.module.audit.dto.AuditMaterialItemRejectRequest; +import com.writeoff.module.audit.dto.AuditMaterialModuleApproveRequest; +import com.writeoff.module.audit.dto.BatchAuditActionRequest; +import com.writeoff.module.audit.dto.BatchRemindRequest; +import com.writeoff.module.audit.dto.TransferAuditTaskRequest; +import com.writeoff.module.audit.model.AuditNode; +import com.writeoff.module.audit.model.AuditFlowNodeInfo; +import com.writeoff.module.audit.model.AuditTask; +import com.writeoff.module.audit.model.AuditTaskStatus; +import com.writeoff.module.audit.repository.AuditTaskRepository; +import com.writeoff.module.notification.dto.DispatchNotificationRequest; +import com.writeoff.module.notification.service.NotificationDispatchService; +import com.writeoff.module.meeting.model.MeetingAuditStatus; +import com.writeoff.module.meeting.model.MeetingMaterial; +import com.writeoff.module.expert.service.ExpertService; +import com.writeoff.module.meeting.service.MeetingMaterialService; +import com.writeoff.module.meeting.service.MeetingService; +import com.writeoff.module.scheduler.service.AsyncJobService; +import com.writeoff.module.system.service.DataPermissionService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@Service +public class AuditService { + private static final Logger log = LoggerFactory.getLogger(AuditService.class); + private final AuditTaskRepository auditTaskRepository; + private final MeetingService meetingService; + private final MeetingMaterialService meetingMaterialService; + private final AsyncJobService asyncJobService; + private final AuditFlowConfigService auditFlowConfigService; + private final DataPermissionService dataPermissionService; + private final NotificationDispatchService notificationDispatchService; + private final JdbcTemplate jdbcTemplate; + private final ExpertService expertService; + private final Map actionIdempotency = new ConcurrentHashMap<>(); + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Autowired + public AuditService(AuditTaskRepository auditTaskRepository, MeetingService meetingService, MeetingMaterialService meetingMaterialService, AsyncJobService asyncJobService, AuditFlowConfigService auditFlowConfigService, DataPermissionService dataPermissionService, NotificationDispatchService notificationDispatchService, JdbcTemplate jdbcTemplate, ExpertService expertService) { + this.auditTaskRepository = auditTaskRepository; + this.meetingService = meetingService; + this.meetingMaterialService = meetingMaterialService; + this.asyncJobService = asyncJobService; + this.auditFlowConfigService = auditFlowConfigService; + this.dataPermissionService = dataPermissionService; + this.notificationDispatchService = notificationDispatchService; + this.jdbcTemplate = jdbcTemplate; + this.expertService = expertService; + } + + public AuditService(AuditTaskRepository auditTaskRepository, MeetingService meetingService, AsyncJobService asyncJobService) { + this(auditTaskRepository, meetingService, null, asyncJobService, null, null, null, null, null); + } + + public PageResult listTasks(boolean mine) { + List filtered = filterTasks(mine, null, null); + return new PageResult<>(filtered, filtered.size(), 1, filtered.size() == 0 ? 20 : filtered.size()); + } + + public PageResult listTasks(boolean mine, String scope) { + List filtered = filterTasks(mine, scope, null); + return new PageResult<>(filtered, filtered.size(), 1, filtered.size() == 0 ? 20 : filtered.size()); + } + + public PageResult listTasks( + boolean mine, + String scope, + Long meetingId, + Integer pageNo, + Integer pageSize, + String sortBy, + String order + ) { + List filtered = filterTasks(mine, scope, meetingId); + sortTasks(filtered, sortBy, order); + int normalizedPageNo = pageNo == null || pageNo < 1 ? 1 : pageNo; + int normalizedPageSize = pageSize == null || pageSize < 1 ? 20 : Math.min(pageSize, 200); + int from = Math.max((normalizedPageNo - 1) * normalizedPageSize, 0); + if (from >= filtered.size()) { + return new PageResult<>(Collections.emptyList(), filtered.size(), normalizedPageNo, normalizedPageSize); + } + int to = Math.min(from + normalizedPageSize, filtered.size()); + return new PageResult<>(filtered.subList(from, to), filtered.size(), normalizedPageNo, normalizedPageSize); + } + + private List filterTasks(boolean mine, String scope, Long meetingId) { + List list = auditTaskRepository.findAll(); + if (dataPermissionService != null) { + DataPermissionService.DataScope dataScope = dataPermissionService.resolveCurrentUserScope(); + Set meetingIds = list.stream().map(AuditTask::getMeetingId).collect(Collectors.toCollection(HashSet::new)); + Map meetingCreatorMap = dataPermissionService.listMeetingCreators(meetingIds); + Map meetingProjectMap = dataPermissionService.listMeetingProjectIds(meetingIds); + Set projectIds = new HashSet<>(meetingProjectMap.values()); + Map projectCreatorMap = dataPermissionService.listProjectCreators(projectIds); + list = list.stream() + .filter(task -> { + try { + Long projectId = meetingProjectMap.get(task.getMeetingId()); + Long meetingCreatedBy = meetingCreatorMap.get(task.getMeetingId()); + Long projectCreatedBy = projectId == null ? null : projectCreatorMap.get(projectId); + return dataPermissionService.canAccessMeeting(task.getMeetingId(), projectId, meetingCreatedBy, projectCreatedBy, dataScope); + } catch (Exception e) { + return false; + } + }) + .collect(Collectors.toList()); + } + if (meetingId != null) { + list = list.stream() + .filter(task -> meetingId.equals(task.getMeetingId())) + .collect(Collectors.toList()); + } + if (mine) { + Long currentUserId = AuthContext.userId(); + String normalizedScope = String.valueOf(scope == null ? "" : scope).trim().toUpperCase(Locale.ROOT); + if ("HANDLED_MINE".equals(normalizedScope)) { + // 我处理过的:我曾是处理人,且任务已不在待处理状态 + list = list.stream() + .filter(task -> task.getAssigneeUserId() != null && task.getAssigneeUserId().equals(currentUserId)) + .filter(task -> task.getStatus() != AuditTaskStatus.PENDING) + .collect(Collectors.toList()); + } else { + // 默认:待我处理 + list = list.stream() + .filter(task -> task.getAssigneeUserId() != null && task.getAssigneeUserId().equals(currentUserId)) + .filter(task -> task.getStatus() == AuditTaskStatus.PENDING) + .collect(Collectors.toList()); + } + } + fillAssigneeUserName(list); + fillFlowNodes(list); + return new ArrayList<>(list); + } + + private void fillAssigneeUserName(List list) { + if (list == null || list.isEmpty() || jdbcTemplate == null) { + return; + } + Set userIds = list.stream() + .map(AuditTask::getAssigneeUserId) + .filter(id -> id != null && id > 0) + .collect(Collectors.toSet()); + if (userIds.isEmpty()) { + return; + } + String placeholders = userIds.stream().map(id -> "?").collect(Collectors.joining(",")); + List args = new ArrayList<>(); + String sql; + if (AuthContext.scope() == AuthScope.PLATFORM) { + sql = "SELECT id, user_name FROM platform_user WHERE is_deleted=0 AND id IN (" + placeholders + ")"; + args.addAll(userIds); + } else { + sql = "SELECT id, user_name FROM sys_user WHERE tenant_id=? AND is_deleted=0 AND id IN (" + placeholders + ")"; + args.add(tenantId()); + args.addAll(userIds); + } + List> rows = jdbcTemplate.queryForList(sql, args.toArray()); + Map userNameMap = new HashMap<>(); + for (Map row : rows) { + Number idNum = (Number) row.get("id"); + if (idNum == null) { + continue; + } + Long id = idNum.longValue(); + String userName = String.valueOf(row.get("user_name") == null ? "" : row.get("user_name")).trim(); + if (!userName.isEmpty()) { + userNameMap.put(id, userName); + } + } + for (AuditTask task : list) { + Long assigneeUserId = task.getAssigneeUserId(); + if (assigneeUserId == null || assigneeUserId <= 0) { + continue; + } + String userName = userNameMap.get(assigneeUserId); + if (userName != null && !userName.isEmpty()) { + task.setAssigneeUserName(userName); + } + } + } + + private void fillFlowNodes(List list) { + if (list == null || list.isEmpty()) { + return; + } + List nodes = buildFlowNodes(); + if (nodes.isEmpty()) { + return; + } + for (AuditTask task : list) { + task.setFlowNodes(nodes); + } + } + + private List buildFlowNodes() { + List enabledNodes; + if (auditFlowConfigService == null) { + enabledNodes = new ArrayList<>(); + enabledNodes.add(AuditNode.INIT_REVIEW); + enabledNodes.add(AuditNode.RE_REVIEW); + enabledNodes.add(AuditNode.FINAL_REVIEW); + } else { + enabledNodes = auditFlowConfigService.getEnabledNodes(tenantId()); + } + List result = new ArrayList<>(); + for (int i = 0; i < enabledNodes.size(); i++) { + AuditNode node = enabledNodes.get(i); + if (node == null) { + continue; + } + String code = node.name(); + result.add(new AuditFlowNodeInfo(null, code, code, i + 1, "ENABLED", null, null)); + } + return result.stream().filter(Objects::nonNull).collect(Collectors.toList()); + } + + private void sortTasks(List list, String sortBy, String order) { + String normalizedSortBy = String.valueOf(sortBy == null ? "lastActionAt" : sortBy).trim().toLowerCase(Locale.ROOT); + String normalizedOrder = String.valueOf(order == null ? "desc" : order).trim().toLowerCase(Locale.ROOT); + Comparator comparator; + if ("id".equals(normalizedSortBy)) { + comparator = Comparator.comparing(task -> task.getId() == null ? 0L : task.getId()); + } else if ("sladeadlineat".equals(normalizedSortBy)) { + comparator = Comparator.comparing(task -> String.valueOf(task.getSlaDeadlineAt() == null ? "" : task.getSlaDeadlineAt())); + } else { + comparator = Comparator + .comparing((AuditTask task) -> String.valueOf(task.getLastActionAt() == null ? "" : task.getLastActionAt())) + .thenComparing(task -> task.getId() == null ? 0L : task.getId()); + } + if (!"asc".equals(normalizedOrder)) { + comparator = comparator.reversed(); + } + list.sort(comparator); + } + + public PageResult listTasks() { + return listTasks(false); + } + + public Map approve(Long taskId, AuditActionRequest request) { + AuditTask task = getPendingTask(taskId, request.getIdempotencyKey()); + task.setStatus(AuditTaskStatus.APPROVED); + task.setOpinion(request.getOpinion()); + auditTaskRepository.save(task); + Long meetingId = task.getMeetingId(); + + AuditNode nextNode; + if (auditFlowConfigService == null) { + if (task.getNode() == AuditNode.INIT_REVIEW) { + nextNode = AuditNode.RE_REVIEW; + } else if (task.getNode() == AuditNode.RE_REVIEW) { + nextNode = AuditNode.FINAL_REVIEW; + } else { + nextNode = null; + } + } else { + nextNode = auditFlowConfigService.nextNode(tenantId(), task.getNode()); + } + if (nextNode == null) { + meetingService.updateAuditStatus(meetingId, MeetingAuditStatus.APPROVED); + asyncJobService.enqueue("EXPORT_REPORT", "meetingId=" + meetingId, "job-export-report-" + meetingId); + triggerAuditNotification(task, request.getOpinion(), "AUDIT_APPROVED"); + } else { + Long nextAssignee = auditFlowConfigService == null ? null : auditFlowConfigService.resolveAssigneeUserId(tenantId(), nextNode); + auditTaskRepository.save(new AuditTask(null, meetingId, nextNode, nextAssignee, AuditTaskStatus.PENDING, "")); + // 同步会议当前节点和审核人信息,保证前端展示与任务一致 + meetingService.updateCurrentAuditNode(meetingId, nextNode.name(), nextAssignee); + } + Map result = new LinkedHashMap<>(); + result.put("taskId", task.getId()); + result.put("nodeStatus", task.getStatus().name()); + return result; + } + + public Map reject(Long taskId, AuditActionRequest request) { + AuditTask task = getPendingTask(taskId, request.getIdempotencyKey()); + task.setStatus(AuditTaskStatus.REJECTED); + task.setOpinion(request.getOpinion()); + auditTaskRepository.save(task); + Long meetingId = task.getMeetingId(); + + if (task.getNode() == AuditNode.INIT_REVIEW) { + // 初审拒绝:整体审核结束,会议审核状态置为已拒绝 + meetingService.updateAuditStatus(meetingId, MeetingAuditStatus.REJECTED); + triggerAuditNotification(task, request.getOpinion(), "AUDIT_REJECTED"); + } else { + // 复审 / 终审拒绝:整体回到初审阶段,重新创建初审任务 + AuditNode firstNode; + Long assigneeUserId; + if (auditFlowConfigService == null) { + firstNode = AuditNode.INIT_REVIEW; + assigneeUserId = null; + } else { + Long tenantId = tenantId(); + firstNode = auditFlowConfigService.firstNode(tenantId); + assigneeUserId = auditFlowConfigService.resolveAssigneeUserId(tenantId, firstNode); + } + + meetingService.updateAuditStatus(meetingId, MeetingAuditStatus.IN_REVIEW); + meetingService.updateCurrentAuditNode(meetingId, firstNode.name(), assigneeUserId); + auditTaskRepository.save(new AuditTask( + null, + meetingId, + firstNode, + assigneeUserId, + AuditTaskStatus.PENDING, + "" + )); + } + Map result = new LinkedHashMap<>(); + result.put("taskId", task.getId()); + result.put("nodeStatus", task.getStatus().name()); + return result; + } + + public Map back(Long taskId, AuditActionRequest request) { + AuditTask task = getPendingTask(taskId, request.getIdempotencyKey()); + task.setStatus(AuditTaskStatus.REJECTED); + task.setOpinion("退回修改:" + request.getOpinion()); + auditTaskRepository.save(task); + meetingService.updateAuditStatus(task.getMeetingId(), MeetingAuditStatus.PENDING); + Map result = new LinkedHashMap<>(); + result.put("taskId", task.getId()); + result.put("nodeStatus", "RETURNED"); + return result; + } + + public Map exportOpinions() { + if (dataPermissionService != null && !dataPermissionService.canExportCurrentUser()) { + throw new BusinessException(20001, "当前账号无导出权限"); + } + List list = listTasks(false).getList(); + Map data = new LinkedHashMap<>(); + data.put("total", list.size()); + data.put("records", list); + return data; + } + + public Map readTaskMaterial(Long taskId, String moduleCode) { + AuditTask task = auditTaskRepository.findById(taskId) + .orElseThrow(() -> new BusinessException(10003, "审核任务不存在")); + if (meetingMaterialService == null) { + throw new BusinessException(10001, "资料服务不可用"); + } + MeetingMaterial material = meetingMaterialService.current(task.getMeetingId(), moduleCode); + Map data = new LinkedHashMap<>(); + data.put("taskId", taskId); + data.put("meetingId", task.getMeetingId()); + data.put("moduleCode", moduleCode); + data.put("material", material); + data.put("materialItems", meetingMaterialService.listMaterialReviewItems(task.getMeetingId(), moduleCode)); + data.put("itemReviews", meetingMaterialService.listMaterialItemReviews(taskId, task.getNode().name(), moduleCode)); + attachBasicInfoExpertSummaries(data, material, moduleCode); + return data; + } + + private Map emptyBasicInfoExpertsPayload() { + Map m = new LinkedHashMap<>(); + m.put("chairman", Collections.emptyList()); + m.put("speaker", Collections.emptyList()); + m.put("host", Collections.emptyList()); + m.put("discussionGuest", Collections.emptyList()); + return m; + } + + private List parseExpertIdListFromJson(Object raw) { + List out = new ArrayList<>(); + if (!(raw instanceof List)) { + return out; + } + for (Object x : (List) raw) { + long v = 0L; + if (x instanceof Number) { + v = ((Number) x).longValue(); + } else if (x != null) { + try { + v = Long.parseLong(String.valueOf(x).trim()); + } catch (NumberFormatException ignored) { + v = 0L; + } + } + if (v > 0) { + out.add(v); + } + } + return out; + } + + private List> toExpertDisplayRows(List orderedIds, Map> displayMap) { + List> rows = new ArrayList<>(); + for (Long id : orderedIds) { + if (id == null || id <= 0) { + continue; + } + Map row = new LinkedHashMap<>(); + row.put("expertId", id); + Map found = displayMap.get(id); + if (found != null) { + row.put("expertName", found.get("expertName")); + row.put("hospital", found.get("hospital")); + } else { + row.put("expertName", null); + row.put("hospital", null); + } + rows.add(row); + } + return rows; + } + + @SuppressWarnings("unchecked") + private void attachBasicInfoExpertSummaries(Map data, MeetingMaterial material, String moduleCode) { + if (!"BASIC_INFO".equals(moduleCode)) { + return; + } + if (material == null || material.getContentJson() == null || material.getContentJson().trim().isEmpty()) { + data.put("basicInfoExperts", emptyBasicInfoExpertsPayload()); + return; + } + try { + Map parsed = objectMapper.readValue(material.getContentJson(), LinkedHashMap.class); + List chairman = parseExpertIdListFromJson(parsed.get("chairmanExpertIds")); + List speaker = parseExpertIdListFromJson(parsed.get("speakerExpertIds")); + List host = parseExpertIdListFromJson(parsed.get("hostExpertIds")); + List discussionGuest = parseExpertIdListFromJson(parsed.get("discussionGuestExpertIds")); + Set all = new HashSet<>(); + all.addAll(chairman); + all.addAll(speaker); + all.addAll(host); + all.addAll(discussionGuest); + Map> displayMap = expertService == null + ? Collections.emptyMap() + : expertService.mapExpertDisplayByIds(all); + Map payload = new LinkedHashMap<>(); + payload.put("chairman", toExpertDisplayRows(chairman, displayMap)); + payload.put("speaker", toExpertDisplayRows(speaker, displayMap)); + payload.put("host", toExpertDisplayRows(host, displayMap)); + payload.put("discussionGuest", toExpertDisplayRows(discussionGuest, displayMap)); + data.put("basicInfoExperts", payload); + } catch (Exception e) { + log.warn("readTaskMaterial BASIC_INFO expert display parse failed taskId={}", data.get("taskId"), e); + data.put("basicInfoExperts", emptyBasicInfoExpertsPayload()); + } + } + + public Map approveMaterialModule(Long taskId, AuditMaterialModuleApproveRequest request) { + checkIdempotencyOnly(request.getIdempotencyKey(), "material-module-approve"); + AuditTask task = requirePendingTask(taskId); + if (meetingMaterialService == null) { + throw new BusinessException(10001, "资料服务不可用"); + } + List> items = meetingMaterialService.listMaterialReviewItems(task.getMeetingId(), request.getModuleCode()); + + // 查出当前已有 REJECTED 状态的 itemKey,批量通过时跳过它们,避免覆盖单项不通过结果 + List> existingReviews = meetingMaterialService.listMaterialItemReviews( + taskId, task.getNode().name(), request.getModuleCode()); + Set rejectedKeys = new HashSet<>(); + for (Map review : existingReviews) { + String result = String.valueOf(review.get("reviewResult") == null ? "" : review.get("reviewResult")).trim().toUpperCase(Locale.ROOT); + if ("REJECTED".equals(result)) { + rejectedKeys.add(String.valueOf(review.get("itemKey") == null ? "" : review.get("itemKey"))); + } + } + List> approveItems = new ArrayList<>(); + for (Map item : items) { + String itemKey = String.valueOf(item.get("itemKey") == null ? "" : item.get("itemKey")); + if (!rejectedKeys.contains(itemKey)) { + approveItems.add(item); + } + } + + int affected = meetingMaterialService.saveMaterialItemReviewRecords( + task.getMeetingId(), + taskId, + task.getNode().name(), + request.getModuleCode(), + approveItems, + "APPROVED", + null, + AuthContext.userId() + ); + Map data = new LinkedHashMap<>(); + data.put("taskId", taskId); + data.put("moduleCode", request.getModuleCode()); + data.put("reviewNode", task.getNode().name()); + data.put("itemCount", items.size()); + data.put("skippedRejected", rejectedKeys.size()); + data.put("savedCount", affected); + return data; + } + + public Map rejectMaterialItem(Long taskId, AuditMaterialItemRejectRequest request) { + checkIdempotencyOnly(request.getIdempotencyKey(), "material-item-reject"); + AuditTask task = requirePendingTask(taskId); + if (meetingMaterialService == null) { + throw new BusinessException(10001, "资料服务不可用"); + } + Map item = new LinkedHashMap<>(); + item.put("itemKey", request.getItemKey()); + item.put("itemLabel", request.getItemLabel()); + int affected = meetingMaterialService.saveMaterialItemReviewRecords( + task.getMeetingId(), + taskId, + task.getNode().name(), + request.getModuleCode(), + Collections.singletonList(item), + "REJECTED", + request.getReason(), + AuthContext.userId() + ); + Map data = new LinkedHashMap<>(); + data.put("taskId", taskId); + data.put("moduleCode", request.getModuleCode()); + data.put("itemKey", request.getItemKey()); + data.put("reviewNode", task.getNode().name()); + data.put("savedCount", affected); + return data; + } + + public Map transfer(Long taskId, TransferAuditTaskRequest request) { + AuditTask task = getPendingTask(taskId, request.getIdempotencyKey()); + Long operatorUserId = AuthContext.userId(); + auditTaskRepository.transfer(taskId, request.getToUserId(), request.getReason(), operatorUserId); + Map data = new LinkedHashMap<>(); + data.put("taskId", taskId); + data.put("fromUserId", task.getAssigneeUserId()); + data.put("toUserId", request.getToUserId()); + data.put("status", "TRANSFERRED"); + return data; + } + + public Map batchRemind(BatchRemindRequest request) { + checkIdempotencyOnly(request.getIdempotencyKey(), "batch-remind"); + List taskIds = request.getTaskIds(); + if (taskIds == null || taskIds.isEmpty()) { + taskIds = listTasks(false).getList().stream() + .filter(t -> t.getStatus() == AuditTaskStatus.PENDING) + .map(AuditTask::getId) + .collect(Collectors.toList()); + } + Long operatorUserId = AuthContext.userId(); + int count = auditTaskRepository.batchRemind(taskIds, operatorUserId); + for (Long taskId : taskIds) { + asyncJobService.enqueue( + "AUDIT_REMIND", + "taskId=" + taskId + "&operatorUserId=" + (operatorUserId == null ? 0L : operatorUserId), + "job-audit-remind-" + taskId + "-" + request.getIdempotencyKey() + ); + } + Map data = new LinkedHashMap<>(); + data.put("taskCount", taskIds.size()); + data.put("acceptedCount", count); + return data; + } + + public Map slaStat() { + return auditTaskRepository.slaStat(); + } + + public Map batchApprove(BatchAuditActionRequest request) { + checkIdempotencyOnly(request.getIdempotencyKey(), "batch-approve"); + List taskIds = request.getTaskIds(); + int successCount = 0; + List failedTaskIds = new ArrayList<>(); + List errors = new ArrayList<>(); + + for (Long taskId : taskIds) { + try { + AuditActionRequest actionRequest = new AuditActionRequest(); + actionRequest.setIdempotencyKey(request.getIdempotencyKey() + "-approve-" + taskId); + actionRequest.setOpinion(request.getOpinion()); + approve(taskId, actionRequest); + successCount++; + } catch (Exception e) { + failedTaskIds.add(taskId); + errors.add("任务" + taskId + ": " + e.getMessage()); + } + } + + Map result = new LinkedHashMap<>(); + result.put("totalCount", taskIds.size()); + result.put("successCount", successCount); + result.put("failCount", failedTaskIds.size()); + result.put("failedTaskIds", failedTaskIds); + result.put("errors", errors); + return result; + } + + public Map batchReject(BatchAuditActionRequest request) { + checkIdempotencyOnly(request.getIdempotencyKey(), "batch-reject"); + List taskIds = request.getTaskIds(); + int successCount = 0; + List failedTaskIds = new ArrayList<>(); + List errors = new ArrayList<>(); + + for (Long taskId : taskIds) { + try { + AuditActionRequest actionRequest = new AuditActionRequest(); + actionRequest.setIdempotencyKey(request.getIdempotencyKey() + "-reject-" + taskId); + actionRequest.setOpinion(request.getOpinion()); + reject(taskId, actionRequest); + successCount++; + } catch (Exception e) { + failedTaskIds.add(taskId); + errors.add("任务" + taskId + ": " + e.getMessage()); + } + } + + Map result = new LinkedHashMap<>(); + result.put("totalCount", taskIds.size()); + result.put("successCount", successCount); + result.put("failCount", failedTaskIds.size()); + result.put("failedTaskIds", failedTaskIds); + result.put("errors", errors); + return result; + } + + private AuditTask getPendingTask(Long taskId, String idempotencyKey) { + if (actionIdempotency.containsKey(idempotencyKey)) { + throw new BusinessException(10002, "请求幂等冲突"); + } + actionIdempotency.put(idempotencyKey, taskId); + + AuditTask task = auditTaskRepository.findById(taskId) + .orElseThrow(() -> new BusinessException(10003, "审核任务不存在")); + if (task.getStatus() != AuditTaskStatus.PENDING) { + throw new BusinessException(30003, "审核任务已处理"); + } + return task; + } + + private void checkIdempotencyOnly(String idempotencyKey, String marker) { + if (actionIdempotency.containsKey(idempotencyKey)) { + throw new BusinessException(10002, "请求幂等冲突"); + } + actionIdempotency.put(idempotencyKey, -1L); + actionIdempotency.put(marker + ":" + idempotencyKey, -1L); + } + + private AuditTask requirePendingTask(Long taskId) { + AuditTask task = auditTaskRepository.findById(taskId) + .orElseThrow(() -> new BusinessException(10003, "审核任务不存在")); + if (task.getStatus() != AuditTaskStatus.PENDING) { + throw new BusinessException(30003, "审核任务已处理"); + } + return task; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private void triggerAuditNotification(AuditTask task, String opinion, String eventCode) { + if (notificationDispatchService == null) { + return; + } + try { + com.writeoff.module.meeting.model.Meeting meeting = meetingService.getById(task.getMeetingId()); + Map vars = new LinkedHashMap(); + vars.put("meetingId", task.getMeetingId()); + vars.put("meetingTopic", meeting.getTopic() == null ? "" : meeting.getTopic()); + vars.put("auditNode", task.getNode() == null ? "" : task.getNode().name()); + vars.put("auditTaskId", task.getId() == null ? 0L : task.getId()); + vars.put("result", "AUDIT_APPROVED".equals(eventCode) ? "通过" : "不通过"); + vars.put("opinion", opinion == null ? "" : opinion); + + DispatchNotificationRequest dispatchRequest = new DispatchNotificationRequest(); + dispatchRequest.setIdempotencyKey("audit-auto-notify-" + eventCode + "-" + (task.getId() == null ? 0L : task.getId())); + dispatchRequest.setEventCode(eventCode); + dispatchRequest.setBizType("MEETING"); + dispatchRequest.setBizId("meeting-" + task.getMeetingId()); + dispatchRequest.setVariablesJson(objectMapper.writeValueAsString(vars)); + notificationDispatchService.dispatch(dispatchRequest); + } catch (BusinessException ex) { + if (ex.getCode() != ErrorCodes.RESOURCE_NOT_FOUND) { + log.warn("自动触发审核通知失败, eventCode={}, taskId={}, code={}, msg={}", + eventCode, task.getId(), ex.getCode(), ex.getMessage()); + } + } catch (Exception ex) { + log.warn("自动触发审核通知异常, eventCode={}, taskId={}", eventCode, task.getId(), ex); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/controller/AuthController.java b/backend/src/main/java/com/writeoff/module/auth/controller/AuthController.java new file mode 100644 index 0000000..6365432 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/controller/AuthController.java @@ -0,0 +1,749 @@ +package com.writeoff.module.auth.controller; + +import com.writeoff.common.api.ApiErrorResponse; +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.auth.dto.LoginRequest; +import com.writeoff.module.auth.dto.PasswordSetupCompleteRequest; +import com.writeoff.module.auth.dto.SwitchTenantRequest; +import com.writeoff.module.auth.dto.TenantLoginRequest; +import com.writeoff.module.auth.model.TenantSwitchOption; +import com.writeoff.module.auth.service.RefreshTokenService; +import com.writeoff.module.system.model.ProfilePreferencesInfo; +import com.writeoff.module.system.service.PlatformIamService; +import com.writeoff.module.system.service.SystemUserService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import com.writeoff.security.CaptchaService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.JwtTokenService; +import com.writeoff.security.LoginPasswordCryptoService; +import com.writeoff.security.LoginAttemptService; +import com.writeoff.security.PasswordCodecService; +import com.writeoff.security.PasswordSetupService; +import com.writeoff.security.PermissionService; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.servlet.http.Cookie; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.validation.Valid; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + private final JdbcTemplate jdbcTemplate; + private final JwtTokenService jwtTokenService; + private final PermissionService permissionService; + private final SystemUserService systemUserService; + private final PlatformIamService platformIamService; + private final RefreshTokenService refreshTokenService; + private final LoginAttemptService loginAttemptService; + private final CaptchaService captchaService; + private final LoginPasswordCryptoService loginPasswordCryptoService; + private final PasswordCodecService passwordCodecService; + private final PasswordSetupService passwordSetupService; + private final String refreshCookieName; + private final boolean refreshCookieSecure; + private final String refreshCookieSameSite; + + public AuthController(JdbcTemplate jdbcTemplate, + JwtTokenService jwtTokenService, + PermissionService permissionService, + SystemUserService systemUserService, + PlatformIamService platformIamService, + RefreshTokenService refreshTokenService, + LoginAttemptService loginAttemptService, + CaptchaService captchaService, + LoginPasswordCryptoService loginPasswordCryptoService, + PasswordCodecService passwordCodecService, + PasswordSetupService passwordSetupService, + @Value("${app.security.refresh-cookie-name:refreshToken}") String refreshCookieName, + @Value("${app.security.refresh-cookie-secure:false}") boolean refreshCookieSecure, + @Value("${app.security.refresh-cookie-same-site:Lax}") String refreshCookieSameSite) { + this.jdbcTemplate = jdbcTemplate; + this.jwtTokenService = jwtTokenService; + this.permissionService = permissionService; + this.systemUserService = systemUserService; + this.platformIamService = platformIamService; + this.refreshTokenService = refreshTokenService; + this.loginAttemptService = loginAttemptService; + this.captchaService = captchaService; + this.loginPasswordCryptoService = loginPasswordCryptoService; + this.passwordCodecService = passwordCodecService; + this.passwordSetupService = passwordSetupService; + this.refreshCookieName = refreshCookieName; + this.refreshCookieSecure = refreshCookieSecure; + this.refreshCookieSameSite = refreshCookieSameSite; + } + + @GetMapping("/password-public-key") + public ApiResponse> passwordPublicKey() { + return ApiResponse.success(Collections.singletonMap("publicKey", loginPasswordCryptoService.getEncodedPublicKey())); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody @Valid TenantLoginRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + // 验证码校验 + if (!captchaService.verify(request.getCaptchaId(), request.getCaptchaCode())) { + Map errors = new LinkedHashMap<>(); + errors.put("captcha", "invalid"); + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) + .body(ApiErrorResponse.of(11010, "验证码错误或已过期", errors)); + } + // 登录锁定检查 + String lockKey = "tenant:" + request.getTenantCode() + ":" + request.getPhone(); + if (loginAttemptService.isLocked(lockKey)) { + long remaining = loginAttemptService.getRemainingLockSeconds(lockKey); + Map errors = new LinkedHashMap<>(); + errors.put("lockRemainingSeconds", String.valueOf(remaining)); + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) + .body(ApiErrorResponse.of(11005, "账号已被锁定,请" + (remaining / 60 + 1) + "分钟后重试", errors)); + } + List> rows = jdbcTemplate.queryForList( + "SELECT u.id, u.tenant_id, u.user_name, u.phone, u.status, u.password_hash, u.valid_from, u.valid_to, " + + "t.tenant_code, t.tenant_name, t.status AS tenant_status " + + "FROM sys_user u JOIN tenant t ON u.tenant_id=t.id " + + "WHERE u.phone=? AND t.tenant_code=? AND u.is_deleted=0 AND t.is_deleted=0 LIMIT 1", + request.getPhone(), + request.getTenantCode() + ); + if (rows.isEmpty()) { + return buildLoginFailResponse(lockKey); + } + Map row = rows.get(0); + String dbPassword = String.valueOf(row.get("password_hash")); + String rawPassword = resolveSubmittedPassword(request.getPassword()); + if (rawPassword == null || !passwordCodecService.matches(rawPassword, dbPassword)) { + return buildLoginFailResponse(lockKey); + } + // 登录成功,清除失败记录 + loginAttemptService.clearFailures(lockKey); + String status = String.valueOf(row.get("status")); + if (!"ENABLED".equals(status)) { + throw new BusinessException(11003, "会话失效"); + } + Long userId = ((Number) row.get("id")).longValue(); + Long tenantId = ((Number) row.get("tenant_id")).longValue(); + systemUserService.onSuccessfulLogin(userId, tenantId, request.getPhone(), rawPassword); + LocalDateTime now = LocalDateTime.now(); + LocalDateTime validFrom = toLocalDateTime(row.get("valid_from")); + LocalDateTime validTo = toLocalDateTime(row.get("valid_to")); + if (validFrom != null && now.isBefore(validFrom)) { + throw new BusinessException(11004, "账号尚未生效"); + } + if (validTo != null && now.isAfter(validTo)) { + throw new BusinessException(11004, "账号已过有效期"); + } + String tenantStatus = String.valueOf(row.get("tenant_status")); + if (!"ENABLED".equals(tenantStatus)) { + throw new BusinessException(11003, "租户已停用"); + } + Map issueResult = refreshTokenService.issueWithSession( + userId, + tenantId, + AuthScope.TENANT, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + Long sessionId = issueResult.get("sessionId") == null ? null : ((Number) issueResult.get("sessionId")).longValue(); + String refreshToken = String.valueOf(issueResult.get("refreshToken")); + String token = jwtTokenService.createTenantToken(userId, tenantId, request.getPhone(), sessionId); + setRefreshCookie(httpResponse, refreshToken, false); + Map data = buildTenantAuthData(userId, tenantId, String.valueOf(row.get("user_name")), request.getPhone(), token); + return ResponseEntity.ok(ApiResponse.success(data)); + } + + @PostMapping("/platform-login") + public ResponseEntity platformLogin(@RequestBody @Valid LoginRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + // 验证码校验 + if (!captchaService.verify(request.getCaptchaId(), request.getCaptchaCode())) { + Map errors = new LinkedHashMap<>(); + errors.put("captcha", "invalid"); + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) + .body(ApiErrorResponse.of(11010, "验证码错误或已过期", errors)); + } + // 登录锁定检查 + String lockKey = "platform:" + request.getPhone(); + if (loginAttemptService.isLocked(lockKey)) { + long remaining = loginAttemptService.getRemainingLockSeconds(lockKey); + Map errors = new LinkedHashMap<>(); + errors.put("lockRemainingSeconds", String.valueOf(remaining)); + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) + .body(ApiErrorResponse.of(11005, "账号已被锁定,请" + (remaining / 60 + 1) + "分钟后重试", errors)); + } + List> rows = jdbcTemplate.queryForList( + "SELECT id, user_name, phone, status, password_hash, valid_from, valid_to " + + "FROM platform_user WHERE phone=? AND is_deleted=0 LIMIT 1", + request.getPhone() + ); + if (rows.isEmpty()) { + return buildLoginFailResponse(lockKey); + } + Map row = rows.get(0); + String dbPassword = String.valueOf(row.get("password_hash")); + String rawPassword = resolveSubmittedPassword(request.getPassword()); + if (rawPassword == null || !passwordCodecService.matches(rawPassword, dbPassword)) { + return buildLoginFailResponse(lockKey); + } + // 登录成功,清除失败记录 + loginAttemptService.clearFailures(lockKey); + String status = String.valueOf(row.get("status")); + if (!"ENABLED".equals(status)) { + throw new BusinessException(11003, "会话失效"); + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime validFrom = toLocalDateTime(row.get("valid_from")); + LocalDateTime validTo = toLocalDateTime(row.get("valid_to")); + if (validFrom != null && now.isBefore(validFrom)) { + throw new BusinessException(11004, "账号尚未生效"); + } + if (validTo != null && now.isAfter(validTo)) { + throw new BusinessException(11004, "账号已过有效期"); + } + Long userId = ((Number) row.get("id")).longValue(); + platformIamService.onSuccessfulLogin(userId, rawPassword); + Map issueResult = refreshTokenService.issueWithSession( + userId, + null, + AuthScope.PLATFORM, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + Long sessionId = issueResult.get("sessionId") == null ? null : ((Number) issueResult.get("sessionId")).longValue(); + String refreshToken = String.valueOf(issueResult.get("refreshToken")); + String token = jwtTokenService.createPlatformToken(userId, request.getPhone(), sessionId); + setRefreshCookie(httpResponse, refreshToken, false); + Map data = buildPlatformAuthData(userId, String.valueOf(row.get("user_name")), request.getPhone(), token); + return ResponseEntity.ok(ApiResponse.success(data)); + } + + @GetMapping("/password-setup/verify") + public ApiResponse> verifyPasswordSetup(@RequestParam("tenantCode") String tenantCode, + @RequestParam("token") String token) { + return ApiResponse.success(passwordSetupService.verifyTenantPasswordSetupToken(tenantCode, token)); + } + + @PostMapping("/password-setup/complete") + public ApiResponse> completePasswordSetup(@RequestBody @Valid PasswordSetupCompleteRequest request) { + return ApiResponse.success(passwordSetupService.completeTenantPasswordSetup( + request.getTenantCode(), + request.getToken(), + request.getNewPassword() + )); + } + + /** + * 构建登录失败响应(含剩余尝试次数结构化数据)。 + */ + private ResponseEntity buildLoginFailResponse(String lockKey) { + LoginAttemptService.LoginAttemptStatus status = loginAttemptService.recordFailure(lockKey); + int remaining = status.getRemainingAttempts(); + Map errors = new LinkedHashMap<>(); + errors.put("remainingAttempts", String.valueOf(remaining)); + if (status.isLocked()) { + errors.put("lockRemainingSeconds", String.valueOf(status.getRemainingLockSeconds())); + } + String message = !status.isLocked() && remaining > 0 + ? "账号或密码错误,还可尝试" + remaining + "次" + : "账号或密码错误,账号已被锁定"; + return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY) + .body(ApiErrorResponse.of(11001, message, errors)); + } + + private String resolveSubmittedPassword(String submittedPassword) { + try { + return loginPasswordCryptoService.unwrapPassword(submittedPassword); + } catch (IllegalArgumentException ex) { + return null; + } + } + + @PostMapping("/refresh") + public ApiResponse> refresh(HttpServletRequest request, HttpServletResponse response) { + String currentRefreshToken = getCookieValue(request, refreshCookieName); + Map rotateResult = refreshTokenService.rotate(currentRefreshToken, request.getRemoteAddr(), request.getHeader("User-Agent")); + Long userId = ((Number) rotateResult.get("userId")).longValue(); + Long tenantId = rotateResult.get("tenantId") == null ? null : ((Number) rotateResult.get("tenantId")).longValue(); + AuthScope scope = (AuthScope) rotateResult.get("scope"); + String nextRefreshToken = String.valueOf(rotateResult.get("refreshToken")); + try { + Map data; + if (scope == AuthScope.TENANT) { + validateTenantSession(userId, tenantId); + String phone = jdbcTemplate.queryForObject( + "SELECT phone FROM sys_user WHERE id=? AND tenant_id=? AND is_deleted=0 LIMIT 1", + String.class, + userId, + tenantId + ); + String userName = jdbcTemplate.queryForObject( + "SELECT user_name FROM sys_user WHERE id=? AND tenant_id=? AND is_deleted=0 LIMIT 1", + String.class, + userId, + tenantId + ); + Long sessionId = rotateResult.get("sessionId") == null ? null : ((Number) rotateResult.get("sessionId")).longValue(); + String token = jwtTokenService.createTenantToken(userId, tenantId, phone, sessionId); + data = buildTenantAuthData(userId, tenantId, userName, phone, token); + } else { + validatePlatformSession(userId); + String phone = jdbcTemplate.queryForObject( + "SELECT phone FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1", + String.class, + userId + ); + String userName = jdbcTemplate.queryForObject( + "SELECT user_name FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1", + String.class, + userId + ); + Long sessionId = rotateResult.get("sessionId") == null ? null : ((Number) rotateResult.get("sessionId")).longValue(); + String token = jwtTokenService.createPlatformToken(userId, phone, sessionId); + data = buildPlatformAuthData(userId, userName, phone, token); + } + setRefreshCookie(response, nextRefreshToken, false); + return ApiResponse.success(data); + } catch (BusinessException ex) { + refreshTokenService.revokeAllByPrincipal(userId, tenantId, scope, "REFRESH_VALIDATE_FAILED"); + setRefreshCookie(response, "", true); + throw ex; + } + } + + @GetMapping("/switchable-tenants") + @RequirePermission(value = "tenant.switch", dataScope = DataScopeType.TENANT, auditAction = "AUTH_SWITCHABLE_TENANTS") + public ApiResponse> switchableTenants() { + Long currentUserId = AuthContext.userId(); + Long currentTenantId = AuthContext.requireTenantId(); + validateTenantSession(currentUserId, currentTenantId); + return ApiResponse.success(loadSwitchableTenants(currentUserId, currentTenantId)); + } + + @PostMapping("/switch-tenant") + @RequirePermission(value = "tenant.switch", dataScope = DataScopeType.TENANT, auditAction = "AUTH_SWITCH_TENANT") + public ApiResponse> switchTenant(@RequestBody @Valid SwitchTenantRequest request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + Long currentUserId = AuthContext.userId(); + Long currentTenantId = AuthContext.requireTenantId(); + validateTenantSession(currentUserId, currentTenantId); + + Map currentIdentity = loadCurrentTenantIdentity(currentUserId, currentTenantId); + Long targetTenantId = request.getTenantId(); + if (targetTenantId == null) { + throw new BusinessException(10001, "\u76ee\u6807\u79df\u6237\u4e0d\u80fd\u4e3a\u7a7a"); + } + Map targetIdentity = loadSwitchTarget( + targetTenantId, + String.valueOf(currentIdentity.get("tenant_switch_account_key")), + String.valueOf(currentIdentity.get("phone")), + String.valueOf(currentIdentity.get("password_hash")) + ); + + Long targetUserId = ((Number) targetIdentity.get("user_id")).longValue(); + validateTenantSession(targetUserId, targetTenantId); + + Map issueResult = refreshTokenService.issueWithSession( + targetUserId, + targetTenantId, + AuthScope.TENANT, + httpRequest.getRemoteAddr(), + httpRequest.getHeader("User-Agent") + ); + Long sessionId = issueResult.get("sessionId") == null ? null : ((Number) issueResult.get("sessionId")).longValue(); + String refreshToken = String.valueOf(issueResult.get("refreshToken")); + String phone = String.valueOf(targetIdentity.get("phone")); + String token = jwtTokenService.createTenantToken(targetUserId, targetTenantId, phone, sessionId); + setRefreshCookie(httpResponse, refreshToken, false); + return ApiResponse.success(buildTenantAuthData(targetUserId, targetTenantId, String.valueOf(targetIdentity.get("user_name")), phone, token)); + } + + @PostMapping("/logout") + public ApiResponse> logout(HttpServletRequest request, HttpServletResponse response) { + String token = getCookieValue(request, refreshCookieName); + refreshTokenService.revokeCurrent(token, "LOGOUT"); + setRefreshCookie(response, "", true); + Map data = new LinkedHashMap(); + data.put("ok", Boolean.TRUE); + return ApiResponse.success(data); + } + + @PostMapping("/logout-all") + public ApiResponse> logoutAll(HttpServletRequest request, HttpServletResponse response) { + String currentRefreshToken = getCookieValue(request, refreshCookieName); + Map rotateResult = refreshTokenService.rotate(currentRefreshToken, request.getRemoteAddr(), request.getHeader("User-Agent")); + Long userId = ((Number) rotateResult.get("userId")).longValue(); + Long tenantId = rotateResult.get("tenantId") == null ? null : ((Number) rotateResult.get("tenantId")).longValue(); + AuthScope scope = (AuthScope) rotateResult.get("scope"); + refreshTokenService.revokeAllByPrincipal(userId, tenantId, scope, "LOGOUT_ALL"); + setRefreshCookie(response, "", true); + Map data = new LinkedHashMap(); + data.put("ok", Boolean.TRUE); + return ApiResponse.success(data); + } + + private LocalDateTime toLocalDateTime(Object value) { + if (value == null) { + return null; + } + if (value instanceof LocalDateTime) { + return (LocalDateTime) value; + } + if (value instanceof java.sql.Timestamp) { + return ((java.sql.Timestamp) value).toLocalDateTime(); + } + if (value instanceof java.util.Date) { + return new java.sql.Timestamp(((java.util.Date) value).getTime()).toLocalDateTime(); + } + if (value instanceof String) { + String text = String.valueOf(value).trim(); + if (text.isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(text.replace(' ', 'T')); + } catch (Exception ignored) { + } + } + return null; + } + + private Map loadTenantInfo(Long tenantId) { + List> tenantRows = jdbcTemplate.queryForList( + "SELECT id, tenant_code, tenant_name, logo_url, status, is_deleted, created_by, created_at, updated_by, updated_at " + + "FROM tenant WHERE id=? LIMIT 1", + tenantId + ); + if (tenantRows.isEmpty()) { + return new LinkedHashMap(); + } + Map r = tenantRows.get(0); + Map tenant = new LinkedHashMap(); + tenant.put("id", r.get("id")); + tenant.put("tenantCode", r.get("tenant_code")); + tenant.put("tenantName", r.get("tenant_name")); + tenant.put("logoUrl", r.get("logo_url")); + tenant.put("status", r.get("status")); + tenant.put("isDeleted", r.get("is_deleted")); + tenant.put("createdBy", r.get("created_by")); + tenant.put("createdAt", formatDateTime(r.get("created_at"))); + tenant.put("updatedBy", r.get("updated_by")); + tenant.put("updatedAt", formatDateTime(r.get("updated_at"))); + return tenant; + } + + private String formatDateTime(Object value) { + LocalDateTime dt = toLocalDateTime(value); + if (dt == null) { + return ""; + } + return dt.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } + + private Map buildTenantAuthData(Long userId, Long tenantId, String userName, String phone, String token) { + Map tenant = loadTenantInfo(tenantId); + Map data = new LinkedHashMap(); + data.put("token", token); + data.put("scope", AuthScope.TENANT.name()); + data.put("userId", userId); + data.put("tenantId", tenantId); + data.put("tenantCode", tenant.get("tenantCode")); + data.put("tenantName", tenant.get("tenantName")); + data.put("tenant", tenant); + data.put("userName", userName); + data.put("roles", systemUserService.getUserRoles(userId, tenantId)); + data.put("permissions", permissionService.getPermissions(userId, tenantId)); + data.put("phone", phone); + data.put("appearance", loadTenantPreferences(userId, tenantId)); + return data; + } + + private Map buildPlatformAuthData(Long userId, String userName, String phone, String token) { + Map data = new LinkedHashMap(); + data.put("token", token); + data.put("scope", AuthScope.PLATFORM.name()); + data.put("userId", userId); + data.put("tenantId", null); + data.put("userName", userName); + data.put("roles", permissionService.getPlatformRoles(userId)); + data.put("permissions", permissionService.getPlatformPermissions(userId)); + data.put("phone", phone); + data.put("appearance", loadPlatformPreferences(userId)); + return data; + } + + private ProfilePreferencesInfo loadTenantPreferences(Long userId, Long tenantId) { + List> rows = jdbcTemplate.queryForList( + "SELECT ui_theme_mode, ui_density, ui_theme_scheme FROM sys_user WHERE id=? AND tenant_id=? AND is_deleted=0 LIMIT 1", + userId, + tenantId + ); + if (rows.isEmpty()) { + return new ProfilePreferencesInfo("SYSTEM", "COMFORTABLE", "SLATE"); + } + Map row = rows.get(0); + return new ProfilePreferencesInfo( + systemUserService.normalizeThemeMode(row.get("ui_theme_mode") == null ? null : String.valueOf(row.get("ui_theme_mode"))), + systemUserService.normalizeDensity(row.get("ui_density") == null ? null : String.valueOf(row.get("ui_density"))), + systemUserService.normalizeThemeScheme(row.get("ui_theme_scheme") == null ? null : String.valueOf(row.get("ui_theme_scheme"))) + ); + } + + private ProfilePreferencesInfo loadPlatformPreferences(Long userId) { + List> rows = jdbcTemplate.queryForList( + "SELECT ui_theme_mode, ui_density, ui_theme_scheme FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1", + userId + ); + if (rows.isEmpty()) { + return new ProfilePreferencesInfo("SYSTEM", "COMFORTABLE", "SLATE"); + } + Map row = rows.get(0); + return new ProfilePreferencesInfo( + normalizeThemeMode(row.get("ui_theme_mode")), + normalizeDensity(row.get("ui_density")), + normalizeThemeScheme(row.get("ui_theme_scheme")) + ); + } + + private String normalizeThemeMode(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ("LIGHT".equals(value) || "DARK".equals(value) || "SYSTEM".equals(value)) { + return value; + } + return "SYSTEM"; + } + + private String normalizeDensity(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ("COMPACT".equals(value) || "COMFORTABLE".equals(value)) { + return value; + } + return "COMFORTABLE"; + } + + private String normalizeThemeScheme(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ( + "SLATE".equals(value) || + "OCEAN".equals(value) || + "FOREST".equals(value) || + "GRAPHITE".equals(value) || + "AMBER".equals(value) || + "RUBY".equals(value) || + "MIST".equals(value) || + "SAGE".equals(value) || + "DAWN".equals(value) + ) { + return value; + } + return "SLATE"; + } + + private void validateTenantSession(Long userId, Long tenantId) { + List> rows = jdbcTemplate.queryForList( + "SELECT u.status, u.valid_from, u.valid_to, t.status AS tenant_status " + + "FROM sys_user u JOIN tenant t ON u.tenant_id=t.id " + + "WHERE u.id=? AND u.tenant_id=? AND u.is_deleted=0 AND t.is_deleted=0 LIMIT 1", + userId, + tenantId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + Map row = rows.get(0); + String userStatus = String.valueOf(row.get("status")); + String tenantStatus = String.valueOf(row.get("tenant_status")); + if (!"ENABLED".equals(userStatus) || !"ENABLED".equals(tenantStatus)) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime validFrom = toLocalDateTime(row.get("valid_from")); + LocalDateTime validTo = toLocalDateTime(row.get("valid_to")); + if (validFrom != null && now.isBefore(validFrom)) { + throw new BusinessException(ErrorCodes.ACCOUNT_EXPIRED, "账号尚未生效"); + } + if (validTo != null && now.isAfter(validTo)) { + throw new BusinessException(ErrorCodes.ACCOUNT_EXPIRED, "账号已过有效期"); + } + } + + private void validatePlatformSession(Long userId) { + List> rows = jdbcTemplate.queryForList( + "SELECT status, valid_from, valid_to FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1", + userId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + Map row = rows.get(0); + String userStatus = String.valueOf(row.get("status")); + if (!"ENABLED".equals(userStatus)) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime validFrom = toLocalDateTime(row.get("valid_from")); + LocalDateTime validTo = toLocalDateTime(row.get("valid_to")); + if (validFrom != null && now.isBefore(validFrom)) { + throw new BusinessException(ErrorCodes.ACCOUNT_EXPIRED, "账号尚未生效"); + } + if (validTo != null && now.isAfter(validTo)) { + throw new BusinessException(ErrorCodes.ACCOUNT_EXPIRED, "账号已过有效期"); + } + } + + private List loadSwitchableTenants(Long currentUserId, Long currentTenantId) { + Map currentIdentity = loadCurrentTenantIdentity(currentUserId, currentTenantId); + String switchAccountKey = normalizeTenantSwitchAccountKey(currentIdentity.get("tenant_switch_account_key")); + if (!switchAccountKey.isEmpty()) { + return jdbcTemplate.query( + "SELECT u.tenant_id, t.tenant_code, t.tenant_name, t.logo_url " + + "FROM sys_user u " + + "JOIN tenant t ON u.tenant_id=t.id " + + "WHERE u.tenant_switch_account_key=? AND u.is_deleted=0 AND u.status='ENABLED' " + + "AND (u.valid_from IS NULL OR u.valid_from<=NOW()) " + + "AND (u.valid_to IS NULL OR u.valid_to>=NOW()) " + + "AND t.is_deleted=0 AND t.status='ENABLED' " + + "ORDER BY CASE WHEN u.tenant_id=? THEN 0 ELSE 1 END, t.tenant_name ASC, t.id ASC", + (rs, n) -> new TenantSwitchOption( + rs.getLong("tenant_id"), + rs.getString("tenant_code"), + rs.getString("tenant_name"), + rs.getString("logo_url"), + rs.getLong("tenant_id") == currentTenantId + ), + switchAccountKey, + currentTenantId + ); + } + String phone = String.valueOf(currentIdentity.get("phone")); + String passwordHash = String.valueOf(currentIdentity.get("password_hash")); + return jdbcTemplate.query( + "SELECT u.tenant_id, t.tenant_code, t.tenant_name, t.logo_url " + + "FROM sys_user u " + + "JOIN tenant t ON u.tenant_id=t.id " + + "WHERE u.phone=? AND u.password_hash=? AND u.is_deleted=0 AND u.status='ENABLED' " + + "AND (u.valid_from IS NULL OR u.valid_from<=NOW()) " + + "AND (u.valid_to IS NULL OR u.valid_to>=NOW()) " + + "AND t.is_deleted=0 AND t.status='ENABLED' " + + "ORDER BY CASE WHEN u.tenant_id=? THEN 0 ELSE 1 END, t.tenant_name ASC, t.id ASC", + (rs, n) -> new TenantSwitchOption( + rs.getLong("tenant_id"), + rs.getString("tenant_code"), + rs.getString("tenant_name"), + rs.getString("logo_url"), + rs.getLong("tenant_id") == currentTenantId + ), + phone, + passwordHash, + currentTenantId + ); + } + + private Map loadCurrentTenantIdentity(Long userId, Long tenantId) { + List> rows = jdbcTemplate.queryForList( + "SELECT id, tenant_id, user_name, phone, password_hash, tenant_switch_account_key " + + "FROM sys_user WHERE id=? AND tenant_id=? AND is_deleted=0 LIMIT 1", + userId, + tenantId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "\u4f1a\u8bdd\u5931\u6548"); + } + return rows.get(0); + } + + private Map loadSwitchTarget(Long tenantId, String switchAccountKey, String phone, String passwordHash) { + String normalizedSwitchAccountKey = normalizeTenantSwitchAccountKey(switchAccountKey); + if (!normalizedSwitchAccountKey.isEmpty()) { + List> accountKeyRows = jdbcTemplate.queryForList( + "SELECT u.id AS user_id, u.tenant_id, u.user_name, u.phone, u.password_hash, u.tenant_switch_account_key " + + "FROM sys_user u " + + "JOIN tenant t ON u.tenant_id=t.id " + + "WHERE u.tenant_id=? AND u.tenant_switch_account_key=? " + + "AND u.is_deleted=0 AND u.status='ENABLED' AND t.is_deleted=0 AND t.status='ENABLED' " + + "LIMIT 1", + tenantId, + normalizedSwitchAccountKey + ); + if (!accountKeyRows.isEmpty()) { + return accountKeyRows.get(0); + } + } + List> rows = jdbcTemplate.queryForList( + "SELECT u.id AS user_id, u.tenant_id, u.user_name, u.phone, u.password_hash, u.tenant_switch_account_key " + + "FROM sys_user u " + + "JOIN tenant t ON u.tenant_id=t.id " + + "WHERE u.tenant_id=? AND u.phone=? AND u.password_hash=? " + + "AND u.is_deleted=0 AND u.status='ENABLED' AND t.is_deleted=0 AND t.status='ENABLED' " + + "LIMIT 1", + tenantId, + phone, + passwordHash + ); + if (rows.isEmpty()) { + throw new BusinessException(10003, "\u76ee\u6807\u79df\u6237\u4e0d\u53ef\u5207\u6362"); + } + return rows.get(0); + } + + private String normalizeTenantSwitchAccountKey(Object raw) { + return raw == null ? "" : String.valueOf(raw).trim(); + } + + private String getCookieValue(HttpServletRequest request, String cookieName) { + Cookie[] cookies = request.getCookies(); + if (cookies == null || cookies.length == 0) { + return null; + } + for (Cookie cookie : cookies) { + if (cookie == null) { + continue; + } + if (cookieName.equals(cookie.getName())) { + return cookie.getValue(); + } + } + return null; + } + + private void setRefreshCookie(HttpServletResponse response, String token, boolean clear) { + String sameSite = refreshCookieSameSite == null || refreshCookieSameSite.trim().isEmpty() + ? "Lax" + : refreshCookieSameSite.trim(); + StringBuilder sb = new StringBuilder(); + sb.append(refreshCookieName).append("=").append(clear ? "" : token); + sb.append("; Path=/api/auth"); + if (clear) { + sb.append("; Max-Age=0"); + sb.append("; Expires=Thu, 01 Jan 1970 00:00:00 GMT"); + } + sb.append("; HttpOnly"); + if (refreshCookieSecure) { + sb.append("; Secure"); + } + sb.append("; SameSite=").append(sameSite); + response.addHeader("Set-Cookie", sb.toString()); + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/controller/CaptchaController.java b/backend/src/main/java/com/writeoff/module/auth/controller/CaptchaController.java new file mode 100644 index 0000000..e407f1e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/controller/CaptchaController.java @@ -0,0 +1,29 @@ +package com.writeoff.module.auth.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.security.CaptchaService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/captcha") +public class CaptchaController { + + private final CaptchaService captchaService; + + public CaptchaController(CaptchaService captchaService) { + this.captchaService = captchaService; + } + + /** + * 获取图形验证码。 + * 返回 captchaId + Base64 编码的 PNG 图片。 + */ + @GetMapping + public ApiResponse> getCaptcha() { + return ApiResponse.success(captchaService.generate()); + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/controller/PlatformAuthSessionController.java b/backend/src/main/java/com/writeoff/module/auth/controller/PlatformAuthSessionController.java new file mode 100644 index 0000000..2ccdaad --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/controller/PlatformAuthSessionController.java @@ -0,0 +1,59 @@ +package com.writeoff.module.auth.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.auth.dto.PlatformSessionRevokePrincipalRequest; +import com.writeoff.module.auth.model.PlatformAuthSessionInfo; +import com.writeoff.module.auth.service.PlatformAuthSessionService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/platform/auth-sessions") +public class PlatformAuthSessionController { + private final PlatformAuthSessionService platformAuthSessionService; + + public PlatformAuthSessionController(PlatformAuthSessionService platformAuthSessionService) { + this.platformAuthSessionService = platformAuthSessionService; + } + + @GetMapping + @RequirePermission(value = "platform.session.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_AUTH_SESSION_LIST") + public ApiResponse> list( + @RequestParam(value = "scope", required = false) String scope, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "userId", required = false) Long userId, + @RequestParam(value = "tenantId", required = false) Long tenantId + ) { + return ApiResponse.success(platformAuthSessionService.list(scope, status, userId, tenantId)); + } + + @PostMapping("/{id}/revoke") + @RequirePermission(value = "platform.session.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_AUTH_SESSION_REVOKE") + public ApiResponse> revoke(@PathVariable("id") Long id) { + return ApiResponse.success(platformAuthSessionService.revokeBySessionId(id)); + } + + @PostMapping("/revoke-principal") + @RequirePermission(value = "platform.session.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_AUTH_SESSION_REVOKE_PRINCIPAL") + public ApiResponse> revokePrincipal(@RequestBody @Valid PlatformSessionRevokePrincipalRequest request) { + return ApiResponse.success( + platformAuthSessionService.revokeByPrincipal( + request.getUserId(), + request.getTenantId(), + request.getScope() + ) + ); + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/dto/LoginRequest.java b/backend/src/main/java/com/writeoff/module/auth/dto/LoginRequest.java new file mode 100644 index 0000000..9a4f7c6 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/dto/LoginRequest.java @@ -0,0 +1,29 @@ +package com.writeoff.module.auth.dto; + +import javax.validation.constraints.NotBlank; + +public class LoginRequest { + @NotBlank(message = "手机号不能为空") + private String phone; + @NotBlank(message = "密码不能为空") + private String password; + + private String captchaId; + private String captchaCode; + + public String getPhone() { + return phone; + } + + public String getPassword() { + return password; + } + + public String getCaptchaId() { + return captchaId; + } + + public String getCaptchaCode() { + return captchaCode; + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/dto/PasswordSetupCompleteRequest.java b/backend/src/main/java/com/writeoff/module/auth/dto/PasswordSetupCompleteRequest.java new file mode 100644 index 0000000..69a45c7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/dto/PasswordSetupCompleteRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.auth.dto; + +import javax.validation.constraints.NotBlank; + +public class PasswordSetupCompleteRequest { + @NotBlank(message = "租户编码不能为空") + private String tenantCode; + + @NotBlank(message = "设置链接不能为空") + private String token; + + @NotBlank(message = "新密码不能为空") + private String newPassword; + + public String getTenantCode() { + return tenantCode; + } + + public String getToken() { + return token; + } + + public String getNewPassword() { + return newPassword; + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/dto/PlatformSessionRevokePrincipalRequest.java b/backend/src/main/java/com/writeoff/module/auth/dto/PlatformSessionRevokePrincipalRequest.java new file mode 100644 index 0000000..9452800 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/dto/PlatformSessionRevokePrincipalRequest.java @@ -0,0 +1,36 @@ +package com.writeoff.module.auth.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class PlatformSessionRevokePrincipalRequest { + @NotNull(message = "用户ID不能为空") + private Long userId; + private Long tenantId; + @NotBlank(message = "scope不能为空") + private String scope; + + public Long getUserId() { + return userId; + } + + public void setUserId(Long userId) { + this.userId = userId; + } + + public Long getTenantId() { + return tenantId; + } + + public void setTenantId(Long tenantId) { + this.tenantId = tenantId; + } + + public String getScope() { + return scope; + } + + public void setScope(String scope) { + this.scope = scope; + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/dto/SwitchTenantRequest.java b/backend/src/main/java/com/writeoff/module/auth/dto/SwitchTenantRequest.java new file mode 100644 index 0000000..2d5d973 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/dto/SwitchTenantRequest.java @@ -0,0 +1,16 @@ +package com.writeoff.module.auth.dto; + +import javax.validation.constraints.NotNull; + +public class SwitchTenantRequest { + @NotNull(message = "\u76ee\u6807\u79df\u6237\u4e0d\u80fd\u4e3a\u7a7a") + private Long tenantId; + + public Long getTenantId() { + return tenantId; + } + + public void setTenantId(Long tenantId) { + this.tenantId = tenantId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/dto/TenantLoginRequest.java b/backend/src/main/java/com/writeoff/module/auth/dto/TenantLoginRequest.java new file mode 100644 index 0000000..0ca2c2d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/dto/TenantLoginRequest.java @@ -0,0 +1,35 @@ +package com.writeoff.module.auth.dto; + +import javax.validation.constraints.NotBlank; + +public class TenantLoginRequest { + @NotBlank(message = "租户编码不能为空") + private String tenantCode; + @NotBlank(message = "手机号不能为空") + private String phone; + @NotBlank(message = "密码不能为空") + private String password; + + private String captchaId; + private String captchaCode; + + public String getTenantCode() { + return tenantCode; + } + + public String getPhone() { + return phone; + } + + public String getPassword() { + return password; + } + + public String getCaptchaId() { + return captchaId; + } + + public String getCaptchaCode() { + return captchaCode; + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/model/PlatformAuthSessionInfo.java b/backend/src/main/java/com/writeoff/module/auth/model/PlatformAuthSessionInfo.java new file mode 100644 index 0000000..ba5aa42 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/model/PlatformAuthSessionInfo.java @@ -0,0 +1,97 @@ +package com.writeoff.module.auth.model; + +public class PlatformAuthSessionInfo { + private final Long id; + private final Long userId; + private final Long tenantId; + private final String scope; + private final String status; + private final String userName; + private final String phone; + private final String tenantName; + private final String issuedAt; + private final String expiresAt; + private final String lastUsedAt; + private final String revokedAt; + private final String revokedReason; + + public PlatformAuthSessionInfo(Long id, + Long userId, + Long tenantId, + String scope, + String status, + String userName, + String phone, + String tenantName, + String issuedAt, + String expiresAt, + String lastUsedAt, + String revokedAt, + String revokedReason) { + this.id = id; + this.userId = userId; + this.tenantId = tenantId; + this.scope = scope; + this.status = status; + this.userName = userName; + this.phone = phone; + this.tenantName = tenantName; + this.issuedAt = issuedAt; + this.expiresAt = expiresAt; + this.lastUsedAt = lastUsedAt; + this.revokedAt = revokedAt; + this.revokedReason = revokedReason; + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public Long getTenantId() { + return tenantId; + } + + public String getScope() { + return scope; + } + + public String getStatus() { + return status; + } + + public String getUserName() { + return userName; + } + + public String getPhone() { + return phone; + } + + public String getTenantName() { + return tenantName; + } + + public String getIssuedAt() { + return issuedAt; + } + + public String getExpiresAt() { + return expiresAt; + } + + public String getLastUsedAt() { + return lastUsedAt; + } + + public String getRevokedAt() { + return revokedAt; + } + + public String getRevokedReason() { + return revokedReason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/model/TenantSwitchOption.java b/backend/src/main/java/com/writeoff/module/auth/model/TenantSwitchOption.java new file mode 100644 index 0000000..f2a44f2 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/model/TenantSwitchOption.java @@ -0,0 +1,37 @@ +package com.writeoff.module.auth.model; + +public class TenantSwitchOption { + private final Long tenantId; + private final String tenantCode; + private final String tenantName; + private final String logoUrl; + private final boolean current; + + public TenantSwitchOption(Long tenantId, String tenantCode, String tenantName, String logoUrl, boolean current) { + this.tenantId = tenantId; + this.tenantCode = tenantCode; + this.tenantName = tenantName; + this.logoUrl = logoUrl; + this.current = current; + } + + public Long getTenantId() { + return tenantId; + } + + public String getTenantCode() { + return tenantCode; + } + + public String getTenantName() { + return tenantName; + } + + public String getLogoUrl() { + return logoUrl; + } + + public boolean isCurrent() { + return current; + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/service/PlatformAuthSessionService.java b/backend/src/main/java/com/writeoff/module/auth/service/PlatformAuthSessionService.java new file mode 100644 index 0000000..71d81c4 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/service/PlatformAuthSessionService.java @@ -0,0 +1,130 @@ +package com.writeoff.module.auth.service; + +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.auth.model.PlatformAuthSessionInfo; +import com.writeoff.security.AuthScope; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class PlatformAuthSessionService { + private final JdbcTemplate jdbcTemplate; + + public PlatformAuthSessionService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public List list(String scope, String status, Long userId, Long tenantId) { + StringBuilder sql = new StringBuilder(); + List args = new ArrayList(); + sql.append("SELECT t.id, t.user_id, t.tenant_id, t.scope, t.status, "); + sql.append("CASE WHEN t.scope='TENANT' THEN su.user_name ELSE pu.user_name END AS user_name, "); + sql.append("CASE WHEN t.scope='TENANT' THEN su.phone ELSE pu.phone END AS phone, "); + sql.append("te.tenant_name, "); + sql.append("DATE_FORMAT(t.issued_at, '%Y-%m-%d %H:%i:%s') AS issued_at, "); + sql.append("DATE_FORMAT(t.expires_at, '%Y-%m-%d %H:%i:%s') AS expires_at, "); + sql.append("DATE_FORMAT(t.last_used_at, '%Y-%m-%d %H:%i:%s') AS last_used_at, "); + sql.append("DATE_FORMAT(t.revoked_at, '%Y-%m-%d %H:%i:%s') AS revoked_at, "); + sql.append("t.revoked_reason "); + sql.append("FROM auth_refresh_token t "); + sql.append("LEFT JOIN sys_user su ON t.scope='TENANT' AND su.id=t.user_id AND su.tenant_id=t.tenant_id "); + sql.append("LEFT JOIN tenant te ON te.id=t.tenant_id "); + sql.append("LEFT JOIN platform_user pu ON t.scope='PLATFORM' AND pu.id=t.user_id "); + sql.append("WHERE t.is_deleted=0 "); + if (scope != null && !scope.trim().isEmpty()) { + sql.append("AND t.scope=? "); + args.add(scope.trim().toUpperCase()); + } + if (status != null && !status.trim().isEmpty()) { + sql.append("AND t.status=? "); + args.add(status.trim().toUpperCase()); + } + if (userId != null && userId > 0) { + sql.append("AND t.user_id=? "); + args.add(userId); + } + if (tenantId != null && tenantId > 0) { + sql.append("AND t.tenant_id=? "); + args.add(tenantId); + } + sql.append("ORDER BY t.id DESC LIMIT 500"); + List> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + List list = new ArrayList(); + for (Map row : rows) { + list.add(new PlatformAuthSessionInfo( + ((Number) row.get("id")).longValue(), + ((Number) row.get("user_id")).longValue(), + row.get("tenant_id") == null ? null : ((Number) row.get("tenant_id")).longValue(), + str(row.get("scope")), + str(row.get("status")), + str(row.get("user_name")), + str(row.get("phone")), + str(row.get("tenant_name")), + str(row.get("issued_at")), + str(row.get("expires_at")), + str(row.get("last_used_at")), + str(row.get("revoked_at")), + str(row.get("revoked_reason")) + )); + } + return list; + } + + @Transactional(rollbackFor = Exception.class) + public Map revokeBySessionId(Long sessionId) { + List> rows = jdbcTemplate.queryForList( + "SELECT id, status FROM auth_refresh_token WHERE id=? AND is_deleted=0 LIMIT 1", + sessionId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会话不存在"); + } + jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='REVOKED', revoked_at=CURRENT_TIMESTAMP, revoked_reason='ADMIN_REVOKE', updated_at=CURRENT_TIMESTAMP " + + "WHERE id=? AND status='ACTIVE'", + sessionId + ); + Map data = new LinkedHashMap(); + data.put("sessionId", sessionId); + data.put("ok", Boolean.TRUE); + return data; + } + + @Transactional(rollbackFor = Exception.class) + public Map revokeByPrincipal(Long userId, Long tenantId, String scopeRaw) { + AuthScope scope = AuthScope.fromClaim(scopeRaw); + if (scope == AuthScope.TENANT && (tenantId == null || tenantId <= 0)) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "租户会话撤销时tenantId不能为空"); + } + int affected; + if (scope == AuthScope.TENANT) { + affected = jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='REVOKED', revoked_at=CURRENT_TIMESTAMP, revoked_reason='ADMIN_REVOKE_PRINCIPAL', updated_at=CURRENT_TIMESTAMP " + + "WHERE user_id=? AND tenant_id=? AND scope='TENANT' AND status='ACTIVE' AND is_deleted=0", + userId, + tenantId + ); + } else { + affected = jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='REVOKED', revoked_at=CURRENT_TIMESTAMP, revoked_reason='ADMIN_REVOKE_PRINCIPAL', updated_at=CURRENT_TIMESTAMP " + + "WHERE user_id=? AND tenant_id IS NULL AND scope='PLATFORM' AND status='ACTIVE' AND is_deleted=0", + userId + ); + } + Map data = new LinkedHashMap(); + data.put("affected", affected); + data.put("ok", Boolean.TRUE); + return data; + } + + private String str(Object value) { + return value == null ? "" : String.valueOf(value); + } +} diff --git a/backend/src/main/java/com/writeoff/module/auth/service/RefreshTokenService.java b/backend/src/main/java/com/writeoff/module/auth/service/RefreshTokenService.java new file mode 100644 index 0000000..3f68c05 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/auth/service/RefreshTokenService.java @@ -0,0 +1,273 @@ +package com.writeoff.module.auth.service; + +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.security.AuthScope; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class RefreshTokenService { + private final JdbcTemplate jdbcTemplate; + private final SecureRandom secureRandom = new SecureRandom(); + private final long refreshExpireDays; + private final long idleTimeoutMinutes; + + public RefreshTokenService(JdbcTemplate jdbcTemplate, + @Value("${app.security.refresh-expire-days:14}") long refreshExpireDays, + @Value("${app.security.idle-timeout-minutes:60}") long idleTimeoutMinutes) { + this.jdbcTemplate = jdbcTemplate; + this.refreshExpireDays = refreshExpireDays <= 0 ? 14 : refreshExpireDays; + this.idleTimeoutMinutes = idleTimeoutMinutes <= 0 ? 60 : idleTimeoutMinutes; + } + + @Transactional(rollbackFor = Exception.class) + public String issue(Long userId, Long tenantId, AuthScope scope, String ip, String userAgent) { + return String.valueOf(issueWithSession(userId, tenantId, scope, ip, userAgent).get("refreshToken")); + } + + @Transactional(rollbackFor = Exception.class) + public Map issueWithSession(Long userId, Long tenantId, AuthScope scope, String ip, String userAgent) { + String rawToken = generateToken(); + String tokenHash = sha256Hex(rawToken); + Long safeUser = userId == null ? 0L : userId; + jdbcTemplate.update( + "INSERT INTO auth_refresh_token (user_id, tenant_id, scope, token_hash, status, expires_at, ip_hash, ua_hash, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, 'ACTIVE', DATE_ADD(NOW(), INTERVAL ? DAY), ?, ?, ?, ?)", + safeUser, + tenantId, + scope.name(), + tokenHash, + refreshExpireDays, + sha256Hex(safe(ip)), + sha256Hex(safe(userAgent)), + safeUser, + safeUser + ); + Long sessionId = jdbcTemplate.queryForObject( + "SELECT id FROM auth_refresh_token WHERE token_hash=? LIMIT 1", + Long.class, + tokenHash + ); + Map data = new LinkedHashMap(); + data.put("sessionId", sessionId); + data.put("refreshToken", rawToken); + return data; + } + + @Transactional(rollbackFor = Exception.class) + public Map rotate(String rawToken, String ip, String userAgent) { + if (rawToken == null || rawToken.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.REFRESH_TOKEN_INVALID, "刷新会话无效"); + } + String tokenHash = sha256Hex(rawToken.trim()); + List> rows = jdbcTemplate.queryForList( + "SELECT id, user_id, tenant_id, scope, status, issued_at, expires_at, last_used_at, is_deleted " + + "FROM auth_refresh_token WHERE token_hash=? LIMIT 1", + tokenHash + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.REFRESH_TOKEN_INVALID, "刷新会话无效"); + } + Map row = rows.get(0); + String status = String.valueOf(row.get("status")); + int isDeleted = toFlagInt(row.get("is_deleted")); + if (isDeleted == 1 || !"ACTIVE".equals(status)) { + throw new BusinessException(ErrorCodes.REFRESH_TOKEN_INVALID, "刷新会话无效"); + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime lastActivityAt = resolveLastActivityAt(row.get("last_used_at"), row.get("issued_at")); + if (lastActivityAt == null || now.isAfter(lastActivityAt.plusMinutes(idleTimeoutMinutes))) { + Long currentId = ((Number) row.get("id")).longValue(); + jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='REVOKED', revoked_at=CURRENT_TIMESTAMP, revoked_reason='IDLE_TIMEOUT', updated_at=CURRENT_TIMESTAMP WHERE id=? AND status='ACTIVE'", + currentId + ); + throw new BusinessException(ErrorCodes.SESSION_INVALID, "浼氳瘽澶辨晥"); + } + LocalDateTime expiresAt = toLocalDateTime(row.get("expires_at")); + if (expiresAt == null || now.isAfter(expiresAt)) { + Long currentId = ((Number) row.get("id")).longValue(); + jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='EXPIRED', revoked_at=CURRENT_TIMESTAMP, revoked_reason='EXPIRED', updated_at=CURRENT_TIMESTAMP WHERE id=?", + currentId + ); + throw new BusinessException(ErrorCodes.REFRESH_TOKEN_EXPIRED, "刷新会话已过期"); + } + + Long currentId = ((Number) row.get("id")).longValue(); + Long userId = ((Number) row.get("user_id")).longValue(); + Long tenantId = row.get("tenant_id") == null ? null : ((Number) row.get("tenant_id")).longValue(); + AuthScope scope = AuthScope.fromClaim(String.valueOf(row.get("scope"))); + + String nextRawToken = generateToken(); + String nextTokenHash = sha256Hex(nextRawToken); + jdbcTemplate.update( + "INSERT INTO auth_refresh_token (user_id, tenant_id, scope, token_hash, status, expires_at, ip_hash, ua_hash, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, 'ACTIVE', DATE_ADD(NOW(), INTERVAL ? DAY), ?, ?, ?, ?)", + userId, + tenantId, + scope.name(), + nextTokenHash, + refreshExpireDays, + sha256Hex(safe(ip)), + sha256Hex(safe(userAgent)), + userId, + userId + ); + Long nextId = jdbcTemplate.queryForObject( + "SELECT id FROM auth_refresh_token WHERE token_hash=? LIMIT 1", + Long.class, + nextTokenHash + ); + jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='ROTATED', rotated_to_id=?, last_used_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?", + nextId, + currentId + ); + Map data = new LinkedHashMap(); + data.put("userId", userId); + data.put("tenantId", tenantId); + data.put("scope", scope); + data.put("refreshToken", nextRawToken); + data.put("sessionId", nextId); + return data; + } + + @Transactional(rollbackFor = Exception.class) + public void revokeCurrent(String rawToken, String reason) { + if (rawToken == null || rawToken.trim().isEmpty()) { + return; + } + String tokenHash = sha256Hex(rawToken.trim()); + jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='REVOKED', revoked_at=CURRENT_TIMESTAMP, revoked_reason=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE token_hash=? AND status='ACTIVE'", + safe(reason), + tokenHash + ); + } + + @Transactional(rollbackFor = Exception.class) + public void revokeAllByPrincipal(Long userId, Long tenantId, AuthScope scope, String reason) { + if (userId == null || scope == null) { + return; + } + if (scope == AuthScope.TENANT) { + jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='REVOKED', revoked_at=CURRENT_TIMESTAMP, revoked_reason=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE user_id=? AND tenant_id=? AND scope=? AND status='ACTIVE'", + safe(reason), + userId, + tenantId, + scope.name() + ); + return; + } + jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='REVOKED', revoked_at=CURRENT_TIMESTAMP, revoked_reason=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE user_id=? AND tenant_id IS NULL AND scope=? AND status='ACTIVE'", + safe(reason), + userId, + scope.name() + ); + } + + private String generateToken() { + byte[] bytes = new byte[48]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String sha256Hex(String value) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(hash.length * 2); + for (byte b : hash) { + String h = Integer.toHexString(b & 0xff); + if (h.length() == 1) { + sb.append('0'); + } + sb.append(h); + } + return sb.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 unavailable", e); + } + } + + private String safe(String value) { + return value == null ? "" : value.trim(); + } + + private int toFlagInt(Object value) { + if (value == null) { + return 0; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + if (value instanceof Boolean) { + return ((Boolean) value) ? 1 : 0; + } + String text = String.valueOf(value).trim(); + if ("true".equalsIgnoreCase(text)) { + return 1; + } + if ("false".equalsIgnoreCase(text)) { + return 0; + } + try { + return Integer.parseInt(text); + } catch (NumberFormatException ex) { + return 0; + } + } + + private LocalDateTime toLocalDateTime(Object value) { + if (value == null) { + return null; + } + if (value instanceof LocalDateTime) { + return (LocalDateTime) value; + } + if (value instanceof java.sql.Timestamp) { + return ((java.sql.Timestamp) value).toLocalDateTime(); + } + if (value instanceof java.util.Date) { + return new java.sql.Timestamp(((java.util.Date) value).getTime()).toLocalDateTime(); + } + if (value instanceof String) { + String text = String.valueOf(value).trim(); + if (text.isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(text.replace(' ', 'T')); + } catch (Exception ignored) { + } + } + return null; + } + + private LocalDateTime resolveLastActivityAt(Object lastUsedAt, Object issuedAt) { + LocalDateTime lastUsed = toLocalDateTime(lastUsedAt); + if (lastUsed != null) { + return lastUsed; + } + return toLocalDateTime(issuedAt); + } +} diff --git a/backend/src/main/java/com/writeoff/module/dashboard/controller/OperationsDashboardController.java b/backend/src/main/java/com/writeoff/module/dashboard/controller/OperationsDashboardController.java new file mode 100644 index 0000000..d134309 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/dashboard/controller/OperationsDashboardController.java @@ -0,0 +1,27 @@ +package com.writeoff.module.dashboard.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.dashboard.service.OperationsDashboardService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/operations/dashboard") +public class OperationsDashboardController { + private final OperationsDashboardService operationsDashboardService; + + public OperationsDashboardController(OperationsDashboardService operationsDashboardService) { + this.operationsDashboardService = operationsDashboardService; + } + + @GetMapping + @RequirePermission(value = "dashboard.read", dataScope = DataScopeType.TENANT, auditAction = "OPERATIONS_DASHBOARD_SUMMARY") + public ApiResponse> summary() { + return ApiResponse.success(operationsDashboardService.summary()); + } +} diff --git a/backend/src/main/java/com/writeoff/module/dashboard/controller/TenantDashboardController.java b/backend/src/main/java/com/writeoff/module/dashboard/controller/TenantDashboardController.java new file mode 100644 index 0000000..1008463 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/dashboard/controller/TenantDashboardController.java @@ -0,0 +1,60 @@ +package com.writeoff.module.dashboard.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.security.AuthContext; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 租户工作台统计接口,聚合"活跃会议数"和"待财务确认账单数"等关键指标, + * 为 TenantDashboardPage 提供实时数据。 + */ +@RestController +@RequestMapping("/api/dashboard") +public class TenantDashboardController { + private final JdbcTemplate jdbcTemplate; + + public TenantDashboardController(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @GetMapping("/stats") + @RequirePermission(value = "dashboard.read", dataScope = DataScopeType.TENANT, auditAction = "DASHBOARD_STATS") + public ApiResponse> stats() { + Long tenantId = AuthContext.requireTenantId(); + Map result = new LinkedHashMap<>(); + + // 活跃会议:状态为 NOT_STARTED 或 IN_PROGRESS 的会议数 + Integer activeMeetingCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM meeting WHERE tenant_id=? AND is_deleted=0 AND meeting_status IN ('NOT_STARTED', 'IN_PROGRESS')", + Integer.class, + tenantId + ); + result.put("activeMeetingCount", activeMeetingCount == null ? 0 : activeMeetingCount); + + // 待财务确认:finance_status 为 WAIT_FINANCE_CONFIRM 的会议数 + Integer pendingFinanceCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM finance_payment WHERE tenant_id=? AND is_deleted=0 AND payment_status='SUBMITTED'", + Integer.class, + tenantId + ); + result.put("pendingFinanceCount", pendingFinanceCount == null ? 0 : pendingFinanceCount); + + // 待审核任务数 + Integer pendingAuditCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_task WHERE tenant_id=? AND is_deleted=0 AND status='PENDING'", + Integer.class, + tenantId + ); + result.put("pendingAuditCount", pendingAuditCount == null ? 0 : pendingAuditCount); + + return ApiResponse.success(result); + } +} diff --git a/backend/src/main/java/com/writeoff/module/dashboard/service/OperationsDashboardService.java b/backend/src/main/java/com/writeoff/module/dashboard/service/OperationsDashboardService.java new file mode 100644 index 0000000..686fba8 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/dashboard/service/OperationsDashboardService.java @@ -0,0 +1,116 @@ +package com.writeoff.module.dashboard.service; + +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class OperationsDashboardService { + private final JdbcTemplate jdbcTemplate; + + public OperationsDashboardService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public Map summary() { + Map data = new LinkedHashMap(); + data.put("notification", notificationSummary()); + data.put("export", exportSummary()); + data.put("alert", alertSummary()); + data.put("trends", trends()); + data.put("tops", tops()); + return data; + } + + private Map notificationSummary() { + Map m = new LinkedHashMap(); + m.put("todayTotal", intVal("SELECT COUNT(1) FROM notification_task WHERE tenant_id=? AND DATE(created_at)=CURDATE()", tenantId())); + m.put("sentTotal", intVal("SELECT COUNT(1) FROM notification_task WHERE tenant_id=? AND status='SENT'", tenantId())); + m.put("deliveredTotal", intVal("SELECT COUNT(1) FROM notification_task WHERE tenant_id=? AND status='DELIVERED'", tenantId())); + m.put("failedTotal", intVal("SELECT COUNT(1) FROM notification_task WHERE tenant_id=? AND status='FAILED'", tenantId())); + m.put("avgDispatchSeconds", doubleVal( + "SELECT IFNULL(AVG(TIMESTAMPDIFF(SECOND, created_at, sent_at)),0) FROM notification_task WHERE tenant_id=? AND sent_at IS NOT NULL", + tenantId())); + return m; + } + + private Map exportSummary() { + Map m = new LinkedHashMap(); + m.put("todayTotal", intVal("SELECT COUNT(1) FROM export_task WHERE tenant_id=? AND DATE(created_at)=CURDATE()", tenantId())); + m.put("successTotal", intVal("SELECT COUNT(1) FROM export_task WHERE tenant_id=? AND status='SUCCESS'", tenantId())); + m.put("failedTotal", intVal("SELECT COUNT(1) FROM export_task WHERE tenant_id=? AND status='FAILED'", tenantId())); + m.put("avgFinishSeconds", doubleVal( + "SELECT IFNULL(AVG(TIMESTAMPDIFF(SECOND, created_at, finished_at)),0) FROM export_task WHERE tenant_id=? AND finished_at IS NOT NULL", + tenantId())); + m.put("expiring24h", intVal( + "SELECT COUNT(1) FROM export_task WHERE tenant_id=? AND status='SUCCESS' AND download_token_expire_at IS NOT NULL AND download_token_expire_at<=DATE_ADD(NOW(), INTERVAL 24 HOUR) AND download_token_expire_at>=NOW()", + tenantId())); + return m; + } + + private Map alertSummary() { + Map m = new LinkedHashMap(); + m.put("activeTotal", intVal("SELECT COUNT(1) FROM alert_event WHERE tenant_id=? AND status='ACTIVE'", tenantId())); + m.put("recoveredToday", intVal("SELECT COUNT(1) FROM alert_event WHERE tenant_id=? AND status='RECOVERED' AND DATE(recovered_at)=CURDATE()", tenantId())); + m.put("avgRecoverMinutes", doubleVal( + "SELECT IFNULL(AVG(TIMESTAMPDIFF(MINUTE, created_at, recovered_at)),0) FROM alert_event WHERE tenant_id=? AND status='RECOVERED' AND recovered_at IS NOT NULL", + tenantId())); + return m; + } + + private Map trends() { + Map data = new LinkedHashMap(); + data.put("notification7d", trendRows("SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS d, COUNT(1) AS c FROM notification_task WHERE tenant_id=? AND created_at>=DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d') ORDER BY d ASC")); + data.put("export7d", trendRows("SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS d, COUNT(1) AS c FROM export_task WHERE tenant_id=? AND created_at>=DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d') ORDER BY d ASC")); + data.put("alert7d", trendRows("SELECT DATE_FORMAT(created_at, '%Y-%m-%d') AS d, COUNT(1) AS c FROM alert_event WHERE tenant_id=? AND created_at>=DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d') ORDER BY d ASC")); + return data; + } + + private Map tops() { + Map data = new LinkedHashMap(); + data.put("notificationFailedChannels", jdbcTemplate.queryForList( + "SELECT channel, COUNT(1) AS cnt FROM notification_task WHERE tenant_id=? AND status='FAILED' GROUP BY channel ORDER BY cnt DESC LIMIT 5", + tenantId() + )); + data.put("exportBizTypeTop", jdbcTemplate.queryForList( + "SELECT biz_type, COUNT(1) AS cnt FROM export_task WHERE tenant_id=? GROUP BY biz_type ORDER BY cnt DESC LIMIT 5", + tenantId() + )); + data.put("alertRuleTop", jdbcTemplate.queryForList( + "SELECT rule_code, COUNT(1) AS cnt FROM alert_event WHERE tenant_id=? GROUP BY rule_code ORDER BY cnt DESC LIMIT 5", + tenantId() + )); + return data; + } + + private List> trendRows(String sql) { + List> rows = jdbcTemplate.queryForList(sql, tenantId()); + List> out = new ArrayList>(); + for (Map row : rows) { + Map item = new LinkedHashMap(); + item.put("date", row.get("d")); + item.put("count", row.get("c")); + out.add(item); + } + return out; + } + + private int intVal(String sql, Object... args) { + Integer v = jdbcTemplate.queryForObject(sql, Integer.class, args); + return v == null ? 0 : v; + } + + private double doubleVal(String sql, Object... args) { + Double v = jdbcTemplate.queryForObject(sql, Double.class, args); + return v == null ? 0D : v; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/controller/ExpertController.java b/backend/src/main/java/com/writeoff/module/expert/controller/ExpertController.java new file mode 100644 index 0000000..581b298 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/controller/ExpertController.java @@ -0,0 +1,105 @@ +package com.writeoff.module.expert.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.model.ImportResult; +import com.writeoff.module.expert.dto.AddBankCardRequest; +import com.writeoff.module.expert.dto.CreateExpertRequest; +import com.writeoff.module.expert.dto.ExpertAssetUploadSignRequest; +import com.writeoff.module.expert.dto.ImportExpertsRequest; +import com.writeoff.module.expert.dto.MergeExpertRequest; +import com.writeoff.module.expert.model.ExpertBankCardInfo; +import com.writeoff.module.expert.model.ExpertInfo; +import com.writeoff.module.expert.service.ExpertService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/experts") +public class ExpertController { + private final ExpertService expertService; + + public ExpertController(ExpertService expertService) { + this.expertService = expertService; + } + + @GetMapping + @RequirePermission(value = "expert.read", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_LIST") + public ApiResponse> list( + @RequestParam(value = "keyword", required = false) String keyword, + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return ApiResponse.success(expertService.list(keyword, pageNo, pageSize)); + } + + @GetMapping("/{id}") + @RequirePermission(value = "expert.read", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_DETAIL") + public ApiResponse get(@PathVariable("id") Long id) { + return ApiResponse.success(expertService.get(id)); + } + + @PostMapping + @RequirePermission(value = "expert.create", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_CREATE") + public ApiResponse create(@RequestBody @Valid CreateExpertRequest request) { + return ApiResponse.success(expertService.create(request)); + } + + @PostMapping("/upload-sign") + @RequirePermission(value = "expert.create", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_UPLOAD_SIGN") + public ApiResponse> uploadSign(@RequestBody @Valid ExpertAssetUploadSignRequest request) { + return ApiResponse.success(expertService.presignAssetUpload(request.getFileName(), request.getContentType())); + } + + @PutMapping("/{id}") + @RequirePermission(value = "expert.create", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid CreateExpertRequest request) { + return ApiResponse.success(expertService.update(id, request)); + } + + @PostMapping("/import") + @RequirePermission(value = "expert.import", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_IMPORT") + public ApiResponse importExperts(@RequestBody @Valid ImportExpertsRequest request) { + return ApiResponse.success(expertService.importExperts(request.getExperts())); + } + + @GetMapping("/export") + @RequirePermission(value = "expert.export", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_EXPORT") + public ApiResponse> exportExperts() { + return ApiResponse.success(expertService.export()); + } + + @PostMapping("/{id}/merge") + @RequirePermission(value = "expert.merge", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_MERGE") + public ApiResponse> merge(@PathVariable("id") Long id, @RequestBody @Valid MergeExpertRequest request) { + return ApiResponse.success(expertService.merge(id, request)); + } + + @GetMapping("/{id}/bank-cards") + @RequirePermission(value = "expert.read", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_CARD_LIST") + public ApiResponse> cards(@PathVariable("id") Long id) { + return ApiResponse.success(expertService.listCards(id)); + } + + @GetMapping("/{id}/bank-cards/{cardId}") + @RequirePermission(value = "expert.read", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_CARD_DETAIL") + public ApiResponse getCard(@PathVariable("id") Long id, @PathVariable("cardId") Long cardId) { + return ApiResponse.success(expertService.getCard(id, cardId)); + } + + @PostMapping("/{id}/bank-cards") + @RequirePermission(value = "expert.card.manage", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_CARD_CREATE") + public ApiResponse addCard(@PathVariable("id") Long id, @RequestBody @Valid AddBankCardRequest request) { + return ApiResponse.success(expertService.addCard(id, request)); + } + + @PutMapping("/{id}/bank-cards/{cardId}") + @RequirePermission(value = "expert.card.manage", dataScope = DataScopeType.TENANT, auditAction = "EXPERT_CARD_UPDATE") + public ApiResponse updateCard(@PathVariable("id") Long id, @PathVariable("cardId") Long cardId, @RequestBody @Valid AddBankCardRequest request) { + return ApiResponse.success(expertService.updateCard(id, cardId, request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/controller/PlatformExpertController.java b/backend/src/main/java/com/writeoff/module/expert/controller/PlatformExpertController.java new file mode 100644 index 0000000..4ca1dc2 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/controller/PlatformExpertController.java @@ -0,0 +1,103 @@ +package com.writeoff.module.expert.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.model.ImportResult; +import com.writeoff.module.expert.dto.AddBankCardRequest; +import com.writeoff.module.expert.dto.CreateExpertRequest; +import com.writeoff.module.expert.dto.ExpertAssetUploadSignRequest; +import com.writeoff.module.expert.dto.ImportExpertsRequest; +import com.writeoff.module.expert.dto.MergeExpertRequest; +import com.writeoff.module.expert.model.ExpertBankCardInfo; +import com.writeoff.module.expert.model.ExpertInfo; +import com.writeoff.module.expert.service.PlatformExpertService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/platform/experts") +public class PlatformExpertController { + private final PlatformExpertService platformExpertService; + + public PlatformExpertController(PlatformExpertService platformExpertService) { + this.platformExpertService = platformExpertService; + } + + @GetMapping + @RequirePermission(value = "platform.expert.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_LIST") + public ApiResponse> list(@RequestParam(value = "keyword", required = false) String keyword) { + return ApiResponse.success(platformExpertService.list(keyword)); + } + + @GetMapping("/{id}") + @RequirePermission(value = "platform.expert.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_DETAIL") + public ApiResponse get(@PathVariable("id") Long id) { + return ApiResponse.success(platformExpertService.get(id)); + } + + @PostMapping + @RequirePermission(value = "platform.expert.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_CREATE") + public ApiResponse create(@RequestBody @Valid CreateExpertRequest request) { + return ApiResponse.success(platformExpertService.create(request)); + } + + @PostMapping("/upload-sign") + @RequirePermission(value = "platform.expert.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_UPLOAD_SIGN") + public ApiResponse> uploadSign(@RequestBody @Valid ExpertAssetUploadSignRequest request) { + return ApiResponse.success(platformExpertService.presignAssetUpload(request.getFileName(), request.getContentType())); + } + + @PutMapping("/{id}") + @RequirePermission(value = "platform.expert.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid CreateExpertRequest request) { + return ApiResponse.success(platformExpertService.update(id, request)); + } + + @PostMapping("/import") + @RequirePermission(value = "platform.expert.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_IMPORT") + public ApiResponse importExperts(@RequestBody @Valid ImportExpertsRequest request) { + return ApiResponse.success(platformExpertService.importExperts(request.getExperts())); + } + + @GetMapping("/export") + @RequirePermission(value = "platform.expert.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_EXPORT") + public ApiResponse> exportExperts() { + return ApiResponse.success(platformExpertService.export()); + } + + @PostMapping("/{id}/merge") + @RequirePermission(value = "platform.expert.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_MERGE") + public ApiResponse> merge(@PathVariable("id") Long id, @RequestBody @Valid MergeExpertRequest request) { + return ApiResponse.success(platformExpertService.merge(id, request)); + } + + @GetMapping("/{id}/bank-cards") + @RequirePermission(value = "platform.expert.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_CARD_LIST") + public ApiResponse> cards(@PathVariable("id") Long id) { + return ApiResponse.success(platformExpertService.listCards(id)); + } + + @GetMapping("/{id}/bank-cards/{cardId}") + @RequirePermission(value = "platform.expert.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_CARD_DETAIL") + public ApiResponse getCard(@PathVariable("id") Long id, @PathVariable("cardId") Long cardId) { + return ApiResponse.success(platformExpertService.getCard(id, cardId)); + } + + @PostMapping("/{id}/bank-cards") + @RequirePermission(value = "platform.expert.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_CARD_CREATE") + public ApiResponse addCard(@PathVariable("id") Long id, @RequestBody @Valid AddBankCardRequest request) { + return ApiResponse.success(platformExpertService.addCard(id, request)); + } + + @PutMapping("/{id}/bank-cards/{cardId}") + @RequirePermission(value = "platform.expert.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_EXPERT_CARD_UPDATE") + public ApiResponse updateCard(@PathVariable("id") Long id, @PathVariable("cardId") Long cardId, @RequestBody @Valid AddBankCardRequest request) { + return ApiResponse.success(platformExpertService.updateCard(id, cardId, request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/dto/AddBankCardRequest.java b/backend/src/main/java/com/writeoff/module/expert/dto/AddBankCardRequest.java new file mode 100644 index 0000000..c07d11b --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/dto/AddBankCardRequest.java @@ -0,0 +1,117 @@ +package com.writeoff.module.expert.dto; + +import javax.validation.constraints.NotBlank; + +public class AddBankCardRequest { + @NotBlank(message = "开户行不能为空") + private String bankName; + private String bankProvince; + private String bankCity; + private String bankBranchName; + @NotBlank(message = "银行卡号不能为空") + private String bankCardNo; + private String bankCardFrontOssKey; + private String bankCardBackOssKey; + @NotBlank(message = "账户名不能为空") + private String accountName; + private Boolean isDefault; + private String cardStatus; + private Boolean inconsistentNameApproved; + private String changeReason; + + public String getBankName() { + return bankName; + } + + public void setBankName(String bankName) { + this.bankName = bankName; + } + + public String getBankCardNo() { + return bankCardNo; + } + + public void setBankCardNo(String bankCardNo) { + this.bankCardNo = bankCardNo; + } + + public String getBankCardFrontOssKey() { + return bankCardFrontOssKey; + } + + public void setBankCardFrontOssKey(String bankCardFrontOssKey) { + this.bankCardFrontOssKey = bankCardFrontOssKey; + } + + public String getBankCardBackOssKey() { + return bankCardBackOssKey; + } + + public void setBankCardBackOssKey(String bankCardBackOssKey) { + this.bankCardBackOssKey = bankCardBackOssKey; + } + + public String getBankProvince() { + return bankProvince; + } + + public void setBankProvince(String bankProvince) { + this.bankProvince = bankProvince; + } + + public String getBankCity() { + return bankCity; + } + + public void setBankCity(String bankCity) { + this.bankCity = bankCity; + } + + public String getBankBranchName() { + return bankBranchName; + } + + public void setBankBranchName(String bankBranchName) { + this.bankBranchName = bankBranchName; + } + + public String getAccountName() { + return accountName; + } + + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + public Boolean getIsDefault() { + return isDefault; + } + + public void setIsDefault(Boolean isDefault) { + this.isDefault = isDefault; + } + + public String getCardStatus() { + return cardStatus; + } + + public void setCardStatus(String cardStatus) { + this.cardStatus = cardStatus; + } + + public Boolean getInconsistentNameApproved() { + return inconsistentNameApproved; + } + + public void setInconsistentNameApproved(Boolean inconsistentNameApproved) { + this.inconsistentNameApproved = inconsistentNameApproved; + } + + public String getChangeReason() { + return changeReason; + } + + public void setChangeReason(String changeReason) { + this.changeReason = changeReason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/dto/CreateExpertRequest.java b/backend/src/main/java/com/writeoff/module/expert/dto/CreateExpertRequest.java new file mode 100644 index 0000000..cfc50c0 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/dto/CreateExpertRequest.java @@ -0,0 +1,153 @@ +package com.writeoff.module.expert.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateExpertRequest { + @NotBlank(message = "专家姓名不能为空") + private String expertName; + private String gender; + private String birthday; + @NotBlank(message = "身份证号不能为空") + private String idNo; + private String idCardValidUntil; + @NotBlank(message = "手机号不能为空") + private String phone; + /** + * 职称字典编码,来自平台字典 EXPERT_TITLE。 + */ + private String titleCode; + /** + * 职称名称快照,兼容历史入参。 + */ + private String title; + /** + * 医院字典编码,来自平台字典 EXPERT_HOSPITAL。 + */ + private String hospitalCode; + /** + * 医院名称快照,兼容历史入参。 + */ + private String organization; + /** + * 身份证正面图片 OSS Key。 + */ + private String idCardFrontOssKey; + /** + * 身份证反面图片 OSS Key。 + */ + private String idCardBackOssKey; + private String statusReason; + private Boolean exportRestricted; + + public String getExpertName() { + return expertName; + } + + public void setExpertName(String expertName) { + this.expertName = expertName; + } + + public String getIdNo() { + return idNo; + } + + public void setIdNo(String idNo) { + this.idNo = idNo; + } + + public String getGender() { + return gender; + } + + public void setGender(String gender) { + this.gender = gender; + } + + public String getBirthday() { + return birthday; + } + + public void setBirthday(String birthday) { + this.birthday = birthday; + } + + public String getIdCardValidUntil() { + return idCardValidUntil; + } + + public void setIdCardValidUntil(String idCardValidUntil) { + this.idCardValidUntil = idCardValidUntil; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getTitleCode() { + return titleCode; + } + + public void setTitleCode(String titleCode) { + this.titleCode = titleCode; + } + + public String getOrganization() { + return organization; + } + + public void setOrganization(String organization) { + this.organization = organization; + } + + public String getHospitalCode() { + return hospitalCode; + } + + public void setHospitalCode(String hospitalCode) { + this.hospitalCode = hospitalCode; + } + + public String getIdCardFrontOssKey() { + return idCardFrontOssKey; + } + + public void setIdCardFrontOssKey(String idCardFrontOssKey) { + this.idCardFrontOssKey = idCardFrontOssKey; + } + + public String getIdCardBackOssKey() { + return idCardBackOssKey; + } + + public void setIdCardBackOssKey(String idCardBackOssKey) { + this.idCardBackOssKey = idCardBackOssKey; + } + + public String getStatusReason() { + return statusReason; + } + + public void setStatusReason(String statusReason) { + this.statusReason = statusReason; + } + + public Boolean getExportRestricted() { + return exportRestricted; + } + + public void setExportRestricted(Boolean exportRestricted) { + this.exportRestricted = exportRestricted; + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/dto/ExpertAssetUploadSignRequest.java b/backend/src/main/java/com/writeoff/module/expert/dto/ExpertAssetUploadSignRequest.java new file mode 100644 index 0000000..0ae5bf8 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/dto/ExpertAssetUploadSignRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.expert.dto; + +import javax.validation.constraints.NotBlank; + +public class ExpertAssetUploadSignRequest { + @NotBlank(message = "文件名不能为空") + private String fileName; + + private String contentType; + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/dto/ImportExpertsRequest.java b/backend/src/main/java/com/writeoff/module/expert/dto/ImportExpertsRequest.java new file mode 100644 index 0000000..27d90cd --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/dto/ImportExpertsRequest.java @@ -0,0 +1,19 @@ +package com.writeoff.module.expert.dto; + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import java.util.List; + +public class ImportExpertsRequest { + @Valid + @NotEmpty(message = "导入列表不能为空") + private List experts; + + public List getExperts() { + return experts; + } + + public void setExperts(List experts) { + this.experts = experts; + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/dto/MergeExpertRequest.java b/backend/src/main/java/com/writeoff/module/expert/dto/MergeExpertRequest.java new file mode 100644 index 0000000..9509017 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/dto/MergeExpertRequest.java @@ -0,0 +1,27 @@ +package com.writeoff.module.expert.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class MergeExpertRequest { + @NotNull(message = "来源专家ID不能为空") + private Long sourceExpertId; + @NotBlank(message = "合并原因不能为空") + private String reason; + + public Long getSourceExpertId() { + return sourceExpertId; + } + + public void setSourceExpertId(Long sourceExpertId) { + this.sourceExpertId = sourceExpertId; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/model/ExpertBankCardInfo.java b/backend/src/main/java/com/writeoff/module/expert/model/ExpertBankCardInfo.java new file mode 100644 index 0000000..1035105 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/model/ExpertBankCardInfo.java @@ -0,0 +1,91 @@ +package com.writeoff.module.expert.model; + +public class ExpertBankCardInfo { + private Long id; + private Long expertId; + private String bankName; + private String bankProvince; + private String bankCity; + private String bankBranchName; + private String bankCardNo; + private String bankCardFrontOssKey; + private String bankCardBackOssKey; + private String accountName; + private String isDefault; + private String cardStatus; + private Boolean inconsistentNameApproved; + private String changeReason; + + public ExpertBankCardInfo(Long id, Long expertId, String bankName, String bankProvince, String bankCity, String bankBranchName, String bankCardNo, String bankCardFrontOssKey, String bankCardBackOssKey, String accountName, String isDefault, String cardStatus, Boolean inconsistentNameApproved, String changeReason) { + this.id = id; + this.expertId = expertId; + this.bankName = bankName; + this.bankProvince = bankProvince; + this.bankCity = bankCity; + this.bankBranchName = bankBranchName; + this.bankCardNo = bankCardNo; + this.bankCardFrontOssKey = bankCardFrontOssKey; + this.bankCardBackOssKey = bankCardBackOssKey; + this.accountName = accountName; + this.isDefault = isDefault; + this.cardStatus = cardStatus; + this.inconsistentNameApproved = inconsistentNameApproved; + this.changeReason = changeReason; + } + + public Long getId() { + return id; + } + + public Long getExpertId() { + return expertId; + } + + public String getBankName() { + return bankName; + } + + public String getBankProvince() { + return bankProvince; + } + + public String getBankCity() { + return bankCity; + } + + public String getBankBranchName() { + return bankBranchName; + } + + public String getBankCardNo() { + return bankCardNo; + } + + public String getBankCardFrontOssKey() { + return bankCardFrontOssKey; + } + + public String getBankCardBackOssKey() { + return bankCardBackOssKey; + } + + public String getAccountName() { + return accountName; + } + + public String getIsDefault() { + return isDefault; + } + + public String getCardStatus() { + return cardStatus; + } + + public Boolean getInconsistentNameApproved() { + return inconsistentNameApproved; + } + + public String getChangeReason() { + return changeReason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/model/ExpertInfo.java b/backend/src/main/java/com/writeoff/module/expert/model/ExpertInfo.java new file mode 100644 index 0000000..1365780 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/model/ExpertInfo.java @@ -0,0 +1,119 @@ +package com.writeoff.module.expert.model; + +public class ExpertInfo { + /** 专家主键ID。 */ + private Long id; + /** 专家姓名。 */ + private String expertName; + private String gender; + private String birthday; + /** 身份证号(敏感字段)。 */ + private String idNo; + private String idCardValidUntil; + /** 身份证正面图片 OSS Key。 */ + private String idCardFrontOssKey; + /** 身份证反面图片 OSS Key。 */ + private String idCardBackOssKey; + /** 手机号(敏感字段)。 */ + private String phone; + /** 职称字典编码。 */ + private String titleCode; + /** 职称名称快照。 */ + private String title; + /** 医院字典编码。 */ + private String hospitalCode; + /** 医院名称快照(历史字段 organization)。 */ + private String organization; + private String status; + private String statusReason; + private String statusChangedAt; + private Boolean exportRestricted; + + public ExpertInfo(Long id, String expertName, String gender, String birthday, String idNo, String idCardValidUntil, String idCardFrontOssKey, String idCardBackOssKey, String phone, String titleCode, String title, String hospitalCode, String organization, String status, String statusReason, String statusChangedAt, Boolean exportRestricted) { + this.id = id; + this.expertName = expertName; + this.gender = gender; + this.birthday = birthday; + this.idNo = idNo; + this.idCardValidUntil = idCardValidUntil; + this.idCardFrontOssKey = idCardFrontOssKey; + this.idCardBackOssKey = idCardBackOssKey; + this.phone = phone; + this.titleCode = titleCode; + this.title = title; + this.hospitalCode = hospitalCode; + this.organization = organization; + this.status = status; + this.statusReason = statusReason; + this.statusChangedAt = statusChangedAt; + this.exportRestricted = exportRestricted; + } + + public Long getId() { + return id; + } + + public String getExpertName() { + return expertName; + } + + public String getGender() { + return gender; + } + + public String getBirthday() { + return birthday; + } + + public String getIdNo() { + return idNo; + } + + public String getIdCardValidUntil() { + return idCardValidUntil; + } + + public String getIdCardFrontOssKey() { + return idCardFrontOssKey; + } + + public String getIdCardBackOssKey() { + return idCardBackOssKey; + } + + public String getPhone() { + return phone; + } + + public String getTitle() { + return title; + } + + public String getTitleCode() { + return titleCode; + } + + public String getOrganization() { + return organization; + } + + public String getHospitalCode() { + return hospitalCode; + } + + public String getStatus() { + return status; + } + + public String getStatusReason() { + return statusReason; + } + + public String getStatusChangedAt() { + return statusChangedAt; + } + + public Boolean getExportRestricted() { + return exportRestricted; + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/service/ExpertService.java b/backend/src/main/java/com/writeoff/module/expert/service/ExpertService.java new file mode 100644 index 0000000..8468a64 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/service/ExpertService.java @@ -0,0 +1,682 @@ +package com.writeoff.module.expert.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.model.ImportResult; +import com.writeoff.common.util.ImportValidationUtils; +import com.writeoff.module.expert.dto.AddBankCardRequest; +import com.writeoff.module.expert.dto.CreateExpertRequest; +import com.writeoff.module.expert.dto.MergeExpertRequest; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.expert.model.ExpertBankCardInfo; +import com.writeoff.module.expert.model.ExpertInfo; +import com.writeoff.module.system.service.DataPermissionService; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +public class ExpertService { + /** + * 专家主数据改为平台级共享,统一落在 tenant_id=0。 + */ + private static final long PLATFORM_TENANT_ID = 0L; + + private static final String DICT_TYPE_EXPERT_TITLE = "EXPERT_TITLE"; + private static final String DICT_TYPE_EXPERT_HOSPITAL = "EXPERT_HOSPITAL"; + + private final JdbcTemplate jdbcTemplate; + private final OssService ossService; + private final DataPermissionService dataPermissionService; + + private static final RowMapper EXPERT_ROW_MAPPER = (rs, n) -> new ExpertInfo( + rs.getLong("id"), + rs.getString("expert_name"), + rs.getString("gender"), + rs.getString("birthday"), + rs.getString("id_no"), + rs.getString("id_card_valid_until"), + rs.getString("id_card_front_oss_key"), + rs.getString("id_card_back_oss_key"), + rs.getString("phone"), + rs.getString("title_code"), + rs.getString("title_name"), + rs.getString("hospital_code"), + rs.getString("hospital_name"), + rs.getString("status"), + rs.getString("status_reason"), + rs.getString("status_changed_at"), + rs.getInt("export_restricted") == 1 + ); + + private static final RowMapper CARD_ROW_MAPPER = (rs, n) -> new ExpertBankCardInfo( + rs.getLong("id"), + rs.getLong("expert_id"), + rs.getString("bank_name"), + rs.getString("bank_province"), + rs.getString("bank_city"), + rs.getString("bank_branch_name"), + rs.getString("bank_card_no"), + rs.getString("bank_card_front_oss_key"), + rs.getString("bank_card_back_oss_key"), + rs.getString("account_name"), + rs.getString("is_default"), + rs.getString("card_status"), + rs.getInt("inconsistent_name_approved") == 1, + rs.getString("change_reason") + ); + + public ExpertService(JdbcTemplate jdbcTemplate, OssService ossService, DataPermissionService dataPermissionService) { + this.jdbcTemplate = jdbcTemplate; + this.ossService = ossService; + this.dataPermissionService = dataPermissionService; + } + + public Map presignAssetUpload(String fileName, String contentType) { + String name = fileName == null ? "" : fileName.trim(); + if (name.isEmpty()) { + throw new BusinessException(10001, "文件名不能为空"); + } + String ext = ""; + int idx = name.lastIndexOf('.'); + if (idx > 0 && idx < name.length() - 1) { + ext = "." + name.substring(idx + 1).toLowerCase(); + } + String normalizedType = normalizeContentType(contentType); + String objectKey = "expert/asset/" + PLATFORM_TENANT_ID + "/" + safeUserId() + "/" + UUID.randomUUID().toString().replace("-", "") + ext; + String uploadUrl = ossService.generateUploadUrl(objectKey, normalizedType); + Map data = new LinkedHashMap(); + data.put("objectKey", objectKey); + data.put("uploadUrl", uploadUrl); + data.put("contentType", normalizedType); + data.put("method", "PUT"); + return data; + } + + public PageResult list(String keyword, int pageNo, int pageSize) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + int offset = (safePage - 1) * safeSize; + + StringBuilder whereClause = new StringBuilder( + "WHERE e.tenant_id=" + PLATFORM_TENANT_ID + " AND e.is_deleted=0" + ); + List countArgs = new ArrayList<>(); + if (keyword != null && !keyword.trim().isEmpty()) { + String kw = keyword.trim().replace("'", "''"); + whereClause.append(" AND (e.expert_name LIKE '%").append(kw).append("%' OR e.id_no LIKE '%").append(kw).append("%' OR e.phone LIKE '%").append(kw).append("%')"); + } + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM expert e " + whereClause, + Integer.class + ); + long totalCount = total == null ? 0 : total; + + StringBuilder sql = new StringBuilder( + "SELECT e.id, e.expert_name, e.gender, e.birthday, e.id_no, e.id_card_valid_until, e.id_card_front_oss_key, e.id_card_back_oss_key, e.phone, " + + "e.title_code, IFNULL(dt.dict_name, e.title) AS title_name, " + + "e.hospital_code, IFNULL(dh.dict_name, e.organization) AS hospital_name, " + + "e.status, e.status_reason, e.status_changed_at, e.export_restricted " + + "FROM expert e " + + "LEFT JOIN platform_dictionary_item dt ON dt.dict_type='EXPERT_TITLE' AND dt.dict_code=e.title_code AND dt.is_deleted=0 " + + "LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " + ); + sql.append(whereClause); + sql.append(" ORDER BY e.id DESC LIMIT ").append(safeSize).append(" OFFSET ").append(offset); + List list = jdbcTemplate.query(sql.toString(), EXPERT_ROW_MAPPER); + list = filterByExpertScope(list); + List maskedList = new java.util.ArrayList(list.size()); + for (ExpertInfo item : list) { + maskedList.add(maskSensitiveFields(item)); + } + return new PageResult(maskedList, totalCount, safePage, safeSize); + } + + public ExpertInfo get(Long id) { + ExpertInfo expert = findById(id); + assertExpertAccessible(expert.getId()); + return expert; + } + + /** + * 按专家主键批量查询姓名与医院展示名(字典名优先,否则 organization),用于会议资料审核等只读展示。 + */ + public Map> mapExpertDisplayByIds(Collection expertIds) { + if (expertIds == null || expertIds.isEmpty()) { + return new LinkedHashMap<>(); + } + List idList = expertIds.stream() + .filter(Objects::nonNull) + .mapToLong(Long::longValue) + .filter(id -> id > 0) + .distinct() + .boxed() + .collect(Collectors.toList()); + if (idList.isEmpty()) { + return new LinkedHashMap<>(); + } + String placeholders = idList.stream().map(id -> "?").collect(Collectors.joining(",")); + String sql = "SELECT e.id, e.expert_name, IFNULL(dh.dict_name, e.organization) AS hospital_name " + + "FROM expert e " + + "LEFT JOIN platform_dictionary_item dh ON dh.dict_type='" + DICT_TYPE_EXPERT_HOSPITAL + "' " + + "AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " + + "WHERE e.tenant_id=? AND e.id IN (" + placeholders + ") AND e.is_deleted=0"; + List args = new ArrayList<>(); + args.add(PLATFORM_TENANT_ID); + args.addAll(idList); + return jdbcTemplate.query(sql, rs -> { + Map> m = new LinkedHashMap<>(); + while (rs.next()) { + long id = rs.getLong("id"); + Map row = new LinkedHashMap<>(); + row.put("expertId", id); + row.put("expertName", rs.getString("expert_name")); + String hospital = rs.getString("hospital_name"); + row.put("hospital", hospital != null ? hospital : ""); + m.put(id, row); + } + return m; + }, args.toArray()); + } + + @Transactional(rollbackFor = Exception.class) + public ExpertInfo create(CreateExpertRequest request) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id_no=? AND is_deleted=0", + Integer.class, + PLATFORM_TENANT_ID, + request.getIdNo() + ); + if (count != null && count > 0) { + throw new BusinessException(10001, "身份证号已存在"); + } + DictionaryItem titleItem = resolveDictionaryItem(DICT_TYPE_EXPERT_TITLE, request.getTitleCode(), request.getTitle(), "职称"); + DictionaryItem hospitalItem = resolveDictionaryItem(DICT_TYPE_EXPERT_HOSPITAL, request.getHospitalCode(), request.getOrganization(), "医院"); + jdbcTemplate.update( + "INSERT INTO expert (tenant_id, expert_name, gender, birthday, id_no, id_card_valid_until, id_card_front_oss_key, id_card_back_oss_key, phone, title_code, title, hospital_code, organization, status, status_reason, status_changed_by, status_changed_at, export_restricted, created_by, updated_by) " + + "VALUES (?, ?, ?, STR_TO_DATE(?, '%Y-%m-%d'), ?, STR_TO_DATE(?, '%Y-%m-%d'), ?, ?, ?, ?, ?, ?, ?, 'ENABLED', ?, ?, NOW(), ?, ?, ?)", + PLATFORM_TENANT_ID, + request.getExpertName(), + request.getGender(), + request.getBirthday(), + request.getIdNo(), + request.getIdCardValidUntil(), + request.getIdCardFrontOssKey(), + request.getIdCardBackOssKey(), + request.getPhone(), + titleItem.getDictCode(), + titleItem.getDictName(), + hospitalItem.getDictCode(), + hospitalItem.getDictName(), + request.getStatusReason(), + safeUserId(), + Boolean.TRUE.equals(request.getExportRestricted()) ? 1 : 0, + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM expert WHERE tenant_id=?", Long.class, PLATFORM_TENANT_ID); + return findById(id == null ? 0L : id); + } + + public ImportResult importExperts(List experts) { + ImportResult result = new ImportResult(); + result.setTotal(experts == null ? 0 : experts.size()); + if (experts == null) { + return result; + } + Set batchIdNos = new HashSet(); + Set batchPhones = new HashSet(); + for (int i = 0; i < experts.size(); i++) { + CreateExpertRequest item = experts.get(i); + int rowNo = i + 2; + try { + validateImportExpert(item, batchIdNos, batchPhones); + create(item); + result.markSuccess(); + } catch (Exception ex) { + result.addError(rowNo, buildExpertIdentifier(item), resolveImportMessage(ex)); + } + } + return result; + } + + @Transactional(rollbackFor = Exception.class) + public ExpertInfo update(Long id, CreateExpertRequest request) { + assertExpertExists(id); + DictionaryItem titleItem = resolveDictionaryItem(DICT_TYPE_EXPERT_TITLE, request.getTitleCode(), request.getTitle(), "职称"); + DictionaryItem hospitalItem = resolveDictionaryItem(DICT_TYPE_EXPERT_HOSPITAL, request.getHospitalCode(), request.getOrganization(), "医院"); + jdbcTemplate.update( + "UPDATE expert SET expert_name=?, gender=?, birthday=STR_TO_DATE(?, '%Y-%m-%d'), id_card_valid_until=STR_TO_DATE(?, '%Y-%m-%d'), " + + "id_card_front_oss_key=?, id_card_back_oss_key=?, phone=?, " + + "title_code=?, title=?, hospital_code=?, organization=?, status_reason=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND id=? AND is_deleted=0", + request.getExpertName(), + request.getGender(), + request.getBirthday(), + request.getIdCardValidUntil(), + request.getIdCardFrontOssKey(), + request.getIdCardBackOssKey(), + request.getPhone(), + titleItem.getDictCode(), + titleItem.getDictName(), + hospitalItem.getDictCode(), + hospitalItem.getDictName(), + request.getStatusReason(), + safeUserId(), + PLATFORM_TENANT_ID, + id + ); + return findById(id); + } + + public List listCards(Long expertId) { + assertExpertExists(expertId); + assertExpertAccessible(expertId); + return jdbcTemplate.query( + "SELECT * FROM expert_bank_card WHERE tenant_id=? AND expert_id=? AND is_deleted=0 ORDER BY is_default DESC, id DESC", + CARD_ROW_MAPPER, + PLATFORM_TENANT_ID, + expertId + ); + } + + public ExpertBankCardInfo getCard(Long expertId, Long cardId) { + assertExpertExists(expertId); + assertExpertAccessible(expertId); + return findCardById(expertId, cardId); + } + + @Transactional(rollbackFor = Exception.class) + public ExpertBankCardInfo addCard(Long expertId, AddBankCardRequest request) { + assertExpertExists(expertId); + assertExpertAccessible(expertId); + if (Boolean.TRUE.equals(request.getIsDefault())) { + jdbcTemplate.update("UPDATE expert_bank_card SET is_default='N', updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND expert_id=?", PLATFORM_TENANT_ID, expertId); + } + jdbcTemplate.update( + "INSERT INTO expert_bank_card (tenant_id, expert_id, bank_name, bank_province, bank_city, bank_branch_name, bank_card_no, bank_card_front_oss_key, bank_card_back_oss_key, account_name, is_default, card_status, inconsistent_name_approved, change_reason, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + PLATFORM_TENANT_ID, + expertId, + request.getBankName(), + request.getBankProvince(), + request.getBankCity(), + request.getBankBranchName(), + request.getBankCardNo(), + request.getBankCardFrontOssKey(), + request.getBankCardBackOssKey(), + request.getAccountName(), + Boolean.TRUE.equals(request.getIsDefault()) ? "Y" : "N", + request.getCardStatus() == null || request.getCardStatus().trim().isEmpty() ? "ENABLED" : request.getCardStatus().trim().toUpperCase(), + Boolean.TRUE.equals(request.getInconsistentNameApproved()) ? 1 : 0, + request.getChangeReason(), + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM expert_bank_card WHERE tenant_id=? AND expert_id=?", Long.class, PLATFORM_TENANT_ID, expertId); + List list = jdbcTemplate.query( + "SELECT * FROM expert_bank_card WHERE tenant_id=? AND id=? LIMIT 1", + CARD_ROW_MAPPER, + PLATFORM_TENANT_ID, + id == null ? 0L : id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "银行卡不存在"); + } + return list.get(0); + } + + @Transactional(rollbackFor = Exception.class) + public ExpertBankCardInfo updateCard(Long expertId, Long cardId, AddBankCardRequest request) { + assertExpertExists(expertId); + assertExpertAccessible(expertId); + findCardById(expertId, cardId); + if (Boolean.TRUE.equals(request.getIsDefault())) { + jdbcTemplate.update( + "UPDATE expert_bank_card SET is_default='N', updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND expert_id=?", + PLATFORM_TENANT_ID, + expertId + ); + } + jdbcTemplate.update( + "UPDATE expert_bank_card SET bank_name=?, bank_province=?, bank_city=?, bank_branch_name=?, bank_card_no=?, bank_card_front_oss_key=?, bank_card_back_oss_key=?, account_name=?, is_default=?, " + + "card_status=?, inconsistent_name_approved=?, change_reason=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND expert_id=? AND id=? AND is_deleted=0", + request.getBankName(), + request.getBankProvince(), + request.getBankCity(), + request.getBankBranchName(), + request.getBankCardNo(), + request.getBankCardFrontOssKey(), + request.getBankCardBackOssKey(), + request.getAccountName(), + Boolean.TRUE.equals(request.getIsDefault()) ? "Y" : "N", + request.getCardStatus() == null || request.getCardStatus().trim().isEmpty() ? "ENABLED" : request.getCardStatus().trim().toUpperCase(), + Boolean.TRUE.equals(request.getInconsistentNameApproved()) ? 1 : 0, + request.getChangeReason(), + safeUserId(), + PLATFORM_TENANT_ID, + expertId, + cardId + ); + return findCardById(expertId, cardId); + } + + @Transactional(rollbackFor = Exception.class) + public Map merge(Long targetExpertId, MergeExpertRequest request) { + assertExpertExists(targetExpertId); + assertExpertExists(request.getSourceExpertId()); + assertExpertAccessible(targetExpertId); + assertExpertAccessible(request.getSourceExpertId()); + if (targetExpertId.equals(request.getSourceExpertId())) { + throw new BusinessException(10001, "来源专家不能与目标专家相同"); + } + List sourceCards = jdbcTemplate.query( + "SELECT * FROM expert_bank_card WHERE tenant_id=? AND expert_id=? AND is_deleted=0", + CARD_ROW_MAPPER, + PLATFORM_TENANT_ID, + request.getSourceExpertId() + ); + for (ExpertBankCardInfo card : sourceCards) { + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM expert_bank_card WHERE tenant_id=? AND expert_id=? AND bank_card_no=? AND is_deleted=0", + Integer.class, + PLATFORM_TENANT_ID, + targetExpertId, + card.getBankCardNo() + ); + if (exists == null || exists == 0) { + jdbcTemplate.update( + "INSERT INTO expert_bank_card (tenant_id, expert_id, bank_name, bank_province, bank_city, bank_branch_name, bank_card_no, bank_card_front_oss_key, bank_card_back_oss_key, account_name, is_default, card_status, inconsistent_name_approved, change_reason, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N', ?, ?, ?, ?, ?)", + PLATFORM_TENANT_ID, + targetExpertId, + card.getBankName(), + card.getBankProvince(), + card.getBankCity(), + card.getBankBranchName(), + card.getBankCardNo(), + card.getBankCardFrontOssKey(), + card.getBankCardBackOssKey(), + card.getAccountName(), + card.getCardStatus() == null ? "ENABLED" : card.getCardStatus(), + Boolean.TRUE.equals(card.getInconsistentNameApproved()) ? 1 : 0, + card.getChangeReason(), + safeUserId(), + safeUserId() + ); + } + } + jdbcTemplate.update( + "UPDATE expert SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + PLATFORM_TENANT_ID, + request.getSourceExpertId() + ); + jdbcTemplate.update( + "INSERT INTO expert_merge_log (tenant_id, target_expert_id, source_expert_id, reason, created_by) VALUES (?, ?, ?, ?, ?)", + PLATFORM_TENANT_ID, + targetExpertId, + request.getSourceExpertId(), + request.getReason(), + safeUserId() + ); + Map data = new LinkedHashMap(); + data.put("targetExpertId", targetExpertId); + data.put("sourceExpertId", request.getSourceExpertId()); + data.put("status", "MERGED"); + return data; + } + + public Map export() { + List list = list(null, 1, 10000).getList(); + Map data = new LinkedHashMap(); + data.put("total", list.size()); + data.put("records", list); + return data; + } + + private List filterByExpertScope(List source) { + DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope(); + if (scope.isExpertAll()) { + return source; + } + Set expertIds = new HashSet(); + for (ExpertInfo item : source) { + if (item != null && item.getId() != null) { + expertIds.add(item.getId()); + } + } + Map creatorMap = dataPermissionService.listExpertCreators(expertIds); + List filtered = new java.util.ArrayList(); + for (ExpertInfo item : source) { + if (item == null || item.getId() == null) { + continue; + } + if (dataPermissionService.canAccessExpert(item.getId(), creatorMap.get(item.getId()), scope)) { + filtered.add(item); + } + } + return filtered; + } + + private void assertExpertAccessible(Long expertId) { + DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope(); + Map creatorMap = dataPermissionService.listExpertCreators(java.util.Collections.singleton(expertId)); + if (!dataPermissionService.canAccessExpert(expertId, creatorMap.get(expertId), scope)) { + throw new BusinessException(10003, "无权访问该专家"); + } + } + + private ExpertInfo maskSensitiveFields(ExpertInfo source) { + return new ExpertInfo( + source.getId(), + source.getExpertName(), + source.getGender(), + source.getBirthday(), + maskIdNo(source.getIdNo()), + source.getIdCardValidUntil(), + source.getIdCardFrontOssKey(), + source.getIdCardBackOssKey(), + maskPhone(source.getPhone()), + source.getTitleCode(), + source.getTitle(), + source.getHospitalCode(), + source.getOrganization(), + source.getStatus(), + source.getStatusReason(), + source.getStatusChangedAt(), + source.getExportRestricted() + ); + } + + private String maskIdNo(String idNo) { + if (idNo == null || idNo.trim().isEmpty()) { + return idNo; + } + String value = idNo.trim(); + if (value.length() <= 8) { + return value; + } + return value.substring(0, 4) + "********" + value.substring(value.length() - 4); + } + + private String maskPhone(String phone) { + if (phone == null || phone.trim().isEmpty()) { + return phone; + } + String value = phone.trim(); + if (value.length() <= 7) { + return value; + } + return value.substring(0, 3) + "****" + value.substring(value.length() - 4); + } + + private ExpertInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT e.id, e.expert_name, e.gender, e.birthday, e.id_no, e.id_card_valid_until, e.id_card_front_oss_key, e.id_card_back_oss_key, e.phone, " + + "e.title_code, IFNULL(dt.dict_name, e.title) AS title_name, " + + "e.hospital_code, IFNULL(dh.dict_name, e.organization) AS hospital_name, " + + "e.status, e.status_reason, e.status_changed_at, e.export_restricted " + + "FROM expert e " + + "LEFT JOIN platform_dictionary_item dt ON dt.dict_type='EXPERT_TITLE' AND dt.dict_code=e.title_code AND dt.is_deleted=0 " + + "LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " + + "WHERE e.tenant_id=? AND e.id=? AND e.is_deleted=0", + EXPERT_ROW_MAPPER, + PLATFORM_TENANT_ID, + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "专家不存在"); + } + return list.get(0); + } + + private void assertExpertExists(Long expertId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + PLATFORM_TENANT_ID, + expertId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "专家不存在"); + } + } + + private ExpertBankCardInfo findCardById(Long expertId, Long cardId) { + List list = jdbcTemplate.query( + "SELECT * FROM expert_bank_card WHERE tenant_id=? AND expert_id=? AND id=? AND is_deleted=0 LIMIT 1", + CARD_ROW_MAPPER, + PLATFORM_TENANT_ID, + expertId, + cardId + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "银行卡不存在"); + } + return list.get(0); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private String normalizeContentType(String contentType) { + if (contentType == null || contentType.trim().isEmpty()) { + return "application/octet-stream"; + } + return contentType.trim(); + } + + /** + * 解析并校验平台字典项: + * 1) 优先按编码命中; + * 2) 兼容历史按名称命中; + * 3) 仅允许启用状态字典项。 + */ + private DictionaryItem resolveDictionaryItem(String dictType, String dictCode, String dictName, String label) { + String code = dictCode == null ? null : dictCode.trim(); + if (code != null && !code.isEmpty()) { + List byCode = jdbcTemplate.query( + "SELECT dict_code, dict_name FROM platform_dictionary_item " + + "WHERE dict_type=? AND dict_code=? AND status='ENABLED' AND is_deleted=0 LIMIT 1", + (rs, n) -> new DictionaryItem(rs.getString("dict_code"), rs.getString("dict_name")), + dictType, + code + ); + if (byCode.isEmpty()) { + throw new BusinessException(10001, label + "字典编码不存在或未启用"); + } + return byCode.get(0); + } + String name = dictName == null ? null : dictName.trim(); + if (name == null || name.isEmpty()) { + throw new BusinessException(10001, label + "不能为空"); + } + List byName = jdbcTemplate.query( + "SELECT dict_code, dict_name FROM platform_dictionary_item " + + "WHERE dict_type=? AND dict_name=? AND status='ENABLED' AND is_deleted=0 LIMIT 1", + (rs, n) -> new DictionaryItem(rs.getString("dict_code"), rs.getString("dict_name")), + dictType, + name + ); + if (byName.isEmpty()) { + throw new BusinessException(10001, label + "不在平台字典内,请先在平台字典中维护"); + } + return byName.get(0); + } + + private void validateImportExpert(CreateExpertRequest request, Set batchIdNos, Set batchPhones) { + if (request == null) { + throw new BusinessException(10001, "导入行不能为空"); + } + if (request.getExpertName() == null || request.getExpertName().trim().isEmpty()) { + throw new BusinessException(10001, "专家姓名不能为空"); + } + ImportValidationUtils.validateIdNo(request.getIdNo()); + ImportValidationUtils.validatePhone(request.getPhone()); + String idNo = ImportValidationUtils.trim(request.getIdNo()).toUpperCase(); + String phone = ImportValidationUtils.trim(request.getPhone()); + if (!batchIdNos.add(idNo)) { + throw new BusinessException(10001, "批次内身份证号重复"); + } + if (!batchPhones.add(phone)) { + throw new BusinessException(10001, "批次内手机号重复"); + } + } + + private String buildExpertIdentifier(CreateExpertRequest request) { + if (request == null) { + return ""; + } + String name = request.getExpertName() == null ? "" : request.getExpertName().trim(); + String idNo = request.getIdNo() == null ? "" : request.getIdNo().trim(); + if (!name.isEmpty() && !idNo.isEmpty()) { + return name + "/" + idNo; + } + return !name.isEmpty() ? name : idNo; + } + + private String resolveImportMessage(Exception ex) { + if (ex instanceof BusinessException) { + return ex.getMessage(); + } + return ex.getMessage() == null || ex.getMessage().trim().isEmpty() + ? "导入失败" + : ex.getMessage(); + } + + private static final class DictionaryItem { + private final String dictCode; + private final String dictName; + + private DictionaryItem(String dictCode, String dictName) { + this.dictCode = dictCode; + this.dictName = dictName; + } + + private String getDictCode() { + return dictCode; + } + + private String getDictName() { + return dictName; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/service/ExpertSnapshotService.java b/backend/src/main/java/com/writeoff/module/expert/service/ExpertSnapshotService.java new file mode 100644 index 0000000..dd55209 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/service/ExpertSnapshotService.java @@ -0,0 +1,131 @@ +package com.writeoff.module.expert.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class ExpertSnapshotService { + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public ExpertSnapshotService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional(rollbackFor = Exception.class) + public int snapshotOnMeetingSubmit(Long meetingId) { + List list = jdbcTemplate.queryForList( + "SELECT content_json FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND module_code='EXPERT_PROFILE' AND is_deleted=0 LIMIT 1", + String.class, + tenantId(), + meetingId + ); + if (list.isEmpty() || list.get(0) == null || list.get(0).trim().isEmpty()) { + return 0; + } + List> profiles = new java.util.ArrayList>(); + try { + Map root = objectMapper.readValue(list.get(0), new TypeReference>() {}); + Object profileObj = root.get("profiles"); + if (!(profileObj instanceof List)) { + return 0; + } + List rawProfiles = (List) profileObj; + for (Object item : rawProfiles) { + if (item instanceof Map) { + Map profile = new LinkedHashMap(); + Map rawMap = (Map) item; + for (Map.Entry entry : rawMap.entrySet()) { + profile.put(String.valueOf(entry.getKey()), entry.getValue()); + } + profiles.add(profile); + } + } + } catch (Exception ex) { + return 0; + } + + jdbcTemplate.update( + "DELETE FROM meeting_expert_snapshot WHERE tenant_id=? AND meeting_id=?", + tenantId(), + meetingId + ); + + int count = 0; + for (Map profile : profiles) { + Long expertId = resolveExpertId(profile); + if (expertId == null) { + continue; + } + String snapshotJson; + try { + Map snapshot = new LinkedHashMap(profile); + snapshot.put("expertId", expertId); + snapshotJson = objectMapper.writeValueAsString(snapshot); + } catch (Exception ex) { + continue; + } + jdbcTemplate.update( + "INSERT INTO meeting_expert_snapshot (tenant_id, meeting_id, expert_id, snapshot_json, created_by) VALUES (?, ?, ?, ?, ?)", + tenantId(), + meetingId, + expertId, + snapshotJson, + safeUserId() + ); + count++; + } + return count; + } + + private Long resolveExpertId(Map profile) { + Object rawId = profile.get("expertId"); + if (rawId != null && String.valueOf(rawId).trim().matches("\\d+")) { + Long expertId = Long.valueOf(String.valueOf(rawId).trim()); + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + expertId + ); + if (count != null && count > 0) { + return expertId; + } + } + String expertName = value(profile.get("expertName")); + String organization = value(profile.get("organization")); + if (expertName.isEmpty()) { + return null; + } + List ids = jdbcTemplate.queryForList( + "SELECT id FROM expert WHERE tenant_id=? AND expert_name=? AND (?='' OR organization=?) AND is_deleted=0 ORDER BY id DESC LIMIT 1", + Long.class, + tenantId(), + expertName, + organization, + organization + ); + return ids.isEmpty() ? null : ids.get(0); + } + + private String value(Object val) { + return val == null ? "" : String.valueOf(val).trim(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/expert/service/PlatformExpertService.java b/backend/src/main/java/com/writeoff/module/expert/service/PlatformExpertService.java new file mode 100644 index 0000000..f208264 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/expert/service/PlatformExpertService.java @@ -0,0 +1,653 @@ +package com.writeoff.module.expert.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.model.ImportResult; +import com.writeoff.common.util.ImportValidationUtils; +import com.writeoff.module.expert.dto.AddBankCardRequest; +import com.writeoff.module.expert.dto.CreateExpertRequest; +import com.writeoff.module.expert.dto.MergeExpertRequest; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.expert.model.ExpertBankCardInfo; +import com.writeoff.module.expert.model.ExpertInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +@Service +public class PlatformExpertService { + private static final long PLATFORM_TENANT_ID = 0L; + private static final String DICT_TYPE_EXPERT_TITLE = "EXPERT_TITLE"; + private static final String DICT_TYPE_EXPERT_HOSPITAL = "EXPERT_HOSPITAL"; + private static final Pattern ID_NO_PATTERN = Pattern.compile("(^\\d{15}$)|(^\\d{17}[\\dXx]$)"); + + private final JdbcTemplate jdbcTemplate; + private final OssService ossService; + + private static final RowMapper EXPERT_ROW_MAPPER = (rs, n) -> new ExpertInfo( + rs.getLong("id"), + rs.getString("expert_name"), + rs.getString("gender"), + rs.getString("birthday"), + rs.getString("id_no"), + rs.getString("id_card_valid_until"), + rs.getString("id_card_front_oss_key"), + rs.getString("id_card_back_oss_key"), + rs.getString("phone"), + rs.getString("title_code"), + rs.getString("title_name"), + rs.getString("hospital_code"), + rs.getString("hospital_name"), + rs.getString("status"), + rs.getString("status_reason"), + rs.getString("status_changed_at"), + rs.getInt("export_restricted") == 1 + ); + + private static final RowMapper CARD_ROW_MAPPER = (rs, n) -> new ExpertBankCardInfo( + rs.getLong("id"), + rs.getLong("expert_id"), + rs.getString("bank_name"), + rs.getString("bank_province"), + rs.getString("bank_city"), + rs.getString("bank_branch_name"), + rs.getString("bank_card_no"), + rs.getString("bank_card_front_oss_key"), + rs.getString("bank_card_back_oss_key"), + rs.getString("account_name"), + rs.getString("is_default"), + rs.getString("card_status"), + rs.getInt("inconsistent_name_approved") == 1, + rs.getString("change_reason") + ); + + public PlatformExpertService(JdbcTemplate jdbcTemplate, OssService ossService) { + this.jdbcTemplate = jdbcTemplate; + this.ossService = ossService; + } + + public Map presignAssetUpload(String fileName, String contentType) { + String name = fileName == null ? "" : fileName.trim(); + if (name.isEmpty()) { + throw new BusinessException(10001, "文件名不能为空"); + } + String ext = ""; + int idx = name.lastIndexOf('.'); + if (idx > 0 && idx < name.length() - 1) { + ext = "." + name.substring(idx + 1).toLowerCase(); + } + String normalizedType = normalizeContentType(contentType); + String objectKey = "expert/asset/" + PLATFORM_TENANT_ID + "/" + safeUserId() + "/" + UUID.randomUUID().toString().replace("-", "") + ext; + String uploadUrl = ossService.generateUploadUrl(objectKey, normalizedType); + Map data = new LinkedHashMap(); + data.put("objectKey", objectKey); + data.put("uploadUrl", uploadUrl); + data.put("contentType", normalizedType); + data.put("method", "PUT"); + return data; + } + + public PageResult list(String keyword) { + StringBuilder sql = new StringBuilder( + "SELECT e.id, e.expert_name, e.gender, e.birthday, e.id_no, e.id_card_valid_until, e.id_card_front_oss_key, e.id_card_back_oss_key, e.phone, " + + "e.title_code, IFNULL(dt.dict_name, e.title) AS title_name, " + + "e.hospital_code, IFNULL(dh.dict_name, e.organization) AS hospital_name, " + + "e.status, e.status_reason, e.status_changed_at, e.export_restricted " + + "FROM expert e " + + "LEFT JOIN platform_dictionary_item dt ON dt.dict_type='EXPERT_TITLE' AND dt.dict_code=e.title_code AND dt.is_deleted=0 " + + "LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " + + "WHERE e.tenant_id=" + PLATFORM_TENANT_ID + " AND e.is_deleted=0" + ); + String idNoKeyword = normalizeIdNoKeyword(keyword); + List params = new ArrayList(); + if (idNoKeyword != null) { + sql.append(" AND e.id_no = ?"); + params.add(idNoKeyword); + } + sql.append(" ORDER BY e.id DESC LIMIT 200"); + List list = params.isEmpty() + ? jdbcTemplate.query(sql.toString(), EXPERT_ROW_MAPPER) + : jdbcTemplate.query(sql.toString(), EXPERT_ROW_MAPPER, params.toArray()); + List maskedList = new ArrayList(list.size()); + for (ExpertInfo item : list) { + maskedList.add(maskSensitiveFields(item)); + } + return new PageResult(maskedList, maskedList.size(), 1, 200); + } + + public ExpertInfo get(Long id) { + return findById(id); + } + + public ExpertInfo findByExactIdNo(String idNo) { + String normalized = normalizeIdNoValue(idNo); + if (normalized == null) { + return null; + } + List list = jdbcTemplate.query( + "SELECT e.id, e.expert_name, e.gender, e.birthday, e.id_no, e.id_card_valid_until, e.id_card_front_oss_key, e.id_card_back_oss_key, e.phone, " + + "e.title_code, IFNULL(dt.dict_name, e.title) AS title_name, " + + "e.hospital_code, IFNULL(dh.dict_name, e.organization) AS hospital_name, " + + "e.status, e.status_reason, e.status_changed_at, e.export_restricted " + + "FROM expert e " + + "LEFT JOIN platform_dictionary_item dt ON dt.dict_type='EXPERT_TITLE' AND dt.dict_code=e.title_code AND dt.is_deleted=0 " + + "LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " + + "WHERE e.tenant_id=? AND e.id_no=? AND e.is_deleted=0 LIMIT 1", + EXPERT_ROW_MAPPER, + PLATFORM_TENANT_ID, + normalized + ); + return list.isEmpty() ? null : list.get(0); + } + + @Transactional(rollbackFor = Exception.class) + public ExpertInfo create(CreateExpertRequest request) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id_no=? AND is_deleted=0", + Integer.class, + PLATFORM_TENANT_ID, + request.getIdNo() + ); + if (count != null && count > 0) { + throw new BusinessException(10001, "身份证号已存在"); + } + DictionaryItem titleItem = resolveOptionalDictionaryItem(DICT_TYPE_EXPERT_TITLE, request.getTitleCode(), request.getTitle(), "职称"); + DictionaryItem hospitalItem = resolveOptionalDictionaryItem(DICT_TYPE_EXPERT_HOSPITAL, request.getHospitalCode(), request.getOrganization(), "医院"); + jdbcTemplate.update( + "INSERT INTO expert (tenant_id, expert_name, gender, birthday, id_no, id_card_valid_until, id_card_front_oss_key, id_card_back_oss_key, phone, title_code, title, hospital_code, organization, status, status_reason, status_changed_by, status_changed_at, export_restricted, created_by, updated_by) " + + "VALUES (?, ?, ?, STR_TO_DATE(?, '%Y-%m-%d'), ?, STR_TO_DATE(?, '%Y-%m-%d'), ?, ?, ?, ?, ?, ?, ?, 'ENABLED', ?, ?, NOW(), ?, ?, ?)", + PLATFORM_TENANT_ID, + request.getExpertName(), + request.getGender(), + request.getBirthday(), + request.getIdNo(), + request.getIdCardValidUntil(), + request.getIdCardFrontOssKey(), + request.getIdCardBackOssKey(), + request.getPhone(), + titleItem == null ? null : titleItem.getDictCode(), + titleItem == null ? null : titleItem.getDictName(), + hospitalItem == null ? null : hospitalItem.getDictCode(), + hospitalItem == null ? null : hospitalItem.getDictName(), + request.getStatusReason(), + safeUserId(), + Boolean.TRUE.equals(request.getExportRestricted()) ? 1 : 0, + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM expert WHERE tenant_id=?", Long.class, PLATFORM_TENANT_ID); + return findById(id == null ? 0L : id); + } + + public ImportResult importExperts(List experts) { + ImportResult result = new ImportResult(); + result.setTotal(experts == null ? 0 : experts.size()); + if (experts == null) { + return result; + } + Set batchIdNos = new HashSet(); + Set batchPhones = new HashSet(); + for (int i = 0; i < experts.size(); i++) { + CreateExpertRequest item = experts.get(i); + int rowNo = i + 2; + try { + validateImportExpert(item, batchIdNos, batchPhones); + create(item); + result.markSuccess(); + } catch (Exception ex) { + result.addError(rowNo, buildExpertIdentifier(item), resolveImportMessage(ex)); + } + } + return result; + } + + @Transactional(rollbackFor = Exception.class) + public ExpertInfo update(Long id, CreateExpertRequest request) { + assertExpertExists(id); + DictionaryItem titleItem = resolveOptionalDictionaryItem(DICT_TYPE_EXPERT_TITLE, request.getTitleCode(), request.getTitle(), "职称"); + DictionaryItem hospitalItem = resolveOptionalDictionaryItem(DICT_TYPE_EXPERT_HOSPITAL, request.getHospitalCode(), request.getOrganization(), "医院"); + jdbcTemplate.update( + "UPDATE expert SET expert_name=?, gender=?, birthday=STR_TO_DATE(?, '%Y-%m-%d'), id_card_valid_until=STR_TO_DATE(?, '%Y-%m-%d'), " + + "id_card_front_oss_key=?, id_card_back_oss_key=?, phone=?, " + + "title_code=?, title=?, hospital_code=?, organization=?, status_reason=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND id=? AND is_deleted=0", + request.getExpertName(), + request.getGender(), + request.getBirthday(), + request.getIdCardValidUntil(), + request.getIdCardFrontOssKey(), + request.getIdCardBackOssKey(), + request.getPhone(), + titleItem == null ? null : titleItem.getDictCode(), + titleItem == null ? null : titleItem.getDictName(), + hospitalItem == null ? null : hospitalItem.getDictCode(), + hospitalItem == null ? null : hospitalItem.getDictName(), + request.getStatusReason(), + safeUserId(), + PLATFORM_TENANT_ID, + id + ); + return findById(id); + } + + public List listCards(Long expertId) { + assertExpertExists(expertId); + return jdbcTemplate.query( + "SELECT * FROM expert_bank_card WHERE tenant_id=? AND expert_id=? AND is_deleted=0 ORDER BY is_default DESC, id DESC", + CARD_ROW_MAPPER, + PLATFORM_TENANT_ID, + expertId + ); + } + + public ExpertBankCardInfo getCard(Long expertId, Long cardId) { + assertExpertExists(expertId); + return findCardById(expertId, cardId); + } + + @Transactional(rollbackFor = Exception.class) + public ExpertBankCardInfo addCard(Long expertId, AddBankCardRequest request) { + assertExpertExists(expertId); + if (Boolean.TRUE.equals(request.getIsDefault())) { + jdbcTemplate.update( + "UPDATE expert_bank_card SET is_default='N', updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND expert_id=?", + PLATFORM_TENANT_ID, + expertId + ); + } + jdbcTemplate.update( + "INSERT INTO expert_bank_card (tenant_id, expert_id, bank_name, bank_province, bank_city, bank_branch_name, bank_card_no, bank_card_front_oss_key, bank_card_back_oss_key, account_name, is_default, card_status, inconsistent_name_approved, change_reason, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + PLATFORM_TENANT_ID, + expertId, + request.getBankName(), + request.getBankProvince(), + request.getBankCity(), + request.getBankBranchName(), + request.getBankCardNo(), + request.getBankCardFrontOssKey(), + request.getBankCardBackOssKey(), + request.getAccountName(), + Boolean.TRUE.equals(request.getIsDefault()) ? "Y" : "N", + request.getCardStatus() == null || request.getCardStatus().trim().isEmpty() ? "ENABLED" : request.getCardStatus().trim().toUpperCase(), + Boolean.TRUE.equals(request.getInconsistentNameApproved()) ? 1 : 0, + request.getChangeReason(), + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM expert_bank_card WHERE tenant_id=? AND expert_id=?", Long.class, PLATFORM_TENANT_ID, expertId); + List list = jdbcTemplate.query( + "SELECT * FROM expert_bank_card WHERE tenant_id=? AND id=? LIMIT 1", + CARD_ROW_MAPPER, + PLATFORM_TENANT_ID, + id == null ? 0L : id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "银行卡不存在"); + } + return list.get(0); + } + + @Transactional(rollbackFor = Exception.class) + public ExpertBankCardInfo updateCard(Long expertId, Long cardId, AddBankCardRequest request) { + assertExpertExists(expertId); + findCardById(expertId, cardId); + if (Boolean.TRUE.equals(request.getIsDefault())) { + jdbcTemplate.update( + "UPDATE expert_bank_card SET is_default='N', updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND expert_id=?", + PLATFORM_TENANT_ID, + expertId + ); + } + jdbcTemplate.update( + "UPDATE expert_bank_card SET bank_name=?, bank_province=?, bank_city=?, bank_branch_name=?, bank_card_no=?, bank_card_front_oss_key=?, bank_card_back_oss_key=?, account_name=?, is_default=?, " + + "card_status=?, inconsistent_name_approved=?, change_reason=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND expert_id=? AND id=? AND is_deleted=0", + request.getBankName(), + request.getBankProvince(), + request.getBankCity(), + request.getBankBranchName(), + request.getBankCardNo(), + request.getBankCardFrontOssKey(), + request.getBankCardBackOssKey(), + request.getAccountName(), + Boolean.TRUE.equals(request.getIsDefault()) ? "Y" : "N", + request.getCardStatus() == null || request.getCardStatus().trim().isEmpty() ? "ENABLED" : request.getCardStatus().trim().toUpperCase(), + Boolean.TRUE.equals(request.getInconsistentNameApproved()) ? 1 : 0, + request.getChangeReason(), + safeUserId(), + PLATFORM_TENANT_ID, + expertId, + cardId + ); + return findCardById(expertId, cardId); + } + + @Transactional(rollbackFor = Exception.class) + public ExpertBankCardInfo addOrUpdateDefaultCard(Long expertId, AddBankCardRequest request) { + assertExpertExists(expertId); + String cardNo = request.getBankCardNo() == null ? "" : request.getBankCardNo().trim(); + List cards = listCards(expertId); + for (ExpertBankCardInfo item : cards) { + String savedNo = item.getBankCardNo() == null ? "" : item.getBankCardNo().trim(); + if (!savedNo.isEmpty() && savedNo.equals(cardNo)) { + request.setIsDefault(true); + return updateCard(expertId, item.getId(), request); + } + } + request.setIsDefault(true); + return addCard(expertId, request); + } + + @Transactional(rollbackFor = Exception.class) + public Map merge(Long targetExpertId, MergeExpertRequest request) { + assertExpertExists(targetExpertId); + assertExpertExists(request.getSourceExpertId()); + if (targetExpertId.equals(request.getSourceExpertId())) { + throw new BusinessException(10001, "来源专家不能与目标专家相同"); + } + List sourceCards = jdbcTemplate.query( + "SELECT * FROM expert_bank_card WHERE tenant_id=? AND expert_id=? AND is_deleted=0", + CARD_ROW_MAPPER, + PLATFORM_TENANT_ID, + request.getSourceExpertId() + ); + for (ExpertBankCardInfo card : sourceCards) { + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM expert_bank_card WHERE tenant_id=? AND expert_id=? AND bank_card_no=? AND is_deleted=0", + Integer.class, + PLATFORM_TENANT_ID, + targetExpertId, + card.getBankCardNo() + ); + if (exists == null || exists == 0) { + jdbcTemplate.update( + "INSERT INTO expert_bank_card (tenant_id, expert_id, bank_name, bank_province, bank_city, bank_branch_name, bank_card_no, bank_card_front_oss_key, bank_card_back_oss_key, account_name, is_default, card_status, inconsistent_name_approved, change_reason, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'N', ?, ?, ?, ?, ?)", + PLATFORM_TENANT_ID, + targetExpertId, + card.getBankName(), + card.getBankProvince(), + card.getBankCity(), + card.getBankBranchName(), + card.getBankCardNo(), + card.getBankCardFrontOssKey(), + card.getBankCardBackOssKey(), + card.getAccountName(), + card.getCardStatus() == null ? "ENABLED" : card.getCardStatus(), + Boolean.TRUE.equals(card.getInconsistentNameApproved()) ? 1 : 0, + card.getChangeReason(), + safeUserId(), + safeUserId() + ); + } + } + jdbcTemplate.update( + "UPDATE expert SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + PLATFORM_TENANT_ID, + request.getSourceExpertId() + ); + jdbcTemplate.update( + "INSERT INTO expert_merge_log (tenant_id, target_expert_id, source_expert_id, reason, created_by) VALUES (?, ?, ?, ?, ?)", + PLATFORM_TENANT_ID, + targetExpertId, + request.getSourceExpertId(), + request.getReason(), + safeUserId() + ); + Map data = new LinkedHashMap(); + data.put("targetExpertId", targetExpertId); + data.put("sourceExpertId", request.getSourceExpertId()); + data.put("status", "MERGED"); + return data; + } + + public Map export() { + List list = list(null).getList(); + Map data = new LinkedHashMap(); + data.put("total", list.size()); + data.put("records", list); + return data; + } + + private ExpertInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT e.id, e.expert_name, e.gender, e.birthday, e.id_no, e.id_card_valid_until, e.id_card_front_oss_key, e.id_card_back_oss_key, e.phone, " + + "e.title_code, IFNULL(dt.dict_name, e.title) AS title_name, " + + "e.hospital_code, IFNULL(dh.dict_name, e.organization) AS hospital_name, " + + "e.status, e.status_reason, e.status_changed_at, e.export_restricted " + + "FROM expert e " + + "LEFT JOIN platform_dictionary_item dt ON dt.dict_type='EXPERT_TITLE' AND dt.dict_code=e.title_code AND dt.is_deleted=0 " + + "LEFT JOIN platform_dictionary_item dh ON dh.dict_type='EXPERT_HOSPITAL' AND dh.dict_code=e.hospital_code AND dh.is_deleted=0 " + + "WHERE e.tenant_id=? AND e.id=? AND e.is_deleted=0", + EXPERT_ROW_MAPPER, + PLATFORM_TENANT_ID, + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "专家不存在"); + } + return list.get(0); + } + + private void assertExpertExists(Long expertId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM expert WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + PLATFORM_TENANT_ID, + expertId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "专家不存在"); + } + } + + private ExpertBankCardInfo findCardById(Long expertId, Long cardId) { + List list = jdbcTemplate.query( + "SELECT * FROM expert_bank_card WHERE tenant_id=? AND expert_id=? AND id=? AND is_deleted=0 LIMIT 1", + CARD_ROW_MAPPER, + PLATFORM_TENANT_ID, + expertId, + cardId + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "银行卡不存在"); + } + return list.get(0); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private String normalizeContentType(String contentType) { + if (contentType == null || contentType.trim().isEmpty()) { + return "application/octet-stream"; + } + return contentType.trim(); + } + + private String normalizeIdNoKeyword(String keyword) { + if (keyword == null || keyword.trim().isEmpty()) { + return null; + } + String trimmed = keyword.trim(); + if (!ID_NO_PATTERN.matcher(trimmed).matches()) { + throw new BusinessException(10001, "仅支持身份证号搜索"); + } + return trimmed.toUpperCase(); + } + + private String normalizeIdNoValue(String idNo) { + if (idNo == null || idNo.trim().isEmpty()) { + return null; + } + String normalized = idNo.trim().toUpperCase(); + if (!ID_NO_PATTERN.matcher(normalized).matches()) { + throw new BusinessException(10001, "身份证号格式不正确"); + } + return normalized; + } + + private ExpertInfo maskSensitiveFields(ExpertInfo source) { + return new ExpertInfo( + source.getId(), + source.getExpertName(), + source.getGender(), + source.getBirthday(), + maskIdNo(source.getIdNo()), + source.getIdCardValidUntil(), + source.getIdCardFrontOssKey(), + source.getIdCardBackOssKey(), + maskPhone(source.getPhone()), + source.getTitleCode(), + source.getTitle(), + source.getHospitalCode(), + source.getOrganization(), + source.getStatus(), + source.getStatusReason(), + source.getStatusChangedAt(), + source.getExportRestricted() + ); + } + + private String maskIdNo(String idNo) { + if (idNo == null || idNo.trim().isEmpty()) { + return idNo; + } + String value = idNo.trim(); + if (value.length() <= 8) { + return value; + } + return value.substring(0, 4) + "********" + value.substring(value.length() - 4); + } + + private String maskPhone(String phone) { + if (phone == null || phone.trim().isEmpty()) { + return phone; + } + String value = phone.trim(); + if (value.length() <= 7) { + return value; + } + return value.substring(0, 3) + "****" + value.substring(value.length() - 4); + } + + /** + * 解析并校验平台字典项: + * 1) 优先按编码命中; + * 2) 兼容历史按名称命中; + * 3) 仅允许启用状态字典项。 + */ + private DictionaryItem resolveDictionaryItem(String dictType, String dictCode, String dictName, String label) { + String code = dictCode == null ? null : dictCode.trim(); + if (code != null && !code.isEmpty()) { + List byCode = jdbcTemplate.query( + "SELECT dict_code, dict_name FROM platform_dictionary_item " + + "WHERE dict_type=? AND dict_code=? AND status='ENABLED' AND is_deleted=0 LIMIT 1", + (rs, n) -> new DictionaryItem(rs.getString("dict_code"), rs.getString("dict_name")), + dictType, + code + ); + if (byCode.isEmpty()) { + throw new BusinessException(10001, label + "字典编码不存在或未启用"); + } + return byCode.get(0); + } + String name = dictName == null ? null : dictName.trim(); + if (name == null || name.isEmpty()) { + throw new BusinessException(10001, label + "不能为空"); + } + List byName = jdbcTemplate.query( + "SELECT dict_code, dict_name FROM platform_dictionary_item " + + "WHERE dict_type=? AND dict_name=? AND status='ENABLED' AND is_deleted=0 LIMIT 1", + (rs, n) -> new DictionaryItem(rs.getString("dict_code"), rs.getString("dict_name")), + dictType, + name + ); + if (byName.isEmpty()) { + throw new BusinessException(10001, label + "不在平台字典内,请先在平台字典中维护"); + } + return byName.get(0); + } + + private DictionaryItem resolveOptionalDictionaryItem(String dictType, String dictCode, String dictName, String label) { + boolean hasCode = dictCode != null && !dictCode.trim().isEmpty(); + boolean hasName = dictName != null && !dictName.trim().isEmpty(); + if (!hasCode && !hasName) { + return null; + } + return resolveDictionaryItem(dictType, dictCode, dictName, label); + } + + private void validateImportExpert(CreateExpertRequest request, Set batchIdNos, Set batchPhones) { + if (request == null) { + throw new BusinessException(10001, "导入行不能为空"); + } + if (request.getExpertName() == null || request.getExpertName().trim().isEmpty()) { + throw new BusinessException(10001, "专家姓名不能为空"); + } + ImportValidationUtils.validateIdNo(request.getIdNo()); + ImportValidationUtils.validatePhone(request.getPhone()); + String idNo = ImportValidationUtils.trim(request.getIdNo()).toUpperCase(); + String phone = ImportValidationUtils.trim(request.getPhone()); + if (!batchIdNos.add(idNo)) { + throw new BusinessException(10001, "批次内身份证号重复"); + } + if (!batchPhones.add(phone)) { + throw new BusinessException(10001, "批次内手机号重复"); + } + } + + private String buildExpertIdentifier(CreateExpertRequest request) { + if (request == null) { + return ""; + } + String name = request.getExpertName() == null ? "" : request.getExpertName().trim(); + String idNo = request.getIdNo() == null ? "" : request.getIdNo().trim(); + if (!name.isEmpty() && !idNo.isEmpty()) { + return name + "/" + idNo; + } + return !name.isEmpty() ? name : idNo; + } + + private String resolveImportMessage(Exception ex) { + if (ex instanceof BusinessException) { + return ex.getMessage(); + } + return ex.getMessage() == null || ex.getMessage().trim().isEmpty() + ? "导入失败" + : ex.getMessage(); + } + + private static final class DictionaryItem { + private final String dictCode; + private final String dictName; + + private DictionaryItem(String dictCode, String dictName) { + this.dictCode = dictCode; + this.dictName = dictName; + } + + private String getDictCode() { + return dictCode; + } + + private String getDictName() { + return dictName; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/export/controller/ExportTaskController.java b/backend/src/main/java/com/writeoff/module/export/controller/ExportTaskController.java new file mode 100644 index 0000000..e03ad7e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/export/controller/ExportTaskController.java @@ -0,0 +1,48 @@ +package com.writeoff.module.export.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.export.dto.CreateExportTaskRequest; +import com.writeoff.module.export.model.ExportTaskInfo; +import com.writeoff.module.export.service.ExportTaskService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.Map; + +@RestController +@RequestMapping("/api/export-tasks") +public class ExportTaskController { + private final ExportTaskService exportTaskService; + + public ExportTaskController(ExportTaskService exportTaskService) { + this.exportTaskService = exportTaskService; + } + + @GetMapping + @RequirePermission(value = "export.task.read", dataScope = DataScopeType.TENANT, auditAction = "EXPORT_TASK_LIST") + public ApiResponse> list() { + return ApiResponse.success(exportTaskService.list()); + } + + @PostMapping + @RequirePermission(value = "export.task.manage", dataScope = DataScopeType.TENANT, auditAction = "EXPORT_TASK_CREATE") + public ApiResponse> create(@RequestBody @Valid CreateExportTaskRequest request) { + return ApiResponse.success(exportTaskService.create(request)); + } + + @PostMapping("/{id}/refresh-token") + @RequirePermission(value = "export.task.manage", dataScope = DataScopeType.TENANT, auditAction = "EXPORT_TASK_REFRESH_TOKEN") + public ApiResponse> refreshToken(@PathVariable("id") Long id) { + return ApiResponse.success(exportTaskService.refreshDownloadToken(id)); + } + + @GetMapping("/{id}/download") + @RequirePermission(value = "export.task.download", dataScope = DataScopeType.TENANT, auditAction = "EXPORT_TASK_DOWNLOAD") + public ApiResponse> download(@PathVariable("id") Long id, + @RequestParam("token") String token) { + return ApiResponse.success(exportTaskService.download(id, token)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/export/dto/CreateExportTaskRequest.java b/backend/src/main/java/com/writeoff/module/export/dto/CreateExportTaskRequest.java new file mode 100644 index 0000000..5e62c29 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/export/dto/CreateExportTaskRequest.java @@ -0,0 +1,63 @@ +package com.writeoff.module.export.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateExportTaskRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotBlank(message = "任务编码不能为空") + private String taskCode; + @NotBlank(message = "业务类型不能为空") + private String bizType; + private String bizId; + private String filtersJson; + private String fileName; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getTaskCode() { + return taskCode; + } + + public void setTaskCode(String taskCode) { + this.taskCode = taskCode; + } + + public String getBizType() { + return bizType; + } + + public void setBizType(String bizType) { + this.bizType = bizType; + } + + public String getBizId() { + return bizId; + } + + public void setBizId(String bizId) { + this.bizId = bizId; + } + + public String getFiltersJson() { + return filtersJson; + } + + public void setFiltersJson(String filtersJson) { + this.filtersJson = filtersJson; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } +} diff --git a/backend/src/main/java/com/writeoff/module/export/model/ExportTaskInfo.java b/backend/src/main/java/com/writeoff/module/export/model/ExportTaskInfo.java new file mode 100644 index 0000000..f90631c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/export/model/ExportTaskInfo.java @@ -0,0 +1,85 @@ +package com.writeoff.module.export.model; + +public class ExportTaskInfo { + private Long id; + private String taskCode; + private String bizType; + private String bizId; + private String fileName; + private String fileOssKey; + private String status; + private Integer retryCount; + private Integer downloadCount; + private String tokenExpireAt; + private String errorMessage; + private String createdAt; + private String finishedAt; + + public ExportTaskInfo(Long id, String taskCode, String bizType, String bizId, String fileName, String fileOssKey, String status, Integer retryCount, Integer downloadCount, String tokenExpireAt, String errorMessage, String createdAt, String finishedAt) { + this.id = id; + this.taskCode = taskCode; + this.bizType = bizType; + this.bizId = bizId; + this.fileName = fileName; + this.fileOssKey = fileOssKey; + this.status = status; + this.retryCount = retryCount; + this.downloadCount = downloadCount; + this.tokenExpireAt = tokenExpireAt; + this.errorMessage = errorMessage; + this.createdAt = createdAt; + this.finishedAt = finishedAt; + } + + public Long getId() { + return id; + } + + public String getTaskCode() { + return taskCode; + } + + public String getBizType() { + return bizType; + } + + public String getBizId() { + return bizId; + } + + public String getFileName() { + return fileName; + } + + public String getFileOssKey() { + return fileOssKey; + } + + public String getStatus() { + return status; + } + + public Integer getRetryCount() { + return retryCount; + } + + public Integer getDownloadCount() { + return downloadCount; + } + + public String getTokenExpireAt() { + return tokenExpireAt; + } + + public String getErrorMessage() { + return errorMessage; + } + + public String getCreatedAt() { + return createdAt; + } + + public String getFinishedAt() { + return finishedAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/export/service/ExportTaskService.java b/backend/src/main/java/com/writeoff/module/export/service/ExportTaskService.java new file mode 100644 index 0000000..2b6af64 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/export/service/ExportTaskService.java @@ -0,0 +1,579 @@ +package com.writeoff.module.export.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.export.dto.CreateExportTaskRequest; +import com.writeoff.module.export.model.ExportTaskInfo; +import com.writeoff.module.meeting.service.MeetingMaterialExportService; +import com.writeoff.module.meeting.service.MeetingSummaryExportService; +import com.writeoff.module.scheduler.service.AsyncJobService; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Locale; +import java.util.stream.Collectors; +import java.nio.charset.StandardCharsets; + +@Service +public class ExportTaskService { + private final JdbcTemplate jdbcTemplate; + private final AsyncJobService asyncJobService; + private final OssService ossService; + private final MeetingMaterialExportService meetingMaterialExportService; + private final MeetingSummaryExportService meetingSummaryExportService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final RowMapper ROW_MAPPER = (rs, n) -> new ExportTaskInfo( + rs.getLong("id"), + rs.getString("task_code"), + rs.getString("biz_type"), + rs.getString("biz_id"), + rs.getString("file_name"), + rs.getString("file_oss_key"), + rs.getString("status"), + rs.getInt("retry_count"), + rs.getInt("download_count"), + rs.getString("token_expire_at"), + rs.getString("error_message"), + rs.getString("created_at"), + rs.getString("finished_at") + ); + + public ExportTaskService(JdbcTemplate jdbcTemplate, + AsyncJobService asyncJobService, + OssService ossService, + MeetingMaterialExportService meetingMaterialExportService, + MeetingSummaryExportService meetingSummaryExportService) { + this.jdbcTemplate = jdbcTemplate; + this.asyncJobService = asyncJobService; + this.ossService = ossService; + this.meetingMaterialExportService = meetingMaterialExportService; + this.meetingSummaryExportService = meetingSummaryExportService; + } + + public PageResult list() { + List list = jdbcTemplate.query( + "SELECT id, task_code, biz_type, biz_id, file_name, file_oss_key, status, retry_count, IFNULL(download_count,0) AS download_count, " + + "DATE_FORMAT(download_token_expire_at, '%Y-%m-%d %H:%i:%s') AS token_expire_at, error_message, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, DATE_FORMAT(finished_at, '%Y-%m-%d %H:%i:%s') AS finished_at " + + "FROM export_task WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC LIMIT 300", + ROW_MAPPER, + tenantId() + ); + return new PageResult(list, list.size(), 1, 300); + } + + @Transactional(rollbackFor = Exception.class) + public Map create(CreateExportTaskRequest request) { + String fileName = request.getFileName() == null || request.getFileName().trim().isEmpty() + ? (request.getTaskCode() + "-" + System.currentTimeMillis() + ".csv") + : request.getFileName().trim(); + jdbcTemplate.update( + "INSERT INTO export_task (tenant_id, task_code, biz_type, biz_id, filters_json, file_name, status, retry_count, max_retry, idempotency_key, requested_by, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, 'PENDING', 0, 3, ?, ?, ?, ?)", + tenantId(), + request.getTaskCode(), + request.getBizType(), + request.getBizId(), + request.getFiltersJson(), + fileName, + request.getIdempotencyKey(), + safeUserId(), + safeUserId(), + safeUserId() + ); + Long taskId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM export_task WHERE tenant_id=?", Long.class, tenantId()); + Long id = taskId == null ? 0L : taskId; + Map payload = new LinkedHashMap(); + payload.put("taskId", id); + try { + asyncJobService.enqueue("EXPORT_TASK", objectMapper.writeValueAsString(payload), "export-task-" + id); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "导出任务入队失败"); + } + Map result = new LinkedHashMap(); + result.put("taskId", id); + result.put("status", "PENDING"); + return result; + } + + public void processTask(String payload) { + Long taskId = parseTaskId(payload); + Map task = findTaskDetail(taskId); + String status = String.valueOf(task.get("status")); + if ("SUCCESS".equalsIgnoreCase(status)) { + return; + } + int retryCount = ((Number) task.get("retry_count")).intValue(); + int maxRetry = ((Number) task.get("max_retry")).intValue(); + Long taskTenantId = toLong(task.get("tenant_id")); + Long requestedBy = toLong(task.get("requested_by")); + try { + ExportContent exportContent = buildExportContent(taskTenantId, task); + String fileOssKey = "exports/" + (taskTenantId == null ? tenantId() : taskTenantId) + "/" + taskId + "-" + System.currentTimeMillis() + exportContent.extension; + ossService.putObject(fileOssKey, exportContent.bytes, exportContent.contentType); + String token = UUID.randomUUID().toString().replace("-", ""); + jdbcTemplate.update( + "UPDATE export_task SET status='SUCCESS', file_oss_key=?, download_token=?, download_token_expire_at=DATE_ADD(NOW(), INTERVAL 7 DAY), error_message=NULL, finished_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + fileOssKey, + token, + requestedBy == null ? 0L : requestedBy, + taskTenantId, + taskId + ); + } catch (Exception ex) { + int nextRetry = retryCount + 1; + String nextStatus = nextRetry >= maxRetry ? "FAILED" : "PENDING"; + jdbcTemplate.update( + "UPDATE export_task SET status=?, retry_count=?, error_message=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + nextStatus, + nextRetry, + truncateErrorMessage(ex), + requestedBy == null ? 0L : requestedBy, + taskTenantId, + taskId + ); + throw new IllegalStateException("导出任务处理失败: " + truncateErrorMessage(ex), ex); + } + } + + private ExportContent buildExportContent(Long taskTenantId, Map task) { + String taskCode = String.valueOf(task.get("task_code")); + String fileName = task.get("file_name") == null ? "" : String.valueOf(task.get("file_name")); + if ("MEETING_MATERIAL_EXPORT".equalsIgnoreCase(taskCode)) { + Map filters = parseJsonMap(task.get("filters_json")); + Long meetingId = toLong(filters.get("meetingId")); + if (meetingId == null || meetingId <= 0L) { + meetingId = toLong(task.get("biz_id")); + } + if (meetingId == null || meetingId <= 0L) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议资料导出缺少会议ID"); + } + byte[] zipBytes = meetingMaterialExportService.buildZip(taskTenantId, meetingId); + return new ExportContent(zipBytes, "application/zip", resolveExtension(fileName, ".zip")); + } + if ("MEETING_SUMMARY_GENERATE".equalsIgnoreCase(taskCode)) { + Map filters = parseJsonMap(task.get("filters_json")); + Long meetingId = toLong(filters.get("meetingId")); + if (meetingId == null || meetingId <= 0L) { + meetingId = toLong(task.get("biz_id")); + } + if (meetingId == null || meetingId <= 0L) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议总结导出缺少会议ID"); + } + byte[] docxBytes = meetingSummaryExportService.buildDocx(taskTenantId, meetingId); + return new ExportContent( + docxBytes, + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + resolveExtension(fileName, ".docx") + ); + } + String csvContent = buildCsvContent(taskTenantId, task); + return new ExportContent(csvContent.getBytes(StandardCharsets.UTF_8), "text/csv;charset=utf-8", resolveExtension(fileName, ".csv")); + } + + public Map refreshDownloadToken(Long taskId) { + Map task = findTaskWithOwner(taskId); + assertOwner(task); + String status = String.valueOf(task.get("status")); + if (!"SUCCESS".equalsIgnoreCase(status)) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "导出任务未完成,不能刷新下载令牌"); + } + String token = UUID.randomUUID().toString().replace("-", ""); + jdbcTemplate.update( + "UPDATE export_task SET download_token=?, download_token_expire_at=DATE_ADD(NOW(), INTERVAL 7 DAY), updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + token, + safeUserId(), + tenantId(), + taskId + ); + Map data = new LinkedHashMap(); + data.put("taskId", taskId); + data.put("downloadToken", token); + data.put("expireAt", jdbcTemplate.queryForObject("SELECT DATE_FORMAT(download_token_expire_at, '%Y-%m-%d %H:%i:%s') FROM export_task WHERE tenant_id=? AND id=?", String.class, tenantId(), taskId)); + return data; + } + + public Map download(Long taskId, String token) { + if (token == null || token.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "下载令牌不能为空"); + } + Map task = findTaskWithOwner(taskId); + assertOwner(task); + String status = String.valueOf(task.get("status")); + if (!"SUCCESS".equalsIgnoreCase(status)) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "导出任务未完成"); + } + String dbToken = task.get("download_token") == null ? "" : String.valueOf(task.get("download_token")); + if (!dbToken.equals(token)) { + throw new BusinessException(ErrorCodes.NO_DATA_PERMISSION, "下载令牌无效"); + } + Integer valid = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM export_task WHERE tenant_id=? AND id=? AND download_token_expire_at IS NOT NULL AND download_token_expire_at>=NOW()", + Integer.class, + tenantId(), + taskId + ); + if (valid == null || valid == 0) { + throw new BusinessException(ErrorCodes.NO_DATA_PERMISSION, "下载令牌已过期"); + } + jdbcTemplate.update( + "UPDATE export_task SET download_count=IFNULL(download_count,0)+1, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + taskId + ); + Map data = new LinkedHashMap(); + data.put("taskId", taskId); + data.put("fileName", task.get("file_name")); + data.put("fileOssKey", task.get("file_oss_key")); + data.put("signedUrl", ossService.generateDownloadUrl(String.valueOf(task.get("file_oss_key")), String.valueOf(task.get("file_name")))); + data.put("expireAt", jdbcTemplate.queryForObject("SELECT DATE_FORMAT(download_token_expire_at, '%Y-%m-%d %H:%i:%s') FROM export_task WHERE tenant_id=? AND id=?", String.class, tenantId(), taskId)); + data.put("downloadCount", jdbcTemplate.queryForObject("SELECT IFNULL(download_count,0) FROM export_task WHERE tenant_id=? AND id=?", Integer.class, tenantId(), taskId)); + return data; + } + + private Long parseTaskId(String payload) { + try { + Map map = objectMapper.readValue(payload, new TypeReference>() {}); + Object val = map.get("taskId"); + if (val == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "导出任务ID缺失"); + } + return Long.valueOf(String.valueOf(val)); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "导出任务参数非法"); + } + } + + private Map findTask(Long taskId) { + List> list = jdbcTemplate.queryForList( + "SELECT id, status, retry_count, max_retry FROM export_task WHERE tenant_id=? AND id=? AND is_deleted=0", + tenantId(), + taskId + ); + if (list.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "导出任务不存在"); + } + return list.get(0); + } + + private Map findTaskDetail(Long taskId) { + List> list = jdbcTemplate.queryForList( + "SELECT id, tenant_id, status, retry_count, max_retry, requested_by, task_code, biz_type, biz_id, filters_json, file_name " + + "FROM export_task WHERE tenant_id=? AND id=? AND is_deleted=0", + tenantId(), + taskId + ); + if (list.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "导出任务不存在"); + } + return list.get(0); + } + + private Map findTaskWithOwner(Long taskId) { + List> list = jdbcTemplate.queryForList( + "SELECT id, status, retry_count, max_retry, requested_by, file_name, file_oss_key, download_token FROM export_task WHERE tenant_id=? AND id=? AND is_deleted=0", + tenantId(), + taskId + ); + if (list.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "导出任务不存在"); + } + return list.get(0); + } + + private void assertOwner(Map task) { + Long requestedBy = task.get("requested_by") == null ? 0L : ((Number) task.get("requested_by")).longValue(); + if (!requestedBy.equals(safeUserId())) { + throw new BusinessException(ErrorCodes.NO_DATA_PERMISSION, "仅任务创建人可下载导出结果"); + } + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long toLong(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + return Long.valueOf(String.valueOf(value)); + } catch (Exception ex) { + return null; + } + } + + private String buildCsvContent(Long taskTenantId, Map task) { + String taskCode = String.valueOf(task.get("task_code")); + Map filters = parseJsonMap(task.get("filters_json")); + if ("MEETING_EXPORT".equalsIgnoreCase(taskCode)) { + return buildMeetingCsv(taskTenantId, filters); + } + if ("USER_EXPORT".equalsIgnoreCase(taskCode)) { + return buildUserCsv(taskTenantId, filters); + } + if ("PROJECT_EXPORT".equalsIgnoreCase(taskCode)) { + return buildProjectCsv(taskTenantId, filters); + } + if ("NOTIFICATION_TASK_EXPORT".equalsIgnoreCase(taskCode)) { + return buildNotificationTaskCsv(taskTenantId, filters); + } + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "不支持的导出任务类型: " + taskCode); + } + + private Map parseJsonMap(Object raw) { + if (raw == null) { + return new LinkedHashMap(); + } + String text = String.valueOf(raw).trim(); + if (text.isEmpty()) { + return new LinkedHashMap(); + } + try { + return objectMapper.readValue(text, new TypeReference>() {}); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "导出筛选条件格式不正确"); + } + } + + private String buildMeetingCsv(Long tenantId, Map filters) { + StringBuilder sql = new StringBuilder(); + sql.append("SELECT m.id, p.project_name, m.topic, m.meeting_status, m.audit_status, "); + sql.append("DATE_FORMAT(m.start_time, '%Y-%m-%d %H:%i:%s') AS start_time, "); + sql.append("DATE_FORMAT(m.end_time, '%Y-%m-%d %H:%i:%s') AS end_time, "); + sql.append("m.budget_cent, m.is_deleted "); + sql.append("FROM meeting m LEFT JOIN project p ON m.tenant_id=p.tenant_id AND m.project_id=p.id "); + sql.append("WHERE m.tenant_id=? "); + List args = new ArrayList(); + args.add(tenantId); + appendDeletedFilter(sql, args, "m.is_deleted", filters); + appendLikeFilter(sql, args, "p.project_name", filters.get("projectName")); + appendLikeFilter(sql, args, "m.topic", filters.get("topic")); + appendEqualsFilter(sql, args, "m.project_id", filters.get("projectId")); + appendEqualsFilter(sql, args, "m.meeting_status", filters.get("meetingStatus")); + appendEqualsFilter(sql, args, "m.audit_status", filters.get("auditStatus")); + sql.append(" ORDER BY m.id DESC"); + List> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + return toCsv( + Arrays.asList("会议ID", "项目名称", "会议主题", "会议状态", "审核状态", "开始时间", "结束时间", "预算(元)", "是否已删除"), + rows.stream().map(row -> Arrays.asList( + row.get("id"), + row.get("project_name"), + row.get("topic"), + row.get("meeting_status"), + row.get("audit_status"), + row.get("start_time"), + row.get("end_time"), + toYuan(row.get("budget_cent")), + toDeletedText(row.get("is_deleted")) + )).collect(Collectors.>toList()) + ); + } + + private String buildUserCsv(Long tenantId, Map filters) { + StringBuilder sql = new StringBuilder(); + sql.append("SELECT u.id, u.user_name, u.phone, u.email, u.status, "); + sql.append("DATE_FORMAT(u.valid_from, '%Y-%m-%d %H:%i:%s') AS valid_from, "); + sql.append("DATE_FORMAT(u.valid_to, '%Y-%m-%d %H:%i:%s') AS valid_to, u.is_deleted, "); + sql.append("COALESCE(GROUP_CONCAT(DISTINCT r.role_name ORDER BY r.id SEPARATOR ','), '') AS role_names "); + sql.append("FROM sys_user u "); + sql.append("LEFT JOIN user_role ur ON u.tenant_id=ur.tenant_id AND u.id=ur.user_id "); + sql.append("LEFT JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id AND r.is_deleted=0 "); + sql.append("WHERE u.tenant_id=? "); + List args = new ArrayList(); + args.add(tenantId); + appendDeletedFilter(sql, args, "u.is_deleted", filters); + sql.append(" GROUP BY u.id, u.user_name, u.phone, u.email, u.status, u.valid_from, u.valid_to, u.is_deleted "); + sql.append("ORDER BY u.id DESC"); + List> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + return toCsv( + Arrays.asList("用户ID", "姓名", "手机号", "邮箱", "状态", "角色", "生效时间", "失效时间", "是否已删除"), + rows.stream().map(row -> Arrays.asList( + row.get("id"), + row.get("user_name"), + row.get("phone"), + row.get("email"), + row.get("status"), + row.get("role_names"), + row.get("valid_from"), + row.get("valid_to"), + toDeletedText(row.get("is_deleted")) + )).collect(Collectors.>toList()) + ); + } + + private String buildProjectCsv(Long tenantId, Map filters) { + StringBuilder sql = new StringBuilder(); + sql.append("SELECT id, project_name, host_enterprise_name, budget_cent, meeting_total, status, "); + sql.append("DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date, DATE_FORMAT(end_date, '%Y-%m-%d') AS end_date, is_deleted "); + sql.append("FROM project WHERE tenant_id=? "); + List args = new ArrayList(); + args.add(tenantId); + appendDeletedFilter(sql, args, "is_deleted", filters); + appendLikeFilter(sql, args, "project_name", filters.get("projectName")); + appendEqualsFilter(sql, args, "parent_project_id", filters.get("parentProjectId")); + sql.append(" ORDER BY id DESC"); + List> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + return toCsv( + Arrays.asList("项目ID", "项目名称", "主办单位", "预算(元)", "会议总期数", "状态", "开始日期", "结束日期", "是否已删除"), + rows.stream().map(row -> Arrays.asList( + row.get("id"), + row.get("project_name"), + row.get("host_enterprise_name"), + toYuan(row.get("budget_cent")), + row.get("meeting_total"), + row.get("status"), + row.get("start_date"), + row.get("end_date"), + toDeletedText(row.get("is_deleted")) + )).collect(Collectors.>toList()) + ); + } + + private String buildNotificationTaskCsv(Long tenantId, Map filters) { + StringBuilder sql = new StringBuilder(); + sql.append("SELECT id, event_code, channel, receiver_type, receiver_ref, status, retry_count, "); + sql.append("provider_message_id, receipt_code, receipt_message, error_message, "); + sql.append("DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at "); + sql.append("FROM notification_task WHERE tenant_id=? "); + List args = new ArrayList(); + args.add(tenantId); + appendDeletedFilter(sql, args, "is_deleted", filters); + sql.append(" ORDER BY id DESC"); + List> rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + return toCsv( + Arrays.asList("任务ID", "事件编码", "渠道", "接收人类型", "接收人", "状态", "重试次数", "供应商消息ID", "回执码", "回执信息", "错误信息", "创建时间"), + rows.stream().map(row -> Arrays.asList( + row.get("id"), + row.get("event_code"), + row.get("channel"), + row.get("receiver_type"), + row.get("receiver_ref"), + row.get("status"), + row.get("retry_count"), + row.get("provider_message_id"), + row.get("receipt_code"), + row.get("receipt_message"), + row.get("error_message"), + row.get("created_at") + )).collect(Collectors.>toList()) + ); + } + + private void appendDeletedFilter(StringBuilder sql, List args, String column, Map filters) { + boolean includeDeleted = filters != null && Boolean.TRUE.equals(parseBoolean(filters.get("includeDeleted"))); + if (!includeDeleted) { + sql.append(" AND ").append(column).append("=0"); + } + } + + private Boolean parseBoolean(Object value) { + if (value instanceof Boolean) { + return (Boolean) value; + } + if (value == null) { + return false; + } + return "true".equalsIgnoreCase(String.valueOf(value)); + } + + private void appendLikeFilter(StringBuilder sql, List args, String column, Object value) { + String text = value == null ? "" : String.valueOf(value).trim(); + if (!text.isEmpty()) { + sql.append(" AND ").append(column).append(" LIKE ?"); + args.add("%" + text + "%"); + } + } + + private void appendEqualsFilter(StringBuilder sql, List args, String column, Object value) { + if (value == null || String.valueOf(value).trim().isEmpty()) { + return; + } + sql.append(" AND ").append(column).append("=?"); + args.add(value); + } + + private String toCsv(List headers, List> rows) { + StringBuilder builder = new StringBuilder(); + builder.append('\uFEFF'); + builder.append(headers.stream().map(this::escapeCsv).collect(Collectors.joining(","))).append("\r\n"); + for (List row : rows) { + builder.append(row.stream() + .map(val -> escapeCsv(val == null ? "" : String.valueOf(val))) + .collect(Collectors.joining(","))); + builder.append("\r\n"); + } + return builder.toString(); + } + + private String escapeCsv(String value) { + String text = value == null ? "" : value; + String normalized = text.replace("\"", "\"\""); + if (normalized.contains(",") || normalized.contains("\"") || normalized.contains("\n") || normalized.contains("\r")) { + return "\"" + normalized + "\""; + } + return normalized; + } + + private String toDeletedText(Object flag) { + return toLong(flag) != null && toLong(flag) == 1L ? "是" : "否"; + } + + private String toYuan(Object cent) { + long value = toLong(cent) == null ? 0L : toLong(cent); + return String.format(Locale.ROOT, "%.2f", value / 100.0d); + } + + private String resolveExtension(String fileName, String defaultExtension) { + String text = fileName == null ? "" : fileName.trim(); + int idx = text.lastIndexOf('.'); + if (idx > 0 && idx < text.length() - 1) { + return text.substring(idx); + } + return defaultExtension; + } + + private String truncateErrorMessage(Exception ex) { + String message = ex == null ? "" : String.valueOf(ex.getMessage()); + if (message == null || message.trim().isEmpty()) { + message = ex == null ? "未知错误" : ex.getClass().getSimpleName(); + } + String safe = message.trim(); + return safe.length() > 500 ? safe.substring(0, 500) : safe; + } + + private static class ExportContent { + private final byte[] bytes; + private final String contentType; + private final String extension; + + private ExportContent(byte[] bytes, String contentType, String extension) { + this.bytes = bytes == null ? new byte[0] : bytes; + this.contentType = contentType; + this.extension = extension; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/file/controller/FileController.java b/backend/src/main/java/com/writeoff/module/file/controller/FileController.java new file mode 100644 index 0000000..b4e096f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/file/controller/FileController.java @@ -0,0 +1,53 @@ +package com.writeoff.module.file.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.system.service.DataPermissionService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import com.writeoff.security.PermissionService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.LinkedHashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/files") +public class FileController { + private final OssService ossService; + private final DataPermissionService dataPermissionService; + private final PermissionService permissionService; + + public FileController(OssService ossService, DataPermissionService dataPermissionService, PermissionService permissionService) { + this.ossService = ossService; + this.dataPermissionService = dataPermissionService; + this.permissionService = permissionService; + } + + @GetMapping("/presign-download") + public ApiResponse> presignDownload(@RequestParam String objectKey) { + Long userId = AuthContext.userId(); + if (userId == null) { + throw new BusinessException(10002, "未登录"); + } + AuthScope scope = AuthContext.scope(); + if (scope == AuthScope.PLATFORM) { + if (!permissionService.hasPlatformPermission(userId, "file.download")) { + throw new BusinessException(10004, "无操作权限"); + } + } else { + if (!permissionService.hasPermission(userId, "file.download")) { + throw new BusinessException(10004, "无操作权限"); + } + } + String signedUrl = ossService.generateDownloadUrl(objectKey); + Map result = new LinkedHashMap<>(); + result.put("objectKey", objectKey); + result.put("signedUrl", signedUrl); + return ApiResponse.success(result); + } +} diff --git a/backend/src/main/java/com/writeoff/module/file/service/OssService.java b/backend/src/main/java/com/writeoff/module/file/service/OssService.java new file mode 100644 index 0000000..05d0e66 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/file/service/OssService.java @@ -0,0 +1,129 @@ +package com.writeoff.module.file.service; + +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; +import com.aliyun.oss.model.OSSObject; +import com.aliyun.oss.model.ObjectMetadata; +import com.aliyun.oss.model.GeneratePresignedUrlRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.net.URL; +import java.net.URLEncoder; +import java.util.Date; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import com.aliyun.oss.HttpMethod; + +@Service +public class OssService { + @Value("${app.oss.endpoint}") + private String endpoint; + @Value("${app.oss.bucket}") + private String bucket; + @Value("${app.oss.access-key-id}") + private String accessKeyId; + @Value("${app.oss.access-key-secret}") + private String accessKeySecret; + @Value("${app.oss.sign-expire-seconds:600}") + private int signExpireSeconds; + + public String generateDownloadUrl(String objectKey) { + return generateDownloadUrl(objectKey, null); + } + + public String generateDownloadUrl(String objectKey, String downloadFileName) { + OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); + try { + Date expiration = new Date(System.currentTimeMillis() + signExpireSeconds * 1000L); + GeneratePresignedUrlRequest req = new GeneratePresignedUrlRequest(bucket, objectKey, HttpMethod.GET); + req.setExpiration(expiration); + String responseDisposition = buildAttachmentDisposition(downloadFileName); + if (responseDisposition != null && !responseDisposition.isEmpty()) { + req.addQueryParameter("response-content-disposition", responseDisposition); + } + URL url = ossClient.generatePresignedUrl(req); + return url.toString(); + } finally { + ossClient.shutdown(); + } + } + + public String generateUploadUrl(String objectKey) { + return generateUploadUrl(objectKey, "application/octet-stream"); + } + + public String generateUploadUrl(String objectKey, String contentType) { + OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); + try { + Date expiration = new Date(System.currentTimeMillis() + signExpireSeconds * 1000L); + GeneratePresignedUrlRequest req = new GeneratePresignedUrlRequest(bucket, objectKey, HttpMethod.PUT); + req.setExpiration(expiration); + req.setContentType(contentType == null || contentType.trim().isEmpty() ? "application/octet-stream" : contentType.trim()); + URL url = ossClient.generatePresignedUrl(req); + return url.toString(); + } finally { + ossClient.shutdown(); + } + } + + public void putTextObject(String objectKey, String content, String contentType) { + byte[] bytes = content == null ? new byte[0] : content.getBytes(StandardCharsets.UTF_8); + putObject(objectKey, bytes, contentType == null || contentType.trim().isEmpty() ? "text/plain;charset=utf-8" : contentType.trim()); + } + + public void putObject(String objectKey, byte[] bytes, String contentType) { + OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); + try { + byte[] safeBytes = bytes == null ? new byte[0] : bytes; + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(safeBytes.length); + metadata.setContentType(contentType == null || contentType.trim().isEmpty() ? "application/octet-stream" : contentType.trim()); + ossClient.putObject(bucket, objectKey, new ByteArrayInputStream(safeBytes), metadata); + } finally { + ossClient.shutdown(); + } + } + + public byte[] getObjectBytes(String objectKey) { + OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret); + try (OSSObject object = ossClient.getObject(bucket, objectKey); + InputStream inputStream = object.getObjectContent()) { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + byte[] buffer = new byte[8192]; + int len; + while ((len = inputStream.read(buffer)) >= 0) { + outputStream.write(buffer, 0, len); + } + return outputStream.toByteArray(); + } catch (IOException ex) { + throw new IllegalStateException("Failed to read OSS object: " + objectKey, ex); + } finally { + ossClient.shutdown(); + } + } + + private String buildAttachmentDisposition(String downloadFileName) { + String fileName = downloadFileName == null ? "" : downloadFileName.trim(); + if (fileName.isEmpty()) { + return ""; + } + String asciiName = fileName.replaceAll("[^\\x20-\\x7E]", "_").replace("\"", "_"); + if (asciiName.trim().isEmpty()) { + asciiName = "download"; + } + String encoded = urlEncodeUtf8(fileName); + return "attachment; filename=\"" + asciiName + "\"; filename*=UTF-8''" + encoded; + } + + private String urlEncodeUtf8(String value) { + try { + return URLEncoder.encode(value, "UTF-8").replace("+", "%20"); + } catch (Exception ex) { + return value == null ? "" : value; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/controller/FinanceController.java b/backend/src/main/java/com/writeoff/module/finance/controller/FinanceController.java new file mode 100644 index 0000000..0487e51 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/controller/FinanceController.java @@ -0,0 +1,80 @@ +package com.writeoff.module.finance.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.finance.dto.ConfirmPaymentRequest; +import com.writeoff.module.finance.dto.FinanceLockRequest; +import com.writeoff.module.finance.dto.FinanceReconciliationRequest; +import com.writeoff.module.finance.dto.UpsertFinanceMeetingBillRequest; +import com.writeoff.module.finance.model.FinanceMeetingBillInfo; +import com.writeoff.module.finance.model.Payment; +import com.writeoff.module.finance.service.FinanceService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import javax.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/finance") +public class FinanceController { + private final FinanceService financeService; + + public FinanceController(FinanceService financeService) { + this.financeService = financeService; + } + + @GetMapping("/projects") + public ApiResponse> projects() { + return ApiResponse.success(financeService.listProjects()); + } + + @PostMapping("/payments") + @RequirePermission(value = "finance.payment.confirm", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_PAYMENT_CONFIRM") + public ApiResponse> confirmPayment(@RequestBody @Valid ConfirmPaymentRequest request) { + return ApiResponse.success(financeService.confirmPayment(request)); + } + + @GetMapping("/ledger/export") + @RequirePermission(value = "finance.ledger.export", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_LEDGER_EXPORT") + public ApiResponse> exportLedger() { + return ApiResponse.success(financeService.exportLedger()); + } + + @PostMapping("/reconciliation") + @RequirePermission(value = "finance.reconciliation", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_RECONCILIATION") + public ApiResponse> reconciliation(@RequestBody @Valid FinanceReconciliationRequest request) { + return ApiResponse.success(financeService.reconciliation(request)); + } + + @PostMapping("/lock") + @RequirePermission(value = "finance.lock", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_LOCK") + public ApiResponse> lock(@RequestBody @Valid FinanceLockRequest request) { + return ApiResponse.success(financeService.lock(request)); + } + + @PostMapping("/unlock") + @RequirePermission(value = "finance.unlock", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_UNLOCK") + public ApiResponse> unlock(@RequestBody @Valid FinanceLockRequest request) { + return ApiResponse.success(financeService.unlock(request)); + } + + @GetMapping("/reconciliation/list") + @RequirePermission(value = "finance.reconciliation", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_RECONCILIATION_LIST") + public ApiResponse> reconciliationList(@RequestParam(value = "projectId", required = false) Long projectId) { + return ApiResponse.success(financeService.reconciliationList(projectId)); + } + + @GetMapping("/meeting-bills") + @RequirePermission(value = "finance.reconciliation", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_MEETING_BILL_LIST") + public ApiResponse> meetingBills(@RequestParam(value = "projectId", required = false) Long projectId) { + return ApiResponse.success(financeService.listMeetingBills(projectId)); + } + + @PostMapping("/meeting-bills") + @RequirePermission(value = "finance.reconciliation", dataScope = DataScopeType.PROJECT, auditAction = "FINANCE_MEETING_BILL_UPSERT") + public ApiResponse upsertMeetingBill(@RequestBody @Valid UpsertFinanceMeetingBillRequest request) { + return ApiResponse.success(financeService.upsertMeetingBill(request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/dto/ConfirmPaymentRequest.java b/backend/src/main/java/com/writeoff/module/finance/dto/ConfirmPaymentRequest.java new file mode 100644 index 0000000..b1f1526 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/dto/ConfirmPaymentRequest.java @@ -0,0 +1,59 @@ +package com.writeoff.module.finance.dto; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class ConfirmPaymentRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotNull(message = "项目ID不能为空") + private Long projectId; + @NotNull(message = "会议ID不能为空") + private Long meetingId; + @NotNull(message = "支付金额不能为空") + @Min(value = 1, message = "支付金额必须大于0") + private Long amountCent; + @NotBlank(message = "支付凭证不能为空") + private String paymentVoucherOssKey; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public Long getMeetingId() { + return meetingId; + } + + public void setMeetingId(Long meetingId) { + this.meetingId = meetingId; + } + + public Long getAmountCent() { + return amountCent; + } + + public void setAmountCent(Long amountCent) { + this.amountCent = amountCent; + } + + public String getPaymentVoucherOssKey() { + return paymentVoucherOssKey; + } + + public void setPaymentVoucherOssKey(String paymentVoucherOssKey) { + this.paymentVoucherOssKey = paymentVoucherOssKey; + } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/dto/FinanceLockRequest.java b/backend/src/main/java/com/writeoff/module/finance/dto/FinanceLockRequest.java new file mode 100644 index 0000000..0b9ddbc --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/dto/FinanceLockRequest.java @@ -0,0 +1,37 @@ +package com.writeoff.module.finance.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class FinanceLockRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotNull(message = "项目ID不能为空") + private Long projectId; + @NotBlank(message = "原因不能为空") + private String reason; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/dto/FinanceReconciliationRequest.java b/backend/src/main/java/com/writeoff/module/finance/dto/FinanceReconciliationRequest.java new file mode 100644 index 0000000..cabb850 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/dto/FinanceReconciliationRequest.java @@ -0,0 +1,37 @@ +package com.writeoff.module.finance.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class FinanceReconciliationRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotNull(message = "项目ID不能为空") + private Long projectId; + @NotNull(message = "应收金额不能为空") + private Long expectedAmountCent; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public Long getExpectedAmountCent() { + return expectedAmountCent; + } + + public void setExpectedAmountCent(Long expectedAmountCent) { + this.expectedAmountCent = expectedAmountCent; + } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/dto/UpsertFinanceMeetingBillRequest.java b/backend/src/main/java/com/writeoff/module/finance/dto/UpsertFinanceMeetingBillRequest.java new file mode 100644 index 0000000..ba32d21 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/dto/UpsertFinanceMeetingBillRequest.java @@ -0,0 +1,95 @@ +package com.writeoff.module.finance.dto; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotNull; + +public class UpsertFinanceMeetingBillRequest { + @NotNull(message = "项目ID不能为空") + private Long projectId; + @NotNull(message = "会议ID不能为空") + private Long meetingId; + @Min(value = 0, message = "会场费用不能小于0") + private Long venueAmountCent; + @Min(value = 0, message = "会议搭建费用不能小于0") + private Long buildAmountCent; + @Min(value = 0, message = "住宿费用不能小于0") + private Long hotelAmountCent; + @Min(value = 0, message = "餐饮费用不能小于0") + private Long cateringAmountCent; + @Min(value = 0, message = "小交通费用不能小于0") + private Long localTrafficAmountCent; + @Min(value = 0, message = "大交通费用不能小于0") + private Long longDistanceTrafficAmountCent; + @Min(value = 0, message = "物料费用不能小于0") + private Long materialAmountCent; + @Min(value = 0, message = "设计稿费用不能小于0") + private Long designAmountCent; + @Min(value = 0, message = "劳务应付费用不能小于0") + private Long laborPayableAmountCent; + @Min(value = 0, message = "劳务实发费用不能小于0") + private Long laborActualAmountCent; + @Min(value = 0, message = "财务审核费不能小于0") + private Long financeReviewFeeCent; + @Min(value = 0, message = "管理费不能小于0") + private Long managementFeeCent; + @Min(value = 0, message = "税费不能小于0") + private Long taxFeeCent; + private String customFeeJson; + @Min(value = 0, message = "已支付金额不能小于0") + private Long paidAmountCent; + @Min(value = 0, message = "待支付金额不能小于0") + private Long unpaidAmountCent; + private String reconciliationResult; + @Min(value = 0, message = "对账差异金额不能小于0") + private Long reconciliationDiffAmountCent; + private String reconciliationDiffReason; + private String settlementNo; + private String status; + + public Long getProjectId() { return projectId; } + public void setProjectId(Long projectId) { this.projectId = projectId; } + public Long getMeetingId() { return meetingId; } + public void setMeetingId(Long meetingId) { this.meetingId = meetingId; } + public Long getVenueAmountCent() { return venueAmountCent; } + public void setVenueAmountCent(Long venueAmountCent) { this.venueAmountCent = venueAmountCent; } + public Long getBuildAmountCent() { return buildAmountCent; } + public void setBuildAmountCent(Long buildAmountCent) { this.buildAmountCent = buildAmountCent; } + public Long getHotelAmountCent() { return hotelAmountCent; } + public void setHotelAmountCent(Long hotelAmountCent) { this.hotelAmountCent = hotelAmountCent; } + public Long getCateringAmountCent() { return cateringAmountCent; } + public void setCateringAmountCent(Long cateringAmountCent) { this.cateringAmountCent = cateringAmountCent; } + public Long getLocalTrafficAmountCent() { return localTrafficAmountCent; } + public void setLocalTrafficAmountCent(Long localTrafficAmountCent) { this.localTrafficAmountCent = localTrafficAmountCent; } + public Long getLongDistanceTrafficAmountCent() { return longDistanceTrafficAmountCent; } + public void setLongDistanceTrafficAmountCent(Long longDistanceTrafficAmountCent) { this.longDistanceTrafficAmountCent = longDistanceTrafficAmountCent; } + public Long getMaterialAmountCent() { return materialAmountCent; } + public void setMaterialAmountCent(Long materialAmountCent) { this.materialAmountCent = materialAmountCent; } + public Long getDesignAmountCent() { return designAmountCent; } + public void setDesignAmountCent(Long designAmountCent) { this.designAmountCent = designAmountCent; } + public Long getLaborPayableAmountCent() { return laborPayableAmountCent; } + public void setLaborPayableAmountCent(Long laborPayableAmountCent) { this.laborPayableAmountCent = laborPayableAmountCent; } + public Long getLaborActualAmountCent() { return laborActualAmountCent; } + public void setLaborActualAmountCent(Long laborActualAmountCent) { this.laborActualAmountCent = laborActualAmountCent; } + public Long getFinanceReviewFeeCent() { return financeReviewFeeCent; } + public void setFinanceReviewFeeCent(Long financeReviewFeeCent) { this.financeReviewFeeCent = financeReviewFeeCent; } + public Long getManagementFeeCent() { return managementFeeCent; } + public void setManagementFeeCent(Long managementFeeCent) { this.managementFeeCent = managementFeeCent; } + public Long getTaxFeeCent() { return taxFeeCent; } + public void setTaxFeeCent(Long taxFeeCent) { this.taxFeeCent = taxFeeCent; } + public String getCustomFeeJson() { return customFeeJson; } + public void setCustomFeeJson(String customFeeJson) { this.customFeeJson = customFeeJson; } + public Long getPaidAmountCent() { return paidAmountCent; } + public void setPaidAmountCent(Long paidAmountCent) { this.paidAmountCent = paidAmountCent; } + public Long getUnpaidAmountCent() { return unpaidAmountCent; } + public void setUnpaidAmountCent(Long unpaidAmountCent) { this.unpaidAmountCent = unpaidAmountCent; } + public String getReconciliationResult() { return reconciliationResult; } + public void setReconciliationResult(String reconciliationResult) { this.reconciliationResult = reconciliationResult; } + public Long getReconciliationDiffAmountCent() { return reconciliationDiffAmountCent; } + public void setReconciliationDiffAmountCent(Long reconciliationDiffAmountCent) { this.reconciliationDiffAmountCent = reconciliationDiffAmountCent; } + public String getReconciliationDiffReason() { return reconciliationDiffReason; } + public void setReconciliationDiffReason(String reconciliationDiffReason) { this.reconciliationDiffReason = reconciliationDiffReason; } + public String getSettlementNo() { return settlementNo; } + public void setSettlementNo(String settlementNo) { this.settlementNo = settlementNo; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/model/FinanceMeetingBillInfo.java b/backend/src/main/java/com/writeoff/module/finance/model/FinanceMeetingBillInfo.java new file mode 100644 index 0000000..5ada75f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/model/FinanceMeetingBillInfo.java @@ -0,0 +1,88 @@ +package com.writeoff.module.finance.model; + +public class FinanceMeetingBillInfo { + private Long id; + private Long projectId; + private Long meetingId; + private Long venueAmountCent; + private Long buildAmountCent; + private Long hotelAmountCent; + private Long cateringAmountCent; + private Long localTrafficAmountCent; + private Long longDistanceTrafficAmountCent; + private Long materialAmountCent; + private Long designAmountCent; + private Long laborPayableAmountCent; + private Long laborActualAmountCent; + private Long financeReviewFeeCent; + private Long managementFeeCent; + private Long taxFeeCent; + private String customFeeJson; + private Long paidAmountCent; + private Long unpaidAmountCent; + private String reconciliationResult; + private Long reconciliationDiffAmountCent; + private String reconciliationDiffReason; + private String settlementNo; + private String status; + private String updatedAt; + + public FinanceMeetingBillInfo(Long id, Long projectId, Long meetingId, Long venueAmountCent, Long buildAmountCent, Long hotelAmountCent, + Long cateringAmountCent, Long localTrafficAmountCent, Long longDistanceTrafficAmountCent, Long materialAmountCent, + Long designAmountCent, Long laborPayableAmountCent, Long laborActualAmountCent, Long financeReviewFeeCent, + Long managementFeeCent, Long taxFeeCent, String customFeeJson, Long paidAmountCent, Long unpaidAmountCent, + String reconciliationResult, Long reconciliationDiffAmountCent, String reconciliationDiffReason, String settlementNo, + String status, String updatedAt) { + this.id = id; + this.projectId = projectId; + this.meetingId = meetingId; + this.venueAmountCent = venueAmountCent; + this.buildAmountCent = buildAmountCent; + this.hotelAmountCent = hotelAmountCent; + this.cateringAmountCent = cateringAmountCent; + this.localTrafficAmountCent = localTrafficAmountCent; + this.longDistanceTrafficAmountCent = longDistanceTrafficAmountCent; + this.materialAmountCent = materialAmountCent; + this.designAmountCent = designAmountCent; + this.laborPayableAmountCent = laborPayableAmountCent; + this.laborActualAmountCent = laborActualAmountCent; + this.financeReviewFeeCent = financeReviewFeeCent; + this.managementFeeCent = managementFeeCent; + this.taxFeeCent = taxFeeCent; + this.customFeeJson = customFeeJson; + this.paidAmountCent = paidAmountCent; + this.unpaidAmountCent = unpaidAmountCent; + this.reconciliationResult = reconciliationResult; + this.reconciliationDiffAmountCent = reconciliationDiffAmountCent; + this.reconciliationDiffReason = reconciliationDiffReason; + this.settlementNo = settlementNo; + this.status = status; + this.updatedAt = updatedAt; + } + + public Long getId() { return id; } + public Long getProjectId() { return projectId; } + public Long getMeetingId() { return meetingId; } + public Long getVenueAmountCent() { return venueAmountCent; } + public Long getBuildAmountCent() { return buildAmountCent; } + public Long getHotelAmountCent() { return hotelAmountCent; } + public Long getCateringAmountCent() { return cateringAmountCent; } + public Long getLocalTrafficAmountCent() { return localTrafficAmountCent; } + public Long getLongDistanceTrafficAmountCent() { return longDistanceTrafficAmountCent; } + public Long getMaterialAmountCent() { return materialAmountCent; } + public Long getDesignAmountCent() { return designAmountCent; } + public Long getLaborPayableAmountCent() { return laborPayableAmountCent; } + public Long getLaborActualAmountCent() { return laborActualAmountCent; } + public Long getFinanceReviewFeeCent() { return financeReviewFeeCent; } + public Long getManagementFeeCent() { return managementFeeCent; } + public Long getTaxFeeCent() { return taxFeeCent; } + public String getCustomFeeJson() { return customFeeJson; } + public Long getPaidAmountCent() { return paidAmountCent; } + public Long getUnpaidAmountCent() { return unpaidAmountCent; } + public String getReconciliationResult() { return reconciliationResult; } + public Long getReconciliationDiffAmountCent() { return reconciliationDiffAmountCent; } + public String getReconciliationDiffReason() { return reconciliationDiffReason; } + public String getSettlementNo() { return settlementNo; } + public String getStatus() { return status; } + public String getUpdatedAt() { return updatedAt; } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/model/Payment.java b/backend/src/main/java/com/writeoff/module/finance/model/Payment.java new file mode 100644 index 0000000..eb15322 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/model/Payment.java @@ -0,0 +1,37 @@ +package com.writeoff.module.finance.model; + +public class Payment { + private Long id; + private Long projectId; + private Long meetingId; + private long amountCent; + private PaymentStatus status; + + public Payment(Long id, Long projectId, Long meetingId, long amountCent, PaymentStatus status) { + this.id = id; + this.projectId = projectId; + this.meetingId = meetingId; + this.amountCent = amountCent; + this.status = status; + } + + public Long getId() { + return id; + } + + public Long getProjectId() { + return projectId; + } + + public Long getMeetingId() { + return meetingId; + } + + public long getAmountCent() { + return amountCent; + } + + public PaymentStatus getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/model/PaymentStatus.java b/backend/src/main/java/com/writeoff/module/finance/model/PaymentStatus.java new file mode 100644 index 0000000..f3f3cb4 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/model/PaymentStatus.java @@ -0,0 +1,8 @@ +package com.writeoff.module.finance.model; + +public enum PaymentStatus { + SUBMITTED, + CONFIRMED, + PARTIAL, + SETTLED +} diff --git a/backend/src/main/java/com/writeoff/module/finance/repository/InMemoryPaymentRepository.java b/backend/src/main/java/com/writeoff/module/finance/repository/InMemoryPaymentRepository.java new file mode 100644 index 0000000..1c595ed --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/repository/InMemoryPaymentRepository.java @@ -0,0 +1,112 @@ +package com.writeoff.module.finance.repository; + +import com.writeoff.module.finance.model.Payment; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "in-memory") +public class InMemoryPaymentRepository implements PaymentRepository { + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + private final ConcurrentHashMap projectLockStore = new ConcurrentHashMap<>(); + private final List> reconciliationStore = new ArrayList<>(); + private final List> lockLogStore = new ArrayList<>(); + private final AtomicLong idGenerator = new AtomicLong(9000); + + @Override + public Payment save(Payment payment) { + if (payment.getId() == null) { + Payment newPayment = new Payment( + idGenerator.incrementAndGet(), + payment.getProjectId(), + payment.getMeetingId(), + payment.getAmountCent(), + payment.getStatus() + ); + store.put(newPayment.getId(), newPayment); + return newPayment; + } + store.put(payment.getId(), payment); + return payment; + } + + @Override + public List findAll() { + return new ArrayList<>(store.values()); + } + + @Override + public boolean isProjectLocked(Long projectId) { + return Boolean.TRUE.equals(projectLockStore.get(projectId)); + } + + @Override + public void lockProject(Long projectId, String reason, Long operatorUserId) { + projectLockStore.put(projectId, true); + Map row = new LinkedHashMap<>(); + row.put("id", lockLogStore.size() + 1L); + row.put("projectId", projectId); + row.put("lockStatus", "LOCKED"); + row.put("reason", reason); + row.put("createdAt", String.valueOf(System.currentTimeMillis())); + lockLogStore.add(0, row); + } + + @Override + public void unlockProject(Long projectId, String reason, Long operatorUserId) { + projectLockStore.put(projectId, false); + Map row = new LinkedHashMap<>(); + row.put("id", lockLogStore.size() + 1L); + row.put("projectId", projectId); + row.put("lockStatus", "UNLOCKED"); + row.put("reason", reason); + row.put("createdAt", String.valueOf(System.currentTimeMillis())); + lockLogStore.add(0, row); + } + + @Override + public Map createReconciliation(Long projectId, Long expectedAmountCent, Long operatorUserId) { + long actual = store.values().stream() + .filter(x -> x.getProjectId().equals(projectId)) + .mapToLong(Payment::getAmountCent) + .sum(); + long expected = expectedAmountCent == null ? actual : expectedAmountCent; + long diff = actual - expected; + Map row = new LinkedHashMap<>(); + row.put("id", reconciliationStore.size() + 1L); + row.put("projectId", projectId); + row.put("expectedAmountCent", expected); + row.put("actualAmountCent", actual); + row.put("diffAmountCent", diff); + row.put("resultStatus", diff == 0 ? "MATCH" : "DIFF"); + row.put("createdAt", String.valueOf(System.currentTimeMillis())); + reconciliationStore.add(0, row); + return row; + } + + @Override + public List> listReconciliations() { + return new ArrayList<>(reconciliationStore); + } + + @Override + public List> listLockLogs(Long projectId) { + if (projectId == null) { + return new ArrayList<>(lockLogStore); + } + List> list = new ArrayList<>(); + for (Map row : lockLogStore) { + if (projectId.equals(row.get("projectId"))) { + list.add(row); + } + } + return list; + } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/repository/JdbcPaymentRepository.java b/backend/src/main/java/com/writeoff/module/finance/repository/JdbcPaymentRepository.java new file mode 100644 index 0000000..91f754d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/repository/JdbcPaymentRepository.java @@ -0,0 +1,187 @@ +package com.writeoff.module.finance.repository; + +import com.writeoff.module.finance.model.Payment; +import com.writeoff.module.finance.model.PaymentStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import com.writeoff.security.AuthContext; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "jdbc", matchIfMissing = true) +public class JdbcPaymentRepository implements PaymentRepository { + private final JdbcTemplate jdbcTemplate; + private static final RowMapper ROW_MAPPER = (rs, n) -> new Payment( + rs.getLong("id"), + rs.getLong("project_id"), + rs.getLong("meeting_id"), + rs.getLong("amount_cent"), + PaymentStatus.valueOf(rs.getString("payment_status")) + ); + + public JdbcPaymentRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public Payment save(Payment payment) { + if (payment.getId() == null) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO finance_payment (tenant_id, project_id, meeting_id, amount_cent, payment_status, created_by, updated_by) VALUES (?, ?, ?, ?, ?, 0, 0)", + Statement.RETURN_GENERATED_KEYS + ); + ps.setLong(1, tenantId()); + ps.setLong(2, payment.getProjectId()); + ps.setLong(3, payment.getMeetingId()); + ps.setLong(4, payment.getAmountCent()); + ps.setString(5, payment.getStatus().name()); + return ps; + }, keyHolder); + Number key = keyHolder.getKey(); + Long id = key == null ? null : key.longValue(); + return new Payment(id, payment.getProjectId(), payment.getMeetingId(), payment.getAmountCent(), payment.getStatus()); + } + return payment; + } + + @Override + public List findAll() { + return jdbcTemplate.query("SELECT * FROM finance_payment WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC", ROW_MAPPER, tenantId()); + } + + @Override + public boolean isProjectLocked(Long projectId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM finance_lock_log WHERE tenant_id=? AND project_id=? AND lock_status='LOCKED' AND is_deleted=0", + Integer.class, + tenantId(), + projectId + ); + return count != null && count > 0; + } + + @Override + public void lockProject(Long projectId, String reason, Long operatorUserId) { + jdbcTemplate.update( + "UPDATE finance_lock_log SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND project_id=? AND lock_status='LOCKED' AND is_deleted=0", + operatorUserId == null ? 0L : operatorUserId, + tenantId(), + projectId + ); + jdbcTemplate.update( + "INSERT INTO finance_lock_log (tenant_id, project_id, lock_status, reason, created_by, updated_by) VALUES (?, ?, 'LOCKED', ?, ?, ?)", + tenantId(), + projectId, + reason, + operatorUserId == null ? 0L : operatorUserId, + operatorUserId == null ? 0L : operatorUserId + ); + } + + @Override + public void unlockProject(Long projectId, String reason, Long operatorUserId) { + jdbcTemplate.update( + "UPDATE finance_lock_log SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND project_id=? AND lock_status='LOCKED' AND is_deleted=0", + operatorUserId == null ? 0L : operatorUserId, + tenantId(), + projectId + ); + jdbcTemplate.update( + "INSERT INTO finance_lock_log (tenant_id, project_id, lock_status, reason, created_by, updated_by) VALUES (?, ?, 'UNLOCKED', ?, ?, ?)", + tenantId(), + projectId, + reason, + operatorUserId == null ? 0L : operatorUserId, + operatorUserId == null ? 0L : operatorUserId + ); + } + + @Override + public Map createReconciliation(Long projectId, Long expectedAmountCent, Long operatorUserId) { + Long actualAmountCent = jdbcTemplate.queryForObject( + "SELECT IFNULL(SUM(amount_cent),0) FROM finance_payment WHERE tenant_id=? AND project_id=? AND is_deleted=0", + Long.class, + tenantId(), + projectId + ); + long actual = actualAmountCent == null ? 0L : actualAmountCent; + long expected = expectedAmountCent == null ? actual : expectedAmountCent; + long diff = actual - expected; + String resultStatus = diff == 0 ? "MATCH" : "DIFF"; + jdbcTemplate.update( + "INSERT INTO finance_reconciliation (tenant_id, project_id, expected_amount_cent, actual_amount_cent, diff_amount_cent, result_status, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + tenantId(), + projectId, + expected, + actual, + diff, + resultStatus, + operatorUserId == null ? 0L : operatorUserId, + operatorUserId == null ? 0L : operatorUserId + ); + Map data = new LinkedHashMap<>(); + data.put("projectId", projectId); + data.put("expectedAmountCent", expected); + data.put("actualAmountCent", actual); + data.put("diffAmountCent", diff); + data.put("resultStatus", resultStatus); + return data; + } + + @Override + public List> listReconciliations() { + return jdbcTemplate.query( + "SELECT id, project_id, expected_amount_cent, actual_amount_cent, diff_amount_cent, result_status, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM finance_reconciliation WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC", + (rs, n) -> { + Map row = new LinkedHashMap<>(); + row.put("id", rs.getLong("id")); + row.put("projectId", rs.getLong("project_id")); + row.put("expectedAmountCent", rs.getLong("expected_amount_cent")); + row.put("actualAmountCent", rs.getLong("actual_amount_cent")); + row.put("diffAmountCent", rs.getLong("diff_amount_cent")); + row.put("resultStatus", rs.getString("result_status")); + row.put("createdAt", rs.getString("created_at")); + return row; + }, + tenantId() + ); + } + + @Override + public List> listLockLogs(Long projectId) { + return jdbcTemplate.query( + "SELECT id, project_id, lock_status, reason, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM finance_lock_log WHERE tenant_id=? AND (? IS NULL OR project_id=?) ORDER BY id DESC", + (rs, n) -> { + Map row = new LinkedHashMap<>(); + row.put("id", rs.getLong("id")); + row.put("projectId", rs.getLong("project_id")); + row.put("lockStatus", rs.getString("lock_status")); + row.put("reason", rs.getString("reason")); + row.put("createdAt", rs.getString("created_at")); + return row; + }, + tenantId(), + projectId, + projectId + ); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/finance/repository/PaymentRepository.java b/backend/src/main/java/com/writeoff/module/finance/repository/PaymentRepository.java new file mode 100644 index 0000000..2f5db26 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/repository/PaymentRepository.java @@ -0,0 +1,24 @@ +package com.writeoff.module.finance.repository; + +import com.writeoff.module.finance.model.Payment; + +import java.util.Map; +import java.util.List; + +public interface PaymentRepository { + Payment save(Payment payment); + + List findAll(); + + boolean isProjectLocked(Long projectId); + + void lockProject(Long projectId, String reason, Long operatorUserId); + + void unlockProject(Long projectId, String reason, Long operatorUserId); + + Map createReconciliation(Long projectId, Long expectedAmountCent, Long operatorUserId); + + List> listReconciliations(); + + List> listLockLogs(Long projectId); +} diff --git a/backend/src/main/java/com/writeoff/module/finance/service/FinanceService.java b/backend/src/main/java/com/writeoff/module/finance/service/FinanceService.java new file mode 100644 index 0000000..4d47644 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/finance/service/FinanceService.java @@ -0,0 +1,267 @@ +package com.writeoff.module.finance.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.finance.dto.ConfirmPaymentRequest; +import com.writeoff.module.finance.dto.FinanceLockRequest; +import com.writeoff.module.finance.dto.FinanceReconciliationRequest; +import com.writeoff.module.finance.dto.UpsertFinanceMeetingBillRequest; +import com.writeoff.module.finance.model.FinanceMeetingBillInfo; +import com.writeoff.module.finance.model.Payment; +import com.writeoff.module.finance.model.PaymentStatus; +import com.writeoff.module.finance.repository.PaymentRepository; +import com.writeoff.module.meeting.model.Meeting; +import com.writeoff.module.meeting.model.MeetingAuditStatus; +import com.writeoff.module.meeting.service.MeetingService; +import com.writeoff.module.system.service.DataPermissionService; +import com.writeoff.security.AuthContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@Service +public class FinanceService { + private final PaymentRepository paymentRepository; + private final MeetingService meetingService; + private final DataPermissionService dataPermissionService; + private final JdbcTemplate jdbcTemplate; + private final Map paymentIdempotency = new ConcurrentHashMap<>(); + private static final RowMapper BILL_ROW_MAPPER = (rs, n) -> new FinanceMeetingBillInfo( + rs.getLong("id"), + rs.getLong("project_id"), + rs.getLong("meeting_id"), + rs.getLong("venue_amount_cent"), + rs.getLong("build_amount_cent"), + rs.getLong("hotel_amount_cent"), + rs.getLong("catering_amount_cent"), + rs.getLong("local_traffic_amount_cent"), + rs.getLong("long_distance_traffic_amount_cent"), + rs.getLong("material_amount_cent"), + rs.getLong("design_amount_cent"), + rs.getLong("labor_payable_amount_cent"), + rs.getLong("labor_actual_amount_cent"), + rs.getLong("finance_review_fee_cent"), + rs.getLong("management_fee_cent"), + rs.getLong("tax_fee_cent"), + rs.getString("custom_fee_json"), + rs.getLong("paid_amount_cent"), + rs.getLong("unpaid_amount_cent"), + rs.getString("reconciliation_result"), + rs.getLong("reconciliation_diff_amount_cent"), + rs.getString("reconciliation_diff_reason"), + rs.getString("settlement_no"), + rs.getString("status"), + rs.getString("updated_at") + ); + + @Autowired + public FinanceService(PaymentRepository paymentRepository, MeetingService meetingService, DataPermissionService dataPermissionService, JdbcTemplate jdbcTemplate) { + this.paymentRepository = paymentRepository; + this.meetingService = meetingService; + this.dataPermissionService = dataPermissionService; + this.jdbcTemplate = jdbcTemplate; + } + + public FinanceService(PaymentRepository paymentRepository, MeetingService meetingService, JdbcTemplate jdbcTemplate) { + this(paymentRepository, meetingService, null, jdbcTemplate); + } + + public FinanceService(PaymentRepository paymentRepository, MeetingService meetingService) { + this(paymentRepository, meetingService, null, null); + } + + public PageResult listProjects() { + List list = paymentRepository.findAll(); + if (dataPermissionService != null) { + DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope(); + Set meetingIds = list.stream().map(Payment::getMeetingId).collect(Collectors.toCollection(HashSet::new)); + Map meetingCreatorMap = dataPermissionService.listMeetingCreators(meetingIds); + Map meetingProjectMap = dataPermissionService.listMeetingProjectIds(meetingIds); + Set projectIds = new HashSet<>(meetingProjectMap.values()); + Map projectCreatorMap = dataPermissionService.listProjectCreators(projectIds); + list = list.stream() + .filter(payment -> { + Long projectId = payment.getProjectId(); + if (projectId == null) { + projectId = meetingProjectMap.get(payment.getMeetingId()); + } + Long meetingCreatedBy = meetingCreatorMap.get(payment.getMeetingId()); + Long projectCreatedBy = projectId == null ? null : projectCreatorMap.get(projectId); + return dataPermissionService.canAccessMeeting(payment.getMeetingId(), projectId, meetingCreatedBy, projectCreatedBy, scope); + }) + .collect(Collectors.toList()); + } + return new PageResult<>(list, list.size(), 1, 20); + } + + public Map confirmPayment(ConfirmPaymentRequest request) { + if (paymentIdempotency.containsKey(request.getIdempotencyKey())) { + throw new BusinessException(ErrorCodes.IDEMPOTENCY_CONFLICT, "请求幂等冲突"); + } + paymentIdempotency.put(request.getIdempotencyKey(), request.getMeetingId()); + + Meeting meeting = meetingService.getById(request.getMeetingId()); + if (meeting.getAuditStatus() != MeetingAuditStatus.APPROVED) { + throw new BusinessException(ErrorCodes.PAYMENT_STATE_INVALID, "当前会议未终审通过,不能支付确认"); + } + if (paymentRepository.isProjectLocked(request.getProjectId())) { + throw new BusinessException(ErrorCodes.PAYMENT_LOCKED, "项目已锁账,暂不允许支付确认"); + } + + Payment payment = paymentRepository.save(new Payment( + null, + request.getProjectId(), + request.getMeetingId(), + request.getAmountCent(), + PaymentStatus.CONFIRMED + )); + + Map result = new LinkedHashMap<>(); + result.put("paymentId", payment.getId()); + result.put("paymentStatus", payment.getStatus().name()); + return result; + } + + public Map exportLedger() { + if (dataPermissionService != null && !dataPermissionService.canExportCurrentUser()) { + throw new BusinessException(ErrorCodes.NO_PERMISSION, "当前账号无导出权限"); + } + List list = listProjects().getList(); + Map result = new LinkedHashMap<>(); + result.put("total", list.size()); + result.put("records", list); + return result; + } + + public Map reconciliation(FinanceReconciliationRequest request) { + checkIdempotency(request.getIdempotencyKey(), request.getProjectId()); + return paymentRepository.createReconciliation(request.getProjectId(), request.getExpectedAmountCent(), 0L); + } + + public Map lock(FinanceLockRequest request) { + checkIdempotency(request.getIdempotencyKey(), request.getProjectId()); + paymentRepository.lockProject(request.getProjectId(), request.getReason(), 0L); + Map result = new LinkedHashMap<>(); + result.put("projectId", request.getProjectId()); + result.put("lockStatus", "LOCKED"); + return result; + } + + public Map unlock(FinanceLockRequest request) { + checkIdempotency(request.getIdempotencyKey(), request.getProjectId()); + paymentRepository.unlockProject(request.getProjectId(), request.getReason(), 0L); + Map result = new LinkedHashMap<>(); + result.put("projectId", request.getProjectId()); + result.put("lockStatus", "UNLOCKED"); + return result; + } + + public Map reconciliationList(Long projectId) { + Map result = new LinkedHashMap<>(); + result.put("reconciliation", paymentRepository.listReconciliations()); + result.put("lockLogs", paymentRepository.listLockLogs(projectId)); + return result; + } + + public PageResult listMeetingBills(Long projectId) { + List list = jdbcTemplate.query( + "SELECT id, project_id, meeting_id, venue_amount_cent, build_amount_cent, hotel_amount_cent, catering_amount_cent, local_traffic_amount_cent, " + + "long_distance_traffic_amount_cent, material_amount_cent, design_amount_cent, labor_payable_amount_cent, labor_actual_amount_cent, finance_review_fee_cent, " + + "management_fee_cent, tax_fee_cent, custom_fee_json, paid_amount_cent, unpaid_amount_cent, reconciliation_result, reconciliation_diff_amount_cent, " + + "reconciliation_diff_reason, settlement_no, status, DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM finance_meeting_bill WHERE tenant_id=? AND is_deleted=0 AND (? IS NULL OR project_id=?) ORDER BY id DESC", + BILL_ROW_MAPPER, + tenantId(), + projectId, + projectId + ); + return new PageResult<>(list, list.size(), 1, 50); + } + + public FinanceMeetingBillInfo upsertMeetingBill(UpsertFinanceMeetingBillRequest request) { + jdbcTemplate.update( + "INSERT INTO finance_meeting_bill (tenant_id, project_id, meeting_id, venue_amount_cent, build_amount_cent, hotel_amount_cent, catering_amount_cent, local_traffic_amount_cent, " + + "long_distance_traffic_amount_cent, material_amount_cent, design_amount_cent, labor_payable_amount_cent, labor_actual_amount_cent, finance_review_fee_cent, management_fee_cent, " + + "tax_fee_cent, custom_fee_json, paid_amount_cent, unpaid_amount_cent, reconciliation_result, reconciliation_diff_amount_cent, reconciliation_diff_reason, settlement_no, status, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE project_id=VALUES(project_id), venue_amount_cent=VALUES(venue_amount_cent), build_amount_cent=VALUES(build_amount_cent), " + + "hotel_amount_cent=VALUES(hotel_amount_cent), catering_amount_cent=VALUES(catering_amount_cent), local_traffic_amount_cent=VALUES(local_traffic_amount_cent), " + + "long_distance_traffic_amount_cent=VALUES(long_distance_traffic_amount_cent), material_amount_cent=VALUES(material_amount_cent), design_amount_cent=VALUES(design_amount_cent), " + + "labor_payable_amount_cent=VALUES(labor_payable_amount_cent), labor_actual_amount_cent=VALUES(labor_actual_amount_cent), finance_review_fee_cent=VALUES(finance_review_fee_cent), " + + "management_fee_cent=VALUES(management_fee_cent), tax_fee_cent=VALUES(tax_fee_cent), custom_fee_json=VALUES(custom_fee_json), paid_amount_cent=VALUES(paid_amount_cent), " + + "unpaid_amount_cent=VALUES(unpaid_amount_cent), reconciliation_result=VALUES(reconciliation_result), reconciliation_diff_amount_cent=VALUES(reconciliation_diff_amount_cent), " + + "reconciliation_diff_reason=VALUES(reconciliation_diff_reason), settlement_no=VALUES(settlement_no), status=VALUES(status), updated_at=CURRENT_TIMESTAMP, updated_by=VALUES(updated_by)", + tenantId(), + request.getProjectId(), + request.getMeetingId(), + nvl(request.getVenueAmountCent()), + nvl(request.getBuildAmountCent()), + nvl(request.getHotelAmountCent()), + nvl(request.getCateringAmountCent()), + nvl(request.getLocalTrafficAmountCent()), + nvl(request.getLongDistanceTrafficAmountCent()), + nvl(request.getMaterialAmountCent()), + nvl(request.getDesignAmountCent()), + nvl(request.getLaborPayableAmountCent()), + nvl(request.getLaborActualAmountCent()), + nvl(request.getFinanceReviewFeeCent()), + nvl(request.getManagementFeeCent()), + nvl(request.getTaxFeeCent()), + request.getCustomFeeJson(), + nvl(request.getPaidAmountCent()), + nvl(request.getUnpaidAmountCent()), + request.getReconciliationResult(), + nvl(request.getReconciliationDiffAmountCent()), + request.getReconciliationDiffReason(), + request.getSettlementNo(), + request.getStatus() == null || request.getStatus().trim().isEmpty() ? "DRAFT" : request.getStatus().trim().toUpperCase(), + safeUserId(), + safeUserId() + ); + + List list = jdbcTemplate.query( + "SELECT id, project_id, meeting_id, venue_amount_cent, build_amount_cent, hotel_amount_cent, catering_amount_cent, local_traffic_amount_cent, " + + "long_distance_traffic_amount_cent, material_amount_cent, design_amount_cent, labor_payable_amount_cent, labor_actual_amount_cent, finance_review_fee_cent, " + + "management_fee_cent, tax_fee_cent, custom_fee_json, paid_amount_cent, unpaid_amount_cent, reconciliation_result, reconciliation_diff_amount_cent, " + + "reconciliation_diff_reason, settlement_no, status, DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM finance_meeting_bill WHERE tenant_id=? AND meeting_id=? AND is_deleted=0 LIMIT 1", + BILL_ROW_MAPPER, + tenantId(), + request.getMeetingId() + ); + if (list.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议账单不存在"); + } + return list.get(0); + } + + private void checkIdempotency(String key, Long marker) { + if (paymentIdempotency.containsKey(key)) { + throw new BusinessException(ErrorCodes.IDEMPOTENCY_CONFLICT, "请求幂等冲突"); + } + paymentIdempotency.put(key, marker == null ? 0L : marker); + } + + private long nvl(Long v) { + return v == null ? 0L : v; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/controller/MeetingController.java b/backend/src/main/java/com/writeoff/module/meeting/controller/MeetingController.java new file mode 100644 index 0000000..ae1aa54 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/controller/MeetingController.java @@ -0,0 +1,294 @@ +package com.writeoff.module.meeting.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.meeting.dto.CreateMeetingRequest; +import com.writeoff.module.meeting.dto.MeetingQueryRequest; +import com.writeoff.module.meeting.dto.CreateMeetingMaterialsExportRequest; +import com.writeoff.module.meeting.dto.GenerateMeetingSummaryRequest; +import com.writeoff.module.meeting.dto.BindMeetingExpertsRequest; +import com.writeoff.module.meeting.dto.MeetingMaterialUploadSignRequest; +import com.writeoff.module.export.dto.CreateExportTaskRequest; +import com.writeoff.module.export.service.ExportTaskService; +import com.writeoff.module.expert.dto.AddBankCardRequest; +import com.writeoff.module.expert.dto.CreateExpertRequest; +import com.writeoff.module.meeting.dto.MeetingInvoiceConfigRequest; +import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractApplyRequest; +import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractQueryRequest; +import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractSubmitRequest; +import com.writeoff.module.meeting.dto.SaveMeetingMaterialRequest; +import com.writeoff.module.meeting.dto.SubmitMeetingRequest; +import com.writeoff.module.meeting.dto.SubmitMeetingMaterialRequest; +import com.writeoff.module.meeting.dto.WithdrawMeetingRequest; +import com.writeoff.module.meeting.model.Meeting; +import com.writeoff.module.meeting.model.MeetingExpertBinding; +import com.writeoff.module.meeting.model.MeetingLaborAgreementExtractResult; +import com.writeoff.module.meeting.model.MeetingMaterial; +import com.writeoff.module.meeting.model.MeetingMaterialHistory; +import com.writeoff.module.expert.model.ExpertBankCardInfo; +import com.writeoff.module.expert.model.ExpertInfo; +import com.writeoff.module.expert.service.PlatformExpertService; +import com.writeoff.module.meeting.service.MeetingExpertBindingService; +import com.writeoff.module.meeting.service.MeetingLaborAgreementExtractService; +import com.writeoff.module.meeting.service.MeetingMaterialService; +import com.writeoff.module.meeting.service.MeetingService; +import com.writeoff.module.template.model.TemplateInfo; +import com.writeoff.module.template.service.TemplateService; +import com.writeoff.module.ocr.dto.DocumentExtractTaskSubmitResponse; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import javax.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; +import java.util.List; + +@RestController +@RequestMapping("/api/meetings") +public class MeetingController { + private final MeetingService meetingService; + private final MeetingMaterialService meetingMaterialService; + private final TemplateService templateService; + private final PlatformExpertService platformExpertService; + private final MeetingExpertBindingService meetingExpertBindingService; + private final MeetingLaborAgreementExtractService meetingLaborAgreementExtractService; + private final ExportTaskService exportTaskService; + + public MeetingController(MeetingService meetingService, + MeetingMaterialService meetingMaterialService, + TemplateService templateService, + PlatformExpertService platformExpertService, + MeetingExpertBindingService meetingExpertBindingService, + MeetingLaborAgreementExtractService meetingLaborAgreementExtractService, + ExportTaskService exportTaskService) { + this.meetingService = meetingService; + this.meetingMaterialService = meetingMaterialService; + this.templateService = templateService; + this.platformExpertService = platformExpertService; + this.meetingExpertBindingService = meetingExpertBindingService; + this.meetingLaborAgreementExtractService = meetingLaborAgreementExtractService; + this.exportTaskService = exportTaskService; + } + + @GetMapping + @RequirePermission(value = "meeting.read", dataScope = DataScopeType.TENANT, auditAction = "MEETING_LIST") + public ApiResponse> list(MeetingQueryRequest query) { + return ApiResponse.success(meetingService.list(query)); + } + + @GetMapping("/tenant-experts") + @RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.TENANT, auditAction = "MEETING_TENANT_EXPERT_LIST") + public ApiResponse> tenantExperts(@RequestParam(value = "keyword", required = false) String keyword) { + return ApiResponse.success(platformExpertService.list(keyword)); + } + + @PostMapping("/tenant-experts") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.TENANT, auditAction = "MEETING_TENANT_EXPERT_CREATE") + public ApiResponse createTenantContextExpert(@RequestBody @Valid CreateExpertRequest request) { + return ApiResponse.success(platformExpertService.create(request)); + } + + @PostMapping("/tenant-experts/{expertId}/bank-cards") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.TENANT, auditAction = "MEETING_TENANT_EXPERT_BANK_CARD_CREATE") + public ApiResponse addTenantContextExpertBankCard(@PathVariable("expertId") Long expertId, + @RequestBody @Valid AddBankCardRequest request) { + return ApiResponse.success(platformExpertService.addCard(expertId, request)); + } + + @GetMapping("/{id}/experts") + @RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.MEETING, auditAction = "MEETING_EXPERT_BINDING_LIST") + public ApiResponse> listExperts(@PathVariable("id") Long id) { + return ApiResponse.success(meetingExpertBindingService.listByMeetingId(id)); + } + + @PostMapping("/{id}/experts/bind") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING, auditAction = "MEETING_EXPERT_BINDING_SAVE") + public ApiResponse> bindExperts(@PathVariable("id") Long id, + @RequestBody @Valid BindMeetingExpertsRequest request) { + return ApiResponse.success(meetingExpertBindingService.bind(id, request)); + } + + @DeleteMapping("/{id}/experts/{expertId}") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING, auditAction = "MEETING_EXPERT_BINDING_DELETE") + public ApiResponse> unbindExpert(@PathVariable("id") Long id, + @PathVariable("expertId") Long expertId) { + return ApiResponse.success(meetingExpertBindingService.unbindOne(id, expertId)); + } + + @PostMapping("/{id}/labor-agreement-extract/task") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_SUBMIT") + public ApiResponse submitLaborAgreementExtractTask(@PathVariable("id") Long id, + @RequestBody @Valid MeetingLaborAgreementExtractSubmitRequest request) { + return ApiResponse.success(meetingLaborAgreementExtractService.submit(id, request)); + } + + @PostMapping("/{id}/labor-agreement-extract/query") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_QUERY") + public ApiResponse queryLaborAgreementExtract(@PathVariable("id") Long id, + @RequestBody @Valid MeetingLaborAgreementExtractQueryRequest request) { + return ApiResponse.success(meetingLaborAgreementExtractService.query(id, request.getTaskId())); + } + + @PostMapping("/{id}/labor-agreement-extract/apply") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING, auditAction = "MEETING_LABOR_AGREEMENT_EXTRACT_APPLY") + public ApiResponse> applyLaborAgreementExtract(@PathVariable("id") Long id, + @RequestBody @Valid MeetingLaborAgreementExtractApplyRequest request) { + return ApiResponse.success(meetingLaborAgreementExtractService.apply(id, request)); + } + + @PostMapping + @RequirePermission(value = "meeting.create", dataScope = DataScopeType.PROJECT, auditAction = "MEETING_CREATE") + public ApiResponse create(@RequestBody @Valid CreateMeetingRequest request) { + return ApiResponse.success(meetingService.create(request)); + } + + @GetMapping("/{id}") + @RequirePermission(value = "meeting.read", dataScope = DataScopeType.MEETING, auditAction = "MEETING_DETAIL") + public ApiResponse detail(@PathVariable("id") Long id) { + return ApiResponse.success(meetingService.getById(id)); + } + + @GetMapping("/{id}/change-logs") + @RequirePermission(value = "meeting.change-log.read", dataScope = DataScopeType.MEETING, auditAction = "MEETING_CHANGE_LOG_LIST") + public ApiResponse>> changeLogs(@PathVariable("id") Long id) { + return ApiResponse.success(meetingService.listChangeLogs(id)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "meeting.create", dataScope = DataScopeType.MEETING, auditAction = "MEETING_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, + @RequestBody @Valid CreateMeetingRequest request) { + return ApiResponse.success(meetingService.update(id, request)); + } + + @PutMapping("/{id}/invoice-config") + @RequirePermission(value = "meeting.invoice.config", dataScope = DataScopeType.MEETING, auditAction = "MEETING_INVOICE_CONFIG_UPDATE") + public ApiResponse updateInvoiceConfig(@PathVariable("id") Long id, + @RequestBody @Valid MeetingInvoiceConfigRequest request) { + return ApiResponse.success(meetingService.updateInvoiceConfig(id, request)); + } + + @PostMapping("/{id}/submit") + @RequirePermission(value = "meeting.submit", dataScope = DataScopeType.MEETING, auditAction = "MEETING_SUBMIT") + public ApiResponse> submit(@PathVariable("id") Long id, + @RequestBody @Valid SubmitMeetingRequest request) { + return ApiResponse.success(meetingService.submit(id, request)); + } + + @PostMapping("/{id}/withdraw") + @RequirePermission(value = "meeting.withdraw", dataScope = DataScopeType.MEETING, auditAction = "MEETING_WITHDRAW") + public ApiResponse> withdraw(@PathVariable("id") Long id, + @RequestBody @Valid WithdrawMeetingRequest request) { + return ApiResponse.success(meetingService.withdraw(id, request)); + } + + @PostMapping("/{id}/delete") + @RequirePermission(value = "meeting.delete", dataScope = DataScopeType.MEETING, auditAction = "MEETING_DELETE") + public ApiResponse deleteDraft(@PathVariable("id") Long id) { + meetingService.deleteDraft(id); + return ApiResponse.success("ok"); + } + + @PostMapping("/{id}/cancel") + @RequirePermission(value = "meeting.cancel", dataScope = DataScopeType.MEETING, auditAction = "MEETING_CANCEL") + public ApiResponse> cancel(@PathVariable("id") Long id, + @RequestBody Map body) { + String reason = body == null ? null : body.get("reason"); + return ApiResponse.success(meetingService.cancel(id, reason)); + } + + @GetMapping("/{id}/materials") + @RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.MEETING_MODULE, auditAction = "MEETING_MATERIAL_LIST") + public ApiResponse> materials(@PathVariable("id") Long id) { + return ApiResponse.success(meetingMaterialService.list(id)); + } + + @PostMapping("/{id}/materials/{moduleCode}/save") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING_MODULE, auditAction = "MEETING_MATERIAL_SAVE") + public ApiResponse saveMaterial(@PathVariable("id") Long id, + @PathVariable("moduleCode") String moduleCode, + @RequestBody @Valid SaveMeetingMaterialRequest request) { + return ApiResponse.success(meetingMaterialService.save(id, moduleCode, request)); + } + + @PostMapping("/{id}/materials/{moduleCode}/submit") + @RequirePermission(value = "meeting.material.submit", dataScope = DataScopeType.MEETING_MODULE, auditAction = "MEETING_MATERIAL_SUBMIT") + public ApiResponse submitMaterial(@PathVariable("id") Long id, + @PathVariable("moduleCode") String moduleCode, + @RequestBody @Valid SubmitMeetingMaterialRequest request) { + return ApiResponse.success(meetingMaterialService.submit(id, moduleCode, request)); + } + + @PostMapping("/{id}/materials/{moduleCode}/upload-sign") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING_MODULE, auditAction = "MEETING_MATERIAL_UPLOAD_SIGN") + public ApiResponse> uploadMaterialSign(@PathVariable("id") Long id, + @PathVariable("moduleCode") String moduleCode, + @RequestBody @Valid MeetingMaterialUploadSignRequest request) { + return ApiResponse.success(meetingMaterialService.presignMaterialUpload(id, moduleCode, request.getFileName(), request.getContentType())); + } + + @GetMapping("/{id}/materials/{moduleCode}/current") + @RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.MEETING_MODULE, auditAction = "MEETING_MATERIAL_CURRENT") + public ApiResponse currentMaterial(@PathVariable("id") Long id, + @PathVariable("moduleCode") String moduleCode) { + return ApiResponse.success(meetingMaterialService.current(id, moduleCode)); + } + + @GetMapping("/{id}/materials/{moduleCode}/history") + @RequirePermission(value = "meeting.material.history.read", dataScope = DataScopeType.MEETING_MODULE, auditAction = "MEETING_MATERIAL_HISTORY") + public ApiResponse> materialHistory(@PathVariable("id") Long id, + @PathVariable("moduleCode") String moduleCode) { + return ApiResponse.success(meetingMaterialService.history(id, moduleCode)); + } + + @GetMapping("/{id}/matched-templates") + @RequirePermission(value = "meeting.material.read", dataScope = DataScopeType.MEETING, auditAction = "MEETING_MATCHED_TEMPLATES") + public ApiResponse> matchedTemplates(@PathVariable("id") Long id) { + Meeting meeting = meetingService.getById(id); + return ApiResponse.success(templateService.listMatchedForMeeting(id, meeting.getProjectId())); + } + + @PostMapping("/{id}/materials/export") + @RequirePermission(value = "meeting.material.export", dataScope = DataScopeType.MEETING, auditAction = "MEETING_MATERIAL_EXPORT") + public ApiResponse> exportMaterials(@PathVariable("id") Long id, + @RequestBody @Valid CreateMeetingMaterialsExportRequest request) { + return ApiResponse.success(meetingMaterialService.createMaterialsExportTask(id, request.getIdempotencyKey(), request.getFileName())); + } + + @PostMapping("/{id}/summary/generate") + @RequirePermission(value = "meeting.material.export", dataScope = DataScopeType.MEETING, auditAction = "MEETING_SUMMARY_GENERATE") + public ApiResponse> generateSummary(@PathVariable("id") Long id, + @RequestBody @Valid GenerateMeetingSummaryRequest request) { + return ApiResponse.success(meetingMaterialService.generateSummaryTask(id, request.getIdempotencyKey(), request.getFileName())); + } + + @GetMapping("/{id}/summary/task-status") + @RequirePermission(value = "meeting.material.export", dataScope = DataScopeType.MEETING, auditAction = "MEETING_SUMMARY_TASK_STATUS") + public ApiResponse> summaryTaskStatus(@PathVariable("id") Long id, + @RequestParam(value = "taskId", required = false) Long taskId) { + return ApiResponse.success(meetingMaterialService.getSummaryTaskStatus(id, taskId)); + } + + @PostMapping("/{id}/summary/refresh-token") + @RequirePermission(value = "meeting.material.export", dataScope = DataScopeType.MEETING, auditAction = "MEETING_SUMMARY_REFRESH_TOKEN") + public ApiResponse> refreshSummaryToken(@PathVariable("id") Long id, + @RequestParam("taskId") Long taskId) { + return ApiResponse.success(meetingMaterialService.refreshSummaryToken(id, taskId)); + } + + @GetMapping("/{id}/summary/download") + @RequirePermission(value = "meeting.material.export", dataScope = DataScopeType.MEETING, auditAction = "MEETING_SUMMARY_DOWNLOAD") + public ApiResponse> downloadSummary(@PathVariable("id") Long id, + @RequestParam("taskId") Long taskId, + @RequestParam("token") String token) { + return ApiResponse.success(meetingMaterialService.downloadSummary(id, taskId, token)); + } + + @PostMapping("/export") + @RequirePermission(value = "meeting.read", dataScope = DataScopeType.TENANT, auditAction = "MEETING_EXPORT") + public ApiResponse> exportMeetings(@RequestBody @Valid CreateExportTaskRequest request) { + request.setTaskCode("MEETING_EXPORT"); + request.setBizType("MEETING"); + return ApiResponse.success(exportTaskService.create(request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/BindMeetingExpertsRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/BindMeetingExpertsRequest.java new file mode 100644 index 0000000..7b571de --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/BindMeetingExpertsRequest.java @@ -0,0 +1,18 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotNull; +import java.util.ArrayList; +import java.util.List; + +public class BindMeetingExpertsRequest { + @NotNull(message = "专家ID列表不能为空") + private List expertIds = new ArrayList(); + + public List getExpertIds() { + return expertIds; + } + + public void setExpertIds(List expertIds) { + this.expertIds = expertIds; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/CreateMeetingMaterialsExportRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/CreateMeetingMaterialsExportRequest.java new file mode 100644 index 0000000..350cc22 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/CreateMeetingMaterialsExportRequest.java @@ -0,0 +1,25 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateMeetingMaterialsExportRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + private String fileName; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/CreateMeetingRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/CreateMeetingRequest.java new file mode 100644 index 0000000..594b04a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/CreateMeetingRequest.java @@ -0,0 +1,125 @@ +package com.writeoff.module.meeting.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import javax.validation.constraints.DecimalMax; +import javax.validation.constraints.DecimalMin; +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +@Schema(description = "创建会议请求") +public class CreateMeetingRequest { + @Schema(description = "项目ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "项目ID不能为空") + private Long projectId; + @Schema(description = "会议主题", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "会议主题不能为空") + private String topic; + @Schema(description = "会议预算,单位:分", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "会议预算不能为空") + @Min(value = 1, message = "会议预算必须大于0") + private Long budgetCent; + @Schema(description = "会议类别", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "会议类别不能为空") + private String meetingCategory; + @Schema(description = "会议形式", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "会议形式不能为空") + private String meetingForm; + @Schema(description = "会议地点", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "会议地点不能为空") + private String location; + @Schema(description = "会议开始时间,格式:yyyy-MM-dd HH:mm:ss", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "会议开始时间不能为空") + private String startTime; + @Schema(description = "会议结束时间,格式:yyyy-MM-dd HH:mm:ss", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "会议结束时间不能为空") + private String endTime; + @Schema(description = "劳务费用占比,范围:[0,1]") + @DecimalMin(value = "0.0", inclusive = true, message = "劳务费用占比不能小于0") + @DecimalMax(value = "1.0", inclusive = true, message = "劳务费用占比不能大于1") + private Double laborRatio; + @Schema(description = "餐费占比,范围:[0,1]") + @DecimalMin(value = "0.0", inclusive = true, message = "餐费占比不能小于0") + @DecimalMax(value = "1.0", inclusive = true, message = "餐费占比不能大于1") + private Double cateringRatio; + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public String getTopic() { + return topic; + } + + public void setTopic(String topic) { + this.topic = topic; + } + + public Long getBudgetCent() { + return budgetCent; + } + + public void setBudgetCent(Long budgetCent) { + this.budgetCent = budgetCent; + } + + public String getMeetingCategory() { + return meetingCategory; + } + + public void setMeetingCategory(String meetingCategory) { + this.meetingCategory = meetingCategory; + } + + public String getMeetingForm() { + return meetingForm; + } + + public void setMeetingForm(String meetingForm) { + this.meetingForm = meetingForm; + } + + public String getLocation() { + return location; + } + + public void setLocation(String location) { + this.location = location; + } + + public String getStartTime() { + return startTime; + } + + public void setStartTime(String startTime) { + this.startTime = startTime; + } + + public String getEndTime() { + return endTime; + } + + public void setEndTime(String endTime) { + this.endTime = endTime; + } + + public Double getLaborRatio() { + return laborRatio; + } + + public void setLaborRatio(Double laborRatio) { + this.laborRatio = laborRatio; + } + + public Double getCateringRatio() { + return cateringRatio; + } + + public void setCateringRatio(Double cateringRatio) { + this.cateringRatio = cateringRatio; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/GenerateMeetingSummaryRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/GenerateMeetingSummaryRequest.java new file mode 100644 index 0000000..f909975 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/GenerateMeetingSummaryRequest.java @@ -0,0 +1,25 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class GenerateMeetingSummaryRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + private String fileName; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingInvoiceConfigRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingInvoiceConfigRequest.java new file mode 100644 index 0000000..ec4b3c6 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingInvoiceConfigRequest.java @@ -0,0 +1,19 @@ +package com.writeoff.module.meeting.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "会议发票配置更新请求") +public class MeetingInvoiceConfigRequest { + + @Schema(description = "配置启用的发票项目code列表") + private List invoiceModules; + + public List getInvoiceModules() { + return invoiceModules; + } + + public void setInvoiceModules(List invoiceModules) { + this.invoiceModules = invoiceModules; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractApplyRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractApplyRequest.java new file mode 100644 index 0000000..1ac1b2c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractApplyRequest.java @@ -0,0 +1,52 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class MeetingLaborAgreementExtractApplyRequest { + @NotBlank(message = "taskId不能为空") + private String taskId; + private Long existingExpertId; + private Boolean updateExistingExpert; + private String objectKey; + private String fileName; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public Long getExistingExpertId() { + return existingExpertId; + } + + public void setExistingExpertId(Long existingExpertId) { + this.existingExpertId = existingExpertId; + } + + public Boolean getUpdateExistingExpert() { + return updateExistingExpert; + } + + public void setUpdateExistingExpert(Boolean updateExistingExpert) { + this.updateExistingExpert = updateExistingExpert; + } + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractQueryRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractQueryRequest.java new file mode 100644 index 0000000..55aa832 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractQueryRequest.java @@ -0,0 +1,16 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class MeetingLaborAgreementExtractQueryRequest { + @NotBlank(message = "taskId不能为空") + private String taskId; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractSubmitRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractSubmitRequest.java new file mode 100644 index 0000000..ee6928f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingLaborAgreementExtractSubmitRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class MeetingLaborAgreementExtractSubmitRequest { + @NotBlank(message = "objectKey不能为空") + private String objectKey; + @NotBlank(message = "fileName不能为空") + private String fileName; + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingMaterialUploadSignRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingMaterialUploadSignRequest.java new file mode 100644 index 0000000..8be79d7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingMaterialUploadSignRequest.java @@ -0,0 +1,25 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class MeetingMaterialUploadSignRequest { + @NotBlank(message = "文件名不能为空") + private String fileName; + private String contentType; + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingQueryRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingQueryRequest.java new file mode 100644 index 0000000..dc3f246 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/MeetingQueryRequest.java @@ -0,0 +1,127 @@ +package com.writeoff.module.meeting.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "会议列表筛选参数") +public class MeetingQueryRequest { + @Schema(description = "项目ID") + private Long projectId; + @Schema(description = "项目名称(模糊匹配)") + private String projectName; + @Schema(description = "会议主题(模糊匹配)") + private String topic; + @Schema(description = "会议状态(NOT_STARTED/IN_PROGRESS/COMPLETED/CANCELED/DELAYED/FROZEN)") + private String meetingStatus; + @Schema(description = "会议审核状态(PENDING/IN_REVIEW/APPROVED/REJECTED)") + private String auditStatus; + @Schema(description = "当前审核节点") + private String currentAuditNode; + @Schema(description = "当前审核人用户ID") + private Long currentAuditorUserId; + @Schema(description = "会议开始时间范围-起,格式:yyyy-MM-dd HH:mm:ss") + private String meetingStartFrom; + @Schema(description = "会议开始时间范围-止,格式:yyyy-MM-dd HH:mm:ss") + private String meetingStartTo; + @Schema(description = "最后提交时间范围-起,格式:yyyy-MM-ddTHH:mm:ss") + private String lastSubmitFrom; + @Schema(description = "最后提交时间范围-止,格式:yyyy-MM-ddTHH:mm:ss") + private String lastSubmitTo; + @Schema(description = "是否包含已删除会议") + private Boolean includeDeleted; + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public String getProjectName() { + return projectName; + } + + public void setProjectName(String projectName) { + this.projectName = projectName; + } + + public String getTopic() { + return topic; + } + + public void setTopic(String topic) { + this.topic = topic; + } + + public String getMeetingStatus() { + return meetingStatus; + } + + public void setMeetingStatus(String meetingStatus) { + this.meetingStatus = meetingStatus; + } + + public String getAuditStatus() { + return auditStatus; + } + + public void setAuditStatus(String auditStatus) { + this.auditStatus = auditStatus; + } + + public String getCurrentAuditNode() { + return currentAuditNode; + } + + public void setCurrentAuditNode(String currentAuditNode) { + this.currentAuditNode = currentAuditNode; + } + + public Long getCurrentAuditorUserId() { + return currentAuditorUserId; + } + + public void setCurrentAuditorUserId(Long currentAuditorUserId) { + this.currentAuditorUserId = currentAuditorUserId; + } + + public String getMeetingStartFrom() { + return meetingStartFrom; + } + + public void setMeetingStartFrom(String meetingStartFrom) { + this.meetingStartFrom = meetingStartFrom; + } + + public String getMeetingStartTo() { + return meetingStartTo; + } + + public void setMeetingStartTo(String meetingStartTo) { + this.meetingStartTo = meetingStartTo; + } + + public String getLastSubmitFrom() { + return lastSubmitFrom; + } + + public void setLastSubmitFrom(String lastSubmitFrom) { + this.lastSubmitFrom = lastSubmitFrom; + } + + public String getLastSubmitTo() { + return lastSubmitTo; + } + + public void setLastSubmitTo(String lastSubmitTo) { + this.lastSubmitTo = lastSubmitTo; + } + + public Boolean getIncludeDeleted() { + return includeDeleted; + } + + public void setIncludeDeleted(Boolean includeDeleted) { + this.includeDeleted = includeDeleted; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/SaveMeetingMaterialRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/SaveMeetingMaterialRequest.java new file mode 100644 index 0000000..e92f5bc --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/SaveMeetingMaterialRequest.java @@ -0,0 +1,25 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class SaveMeetingMaterialRequest { + @NotBlank(message = "资料内容不能为空") + private String contentJson; + private String remark; + + public String getContentJson() { + return contentJson; + } + + public void setContentJson(String contentJson) { + this.contentJson = contentJson; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/SubmitMeetingMaterialRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/SubmitMeetingMaterialRequest.java new file mode 100644 index 0000000..8658a00 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/SubmitMeetingMaterialRequest.java @@ -0,0 +1,25 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class SubmitMeetingMaterialRequest { + @NotBlank(message = "资料内容不能为空") + private String contentJson; + private String remark; + + public String getContentJson() { + return contentJson; + } + + public void setContentJson(String contentJson) { + this.contentJson = contentJson; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/SubmitMeetingRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/SubmitMeetingRequest.java new file mode 100644 index 0000000..e25c44e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/SubmitMeetingRequest.java @@ -0,0 +1,25 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class SubmitMeetingRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + private String remark; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/dto/WithdrawMeetingRequest.java b/backend/src/main/java/com/writeoff/module/meeting/dto/WithdrawMeetingRequest.java new file mode 100644 index 0000000..59053f0 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/dto/WithdrawMeetingRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.meeting.dto; + +import javax.validation.constraints.NotBlank; + +public class WithdrawMeetingRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotBlank(message = "撤回原因不能为空") + private String reason; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/model/Meeting.java b/backend/src/main/java/com/writeoff/module/meeting/model/Meeting.java new file mode 100644 index 0000000..de3da35 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/model/Meeting.java @@ -0,0 +1,413 @@ +package com.writeoff.module.meeting.model; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "会议实体") +public class Meeting { + @Schema(description = "会议ID") + private Long id; + @Schema(description = "项目ID") + private Long projectId; + @Schema(description = "项目名称(展示字段)") + private String projectName; + @Schema(description = "会议主题") + private String topic; + @Schema(description = "会议类别") + private String meetingCategory; + @Schema(description = "会议形式") + private String meetingForm; + @Schema(description = "会议地点") + private String location; + @Schema(description = "会议开始时间,格式:yyyy-MM-dd HH:mm:ss") + private String startTime; + @Schema(description = "会议结束时间,格式:yyyy-MM-dd HH:mm:ss") + private String endTime; + @Schema(description = "会议预算,单位:分") + private long budgetCent; + @Schema(description = "劳务费用占比,范围:[0,1]") + private double laborRatio; + @Schema(description = "餐费占比,范围:[0,1]") + private double cateringRatio; + @Schema(description = "会议状态") + private MeetingStatus status; + @Schema(description = "会议审核状态") + private MeetingAuditStatus auditStatus; + @Schema(description = "当前审核节点") + private String currentAuditNode; + @Schema(description = "最后提交时间,格式:yyyy-MM-ddTHH:mm:ss") + private String lastSubmitAt; + @Schema(description = "最后驳回原因摘要") + private String lastRejectReason; + @Schema(description = "逾期天数") + private int overdueDays; + @Schema(description = "风险标记JSON") + private String riskFlagsJson; + @Schema(description = "是否冻结") + private boolean frozen; + @Schema(description = "冻结原因") + private String freezeReason; + @Schema(description = "当前审核人用户ID") + private Long currentAuditorUserId; + @Schema(description = "当前节点SLA截止时间,格式:yyyy-MM-ddTHH:mm:ss") + private String nodeDeadlineAt; + @Schema(description = "累计驳回次数") + private int rejectCount; + @Schema(description = "最后一次流程动作时间,格式:yyyy-MM-ddTHH:mm:ss") + private String lastActionAt; + @Schema(description = "会议状态最近变更时间,格式:yyyy-MM-ddTHH:mm:ss") + private String statusChangedAt; + @Schema(description = "会议状态最近变更人用户ID") + private Long statusChangedBy; + @Schema(description = "取消原因") + private String cancelReason; + @Schema(description = "延期原因") + private String postponeReason; + @Schema(description = "撤回原因") + private String withdrawReason; + @Schema(description = "字段锁版本号(并发控制)") + private int lockVersion; + @Schema(description = "字段锁定时间,格式:yyyy-MM-ddTHH:mm:ss") + private String lockAt; + @Schema(description = "字段锁定操作人用户ID") + private Long lockedBy; + @Schema(description = "动态会议发票模块配置JSON") + private String invoiceConfigJson; + @Schema(description = "是否已软删除") + private boolean deleted; + + public Meeting(Long id, Long projectId, String topic, long budgetCent, MeetingStatus status, MeetingAuditStatus auditStatus) { + this( + id, projectId, topic, null, null, null, null, null, budgetCent, 0d, 0d, status, auditStatus, null, + null, null, 0, null, false, null, null, null, 0, null, null, null, null, null, null, 0, null, null, null + ); + } + + public Meeting(Long id, + Long projectId, + String topic, + String meetingCategory, + String meetingForm, + String location, + String startTime, + String endTime, + long budgetCent, + double laborRatio, + double cateringRatio, + MeetingStatus status, + MeetingAuditStatus auditStatus, + String currentAuditNode, + String lastSubmitAt, + String lastRejectReason, + int overdueDays, + String riskFlagsJson, + boolean frozen, + String freezeReason, + Long currentAuditorUserId, + String nodeDeadlineAt, + int rejectCount, + String lastActionAt, + String statusChangedAt, + Long statusChangedBy, + String cancelReason, + String postponeReason, + String withdrawReason, + int lockVersion, + String lockAt, + Long lockedBy, + String invoiceConfigJson) { + this(id, projectId, topic, meetingCategory, meetingForm, location, startTime, endTime, budgetCent, laborRatio, cateringRatio, + status, auditStatus, currentAuditNode, lastSubmitAt, lastRejectReason, overdueDays, riskFlagsJson, frozen, freezeReason, + currentAuditorUserId, nodeDeadlineAt, rejectCount, lastActionAt, statusChangedAt, statusChangedBy, cancelReason, + postponeReason, withdrawReason, lockVersion, lockAt, lockedBy, invoiceConfigJson, false); + } + + public Meeting(Long id, + Long projectId, + String topic, + String meetingCategory, + String meetingForm, + String location, + String startTime, + String endTime, + long budgetCent, + double laborRatio, + double cateringRatio, + MeetingStatus status, + MeetingAuditStatus auditStatus, + String currentAuditNode, + String lastSubmitAt, + String lastRejectReason, + int overdueDays, + String riskFlagsJson, + boolean frozen, + String freezeReason, + Long currentAuditorUserId, + String nodeDeadlineAt, + int rejectCount, + String lastActionAt, + String statusChangedAt, + Long statusChangedBy, + String cancelReason, + String postponeReason, + String withdrawReason, + int lockVersion, + String lockAt, + Long lockedBy, + String invoiceConfigJson, + boolean deleted) { + this.id = id; + this.projectId = projectId; + this.topic = topic; + this.meetingCategory = meetingCategory; + this.meetingForm = meetingForm; + this.location = location; + this.startTime = startTime; + this.endTime = endTime; + this.budgetCent = budgetCent; + this.laborRatio = laborRatio; + this.cateringRatio = cateringRatio; + this.status = status; + this.auditStatus = auditStatus; + this.currentAuditNode = currentAuditNode; + this.lastSubmitAt = lastSubmitAt; + this.lastRejectReason = lastRejectReason; + this.overdueDays = overdueDays; + this.riskFlagsJson = riskFlagsJson; + this.frozen = frozen; + this.freezeReason = freezeReason; + this.currentAuditorUserId = currentAuditorUserId; + this.nodeDeadlineAt = nodeDeadlineAt; + this.rejectCount = rejectCount; + this.lastActionAt = lastActionAt; + this.statusChangedAt = statusChangedAt; + this.statusChangedBy = statusChangedBy; + this.cancelReason = cancelReason; + this.postponeReason = postponeReason; + this.withdrawReason = withdrawReason; + this.lockVersion = lockVersion; + this.lockAt = lockAt; + this.lockedBy = lockedBy; + this.invoiceConfigJson = invoiceConfigJson; + this.deleted = deleted; + } + + public Long getId() { + return id; + } + + public Long getProjectId() { + return projectId; + } + + public String getProjectName() { + return projectName; + } + + public String getTopic() { + return topic; + } + + public String getMeetingCategory() { + return meetingCategory; + } + + public String getMeetingForm() { + return meetingForm; + } + + public String getLocation() { + return location; + } + + public String getStartTime() { + return startTime; + } + + public String getEndTime() { + return endTime; + } + + public long getBudgetCent() { + return budgetCent; + } + + public double getLaborRatio() { + return laborRatio; + } + + public double getCateringRatio() { + return cateringRatio; + } + + public MeetingStatus getStatus() { + return status; + } + + public MeetingAuditStatus getAuditStatus() { + return auditStatus; + } + + public String getCurrentAuditNode() { + return currentAuditNode; + } + + public String getLastSubmitAt() { + return lastSubmitAt; + } + + public String getLastRejectReason() { + return lastRejectReason; + } + + public int getOverdueDays() { + return overdueDays; + } + + public String getRiskFlagsJson() { + return riskFlagsJson; + } + + public boolean isFrozen() { + return frozen; + } + + public String getFreezeReason() { + return freezeReason; + } + + public Long getCurrentAuditorUserId() { + return currentAuditorUserId; + } + + public String getNodeDeadlineAt() { + return nodeDeadlineAt; + } + + public int getRejectCount() { + return rejectCount; + } + + public String getLastActionAt() { + return lastActionAt; + } + + public String getStatusChangedAt() { + return statusChangedAt; + } + + public Long getStatusChangedBy() { + return statusChangedBy; + } + + public String getCancelReason() { + return cancelReason; + } + + public String getPostponeReason() { + return postponeReason; + } + + public String getWithdrawReason() { + return withdrawReason; + } + + public int getLockVersion() { + return lockVersion; + } + + public String getLockAt() { + return lockAt; + } + + public Long getLockedBy() { + return lockedBy; + } + + public void setStatus(MeetingStatus status) { + this.status = status; + } + + public void setAuditStatus(MeetingAuditStatus auditStatus) { + this.auditStatus = auditStatus; + } + + public void setCurrentAuditNode(String currentAuditNode) { + this.currentAuditNode = currentAuditNode; + } + + public void setLastSubmitAt(String lastSubmitAt) { + this.lastSubmitAt = lastSubmitAt; + } + + public void setLastRejectReason(String lastRejectReason) { + this.lastRejectReason = lastRejectReason; + } + + public void setProjectName(String projectName) { + this.projectName = projectName; + } + + public void setCurrentAuditorUserId(Long currentAuditorUserId) { + this.currentAuditorUserId = currentAuditorUserId; + } + + public void setNodeDeadlineAt(String nodeDeadlineAt) { + this.nodeDeadlineAt = nodeDeadlineAt; + } + + public void setRejectCount(int rejectCount) { + this.rejectCount = rejectCount; + } + + public void setLastActionAt(String lastActionAt) { + this.lastActionAt = lastActionAt; + } + + public void setStatusChangedAt(String statusChangedAt) { + this.statusChangedAt = statusChangedAt; + } + + public void setStatusChangedBy(Long statusChangedBy) { + this.statusChangedBy = statusChangedBy; + } + + public void setCancelReason(String cancelReason) { + this.cancelReason = cancelReason; + } + + public void setPostponeReason(String postponeReason) { + this.postponeReason = postponeReason; + } + + public void setWithdrawReason(String withdrawReason) { + this.withdrawReason = withdrawReason; + } + + public void setLockVersion(int lockVersion) { + this.lockVersion = lockVersion; + } + + public void setLockAt(String lockAt) { + this.lockAt = lockAt; + } + + public void setLockedBy(Long lockedBy) { + this.lockedBy = lockedBy; + } + + public String getInvoiceConfigJson() { + return invoiceConfigJson; + } + + public void setInvoiceConfigJson(String invoiceConfigJson) { + this.invoiceConfigJson = invoiceConfigJson; + } + + public boolean isDeleted() { + return deleted; + } + + public void setDeleted(boolean deleted) { + this.deleted = deleted; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/model/MeetingAuditStatus.java b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingAuditStatus.java new file mode 100644 index 0000000..94e1fcf --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingAuditStatus.java @@ -0,0 +1,8 @@ +package com.writeoff.module.meeting.model; + +public enum MeetingAuditStatus { + PENDING, + IN_REVIEW, + APPROVED, + REJECTED +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/model/MeetingExpertBinding.java b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingExpertBinding.java new file mode 100644 index 0000000..01f1b80 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingExpertBinding.java @@ -0,0 +1,49 @@ +package com.writeoff.module.meeting.model; + +public class MeetingExpertBinding { + private Long id; + private Long meetingId; + private Long expertId; + private String expertName; + private String phone; + private String title; + private String organization; + + public MeetingExpertBinding(Long id, Long meetingId, Long expertId, String expertName, String phone, String title, String organization) { + this.id = id; + this.meetingId = meetingId; + this.expertId = expertId; + this.expertName = expertName; + this.phone = phone; + this.title = title; + this.organization = organization; + } + + public Long getId() { + return id; + } + + public Long getMeetingId() { + return meetingId; + } + + public Long getExpertId() { + return expertId; + } + + public String getExpertName() { + return expertName; + } + + public String getPhone() { + return phone; + } + + public String getTitle() { + return title; + } + + public String getOrganization() { + return organization; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/model/MeetingLaborAgreementExtractResult.java b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingLaborAgreementExtractResult.java new file mode 100644 index 0000000..34a6eab --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingLaborAgreementExtractResult.java @@ -0,0 +1,253 @@ +package com.writeoff.module.meeting.model; + +public class MeetingLaborAgreementExtractResult { + private String taskId; + private String status; + private String reason; + private String createdAt; + private String startedAt; + private String finishedAt; + private Long duration; + private String logId; + private ParsedExpert parsedExpert; + private ExistingExpert existingExpert; + private Boolean needsConfirm; + private Boolean nameMismatchFlag; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + public String getStartedAt() { + return startedAt; + } + + public void setStartedAt(String startedAt) { + this.startedAt = startedAt; + } + + public String getFinishedAt() { + return finishedAt; + } + + public void setFinishedAt(String finishedAt) { + this.finishedAt = finishedAt; + } + + public Long getDuration() { + return duration; + } + + public void setDuration(Long duration) { + this.duration = duration; + } + + public String getLogId() { + return logId; + } + + public void setLogId(String logId) { + this.logId = logId; + } + + public ParsedExpert getParsedExpert() { + return parsedExpert; + } + + public void setParsedExpert(ParsedExpert parsedExpert) { + this.parsedExpert = parsedExpert; + } + + public ExistingExpert getExistingExpert() { + return existingExpert; + } + + public void setExistingExpert(ExistingExpert existingExpert) { + this.existingExpert = existingExpert; + } + + public Boolean getNeedsConfirm() { + return needsConfirm; + } + + public void setNeedsConfirm(Boolean needsConfirm) { + this.needsConfirm = needsConfirm; + } + + public Boolean getNameMismatchFlag() { + return nameMismatchFlag; + } + + public void setNameMismatchFlag(Boolean nameMismatchFlag) { + this.nameMismatchFlag = nameMismatchFlag; + } + + public static class ParsedExpert { + private String expertName; + private String hospitalName; + private String phone; + private String laborFeeText; + private Long laborFeeCent; + private String bankName; + private String bankCardNo; + private String accountName; + private String idNo; + private Boolean nameMismatchFlag; + + public String getExpertName() { + return expertName; + } + + public void setExpertName(String expertName) { + this.expertName = expertName; + } + + public String getHospitalName() { + return hospitalName; + } + + public void setHospitalName(String hospitalName) { + this.hospitalName = hospitalName; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getLaborFeeText() { + return laborFeeText; + } + + public void setLaborFeeText(String laborFeeText) { + this.laborFeeText = laborFeeText; + } + + public Long getLaborFeeCent() { + return laborFeeCent; + } + + public void setLaborFeeCent(Long laborFeeCent) { + this.laborFeeCent = laborFeeCent; + } + + public String getBankName() { + return bankName; + } + + public void setBankName(String bankName) { + this.bankName = bankName; + } + + public String getBankCardNo() { + return bankCardNo; + } + + public void setBankCardNo(String bankCardNo) { + this.bankCardNo = bankCardNo; + } + + public String getAccountName() { + return accountName; + } + + public void setAccountName(String accountName) { + this.accountName = accountName; + } + + public String getIdNo() { + return idNo; + } + + public void setIdNo(String idNo) { + this.idNo = idNo; + } + + public Boolean getNameMismatchFlag() { + return nameMismatchFlag; + } + + public void setNameMismatchFlag(Boolean nameMismatchFlag) { + this.nameMismatchFlag = nameMismatchFlag; + } + } + + public static class ExistingExpert { + private Long expertId; + private String expertName; + private String phoneMasked; + private String idNoMasked; + private String hospitalName; + + public Long getExpertId() { + return expertId; + } + + public void setExpertId(Long expertId) { + this.expertId = expertId; + } + + public String getExpertName() { + return expertName; + } + + public void setExpertName(String expertName) { + this.expertName = expertName; + } + + public String getPhoneMasked() { + return phoneMasked; + } + + public void setPhoneMasked(String phoneMasked) { + this.phoneMasked = phoneMasked; + } + + public String getIdNoMasked() { + return idNoMasked; + } + + public void setIdNoMasked(String idNoMasked) { + this.idNoMasked = idNoMasked; + } + + public String getHospitalName() { + return hospitalName; + } + + public void setHospitalName(String hospitalName) { + this.hospitalName = hospitalName; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/model/MeetingMaterial.java b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingMaterial.java new file mode 100644 index 0000000..17da495 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingMaterial.java @@ -0,0 +1,104 @@ +package com.writeoff.module.meeting.model; + +public class MeetingMaterial { + private Long id; + private Long meetingId; + private String moduleCode; + private String contentJson; + private String status; + private String auditNodeStatus; + private String auditAggregateStatus; + private String submitRemark; + private Integer rejectCount; + private String lastRejectReason; + private String resubmitAt; + private Integer versionNo; + private Boolean latestVersion; + private String updatedAt; + + public MeetingMaterial(Long id, + Long meetingId, + String moduleCode, + String contentJson, + String status, + String auditNodeStatus, + String auditAggregateStatus, + String submitRemark, + Integer rejectCount, + String lastRejectReason, + String resubmitAt, + Integer versionNo, + Boolean latestVersion, + String updatedAt) { + this.id = id; + this.meetingId = meetingId; + this.moduleCode = moduleCode; + this.contentJson = contentJson; + this.status = status; + this.auditNodeStatus = auditNodeStatus; + this.auditAggregateStatus = auditAggregateStatus; + this.submitRemark = submitRemark; + this.rejectCount = rejectCount; + this.lastRejectReason = lastRejectReason; + this.resubmitAt = resubmitAt; + this.versionNo = versionNo; + this.latestVersion = latestVersion; + this.updatedAt = updatedAt; + } + + public Long getId() { + return id; + } + + public Long getMeetingId() { + return meetingId; + } + + public String getModuleCode() { + return moduleCode; + } + + public String getContentJson() { + return contentJson; + } + + public String getStatus() { + return status; + } + + public String getAuditNodeStatus() { + return auditNodeStatus; + } + + public String getAuditAggregateStatus() { + return auditAggregateStatus; + } + + public String getSubmitRemark() { + return submitRemark; + } + + public Integer getRejectCount() { + return rejectCount; + } + + public String getLastRejectReason() { + return lastRejectReason; + } + + public String getResubmitAt() { + return resubmitAt; + } + + public Integer getVersionNo() { + return versionNo; + } + + public Boolean getLatestVersion() { + return latestVersion; + } + + public String getUpdatedAt() { + return updatedAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/model/MeetingMaterialHistory.java b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingMaterialHistory.java new file mode 100644 index 0000000..b415bb9 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingMaterialHistory.java @@ -0,0 +1,55 @@ +package com.writeoff.module.meeting.model; + +public class MeetingMaterialHistory { + private Long id; + private Long meetingId; + private String moduleCode; + private Integer versionNo; + private String actionType; + private String contentJson; + private String remark; + private String createdAt; + + public MeetingMaterialHistory(Long id, Long meetingId, String moduleCode, Integer versionNo, String actionType, String contentJson, String remark, String createdAt) { + this.id = id; + this.meetingId = meetingId; + this.moduleCode = moduleCode; + this.versionNo = versionNo; + this.actionType = actionType; + this.contentJson = contentJson; + this.remark = remark; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public Long getMeetingId() { + return meetingId; + } + + public String getModuleCode() { + return moduleCode; + } + + public Integer getVersionNo() { + return versionNo; + } + + public String getActionType() { + return actionType; + } + + public String getContentJson() { + return contentJson; + } + + public String getRemark() { + return remark; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/model/MeetingStatus.java b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingStatus.java new file mode 100644 index 0000000..b73ed5c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/model/MeetingStatus.java @@ -0,0 +1,10 @@ +package com.writeoff.module.meeting.model; + +public enum MeetingStatus { + NOT_STARTED, + IN_PROGRESS, + COMPLETED, + CANCELED, + DELAYED, + FROZEN +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/repository/InMemoryMeetingRepository.java b/backend/src/main/java/com/writeoff/module/meeting/repository/InMemoryMeetingRepository.java new file mode 100644 index 0000000..9ff932c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/repository/InMemoryMeetingRepository.java @@ -0,0 +1,82 @@ +package com.writeoff.module.meeting.repository; + +import com.writeoff.module.meeting.model.Meeting; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "in-memory") +public class InMemoryMeetingRepository implements MeetingRepository { + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(2000); + + @Override + public Meeting save(Meeting meeting) { + if (meeting.getId() == null) { + Meeting newMeeting = new Meeting( + idGenerator.incrementAndGet(), + meeting.getProjectId(), + meeting.getTopic(), + meeting.getMeetingCategory(), + meeting.getMeetingForm(), + meeting.getLocation(), + meeting.getStartTime(), + meeting.getEndTime(), + meeting.getBudgetCent(), + meeting.getLaborRatio(), + meeting.getCateringRatio(), + meeting.getStatus(), + meeting.getAuditStatus(), + meeting.getCurrentAuditNode(), + meeting.getLastSubmitAt(), + meeting.getLastRejectReason(), + meeting.getOverdueDays(), + meeting.getRiskFlagsJson(), + meeting.isFrozen(), + meeting.getFreezeReason(), + meeting.getCurrentAuditorUserId(), + meeting.getNodeDeadlineAt(), + meeting.getRejectCount(), + meeting.getLastActionAt(), + meeting.getStatusChangedAt(), + meeting.getStatusChangedBy(), + meeting.getCancelReason(), + meeting.getPostponeReason(), + meeting.getWithdrawReason(), + meeting.getLockVersion(), + meeting.getLockAt(), + meeting.getLockedBy(), + meeting.getInvoiceConfigJson() + ); + store.put(newMeeting.getId(), newMeeting); + return newMeeting; + } + store.put(meeting.getId(), meeting); + return meeting; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAll(boolean includeDeleted) { + return new ArrayList<>(store.values()); + } + + @Override + public void softDelete(Long id) { + Meeting meeting = store.get(id); + if (meeting != null) { + meeting.setDeleted(true); + store.put(id, meeting); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/repository/JdbcMeetingRepository.java b/backend/src/main/java/com/writeoff/module/meeting/repository/JdbcMeetingRepository.java new file mode 100644 index 0000000..7bcb566 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/repository/JdbcMeetingRepository.java @@ -0,0 +1,252 @@ +package com.writeoff.module.meeting.repository; + +import com.writeoff.module.meeting.model.Meeting; +import com.writeoff.module.meeting.model.MeetingAuditStatus; +import com.writeoff.module.meeting.model.MeetingStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import com.writeoff.security.AuthContext; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.List; +import java.util.Optional; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "jdbc", matchIfMissing = true) +public class JdbcMeetingRepository implements MeetingRepository { + private final JdbcTemplate jdbcTemplate; + private static final RowMapper ROW_MAPPER = (rs, n) -> { + Meeting meeting = new Meeting( + rs.getLong("id"), + rs.getLong("project_id"), + rs.getString("topic"), + rs.getString("meeting_category"), + rs.getString("meeting_form"), + rs.getString("location"), + rs.getString("start_time"), + rs.getString("end_time"), + rs.getLong("budget_cent"), + rs.getBigDecimal("labor_ratio") == null ? 0d : rs.getBigDecimal("labor_ratio").doubleValue(), + rs.getBigDecimal("catering_ratio") == null ? 0d : rs.getBigDecimal("catering_ratio").doubleValue(), + MeetingStatus.valueOf(rs.getString("meeting_status")), + MeetingAuditStatus.valueOf(rs.getString("audit_status")), + rs.getString("current_audit_node"), + rs.getString("last_submit_at"), + rs.getString("last_reject_reason"), + rs.getInt("overdue_days"), + rs.getString("risk_flags_json"), + rs.getInt("is_frozen") == 1, + rs.getString("freeze_reason"), + rs.getObject("current_auditor_user_id") == null ? null : rs.getLong("current_auditor_user_id"), + rs.getString("node_deadline_at"), + rs.getInt("reject_count"), + rs.getString("last_action_at"), + rs.getString("status_changed_at"), + rs.getObject("status_changed_by") == null ? null : rs.getLong("status_changed_by"), + rs.getString("cancel_reason"), + rs.getString("postpone_reason"), + rs.getString("withdraw_reason"), + rs.getInt("lock_version"), + rs.getString("lock_at"), + rs.getObject("locked_by") == null ? null : rs.getLong("locked_by"), + rs.getString("invoice_config_json") + ); + meeting.setProjectName(rs.getString("project_name")); + meeting.setDeleted(rs.getInt("is_deleted") == 1); + return meeting; + }; + + public JdbcMeetingRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public Meeting save(Meeting meeting) { + if (meeting.getId() == null) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO meeting (tenant_id, project_id, topic, meeting_category, meeting_form, location, start_time, end_time, budget_cent, labor_ratio, catering_ratio, meeting_status, audit_status, current_audit_node, last_submit_at, last_reject_reason, overdue_days, risk_flags_json, is_frozen, freeze_reason, current_auditor_user_id, node_deadline_at, reject_count, last_action_at, status_changed_at, status_changed_by, cancel_reason, postpone_reason, withdraw_reason, lock_version, lock_at, locked_by, invoice_config_json, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), ?, ?, ?, ?, ?, ?, STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), ?, ?, ?, ?, ?, ?, STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), ?, STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), ?, ?, ?, ?, ?, STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), ?, ?, ?, ?)", + Statement.RETURN_GENERATED_KEYS + ); + Long operator = safeUserId(); + ps.setLong(1, tenantId()); + ps.setLong(2, meeting.getProjectId()); + ps.setString(3, meeting.getTopic()); + ps.setString(4, meeting.getMeetingCategory()); + ps.setString(5, meeting.getMeetingForm()); + ps.setString(6, meeting.getLocation()); + ps.setString(7, meeting.getStartTime()); + ps.setString(8, meeting.getEndTime()); + ps.setLong(9, meeting.getBudgetCent()); + ps.setBigDecimal(10, java.math.BigDecimal.valueOf(meeting.getLaborRatio())); + ps.setBigDecimal(11, java.math.BigDecimal.valueOf(meeting.getCateringRatio())); + ps.setString(12, meeting.getStatus().name()); + ps.setString(13, meeting.getAuditStatus().name()); + ps.setString(14, meeting.getCurrentAuditNode()); + ps.setString(15, meeting.getLastSubmitAt()); + ps.setString(16, meeting.getLastRejectReason()); + ps.setInt(17, meeting.getOverdueDays()); + ps.setString(18, meeting.getRiskFlagsJson()); + ps.setInt(19, meeting.isFrozen() ? 1 : 0); + ps.setString(20, meeting.getFreezeReason()); + ps.setObject(21, meeting.getCurrentAuditorUserId()); + ps.setString(22, meeting.getNodeDeadlineAt()); + ps.setInt(23, meeting.getRejectCount()); + ps.setString(24, meeting.getLastActionAt()); + ps.setString(25, meeting.getStatusChangedAt()); + ps.setObject(26, meeting.getStatusChangedBy()); + ps.setString(27, meeting.getCancelReason()); + ps.setString(28, meeting.getPostponeReason()); + ps.setString(29, meeting.getWithdrawReason()); + ps.setInt(30, meeting.getLockVersion()); + ps.setString(31, meeting.getLockAt()); + ps.setObject(32, meeting.getLockedBy()); + ps.setString(33, meeting.getInvoiceConfigJson()); + ps.setLong(34, operator); + ps.setLong(35, operator); + return ps; + }, keyHolder); + Number key = keyHolder.getKey(); + Long id = key == null ? null : key.longValue(); + return new Meeting( + id, + meeting.getProjectId(), + meeting.getTopic(), + meeting.getMeetingCategory(), + meeting.getMeetingForm(), + meeting.getLocation(), + meeting.getStartTime(), + meeting.getEndTime(), + meeting.getBudgetCent(), + meeting.getLaborRatio(), + meeting.getCateringRatio(), + meeting.getStatus(), + meeting.getAuditStatus(), + meeting.getCurrentAuditNode(), + meeting.getLastSubmitAt(), + meeting.getLastRejectReason(), + meeting.getOverdueDays(), + meeting.getRiskFlagsJson(), + meeting.isFrozen(), + meeting.getFreezeReason(), + meeting.getCurrentAuditorUserId(), + meeting.getNodeDeadlineAt(), + meeting.getRejectCount(), + meeting.getLastActionAt(), + meeting.getStatusChangedAt(), + meeting.getStatusChangedBy(), + meeting.getCancelReason(), + meeting.getPostponeReason(), + meeting.getWithdrawReason(), + meeting.getLockVersion(), + meeting.getLockAt(), + meeting.getLockedBy(), + meeting.getInvoiceConfigJson() + ); + } + jdbcTemplate.update( + "UPDATE meeting SET topic=?, meeting_category=?, meeting_form=?, location=?, " + + "start_time=STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), end_time=STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), budget_cent=?, labor_ratio=?, catering_ratio=?, " + + "meeting_status=?, audit_status=?, current_audit_node=?, last_submit_at=STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), last_reject_reason=?, overdue_days=?, " + + "risk_flags_json=?, is_frozen=?, freeze_reason=?, current_auditor_user_id=?, node_deadline_at=STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), reject_count=?, last_action_at=STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), " + + "status_changed_at=STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), status_changed_by=?, cancel_reason=?, postpone_reason=?, withdraw_reason=?, lock_version=?, lock_at=STR_TO_DATE(?, '%Y-%m-%dT%H:%i:%s'), locked_by=?, invoice_config_json=?, updated_by=? WHERE tenant_id=? AND id=?", + meeting.getTopic(), + meeting.getMeetingCategory(), + meeting.getMeetingForm(), + meeting.getLocation(), + meeting.getStartTime(), + meeting.getEndTime(), + meeting.getBudgetCent(), + java.math.BigDecimal.valueOf(meeting.getLaborRatio()), + java.math.BigDecimal.valueOf(meeting.getCateringRatio()), + meeting.getStatus().name(), + meeting.getAuditStatus().name(), + meeting.getCurrentAuditNode(), + meeting.getLastSubmitAt(), + meeting.getLastRejectReason(), + meeting.getOverdueDays(), + meeting.getRiskFlagsJson(), + meeting.isFrozen() ? 1 : 0, + meeting.getFreezeReason(), + meeting.getCurrentAuditorUserId(), + meeting.getNodeDeadlineAt(), + meeting.getRejectCount(), + meeting.getLastActionAt(), + meeting.getStatusChangedAt(), + meeting.getStatusChangedBy(), + meeting.getCancelReason(), + meeting.getPostponeReason(), + meeting.getWithdrawReason(), + meeting.getLockVersion(), + meeting.getLockAt(), + meeting.getLockedBy(), + meeting.getInvoiceConfigJson(), + safeUserId(), + tenantId(), + meeting.getId() + ); + return meeting; + } + + @Override + public Optional findById(Long id) { + List list = jdbcTemplate.query( + "SELECT m.id, m.project_id, p.project_name, m.topic, m.meeting_category, m.meeting_form, m.location, " + + "DATE_FORMAT(m.start_time, '%Y-%m-%d %H:%i:%s') AS start_time, DATE_FORMAT(m.end_time, '%Y-%m-%d %H:%i:%s') AS end_time, " + + "m.budget_cent, m.labor_ratio, m.catering_ratio, m.meeting_status, m.audit_status, m.current_audit_node, " + + "DATE_FORMAT(m.last_submit_at, '%Y-%m-%dT%H:%i:%s') AS last_submit_at, m.last_reject_reason, m.overdue_days, m.risk_flags_json, m.is_frozen, m.freeze_reason, " + + "m.current_auditor_user_id, DATE_FORMAT(m.node_deadline_at, '%Y-%m-%dT%H:%i:%s') AS node_deadline_at, m.reject_count, DATE_FORMAT(m.last_action_at, '%Y-%m-%dT%H:%i:%s') AS last_action_at, " + + "DATE_FORMAT(m.status_changed_at, '%Y-%m-%dT%H:%i:%s') AS status_changed_at, m.status_changed_by, m.cancel_reason, m.postpone_reason, m.withdraw_reason, m.lock_version, DATE_FORMAT(m.lock_at, '%Y-%m-%dT%H:%i:%s') AS lock_at, m.locked_by, m.invoice_config_json, m.is_deleted " + + "FROM meeting m LEFT JOIN project p ON m.tenant_id=p.tenant_id AND m.project_id=p.id " + + "WHERE m.tenant_id=? AND m.id=? AND m.is_deleted=0", + ROW_MAPPER, + tenantId(), + id + ); + return list.stream().findFirst(); + } + + @Override + public List findAll(boolean includeDeleted) { + String whereSql = includeDeleted ? "WHERE m.tenant_id=? " : "WHERE m.tenant_id=? AND m.is_deleted=0 "; + return jdbcTemplate.query( + "SELECT m.id, m.project_id, p.project_name, m.topic, m.meeting_category, m.meeting_form, m.location, " + + "DATE_FORMAT(m.start_time, '%Y-%m-%d %H:%i:%s') AS start_time, DATE_FORMAT(m.end_time, '%Y-%m-%d %H:%i:%s') AS end_time, " + + "m.budget_cent, m.labor_ratio, m.catering_ratio, m.meeting_status, m.audit_status, m.current_audit_node, " + + "DATE_FORMAT(m.last_submit_at, '%Y-%m-%dT%H:%i:%s') AS last_submit_at, m.last_reject_reason, m.overdue_days, m.risk_flags_json, m.is_frozen, m.freeze_reason, " + + "m.current_auditor_user_id, DATE_FORMAT(m.node_deadline_at, '%Y-%m-%dT%H:%i:%s') AS node_deadline_at, m.reject_count, DATE_FORMAT(m.last_action_at, '%Y-%m-%dT%H:%i:%s') AS last_action_at, " + + "DATE_FORMAT(m.status_changed_at, '%Y-%m-%dT%H:%i:%s') AS status_changed_at, m.status_changed_by, m.cancel_reason, m.postpone_reason, m.withdraw_reason, m.lock_version, DATE_FORMAT(m.lock_at, '%Y-%m-%dT%H:%i:%s') AS lock_at, m.locked_by, m.invoice_config_json, m.is_deleted " + + "FROM meeting m LEFT JOIN project p ON m.tenant_id=p.tenant_id AND m.project_id=p.id " + + whereSql + + "ORDER BY m.id DESC", + ROW_MAPPER, + tenantId() + ); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + @Override + public void softDelete(Long id) { + jdbcTemplate.update( + "UPDATE meeting SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + id + ); + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/repository/MeetingRepository.java b/backend/src/main/java/com/writeoff/module/meeting/repository/MeetingRepository.java new file mode 100644 index 0000000..8a998f0 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/repository/MeetingRepository.java @@ -0,0 +1,16 @@ +package com.writeoff.module.meeting.repository; + +import com.writeoff.module.meeting.model.Meeting; + +import java.util.List; +import java.util.Optional; + +public interface MeetingRepository { + Meeting save(Meeting meeting); + + Optional findById(Long id); + + List findAll(boolean includeDeleted); + + void softDelete(Long id); +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/service/MeetingExpertBindingService.java b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingExpertBindingService.java new file mode 100644 index 0000000..e81f754 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingExpertBindingService.java @@ -0,0 +1,460 @@ +package com.writeoff.module.meeting.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.meeting.dto.BindMeetingExpertsRequest; +import com.writeoff.module.meeting.model.MeetingExpertBinding; +import com.writeoff.module.system.service.BizChangeLogService; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +public class MeetingExpertBindingService { + private static final Long PLATFORM_TENANT_ID = 0L; + private static final String EXPERT_LIST_MODULE_CODE = "EXPERT_LIST"; + private static final String UNBIND_BLOCKED_MESSAGE = "该专家在会议资料-专家列表中已有已保存资料,请先删除相关信息后再解绑"; + + private final JdbcTemplate jdbcTemplate; + private final MeetingService meetingService; + private final BizChangeLogService bizChangeLogService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final RowMapper ROW_MAPPER = (rs, n) -> new MeetingExpertBinding( + rs.getLong("id"), + rs.getLong("meeting_id"), + rs.getLong("expert_id"), + rs.getString("expert_name"), + maskPhone(rs.getString("phone")), + rs.getString("title"), + rs.getString("organization") + ); + + public MeetingExpertBindingService(JdbcTemplate jdbcTemplate, MeetingService meetingService, BizChangeLogService bizChangeLogService) { + this.jdbcTemplate = jdbcTemplate; + this.meetingService = meetingService; + this.bizChangeLogService = bizChangeLogService; + } + + public List listByMeetingId(Long meetingId) { + meetingService.getById(meetingId); + return jdbcTemplate.query( + "SELECT id, meeting_id, expert_id, expert_name, phone, title, organization " + + "FROM meeting_expert_binding WHERE tenant_id=? AND meeting_id=? ORDER BY id DESC", + ROW_MAPPER, + tenantId(), + meetingId + ); + } + + @Transactional(rollbackFor = Exception.class) + public List bind(Long meetingId, BindMeetingExpertsRequest request) { + meetingService.getById(meetingId); + List beforeBindings = listByMeetingId(meetingId); + List rawIds = request.getExpertIds() == null ? new ArrayList() : request.getExpertIds(); + Set idSet = new HashSet(); + for (Long id : rawIds) { + if (id != null && id > 0) { + idSet.add(id); + } + } + List expertIds = new ArrayList(idSet); + validateRemovedExpertCanUnbind(meetingId, beforeBindings, expertIds); + + jdbcTemplate.update( + "DELETE FROM meeting_expert_binding WHERE tenant_id=? AND meeting_id=?", + tenantId(), + meetingId + ); + if (expertIds.isEmpty()) { + return listByMeetingId(meetingId); + } + + StringBuilder sql = new StringBuilder("SELECT id, expert_name, phone, title, organization FROM expert WHERE tenant_id=? AND is_deleted=0 AND id IN ("); + for (int i = 0; i < expertIds.size(); i++) { + if (i > 0) { + sql.append(","); + } + sql.append("?"); + } + sql.append(")"); + + List args = new ArrayList(); + args.add(PLATFORM_TENANT_ID); + args.addAll(expertIds); + List> experts = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + Map> expertMap = new LinkedHashMap>(); + for (Map item : experts) { + Object idVal = item.get("id"); + if (idVal instanceof Number) { + expertMap.put(((Number) idVal).longValue(), item); + } + } + if (expertMap.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "未匹配到平台专家"); + } + logBindingDiff(meetingId, beforeBindings, expertIds, expertMap); + Long operator = safeUserId(); + for (Long expertId : expertIds) { + Map expert = expertMap.get(expertId); + if (expert == null) { + continue; + } + jdbcTemplate.update( + "INSERT INTO meeting_expert_binding (tenant_id, meeting_id, expert_id, expert_name, phone, title, organization, created_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + tenantId(), + meetingId, + expertId, + String.valueOf(expert.get("expert_name") == null ? "" : expert.get("expert_name")), + String.valueOf(expert.get("phone") == null ? "" : expert.get("phone")), + String.valueOf(expert.get("title") == null ? "" : expert.get("title")), + String.valueOf(expert.get("organization") == null ? "" : expert.get("organization")), + operator + ); + } + return listByMeetingId(meetingId); + } + + @Transactional(rollbackFor = Exception.class) + public List unbindOne(Long meetingId, Long expertId) { + meetingService.getById(meetingId); + validateExpertMaterialCanUnbind(meetingId, expertId); + MeetingExpertBinding beforeBinding = findBinding(meetingId, expertId); + jdbcTemplate.update( + "DELETE FROM meeting_expert_binding WHERE tenant_id=? AND meeting_id=? AND expert_id=?", + tenantId(), + meetingId, + expertId + ); + if (beforeBinding != null && bizChangeLogService != null) { + bizChangeLogService.logRelationRemove("MEETING", meetingId, "MEETING_EXPERT_UNBIND", "expertBinding", "绑定专家", beforeBinding.getExpertId(), beforeBinding.getExpertName(), null, null); + } + return listByMeetingId(meetingId); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private static String maskPhone(String phone) { + if (phone == null || phone.trim().isEmpty()) { + return phone; + } + String value = phone.trim(); + if (value.length() <= 7) { + return value; + } + return value.substring(0, 3) + "****" + value.substring(value.length() - 4); + } + + private void validateExpertMaterialCanUnbind(Long meetingId, Long expertId) { + long id = expertId == null ? 0L : expertId; + if (id <= 0L) { + return; + } + String contentJson = loadExpertListContentJson(meetingId); + if (!hasSavedExpertMaterial(contentJson, id)) { + return; + } + throw new BusinessException( + ErrorCodes.INVALID_STATE, + UNBIND_BLOCKED_MESSAGE + ); + } + + private void validateRemovedExpertCanUnbind(Long meetingId, List beforeBindings, List nextExpertIds) { + if (beforeBindings == null || beforeBindings.isEmpty()) { + return; + } + Set nextIdSet = new HashSet(nextExpertIds == null ? new ArrayList() : nextExpertIds); + for (MeetingExpertBinding binding : beforeBindings) { + if (binding == null || binding.getExpertId() == null) { + continue; + } + Long expertId = binding.getExpertId(); + if (nextIdSet.contains(expertId)) { + continue; + } + validateExpertMaterialCanUnbind(meetingId, expertId); + } + } + + private String loadExpertListContentJson(Long meetingId) { + List rows = jdbcTemplate.query( + "SELECT content_json FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND module_code=? AND is_deleted=0 LIMIT 1", + (rs, n) -> rs.getString("content_json"), + tenantId(), + meetingId, + EXPERT_LIST_MODULE_CODE + ); + if (rows.isEmpty()) { + return ""; + } + return rows.get(0) == null ? "" : rows.get(0); + } + + private boolean hasSavedExpertMaterial(String contentJson, long expertId) { + if (contentJson == null || contentJson.trim().isEmpty()) { + return false; + } + try { + Map root = objectMapper.readValue(contentJson, new TypeReference>() {}); + return hasExpertOnsitePhoto(root, expertId) + || hasExpertOnsiteSummary(root, expertId) + || hasExpertLaborDetail(root, expertId) + || hasExpertInvoiceDetail(root, expertId); + } catch (Exception ignore) { + return false; + } + } + + private boolean hasExpertOnsitePhoto(Map root, long expertId) { + Map onsitePhoto = asMap(root.get("onsitePhoto")); + Object photosObj = onsitePhoto.get("photos"); + if (!(photosObj instanceof Collection)) { + return false; + } + for (Object obj : (Collection) photosObj) { + if (!(obj instanceof Map)) { + continue; + } + Map photo = (Map) obj; + if (!matchesExpert(photo.get("expertId"), expertId)) { + continue; + } + if (hasText(photo.get("ossKey")) || hasText(firstNonNull(photo.get("fileName"), photo.get("name")))) { + return true; + } + } + return false; + } + + private boolean hasExpertOnsiteSummary(Map root, long expertId) { + Map onsitePhoto = asMap(root.get("onsitePhoto")); + Object expertSummariesObj = onsitePhoto.get("expertSummaries"); + if (expertSummariesObj instanceof Collection) { + for (Object obj : (Collection) expertSummariesObj) { + if (!(obj instanceof Map)) { + continue; + } + Map summary = (Map) obj; + if (matchesExpert(summary.get("expertId"), expertId) && hasText(summary.get("summary"))) { + return true; + } + } + } + Object globalSummary = onsitePhoto.get("summary"); + return hasText(globalSummary); + } + + private boolean hasExpertLaborDetail(Map root, long expertId) { + Map laborProtocol = asMap(root.get("laborProtocol")); + Object detailsObj = laborProtocol.get("details"); + if (!(detailsObj instanceof Collection)) { + return false; + } + for (Object obj : (Collection) detailsObj) { + if (!(obj instanceof Map)) { + continue; + } + Map detail = (Map) obj; + if (!matchesExpert(detail.get("expertId"), expertId)) { + continue; + } + if (hasExpertLaborContent(detail)) { + return true; + } + } + return false; + } + + private boolean hasExpertInvoiceDetail(Map root, long expertId) { + Map invoiceDetail = asMap(root.get("invoiceDetail")); + Object invoicesObj = firstNonNull(invoiceDetail.get("invoices"), invoiceDetail.get("items")); + if (!(invoicesObj instanceof Collection)) { + return false; + } + for (Object obj : (Collection) invoicesObj) { + if (!(obj instanceof Map)) { + continue; + } + Map invoice = (Map) obj; + if (!matchesExpert(invoice.get("expertId"), expertId)) { + continue; + } + if (hasText(invoice.get("invoiceNo")) + || hasText(invoice.get("vendorName")) + || hasText(invoice.get("remark")) + || hasPositiveNumber(firstNonNull(invoice.get("invoiceAmountCent"), invoice.get("amountCent"))) + || hasInvoiceFiles(invoice)) { + return true; + } + } + return false; + } + + private boolean hasExpertLaborContent(Map detail) { + Map protocolFile = asMap(detail.get("protocolFile")); + Map invoiceFile = asMap(detail.get("invoiceFile")); + Object invoiceFilesObj = detail.get("invoiceFiles"); + return hasText(protocolFile.get("ossKey")) + || hasText(firstNonNull(protocolFile.get("fileName"), protocolFile.get("name"))) + || hasText(invoiceFile.get("ossKey")) + || hasText(firstNonNull(invoiceFile.get("fileName"), invoiceFile.get("name"))) + || hasInvoiceFiles(invoiceFilesObj) + || hasPositiveNumber(detail.get("amountCent")) + || hasText(detail.get("remark")) + || hasNonIdleInvoiceOcr(detail.get("invoiceOcr")); + } + + private boolean hasInvoiceFiles(Map invoice) { + return hasInvoiceFiles(firstNonNull(invoice.get("files"), invoice.get("file"))); + } + + private boolean hasInvoiceFiles(Object filesObj) { + if (filesObj instanceof Collection) { + for (Object obj : (Collection) filesObj) { + if (!(obj instanceof Map)) { + continue; + } + Map file = (Map) obj; + if (hasText(file.get("ossKey")) || hasText(firstNonNull(file.get("fileName"), file.get("name")))) { + return true; + } + } + return false; + } + if (filesObj instanceof Map) { + Map file = (Map) filesObj; + return hasText(file.get("ossKey")) || hasText(firstNonNull(file.get("fileName"), file.get("name"))); + } + return false; + } + + private boolean hasNonIdleInvoiceOcr(Object invoiceOcrObj) { + if (!(invoiceOcrObj instanceof Map)) { + return false; + } + Map invoiceOcr = (Map) invoiceOcrObj; + String status = stringValue(invoiceOcr.get("status")); + if (status != null && !status.isEmpty() && !"idle".equalsIgnoreCase(status)) { + return true; + } + return hasText(invoiceOcr.get("message")) || !asMap(invoiceOcr.get("normalized")).isEmpty(); + } + + private boolean matchesExpert(Object value, long expertId) { + if (value == null) { + return false; + } + if (value instanceof Number) { + return ((Number) value).longValue() == expertId; + } + try { + return Long.parseLong(String.valueOf(value).trim()) == expertId; + } catch (Exception ignore) { + return false; + } + } + + private boolean hasPositiveNumber(Object value) { + if (value == null) { + return false; + } + try { + return Long.parseLong(String.valueOf(value).trim()) > 0L; + } catch (Exception ignore) { + return false; + } + } + + private boolean hasText(Object value) { + return value != null && !String.valueOf(value).trim().isEmpty(); + } + + private String stringValue(Object value) { + return value == null ? null : String.valueOf(value).trim(); + } + + private Object firstNonNull(Object first, Object second) { + return first != null ? first : second; + } + + private void logBindingDiff(Long meetingId, + List beforeBindings, + List afterExpertIds, + Map> expertMap) { + if (bizChangeLogService == null) { + return; + } + Map beforeMap = new LinkedHashMap(); + for (MeetingExpertBinding item : beforeBindings) { + if (item != null && item.getExpertId() != null) { + beforeMap.put(item.getExpertId(), item.getExpertName()); + } + } + Map afterMap = new LinkedHashMap(); + for (Long expertId : afterExpertIds) { + Map expert = expertMap.get(expertId); + if (expert == null) { + continue; + } + afterMap.put(expertId, String.valueOf(expert.get("expert_name") == null ? "" : expert.get("expert_name")).trim()); + } + Set allIds = new HashSet(); + allIds.addAll(beforeMap.keySet()); + allIds.addAll(afterMap.keySet()); + String batchId = bizChangeLogService.newBatchId(); + for (Long expertId : allIds) { + boolean beforeExists = beforeMap.containsKey(expertId); + boolean afterExists = afterMap.containsKey(expertId); + String expertName = beforeExists ? beforeMap.get(expertId) : afterMap.get(expertId); + if (!beforeExists && afterExists) { + bizChangeLogService.logRelationAdd("MEETING", meetingId, "MEETING_EXPERT_BIND_ADD", "expertBinding", "绑定专家", expertId, expertName, batchId, null); + } else if (beforeExists && !afterExists) { + bizChangeLogService.logRelationRemove("MEETING", meetingId, "MEETING_EXPERT_BIND_REMOVE", "expertBinding", "绑定专家", expertId, expertName, batchId, null); + } + } + } + + private MeetingExpertBinding findBinding(Long meetingId, Long expertId) { + if (expertId == null || expertId <= 0L) { + return null; + } + List list = jdbcTemplate.query( + "SELECT id, meeting_id, expert_id, expert_name, phone, title, organization " + + "FROM meeting_expert_binding WHERE tenant_id=? AND meeting_id=? AND expert_id=? LIMIT 1", + ROW_MAPPER, + tenantId(), + meetingId, + expertId + ); + return list.isEmpty() ? null : list.get(0); + } + + @SuppressWarnings("unchecked") + private Map asMap(Object value) { + if (value instanceof Map) { + return (Map) value; + } + return new LinkedHashMap(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/service/MeetingLaborAgreementExtractService.java b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingLaborAgreementExtractService.java new file mode 100644 index 0000000..270ae8e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingLaborAgreementExtractService.java @@ -0,0 +1,541 @@ +package com.writeoff.module.meeting.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.expert.dto.AddBankCardRequest; +import com.writeoff.module.expert.dto.CreateExpertRequest; +import com.writeoff.module.expert.model.ExpertInfo; +import com.writeoff.module.expert.service.PlatformExpertService; +import com.writeoff.module.meeting.dto.BindMeetingExpertsRequest; +import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractApplyRequest; +import com.writeoff.module.meeting.dto.MeetingLaborAgreementExtractSubmitRequest; +import com.writeoff.module.meeting.model.Meeting; +import com.writeoff.module.meeting.model.MeetingAuditStatus; +import com.writeoff.module.meeting.model.MeetingLaborAgreementExtractResult; +import com.writeoff.module.ocr.dto.DocumentExtractTaskQueryResponse; +import com.writeoff.module.ocr.dto.DocumentExtractTaskSubmitRequest; +import com.writeoff.module.ocr.dto.DocumentExtractTaskSubmitResponse; +import com.writeoff.module.ocr.service.BaiduDocumentExtractService; +import com.writeoff.module.system.model.PlatformDictionaryItem; +import com.writeoff.module.system.service.PlatformDictionaryService; +import com.writeoff.security.AuthContext; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Service +public class MeetingLaborAgreementExtractService { + private static final String MODULE_CODE = "EXPERT_LIST"; + private static final String HOSPITAL_DICT_TYPE = "EXPERT_HOSPITAL"; + private static final List OCR_KEYS_NAME = asList("涔欐柟"); + private static final List OCR_KEYS_HOSPITAL = asList("宸ヤ綔鍗曚綅"); + private static final List OCR_KEYS_PHONE = asList("鑱旂郴鐢佃瘽"); + private static final List OCR_KEYS_FEE = asList("鍔冲姟璐?); + private static final List OCR_KEYS_BANK_NAME = asList("寮€鎴烽摱琛?); + private static final List OCR_KEYS_BANK_CARD = asList("寮€鎴峰笎鍙?, "寮€鎴疯处鍙?); + private static final List OCR_KEYS_ID_NO = asList("韬唤璇佸彿鐮?, "韬唤璇佸彿"); + private static final List OCR_KEYS_ACCOUNT_NAME = asList("璐︽埛鍚?); + + private final MeetingService meetingService; + private final BaiduDocumentExtractService documentExtractService; + private final PlatformExpertService platformExpertService; + private final PlatformDictionaryService platformDictionaryService; + private final MeetingExpertBindingService meetingExpertBindingService; + private final MeetingMaterialService meetingMaterialService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public MeetingLaborAgreementExtractService(MeetingService meetingService, + BaiduDocumentExtractService documentExtractService, + PlatformExpertService platformExpertService, + PlatformDictionaryService platformDictionaryService, + MeetingExpertBindingService meetingExpertBindingService, + MeetingMaterialService meetingMaterialService) { + this.meetingService = meetingService; + this.documentExtractService = documentExtractService; + this.platformExpertService = platformExpertService; + this.platformDictionaryService = platformDictionaryService; + this.meetingExpertBindingService = meetingExpertBindingService; + this.meetingMaterialService = meetingMaterialService; + } + + public DocumentExtractTaskSubmitResponse submit(Long meetingId, MeetingLaborAgreementExtractSubmitRequest request) { + assertMeetingEditable(meetingId); + DocumentExtractTaskSubmitRequest submitRequest = new DocumentExtractTaskSubmitRequest(); + submitRequest.setObjectKey(trimToNull(request.getObjectKey())); + submitRequest.setFileName(trimToNull(request.getFileName())); + submitRequest.setManifest(buildManifest()); + submitRequest.setRemoveDuplicates(Boolean.TRUE); + submitRequest.setEraseWatermark(Boolean.TRUE); + return documentExtractService.submitTask(submitRequest); + } + + public MeetingLaborAgreementExtractResult query(Long meetingId, String taskId) { + assertMeetingEditable(meetingId); + return buildResult(documentExtractService.queryTask(taskId)); + } + + @Transactional(rollbackFor = Exception.class) + public Map apply(Long meetingId, MeetingLaborAgreementExtractApplyRequest request) { + assertMeetingEditable(meetingId); + MeetingLaborAgreementExtractResult result = buildResult(documentExtractService.queryTask(request.getTaskId())); + if (!"Success".equalsIgnoreCase(trimToEmpty(result.getStatus()))) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "OCR浠诲姟鏈畬鎴愶紝涓嶈兘搴旂敤"); + } + MeetingLaborAgreementExtractResult.ParsedExpert parsed = result.getParsedExpert(); + if (parsed == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "鏈В鏋愬埌涓撳淇℃伅"); + } + String idNo = trimToNull(parsed.getIdNo()); + if (idNo == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "鏈瘑鍒埌韬唤璇佸彿鐮?); + } + + ExpertInfo existing = platformExpertService.findByExactIdNo(idNo); + Long requestedExpertId = request.getExistingExpertId(); + boolean updateExisting = Boolean.TRUE.equals(request.getUpdateExistingExpert()); + ExpertInfo targetExpert; + boolean created = false; + boolean updated = false; + + if (existing != null) { + if (!updateExisting) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "宸插瓨鍦ㄥ悓韬唤璇佷笓瀹讹紝璇风‘璁ゆ槸鍚﹀鐢ㄥ苟鏇存柊"); + } + if (requestedExpertId == null || !existing.getId().equals(requestedExpertId)) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "纭涓撳涓庣郴缁熷尮閰嶇粨鏋滀笉涓€鑷?); + } + targetExpert = updateExistingExpert(existing, parsed); + updated = true; + } else { + targetExpert = createExpert(parsed); + created = true; + } + + upsertBankCard(targetExpert.getId(), parsed); + bindExpertToMeeting(meetingId, targetExpert.getId()); + saveLaborToMeeting(meetingId, targetExpert, parsed, request.getObjectKey(), request.getFileName()); + + Map data = new LinkedHashMap(); + data.put("taskId", request.getTaskId()); + data.put("expertId", targetExpert.getId()); + data.put("createdExpert", created); + data.put("updatedExpert", updated); + data.put("boundMeeting", true); + data.put("nameMismatchFlag", Boolean.TRUE.equals(parsed.getNameMismatchFlag())); + return data; + } + + private MeetingLaborAgreementExtractResult buildResult(DocumentExtractTaskQueryResponse response) { + MeetingLaborAgreementExtractResult result = new MeetingLaborAgreementExtractResult(); + result.setTaskId(response.getTaskId()); + result.setStatus(response.getStatus()); + result.setReason(response.getReason()); + result.setCreatedAt(response.getCreatedAt()); + result.setStartedAt(response.getStartedAt()); + result.setFinishedAt(response.getFinishedAt()); + result.setDuration(response.getDuration()); + result.setLogId(response.getLogId()); + if (!"Success".equalsIgnoreCase(trimToEmpty(response.getStatus()))) { + return result; + } + MeetingLaborAgreementExtractResult.ParsedExpert parsedExpert = parseExtractedExpert(response.getRaw()); + result.setParsedExpert(parsedExpert); + ExpertInfo existing = parsedExpert == null ? null : platformExpertService.findByExactIdNo(parsedExpert.getIdNo()); + if (existing != null) { + MeetingLaborAgreementExtractResult.ExistingExpert existingExpert = new MeetingLaborAgreementExtractResult.ExistingExpert(); + existingExpert.setExpertId(existing.getId()); + existingExpert.setExpertName(existing.getExpertName()); + existingExpert.setPhoneMasked(existing.getPhone()); + existingExpert.setIdNoMasked(existing.getIdNo()); + existingExpert.setHospitalName(existing.getOrganization()); + result.setExistingExpert(existingExpert); + result.setNeedsConfirm(Boolean.TRUE); + } else { + result.setNeedsConfirm(Boolean.FALSE); + } + result.setNameMismatchFlag(parsedExpert != null && Boolean.TRUE.equals(parsedExpert.getNameMismatchFlag())); + return result; + } + + private MeetingLaborAgreementExtractResult.ParsedExpert parseExtractedExpert(Map raw) { + Map resultMap = asMap(raw == null ? null : raw.get("result")); + List> extractResults = listOfMap(resultMap.get("extractResult")); + if (extractResults.isEmpty()) { + return null; + } + Map first = extractResults.get(0); + Map data = asMap(first.get("data")); + Map singleKey = asMap(data.get("singleKey")); + MeetingLaborAgreementExtractResult.ParsedExpert expert = new MeetingLaborAgreementExtractResult.ParsedExpert(); + expert.setExpertName(firstWord(singleKey, OCR_KEYS_NAME)); + expert.setHospitalName(firstWord(singleKey, OCR_KEYS_HOSPITAL)); + expert.setPhone(normalizePhone(firstWord(singleKey, OCR_KEYS_PHONE))); + String laborFeeText = firstWord(singleKey, OCR_KEYS_FEE); + expert.setLaborFeeText(laborFeeText); + expert.setLaborFeeCent(parseAmountCent(laborFeeText)); + expert.setBankName(firstWord(singleKey, OCR_KEYS_BANK_NAME)); + expert.setBankCardNo(normalizeBankCardNo(firstWord(singleKey, OCR_KEYS_BANK_CARD))); + expert.setAccountName(firstWord(singleKey, OCR_KEYS_ACCOUNT_NAME)); + expert.setIdNo(normalizeIdNo(firstWord(singleKey, OCR_KEYS_ID_NO))); + boolean mismatch = hasText(expert.getExpertName()) + && hasText(expert.getAccountName()) + && !trimToEmpty(expert.getExpertName()).equals(trimToEmpty(expert.getAccountName())); + expert.setNameMismatchFlag(mismatch); + return expert; + } + + private ExpertInfo createExpert(MeetingLaborAgreementExtractResult.ParsedExpert parsed) { + PlatformDictionaryItem hospitalItem = ensureHospitalDictionary(parsed.getHospitalName()); + CreateExpertRequest request = new CreateExpertRequest(); + request.setExpertName(trimToEmpty(parsed.getExpertName())); + request.setIdNo(trimToEmpty(parsed.getIdNo())); + request.setPhone(trimToEmpty(parsed.getPhone())); + request.setHospitalCode(hospitalItem == null ? null : hospitalItem.getDictCode()); + request.setOrganization(hospitalItem == null ? trimToEmpty(parsed.getHospitalName()) : hospitalItem.getDictName()); + return platformExpertService.create(request); + } + + private ExpertInfo updateExistingExpert(ExpertInfo existing, MeetingLaborAgreementExtractResult.ParsedExpert parsed) { + PlatformDictionaryItem hospitalItem = ensureHospitalDictionary(parsed.getHospitalName()); + CreateExpertRequest request = new CreateExpertRequest(); + request.setExpertName(hasText(parsed.getExpertName()) ? parsed.getExpertName().trim() : existing.getExpertName()); + request.setIdNo(existing.getIdNo()); + request.setPhone(hasText(parsed.getPhone()) ? parsed.getPhone().trim() : existing.getPhone()); + request.setGender(existing.getGender()); + request.setBirthday(existing.getBirthday()); + request.setIdCardValidUntil(existing.getIdCardValidUntil()); + request.setIdCardFrontOssKey(existing.getIdCardFrontOssKey()); + request.setIdCardBackOssKey(existing.getIdCardBackOssKey()); + request.setTitleCode(existing.getTitleCode()); + request.setTitle(existing.getTitle()); + request.setHospitalCode(hospitalItem == null ? existing.getHospitalCode() : hospitalItem.getDictCode()); + request.setOrganization(hospitalItem == null + ? trimToEmpty(existing.getOrganization()) + : hospitalItem.getDictName()); + request.setExportRestricted(existing.getExportRestricted()); + return platformExpertService.update(existing.getId(), request); + } + + private void upsertBankCard(Long expertId, MeetingLaborAgreementExtractResult.ParsedExpert parsed) { + if (expertId == null || expertId <= 0L) { + return; + } + AddBankCardRequest request = new AddBankCardRequest(); + request.setBankName(trimToEmpty(parsed.getBankName())); + request.setBankCardNo(trimToEmpty(parsed.getBankCardNo())); + request.setAccountName(hasText(parsed.getAccountName()) ? parsed.getAccountName().trim() : trimToEmpty(parsed.getExpertName())); + request.setIsDefault(Boolean.TRUE); + boolean mismatch = Boolean.TRUE.equals(parsed.getNameMismatchFlag()); + request.setInconsistentNameApproved(mismatch ? Boolean.TRUE : Boolean.FALSE); + request.setChangeReason(mismatch ? "鍔冲姟鍗忚OCR璇嗗埆鍒拌处鎴峰悕涓庝箼鏂逛笉涓€鑷达紝宸叉墦鏍囦繚瀛? : "鍔冲姟鍗忚OCR鑷姩瀵煎叆"); + platformExpertService.addOrUpdateDefaultCard(expertId, request); + } + + private void bindExpertToMeeting(Long meetingId, Long expertId) { + List ids = new ArrayList(); + List existing = meetingExpertBindingService.listByMeetingId(meetingId); + for (Object obj : existing) { + if (!(obj instanceof com.writeoff.module.meeting.model.MeetingExpertBinding)) { + continue; + } + com.writeoff.module.meeting.model.MeetingExpertBinding row = (com.writeoff.module.meeting.model.MeetingExpertBinding) obj; + if (row.getExpertId() != null && row.getExpertId() > 0L) { + ids.add(row.getExpertId()); + } + } + ids.add(expertId); + Set unique = new LinkedHashSet(ids); + BindMeetingExpertsRequest bindRequest = new BindMeetingExpertsRequest(); + bindRequest.setExpertIds(new ArrayList(unique)); + meetingExpertBindingService.bind(meetingId, bindRequest); + } + + private void saveLaborToMeeting(Long meetingId, ExpertInfo expert, MeetingLaborAgreementExtractResult.ParsedExpert parsed, String protocolObjectKey, String protocolFileName) { + String contentJson = meetingMaterialService.currentContentJsonOrEmpty(meetingId, MODULE_CODE); + Map root = parseJsonObject(contentJson); + Map onsitePhoto = ensureMap(root, "onsitePhoto"); + if (!(onsitePhoto.get("photos") instanceof List)) { + onsitePhoto.put("photos", new ArrayList()); + } + Map laborProtocol = ensureMap(root, "laborProtocol"); + List> details = ensureListOfMap(laborProtocol, "details"); + Map invoiceDetail = ensureMap(root, "invoiceDetail"); + if (!(invoiceDetail.get("invoices") instanceof List)) { + invoiceDetail.put("invoices", new ArrayList()); + } + + long expertId = expert.getId() == null ? 0L : expert.getId(); + Map targetRow = null; + for (Map row : details) { + if (longValue(row.get("expertId")) == expertId) { + targetRow = row; + break; + } + } + if (targetRow == null) { + targetRow = new LinkedHashMap(); + details.add(targetRow); + } + targetRow.put("expertId", expertId); + targetRow.put("expertName", trimToEmpty(expert.getExpertName())); + Map protocolFile = ensureMap(targetRow, "protocolFile"); + String protocolOssKey = trimToEmpty(protocolObjectKey); + String protocolName = trimToEmpty(protocolFileName); + if (hasText(protocolOssKey)) { + protocolFile.put("name", protocolName); + protocolFile.put("fileName", protocolName); + protocolFile.put("ossKey", protocolOssKey); + } else if (!hasText(protocolFile.get("ossKey"))) { + protocolFile.put("name", ""); + protocolFile.put("fileName", ""); + protocolFile.put("ossKey", ""); + } + Map invoiceFile = ensureMap(targetRow, "invoiceFile"); + if (!hasText(invoiceFile.get("ossKey"))) { + invoiceFile.put("name", ""); + invoiceFile.put("ossKey", ""); + } + if (!(targetRow.get("invoiceFiles") instanceof List)) { + targetRow.put("invoiceFiles", new ArrayList()); + } + targetRow.put("amountCent", parsed.getLaborFeeCent() == null ? 0L : parsed.getLaborFeeCent()); + String remark = Boolean.TRUE.equals(parsed.getNameMismatchFlag()) + ? "OCR璇嗗埆鎻愮ず锛氫箼鏂逛笌璐︽埛鍚嶄笉涓€鑷达紝璇蜂汉宸ュ鏍? + : ""; + targetRow.put("remark", remark); + meetingMaterialService.saveRawContent(meetingId, MODULE_CODE, toJson(root), "鍔冲姟鍗忚OCR鑷姩瀵煎叆"); + } + + private PlatformDictionaryItem ensureHospitalDictionary(String hospitalName) { + String name = trimToNull(hospitalName); + if (name == null) { + return null; + } + PlatformDictionaryItem existing = platformDictionaryService.findEnabledItemByName(HOSPITAL_DICT_TYPE, name); + if (existing != null) { + return existing; + } + return platformDictionaryService.createEnabledItem(HOSPITAL_DICT_TYPE, name, "AUTO_HOSPITAL", "鍔冲姟鍗忚OCR鑷姩鍒涘缓"); + } + + private void assertMeetingEditable(Long meetingId) { + Meeting meeting = meetingService.getById(meetingId); + MeetingAuditStatus auditStatus = meeting.getAuditStatus(); + if (auditStatus == MeetingAuditStatus.IN_REVIEW || auditStatus == MeetingAuditStatus.APPROVED) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "璇ヤ細璁祫鏂欏鏍镐腑鎴栧凡瀹℃牳閫氳繃锛屼笉鍏佽鍐嶄慨鏀?); + } + } + + private List buildManifest() { + List list = new ArrayList(); + list.add(manifestField("涔欐柟")); + list.add(manifestField("宸ヤ綔鍗曚綅")); + list.add(manifestField("鑱旂郴鐢佃瘽")); + list.add(manifestField("鍔冲姟璐?)); + list.add(manifestField("寮€鎴烽摱琛?)); + list.add(manifestField("寮€鎴峰笎鍙?)); + list.add(manifestField("璐︽埛鍚?)); + list.add(manifestField("韬唤璇佸彿鐮?)); + return list; + } + + private DocumentExtractTaskSubmitRequest.ManifestField manifestField(String key) { + DocumentExtractTaskSubmitRequest.ManifestField field = new DocumentExtractTaskSubmitRequest.ManifestField(); + field.setKey(key); + field.setParentKey("root"); + field.setDescription(""); + return field; + } + + private static List asList(String... values) { + List list = new ArrayList(); + if (values != null) { + for (String value : values) { + list.add(value); + } + } + return list; + } + + private String firstWord(Map singleKey, List keys) { + for (String key : keys) { + Object rowsObj = singleKey.get(key); + if (!(rowsObj instanceof Collection)) { + continue; + } + for (Object one : (Collection) rowsObj) { + if (!(one instanceof Map)) { + continue; + } + String word = trimToNull(((Map) one).get("word")); + if (word != null) { + return word; + } + } + } + return ""; + } + + private Long parseAmountCent(String text) { + String raw = trimToNull(text); + if (raw == null) { + return 0L; + } + String normalized = raw.replace(",", "").replace("锛?, "").replace("鍏?, "").replace("浜烘皯甯?, "").trim(); + if (normalized.isEmpty()) { + return 0L; + } + StringBuilder builder = new StringBuilder(); + boolean dotSeen = false; + for (int i = 0; i < normalized.length(); i++) { + char ch = normalized.charAt(i); + if (Character.isDigit(ch)) { + builder.append(ch); + } else if (ch == '.' && !dotSeen) { + builder.append(ch); + dotSeen = true; + } + } + if (builder.length() == 0) { + return 0L; + } + try { + BigDecimal yuan = new BigDecimal(builder.toString()); + return yuan.movePointRight(2).setScale(0, BigDecimal.ROUND_HALF_UP).longValue(); + } catch (Exception ex) { + return 0L; + } + } + + private Map parseJsonObject(String json) { + if (!hasText(json)) { + return new LinkedHashMap(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception ex) { + return new LinkedHashMap(); + } + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "JSON搴忓垪鍖栧け璐?); + } + } + + private Map ensureMap(Map parent, String key) { + Map value = asMap(parent.get(key)); + if (value.isEmpty() && !(parent.get(key) instanceof Map)) { + value = new LinkedHashMap(); + parent.put(key, value); + } else if (!(parent.get(key) instanceof Map)) { + parent.put(key, value); + } + return value; + } + + private List> ensureListOfMap(Map parent, String key) { + Object value = parent.get(key); + if (!(value instanceof List)) { + List> list = new ArrayList>(); + parent.put(key, list); + return list; + } + List> list = new ArrayList>(); + for (Object one : (List) value) { + if (one instanceof Map) { + list.add(new LinkedHashMap((Map) one)); + } + } + parent.put(key, list); + return list; + } + + private Map asMap(Object value) { + if (!(value instanceof Map)) { + return new LinkedHashMap(); + } + return new LinkedHashMap((Map) value); + } + + private List> listOfMap(Object value) { + List> list = new ArrayList>(); + if (!(value instanceof Collection)) { + return list; + } + for (Object one : (Collection) value) { + if (one instanceof Map) { + list.add(new LinkedHashMap((Map) one)); + } + } + return list; + } + + private long longValue(Object value) { + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + return Long.parseLong(trimToEmpty(value)); + } catch (Exception ex) { + return 0L; + } + } + + private String normalizeBankCardNo(String value) { + String raw = trimToNull(value); + if (raw == null) { + return ""; + } + return raw.replaceAll("\\s+", ""); + } + + private String normalizePhone(String value) { + String raw = trimToNull(value); + if (raw == null) { + return ""; + } + return raw.replaceAll("[^0-9]", ""); + } + + private String normalizeIdNo(String value) { + String raw = trimToNull(value); + if (raw == null) { + return ""; + } + return raw.replace(" ", "").toUpperCase(Locale.ROOT); + } + + private boolean hasText(Object value) { + return trimToNull(value) != null; + } + + private String trimToEmpty(Object value) { + String result = trimToNull(value); + return result == null ? "" : result; + } + + private String trimToNull(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } +} + + diff --git a/backend/src/main/java/com/writeoff/module/meeting/service/MeetingMaterialExportService.java b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingMaterialExportService.java new file mode 100644 index 0000000..a0f1563 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingMaterialExportService.java @@ -0,0 +1,643 @@ +package com.writeoff.module.meeting.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.file.service.OssService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +@Service +public class MeetingMaterialExportService { + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final Pattern ILLEGAL_FILE_CHARS = Pattern.compile("[\\\\/:*?\"<>|]"); + private static final List MODULE_SPECS = Arrays.asList( + new ModuleSpec("BASIC_INFO", "01_会议基本信息模块", "basic-info.json"), + new ModuleSpec("WRITE_OFF_DOCS", "02_核销材料模块", "write-off-docs.json"), + new ModuleSpec("EXPERT_LIST", "03_专家列表模块", "expert-list.json"), + new ModuleSpec("MEETING_INVOICE", "04_会议发票模块", "meeting-invoice.json") + ); + private static final Map INVOICE_SECTION_TITLES = new LinkedHashMap() {{ + put("SETTLEMENT_DETAIL", "会议结算单明细"); + put("VENUE_CONFIRMATION", "会场确认函、会场协议"); + put("CONSTRUCTION_DETAIL", "会议搭建明细"); + put("ACCOMMODATION_DETAIL", "住宿明细"); + put("CATERING_DETAIL", "餐饮明细"); + put("LOCAL_TRANSPORT_DETAIL", "小交通明细"); + put("INTERCITY_TRANSPORT_DETAIL", "大交通明细"); + put("MATERIAL_DETAIL", "物料明细"); + put("DESIGN_DRAFT_DETAIL", "设计稿明细"); + put("OTHER", "其他"); + }}; + + private final JdbcTemplate jdbcTemplate; + private final OssService ossService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public MeetingMaterialExportService(JdbcTemplate jdbcTemplate, OssService ossService) { + this.jdbcTemplate = jdbcTemplate; + this.ossService = ossService; + } + + public byte[] buildZip(Long tenantId, Long meetingId) { + Map meeting = findMeeting(tenantId, meetingId); + String meetingTopic = stringValue(meeting.get("topic")); + String exportedAt = DATE_TIME_FORMATTER.format(LocalDateTime.now()); + String rootFolder = "会议资料包/" + sanitizeSegment((meetingTopic.isEmpty() ? "会议" : meetingTopic) + "-" + meetingId) + "/"; + List> materialRows = jdbcTemplate.queryForList( + "SELECT module_code, content_json FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND is_deleted=0 ORDER BY id ASC", + tenantId, + meetingId + ); + Map materialJsonByCode = new LinkedHashMap(); + for (Map row : materialRows) { + String moduleCode = stringValue(row.get("module_code")); + if (!moduleCode.isEmpty()) { + materialJsonByCode.put(moduleCode, stringValue(row.get("content_json"))); + } + } + + List> manifestModules = new ArrayList>(); + List> manifestFiles = new ArrayList>(); + List failureLines = new ArrayList(); + Set usedEntries = new LinkedHashSet(); + int attemptedAttachmentCount = 0; + int successAttachmentCount = 0; + int failedAttachmentCount = 0; + + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream, StandardCharsets.UTF_8)) { + for (ModuleSpec moduleSpec : MODULE_SPECS) { + String contentJson = materialJsonByCode.get(moduleSpec.moduleCode); + if (contentJson == null || contentJson.trim().isEmpty()) { + continue; + } + Map moduleManifest = new LinkedHashMap(); + moduleManifest.put("moduleCode", moduleSpec.moduleCode); + moduleManifest.put("moduleTitle", moduleSpec.folderName.substring(3)); + + String moduleFolder = rootFolder + moduleSpec.folderName + "/"; + String jsonEntryPath = uniqueEntryPath(moduleFolder, moduleSpec.jsonFileName, usedEntries); + writeTextEntry(zipOutputStream, jsonEntryPath, prettyJson(contentJson)); + manifestFiles.add(buildManifestFile(moduleSpec.moduleCode, moduleSpec.folderName.substring(3), "JSON", moduleSpec.jsonFileName, null, jsonEntryPath, "SUCCESS", null)); + + Map parsedJson = parseJsonObject(contentJson, moduleSpec.moduleCode, failureLines); + List attachments = extractAttachments(moduleSpec.moduleCode, parsedJson); + moduleManifest.put("jsonEntryPath", jsonEntryPath); + moduleManifest.put("attachmentCount", attachments.size()); + + for (ExportAttachment attachment : attachments) { + attemptedAttachmentCount++; + String displayName = resolveDisplayName(attachment.fileName, attachment.objectKey, attachment.fallbackName); + String outputFileName = withOptionalPrefix(attachment.fileNamePrefix, displayName); + String folderPath = moduleFolder + sanitizeSegment(attachment.subFolder) + "/"; + String zipEntryPath = uniqueEntryPath(folderPath, outputFileName, usedEntries); + try { + byte[] fileBytes = ossService.getObjectBytes(attachment.objectKey); + writeBinaryEntry(zipOutputStream, zipEntryPath, fileBytes); + manifestFiles.add(buildManifestFile(moduleSpec.moduleCode, moduleSpec.folderName.substring(3), attachment.subFolder, outputFileName, attachment.objectKey, zipEntryPath, "SUCCESS", null)); + successAttachmentCount++; + } catch (Exception ex) { + String errorMessage = ex.getMessage() == null ? "下载失败" : ex.getMessage(); + failureLines.add(moduleSpec.folderName.substring(3) + "/" + attachment.subFolder + "/" + outputFileName + ":" + errorMessage); + manifestFiles.add(buildManifestFile(moduleSpec.moduleCode, moduleSpec.folderName.substring(3), attachment.subFolder, outputFileName, attachment.objectKey, zipEntryPath, "FAILED", errorMessage)); + failedAttachmentCount++; + } + } + moduleManifest.put("successAttachmentCount", successAttachmentCount); + moduleManifest.put("failedAttachmentCount", failedAttachmentCount); + manifestModules.add(moduleManifest); + } + + if (attemptedAttachmentCount > 0 && successAttachmentCount == 0) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "会议资料附件全部导出失败"); + } + + writeTextEntry(zipOutputStream, rootFolder + "00_导出说明.txt", buildReadme(meetingId, meetingTopic, exportedAt, attemptedAttachmentCount, successAttachmentCount, failedAttachmentCount, failureLines)); + + Map manifest = new LinkedHashMap(); + manifest.put("meetingId", meetingId); + manifest.put("meetingTopic", meetingTopic); + manifest.put("exportedAt", exportedAt); + manifest.put("attemptedAttachmentCount", attemptedAttachmentCount); + manifest.put("successAttachmentCount", successAttachmentCount); + manifest.put("failedAttachmentCount", failedAttachmentCount); + manifest.put("modules", manifestModules); + manifest.put("files", manifestFiles); + writeTextEntry(zipOutputStream, rootFolder + "00_manifest.json", objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(manifest)); + + zipOutputStream.finish(); + return outputStream.toByteArray(); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "会议资料打包失败"); + } + } + + private Map findMeeting(Long tenantId, Long meetingId) { + List> rows = jdbcTemplate.queryForList( + "SELECT id, topic FROM meeting WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + tenantId, + meetingId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在"); + } + return rows.get(0); + } + + private Map parseJsonObject(String contentJson, String moduleCode, List failureLines) { + try { + return objectMapper.readValue(contentJson, new TypeReference>() {}); + } catch (Exception ex) { + failureLines.add(moduleCode + " 模块数据解析失败:" + ex.getMessage()); + return new LinkedHashMap(); + } + } + + private List extractAttachments(String moduleCode, Map content) { + if ("WRITE_OFF_DOCS".equalsIgnoreCase(moduleCode)) { + return extractWriteOffDocs(content); + } + if ("EXPERT_LIST".equalsIgnoreCase(moduleCode)) { + return extractExpertList(content); + } + if ("MEETING_INVOICE".equalsIgnoreCase(moduleCode)) { + return extractMeetingInvoices(content); + } + return Collections.emptyList(); + } + + private List extractWriteOffDocs(Map content) { + List attachments = new ArrayList(); + List> agendas = listOfMap(content.get("agenda")); + for (int i = 0; i < agendas.size(); i++) { + Map agenda = agendas.get(i); + attachments.add(new ExportAttachment( + "会议议程", + stringValue(agenda.get("name")), + stringValue(agenda.get("ossKey")), + "会议议程" + (i + 1), + null + )); + } + + Map signInSheet = mapValue(content.get("signInSheet")); + if (!signInSheet.isEmpty()) { + attachments.add(new ExportAttachment( + "签到表", + stringValue(signInSheet.get("name")), + stringValue(signInSheet.get("ossKey")), + "签到表", + null + )); + } + + Map themePhoto = mapValue(content.get("themePhoto")); + if (!stringValue(themePhoto.get("ossKey")).isEmpty()) { + attachments.add(new ExportAttachment( + "主题照片", + stringValue(firstNonEmpty(themePhoto.get("name"), themePhoto.get("fileName"))), + stringValue(themePhoto.get("ossKey")), + "主题照片", + null + )); + } + + List> invitations = listOfMap(content.get("invitation")); + for (int i = 0; i < invitations.size(); i++) { + Map invitation = invitations.get(i); + attachments.add(new ExportAttachment( + "邀请函", + stringValue(invitation.get("name")), + stringValue(invitation.get("ossKey")), + "邀请函" + (i + 1), + null + )); + } + + Map profileFile = mapValue(content.get("profileFile")); + if (!profileFile.isEmpty()) { + attachments.add(new ExportAttachment( + "专家简介串场稿", + stringValue(profileFile.get("name")), + stringValue(profileFile.get("ossKey")), + "专家简介串场稿", + null + )); + } + return filterValidAttachments(attachments); + } + + private List extractExpertList(Map content) { + List attachments = new ArrayList(); + Map onsitePhoto = mapValue(content.get("onsitePhoto")); + List> photos = listOfMap(onsitePhoto.get("photos")); + for (int i = 0; i < photos.size(); i++) { + Map photo = photos.get(i); + attachments.add(new ExportAttachment( + "现场照片/" + resolveExpertFolder(photo, i + 1), + stringValue(photo.get("name")), + stringValue(photo.get("ossKey")), + "现场照片" + (i + 1), + null + )); + } + + Map laborProtocol = mapValue(content.get("laborProtocol")); + List> details = listOfMap(laborProtocol.get("details")); + for (int i = 0; i < details.size(); i++) { + Map detail = details.get(i); + String expertFolder = resolveExpertFolder(detail, i + 1); + Map protocolFile = mapValue(detail.get("protocolFile")); + if (!protocolFile.isEmpty()) { + attachments.add(new ExportAttachment( + "劳务协议/" + expertFolder, + stringValue(firstNonEmpty(protocolFile.get("fileName"), protocolFile.get("name"))), + stringValue(protocolFile.get("ossKey")), + "劳务协议" + (i + 1), + null + )); + } + Map invoiceFile = mapValue(detail.get("invoiceFile")); + if (!invoiceFile.isEmpty()) { + attachments.add(new ExportAttachment( + "劳务协议/" + expertFolder, + stringValue(firstNonEmpty(invoiceFile.get("fileName"), invoiceFile.get("name"))), + stringValue(invoiceFile.get("ossKey")), + "劳务发票" + (i + 1), + null + )); + } + } + + Map invoiceDetail = mapValue(content.get("invoiceDetail")); + List> invoices = listOfMap(invoiceDetail.get("invoices")); + for (int i = 0; i < invoices.size(); i++) { + Map invoice = invoices.get(i); + String expertFolder = resolveExpertFolder(invoice, i + 1); + String prefix = invoicePrefix(invoice); + List> files = listOfMap(invoice.get("files")); + if (files.isEmpty()) { + Map singleFile = mapValue(invoice.get("file")); + if (!singleFile.isEmpty()) { + files = Collections.singletonList(singleFile); + } + } + for (int fileIndex = 0; fileIndex < files.size(); fileIndex++) { + Map file = files.get(fileIndex); + attachments.add(new ExportAttachment( + "专家发票/" + expertFolder, + stringValue(firstNonEmpty(file.get("fileName"), file.get("name"))), + stringValue(file.get("ossKey")), + "专家发票" + (i + 1) + "-" + (fileIndex + 1), + prefix + )); + } + } + return filterValidAttachments(attachments); + } + + private List extractMeetingInvoices(Map content) { + List attachments = new ArrayList(); + List> sections = listOfMap(content.get("sections")); + for (int i = 0; i < sections.size(); i++) { + Map section = sections.get(i); + String sectionCode = stringValue(section.get("sectionCode")); + String sectionTitle = stringValue(firstNonEmpty(section.get("sectionTitle"), INVOICE_SECTION_TITLES.get(sectionCode))); + if (sectionTitle.isEmpty()) { + sectionTitle = "发票分组" + (i + 1); + } + List> files = listOfMap(section.get("files")); + for (int fileIndex = 0; fileIndex < files.size(); fileIndex++) { + Map file = files.get(fileIndex); + attachments.add(new ExportAttachment( + sectionTitle, + stringValue(file.get("fileName")), + stringValue(file.get("ossKey")), + sectionTitle + (fileIndex + 1), + stringValue(file.get("label")) + )); + } + } + return filterValidAttachments(attachments); + } + + private List filterValidAttachments(List attachments) { + List valid = new ArrayList(); + for (ExportAttachment attachment : attachments) { + if (attachment == null) { + continue; + } + String subFolder = sanitizeNestedFolder(attachment.subFolder); + valid.add(new ExportAttachment(subFolder, attachment.fileName, attachment.objectKey, attachment.fallbackName, attachment.fileNamePrefix)); + } + return valid; + } + + private String resolveExpertFolder(Map row, int fallbackIndex) { + String expertName = stringValue(firstNonEmpty(row.get("expertName"), row.get("name"))); + if (!expertName.isEmpty()) { + return sanitizeSegment(expertName); + } + Long expertId = longValue(row.get("expertId")); + if (expertId != null && expertId > 0L) { + return "专家-" + expertId; + } + return "专家-" + fallbackIndex; + } + + private String invoicePrefix(Map invoice) { + List parts = new ArrayList(); + String expenseType = stringValue(invoice.get("expenseType")); + if (!expenseType.isEmpty()) { + parts.add(expenseType); + } + String invoiceNo = stringValue(invoice.get("invoiceNo")); + if (!invoiceNo.isEmpty()) { + parts.add(invoiceNo); + } + return parts.isEmpty() ? null : String.join("-", parts); + } + + private Map buildManifestFile(String moduleCode, + String moduleTitle, + String subFolder, + String fileName, + String objectKey, + String zipEntryPath, + String status, + String errorMessage) { + Map file = new LinkedHashMap(); + file.put("moduleCode", moduleCode); + file.put("moduleTitle", moduleTitle); + file.put("subFolder", subFolder); + file.put("displayName", fileName); + file.put("objectKey", objectKey); + file.put("zipEntryPath", zipEntryPath); + file.put("status", status); + file.put("errorMessage", errorMessage); + return file; + } + + private void writeTextEntry(ZipOutputStream zipOutputStream, String entryPath, String content) throws IOException { + writeBinaryEntry(zipOutputStream, entryPath, content == null ? new byte[0] : content.getBytes(StandardCharsets.UTF_8)); + } + + private void writeBinaryEntry(ZipOutputStream zipOutputStream, String entryPath, byte[] bytes) throws IOException { + ZipEntry entry = new ZipEntry(entryPath); + zipOutputStream.putNextEntry(entry); + zipOutputStream.write(bytes == null ? new byte[0] : bytes); + zipOutputStream.closeEntry(); + } + + private String buildReadme(Long meetingId, + String meetingTopic, + String exportedAt, + int attemptedAttachmentCount, + int successAttachmentCount, + int failedAttachmentCount, + List failureLines) { + StringBuilder builder = new StringBuilder(); + builder.append("会议资料包导出说明").append("\r\n"); + builder.append("会议ID:").append(meetingId).append("\r\n"); + builder.append("会议主题:").append(meetingTopic == null || meetingTopic.trim().isEmpty() ? "-" : meetingTopic.trim()).append("\r\n"); + builder.append("导出时间:").append(exportedAt).append("\r\n"); + builder.append("附件总数:").append(attemptedAttachmentCount).append("\r\n"); + builder.append("成功数量:").append(successAttachmentCount).append("\r\n"); + builder.append("失败数量:").append(failedAttachmentCount).append("\r\n"); + builder.append("\r\n"); + builder.append("失败明细:").append("\r\n"); + if (failureLines.isEmpty()) { + builder.append("无").append("\r\n"); + } else { + for (String failureLine : failureLines) { + builder.append("- ").append(failureLine).append("\r\n"); + } + } + return builder.toString(); + } + + private String prettyJson(String contentJson) { + try { + Object parsed = objectMapper.readValue(contentJson, Object.class); + return objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(parsed); + } catch (Exception ex) { + return contentJson == null ? "" : contentJson; + } + } + + private String resolveDisplayName(String fileName, String objectKey, String fallbackName) { + String displayName = sanitizeFileName(fileName); + if (!displayName.isEmpty()) { + return appendExtensionIfMissing(displayName, objectKey); + } + String keyName = sanitizeFileName(fileNameFromObjectKey(objectKey)); + if (!keyName.isEmpty()) { + return keyName; + } + return appendExtensionIfMissing(sanitizeFileName(fallbackName), objectKey); + } + + private String appendExtensionIfMissing(String fileName, String objectKey) { + String safeFileName = sanitizeFileName(fileName); + if (safeFileName.isEmpty() || hasFileExtension(safeFileName)) { + return safeFileName; + } + String objectFileName = sanitizeFileName(fileNameFromObjectKey(objectKey)); + String extension = fileExtension(objectFileName); + if (extension.isEmpty()) { + return safeFileName; + } + return safeFileName + extension; + } + + private String uniqueEntryPath(String directory, String fileName, Set usedEntries) { + String safeDirectory = directory; + String safeFileName = sanitizeFileName(fileName); + if (safeFileName.isEmpty()) { + safeFileName = "附件"; + } + String extension = ""; + String baseName = safeFileName; + int dotIndex = safeFileName.lastIndexOf('.'); + if (dotIndex > 0 && dotIndex < safeFileName.length() - 1) { + extension = safeFileName.substring(dotIndex); + baseName = safeFileName.substring(0, dotIndex); + } + String candidate = safeDirectory + safeFileName; + int suffix = 2; + while (!usedEntries.add(candidate)) { + candidate = safeDirectory + baseName + "(" + suffix + ")" + extension; + suffix++; + } + return candidate; + } + + private String sanitizeNestedFolder(String rawFolder) { + String text = rawFolder == null ? "" : rawFolder.trim(); + if (text.isEmpty()) { + return "未分类"; + } + String[] parts = text.split("/"); + List safeParts = new ArrayList(); + for (String part : parts) { + String safe = sanitizeSegment(part); + if (!safe.isEmpty()) { + safeParts.add(safe); + } + } + return safeParts.isEmpty() ? "未分类" : String.join("/", safeParts); + } + + private String sanitizeSegment(String value) { + String text = value == null ? "" : value.trim(); + if (text.isEmpty()) { + return ""; + } + String safe = ILLEGAL_FILE_CHARS.matcher(text).replaceAll("_"); + safe = safe.replaceAll("\\s+", " ").trim(); + return safe.isEmpty() ? "" : safe; + } + + private String sanitizeFileName(String value) { + String safe = sanitizeSegment(value); + if (safe.isEmpty()) { + return ""; + } + if (".".equals(safe) || "..".equals(safe)) { + return "附件"; + } + return safe; + } + + private String fileNameFromObjectKey(String objectKey) { + String key = stringValue(objectKey); + if (key.isEmpty()) { + return ""; + } + int index = key.lastIndexOf('/'); + return index >= 0 ? key.substring(index + 1) : key; + } + + private boolean hasFileExtension(String fileName) { + return !fileExtension(fileName).isEmpty(); + } + + private String fileExtension(String fileName) { + String safeFileName = sanitizeFileName(fileName); + if (safeFileName.isEmpty()) { + return ""; + } + int dotIndex = safeFileName.lastIndexOf('.'); + if (dotIndex <= 0 || dotIndex >= safeFileName.length() - 1) { + return ""; + } + return safeFileName.substring(dotIndex); + } + + private String withOptionalPrefix(String prefix, String fileName) { + String safeFileName = sanitizeFileName(fileName); + String safePrefix = sanitizeSegment(prefix); + if (safePrefix.isEmpty()) { + return safeFileName; + } + if (safeFileName.isEmpty()) { + return safePrefix; + } + return safePrefix + "-" + safeFileName; + } + + private Object firstNonEmpty(Object first, Object second) { + String firstText = stringValue(first); + if (!firstText.isEmpty()) { + return first; + } + return second; + } + + private List> listOfMap(Object value) { + if (!(value instanceof List)) { + return Collections.emptyList(); + } + List list = (List) value; + List> result = new ArrayList>(); + for (Object item : list) { + if (item instanceof Map) { + result.add((Map) item); + } + } + return result; + } + + private Map mapValue(Object value) { + if (value instanceof Map) { + return (Map) value; + } + return Collections.emptyMap(); + } + + private String stringValue(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private Long longValue(Object value) { + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + String text = stringValue(value); + return text.isEmpty() ? null : Long.valueOf(text); + } catch (Exception ex) { + return null; + } + } + + private static class ModuleSpec { + private final String moduleCode; + private final String folderName; + private final String jsonFileName; + + private ModuleSpec(String moduleCode, String folderName, String jsonFileName) { + this.moduleCode = moduleCode; + this.folderName = folderName; + this.jsonFileName = jsonFileName; + } + } + + private static class ExportAttachment { + private final String subFolder; + private final String fileName; + private final String objectKey; + private final String fallbackName; + private final String fileNamePrefix; + + private ExportAttachment(String subFolder, String fileName, String objectKey, String fallbackName, String fileNamePrefix) { + this.subFolder = subFolder; + this.fileName = fileName; + this.objectKey = objectKey; + this.fallbackName = fallbackName; + this.fileNamePrefix = fileNamePrefix; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/service/MeetingMaterialService.java b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingMaterialService.java new file mode 100644 index 0000000..ba0dec4 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingMaterialService.java @@ -0,0 +1,1479 @@ +package com.writeoff.module.meeting.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.export.dto.CreateExportTaskRequest; +import com.writeoff.module.export.service.ExportTaskService; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.meeting.dto.SaveMeetingMaterialRequest; +import com.writeoff.module.meeting.dto.SubmitMeetingMaterialRequest; +import com.writeoff.module.meeting.model.Meeting; +import com.writeoff.module.meeting.model.MeetingMaterial; +import com.writeoff.module.meeting.model.MeetingMaterialHistory; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.math.BigDecimal; +import java.math.RoundingMode; + +@Service +public class MeetingMaterialService { + private static final Set SUPPORTED_MODULES = new HashSet<>(Arrays.asList("BASIC_INFO", "WRITE_OFF_DOCS", "EXPERT_PROFILE", "EXPERT_LIST", "MEETING_INVOICE")); + private static final Set MEETING_INVOICE_AMOUNT_SECTION_CODES = new HashSet<>(Arrays.asList( + "VENUE_CONFIRMATION", + "CONSTRUCTION_DETAIL", + "ACCOMMODATION_DETAIL", + "CATERING_DETAIL", + "LOCAL_TRANSPORT_DETAIL", + "INTERCITY_TRANSPORT_DETAIL", + "MATERIAL_DETAIL", + "DESIGN_DRAFT_DETAIL" + )); + private static final List MEETING_INVOICE_SECTION_CODES = Arrays.asList( + "SETTLEMENT_DETAIL", + "VENUE_CONFIRMATION", + "CONSTRUCTION_DETAIL", + "ACCOMMODATION_DETAIL", + "CATERING_DETAIL", + "LOCAL_TRANSPORT_DETAIL", + "INTERCITY_TRANSPORT_DETAIL", + "MATERIAL_DETAIL", + "DESIGN_DRAFT_DETAIL", + "OTHER" + ); + private static final Map> MEETING_INVOICE_SECTION_FIELD_KEYS = new LinkedHashMap>(); + static { + MEETING_INVOICE_SECTION_FIELD_KEYS.put("SETTLEMENT_DETAIL", Arrays.asList("settlementFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("VENUE_CONFIRMATION", Arrays.asList("invoiceFile", "detailFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("CONSTRUCTION_DETAIL", Arrays.asList("invoiceFile", "detailFile", "equipmentFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("ACCOMMODATION_DETAIL", Arrays.asList("invoiceFile", "detailFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("CATERING_DETAIL", Arrays.asList("invoiceFile", "detailFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("LOCAL_TRANSPORT_DETAIL", Arrays.asList("invoiceFile", "detailFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("INTERCITY_TRANSPORT_DETAIL", Arrays.asList("invoiceFile", "detailFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("MATERIAL_DETAIL", Arrays.asList("invoiceFile", "detailFile", "materialFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("DESIGN_DRAFT_DETAIL", Arrays.asList("invoiceFile", "detailFile", "designDraftFile")); + MEETING_INVOICE_SECTION_FIELD_KEYS.put("OTHER", Arrays.asList("invoiceFile", "detailFile", "equipmentFile", "materialFile", "designDraftFile")); + } + private final JdbcTemplate jdbcTemplate; + private final MeetingService meetingService; + private final ExportTaskService exportTaskService; + private final OssService ossService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final RowMapper MATERIAL_ROW_MAPPER = (rs, n) -> new MeetingMaterial( + rs.getLong("id"), + rs.getLong("meeting_id"), + rs.getString("module_code"), + rs.getString("content_json"), + rs.getString("status"), + rs.getString("audit_node_status"), + rs.getString("audit_aggregate_status"), + rs.getString("submit_remark"), + rs.getInt("reject_count"), + rs.getString("last_reject_reason"), + rs.getString("resubmit_at"), + rs.getInt("version_no"), + rs.getInt("is_latest_version") == 1, + rs.getString("updated_at") + ); + + private static final RowMapper HISTORY_ROW_MAPPER = (rs, n) -> new MeetingMaterialHistory( + rs.getLong("id"), + rs.getLong("meeting_id"), + rs.getString("module_code"), + rs.getInt("version_no"), + rs.getString("action_type"), + rs.getString("content_json"), + rs.getString("remark"), + rs.getString("created_at") + ); + + public MeetingMaterialService(JdbcTemplate jdbcTemplate, MeetingService meetingService, ExportTaskService exportTaskService, OssService ossService) { + this.jdbcTemplate = jdbcTemplate; + this.meetingService = meetingService; + this.exportTaskService = exportTaskService; + this.ossService = ossService; + } + + public PageResult list(Long meetingId) { + meetingService.getById(meetingId); + List list = jdbcTemplate.query( + "SELECT id, meeting_id, module_code, content_json, status, " + + "audit_node_status, audit_aggregate_status, submit_remark, reject_count, last_reject_reason, " + + "DATE_FORMAT(resubmit_at, '%Y-%m-%d %H:%i:%s') AS resubmit_at, version_no, is_latest_version, " + + "DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND is_deleted=0 ORDER BY id ASC", + MATERIAL_ROW_MAPPER, + tenantId(), + meetingId + ); + return new PageResult<>(list, list.size(), 1, 20); + } + + public MeetingMaterial current(Long meetingId, String moduleCode) { + meetingService.getById(meetingId); + validateModule(moduleCode); + String storageModuleCode = resolveStorageModuleCode(moduleCode); + List list = jdbcTemplate.query( + "SELECT id, meeting_id, module_code, content_json, status, " + + "audit_node_status, audit_aggregate_status, submit_remark, reject_count, last_reject_reason, " + + "DATE_FORMAT(resubmit_at, '%Y-%m-%d %H:%i:%s') AS resubmit_at, version_no, is_latest_version, " + + "DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND module_code=? AND is_deleted=0 LIMIT 1", + MATERIAL_ROW_MAPPER, + tenantId(), + meetingId, + storageModuleCode + ); + return list.isEmpty() ? null : list.get(0); + } + + public String currentContentJsonOrEmpty(Long meetingId, String moduleCode) { + MeetingMaterial material = current(meetingId, moduleCode); + if (material == null || material.getContentJson() == null) { + return ""; + } + return material.getContentJson(); + } + + @Transactional + public MeetingMaterial save(Long meetingId, String moduleCode, SaveMeetingMaterialRequest request) { + validateMeetingBudgetLimit(meetingId, moduleCode, request.getContentJson()); + return upsert(meetingId, moduleCode, request.getContentJson(), request.getRemark(), "SAVE", "DRAFT"); + } + + @Transactional + public MeetingMaterial saveRawContent(Long meetingId, String moduleCode, String contentJson, String remark) { + validateMeetingBudgetLimit(meetingId, moduleCode, contentJson); + return upsert(meetingId, moduleCode, contentJson, remark, "SAVE", "DRAFT"); + } + + @Transactional + public MeetingMaterial submit(Long meetingId, String moduleCode, SubmitMeetingMaterialRequest request) { + validateSubmitContent(moduleCode, request.getContentJson()); + validateMeetingBudgetLimit(meetingId, moduleCode, request.getContentJson()); + return upsert(meetingId, moduleCode, request.getContentJson(), request.getRemark(), "SUBMIT", "SUBMITTED"); + } + + public PageResult history(Long meetingId, String moduleCode) { + meetingService.getById(meetingId); + validateModule(moduleCode); + String storageModuleCode = resolveStorageModuleCode(moduleCode); + List list = jdbcTemplate.query( + "SELECT id, meeting_id, module_code, version_no, action_type, content_json, remark, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM meeting_material_history WHERE tenant_id=? AND meeting_id=? AND module_code=? ORDER BY version_no DESC", + HISTORY_ROW_MAPPER, + tenantId(), + meetingId, + storageModuleCode + ); + return new PageResult<>(list, list.size(), 1, 20); + } + + public List> listMaterialReviewItems(Long meetingId, String moduleCode) { + MeetingMaterial material = current(meetingId, moduleCode); + if (material == null || material.getContentJson() == null || material.getContentJson().trim().isEmpty()) { + return Collections.emptyList(); + } + return buildReviewItems(moduleCode, material.getContentJson()); + } + + public List> listMaterialItemReviews(Long taskId, String reviewNode, String moduleCode) { + validateModule(moduleCode); + return jdbcTemplate.query( + "SELECT item_key, item_label, review_result, review_reason, reviewer_user_id, " + + "DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM audit_material_item_review " + + "WHERE tenant_id=? AND task_id=? AND review_node=? AND module_code=? ORDER BY item_key ASC", + (rs, n) -> { + Map row = new LinkedHashMap<>(); + row.put("itemKey", rs.getString("item_key")); + row.put("itemLabel", rs.getString("item_label")); + row.put("reviewResult", rs.getString("review_result")); + row.put("reviewReason", rs.getString("review_reason")); + row.put("reviewerUserId", rs.getLong("reviewer_user_id")); + row.put("updatedAt", rs.getString("updated_at")); + return row; + }, + tenantId(), + taskId, + reviewNode, + moduleCode + ); + } + + @Transactional + public int saveMaterialItemReviewRecords(Long meetingId, + Long taskId, + String reviewNode, + String moduleCode, + List> items, + String reviewResult, + String reviewReason, + Long reviewerUserId) { + validateModule(moduleCode); + if (items == null || items.isEmpty()) { + return 0; + } + int affected = 0; + for (Map item : items) { + String itemKey = stringValue(item.get("itemKey")); + String itemLabel = stringValue(item.get("itemLabel")); + if (itemKey == null || itemKey.isEmpty() || itemLabel == null || itemLabel.isEmpty()) { + continue; + } + affected += jdbcTemplate.update( + "INSERT INTO audit_material_item_review (tenant_id, meeting_id, task_id, review_node, module_code, item_key, item_label, review_result, review_reason, reviewer_user_id) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON DUPLICATE KEY UPDATE item_label=VALUES(item_label), review_result=VALUES(review_result), review_reason=VALUES(review_reason), reviewer_user_id=VALUES(reviewer_user_id), updated_at=CURRENT_TIMESTAMP", + tenantId(), + meetingId, + taskId, + reviewNode, + moduleCode, + itemKey, + itemLabel, + reviewResult, + reviewReason, + reviewerUserId == null ? 0L : reviewerUserId + ); + } + return affected; + } + + public Map createMaterialsExportTask(Long meetingId, String idempotencyKey, String fileName) { + Meeting meeting = meetingService.getById(meetingId); + CreateExportTaskRequest request = new CreateExportTaskRequest(); + request.setIdempotencyKey(idempotencyKey); + request.setTaskCode("MEETING_MATERIAL_EXPORT"); + request.setBizType("MEETING_MATERIAL"); + request.setBizId(String.valueOf(meetingId)); + request.setFiltersJson("{\"meetingId\":" + meetingId + "}"); + request.setFileName(resolveMeetingExportFileName(meeting, fileName, "会议资料包", ".zip", "meeting-material-")); + return exportTaskService.create(request); + } + + public Map generateSummaryTask(Long meetingId, String idempotencyKey, String fileName) { + Meeting meeting = meetingService.getById(meetingId); + CreateExportTaskRequest request = new CreateExportTaskRequest(); + request.setIdempotencyKey(idempotencyKey); + request.setTaskCode("MEETING_SUMMARY_GENERATE"); + request.setBizType("MEETING_SUMMARY"); + request.setBizId(String.valueOf(meetingId)); + request.setFiltersJson("{\"meetingId\":" + meetingId + "}"); + request.setFileName(resolveMeetingExportFileName(meeting, fileName, "会议总结", ".docx", "meeting-summary-")); + return exportTaskService.create(request); + } + + private String resolveMeetingExportFileName(Meeting meeting, + String requestedFileName, + String suffixLabel, + String extension, + String genericPrefix) { + String normalizedExtension = normalizeExtension(extension); + if (isMeaningfulExportFileName(requestedFileName, normalizedExtension, genericPrefix)) { + return ensureFileExtension(sanitizeFileName(requestedFileName), normalizedExtension); + } + String meetingTopic = meeting == null ? "" : stringValue(meeting.getTopic()); + String baseName = sanitizeFileName(firstNonEmptyText(meetingTopic, "会议")); + return ensureFileExtension(baseName + "-" + suffixLabel, normalizedExtension); + } + + private boolean isMeaningfulExportFileName(String fileName, String extension, String genericPrefix) { + String text = sanitizeFileName(fileName); + if (text.isEmpty()) { + return false; + } + String lower = text.toLowerCase(Locale.ROOT); + String normalizedExtension = normalizeExtension(extension).toLowerCase(Locale.ROOT); + if (!lower.endsWith(normalizedExtension)) { + return false; + } + String prefix = genericPrefix == null ? "" : genericPrefix.trim().toLowerCase(Locale.ROOT); + return prefix.isEmpty() || !lower.startsWith(prefix); + } + + private String ensureFileExtension(String fileName, String extension) { + String safeName = sanitizeFileName(fileName); + String normalizedExtension = normalizeExtension(extension); + if (safeName.isEmpty()) { + return "文件" + normalizedExtension; + } + if (safeName.toLowerCase(Locale.ROOT).endsWith(normalizedExtension.toLowerCase(Locale.ROOT))) { + return safeName; + } + return safeName + normalizedExtension; + } + + private String normalizeExtension(String extension) { + String text = stringValue(extension); + if (text.isEmpty()) { + return ""; + } + return text.startsWith(".") ? text : "." + text; + } + + private String sanitizeFileName(String value) { + String text = stringValue(value).replaceAll("[\\\\/:*?\"<>|]", "_"); + text = text.replaceAll("\\s+", " ").trim(); + if (text.isEmpty()) { + return ""; + } + return text.length() > 120 ? text.substring(0, 120).trim() : text; + } + + private String firstNonEmptyText(String... values) { + for (String value : values) { + if (value != null && !value.trim().isEmpty()) { + return value.trim(); + } + } + return ""; + } + + private String stringValue(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + public Map getSummaryTaskStatus(Long meetingId, Long taskId) { + meetingService.getById(meetingId); + List> rows; + List args = new ArrayList(); + StringBuilder sql = new StringBuilder(); + sql.append("SELECT id, file_name, status, file_oss_key, download_token, retry_count, max_retry, "); + sql.append("DATE_FORMAT(download_token_expire_at, '%Y-%m-%d %H:%i:%s') AS expire_at, "); + sql.append("error_message, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, "); + sql.append("DATE_FORMAT(finished_at, '%Y-%m-%d %H:%i:%s') AS finished_at "); + sql.append("FROM export_task "); + sql.append("WHERE tenant_id=? AND biz_type='MEETING_SUMMARY' AND biz_id=? AND requested_by=? AND is_deleted=0 "); + args.add(tenantId()); + args.add(String.valueOf(meetingId)); + args.add(safeUserId()); + if (taskId != null && taskId > 0L) { + sql.append("AND id=? "); + args.add(taskId); + } + sql.append("ORDER BY id DESC LIMIT 1"); + rows = jdbcTemplate.queryForList(sql.toString(), args.toArray()); + if (rows.isEmpty()) { + throw new BusinessException(10003, "会议总结任务不存在"); + } + Map row = rows.get(0); + row = reconcileFailedSummaryTask(row); + Map data = new LinkedHashMap(); + data.put("taskId", row.get("id")); + data.put("fileName", row.get("file_name")); + data.put("status", row.get("status")); + data.put("fileOssKey", row.get("file_oss_key")); + data.put("downloadToken", row.get("download_token")); + data.put("expireAt", row.get("expire_at")); + data.put("errorMessage", row.get("error_message")); + data.put("createdAt", row.get("created_at")); + data.put("finishedAt", row.get("finished_at")); + return data; + } + + public Map refreshSummaryToken(Long meetingId, Long taskId) { + meetingService.getById(meetingId); + if (taskId == null || taskId <= 0L) { + throw new BusinessException(10001, "会议总结任务ID不能为空"); + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM export_task WHERE tenant_id=? AND id=? AND biz_type='MEETING_SUMMARY' AND biz_id=? AND requested_by=? AND is_deleted=0", + Integer.class, + tenantId(), + taskId, + String.valueOf(meetingId), + safeUserId() + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "会议总结任务不存在"); + } + return exportTaskService.refreshDownloadToken(taskId); + } + + public Map downloadSummary(Long meetingId, Long taskId, String token) { + meetingService.getById(meetingId); + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM export_task WHERE tenant_id=? AND id=? AND biz_type='MEETING_SUMMARY' AND biz_id=? AND is_deleted=0", + Integer.class, + tenantId(), + taskId, + String.valueOf(meetingId) + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "会议总结任务不存在"); + } + return exportTaskService.download(taskId, token); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Map reconcileFailedSummaryTask(Map row) { + String status = textValue(row.get("status")).toUpperCase(); + Long taskId = toLong(row.get("id")); + if (taskId == null || taskId <= 0L || !"PENDING".equals(status)) { + return row; + } + List> jobs = jdbcTemplate.queryForList( + "SELECT status, retry_count FROM async_job WHERE tenant_id=? AND job_type='EXPORT_TASK' AND payload=? ORDER BY id DESC LIMIT 1", + tenantId(), + "{\"taskId\":" + taskId + "}" + ); + if (jobs.isEmpty()) { + return row; + } + String jobStatus = textValue(jobs.get(0).get("status")).toUpperCase(); + if (!"FAILED".equals(jobStatus)) { + return row; + } + String errorMessage = firstNonEmptyText( + textValue(row.get("error_message")), + "异步导出任务已失败,请重新生成会议总结" + ); + jdbcTemplate.update( + "UPDATE export_task SET status='FAILED', retry_count=max_retry, error_message=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=? AND status='PENDING'", + errorMessage, + safeUserId(), + tenantId(), + taskId + ); + row.put("status", "FAILED"); + row.put("retry_count", row.get("max_retry")); + row.put("error_message", errorMessage); + return row; + } + + + private String textValue(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private Long toLong(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : Long.valueOf(text); + } catch (Exception ex) { + return null; + } + } + + public Map presignMaterialUpload(Long meetingId, String moduleCode, String fileName, String contentType) { + meetingService.getById(meetingId); + validateModule(moduleCode); + String name = fileName == null ? "" : fileName.trim(); + if (name.isEmpty()) { + throw new BusinessException(10001, "文件名不能为空"); + } + String ext = ""; + int idx = name.lastIndexOf('.'); + if (idx > 0 && idx < name.length() - 1) { + ext = "." + name.substring(idx + 1).toLowerCase(); + } + String normalizedType = contentType == null || contentType.trim().isEmpty() ? "application/octet-stream" : contentType.trim(); + String objectKey = "meeting/material/" + tenantId() + "/" + meetingId + "/" + moduleCode.toLowerCase() + "/" + + UUID.randomUUID().toString().replace("-", "") + ext; + String uploadUrl = ossService.generateUploadUrl(objectKey, normalizedType); + Map result = new LinkedHashMap(); + result.put("objectKey", objectKey); + result.put("uploadUrl", uploadUrl); + result.put("contentType", normalizedType); + result.put("method", "PUT"); + return result; + } + + private MeetingMaterial upsert(Long meetingId, String moduleCode, String contentJson, String remark, String actionType, String status) { + meetingService.getById(meetingId); + validateModule(moduleCode); + + List existingList = jdbcTemplate.query( + "SELECT id, meeting_id, module_code, content_json, status, " + + "audit_node_status, audit_aggregate_status, submit_remark, reject_count, last_reject_reason, " + + "DATE_FORMAT(resubmit_at, '%Y-%m-%d %H:%i:%s') AS resubmit_at, version_no, is_latest_version, " + + "DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND module_code=? AND is_deleted=0 LIMIT 1", + MATERIAL_ROW_MAPPER, + tenantId(), + meetingId, + moduleCode + ); + int nextVersion = 1; + Long materialId; + if (existingList.isEmpty()) { + jdbcTemplate.update( + "INSERT INTO meeting_material (tenant_id, meeting_id, module_code, content_json, status, audit_node_status, audit_aggregate_status, submit_remark, reject_count, version_no, is_latest_version, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 1, 1, 0, 0)", + tenantId(), + meetingId, + moduleCode, + contentJson, + status, + "SUBMIT".equals(actionType) ? "PENDING" : null, + "SUBMIT".equals(actionType) ? "PENDING" : null, + remark + ); + materialId = jdbcTemplate.queryForObject( + "SELECT id FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND module_code=? LIMIT 1", + Long.class, + tenantId(), + meetingId, + moduleCode + ); + } else { + MeetingMaterial existing = existingList.get(0); + nextVersion = existing.getVersionNo() + 1; + materialId = existing.getId(); + jdbcTemplate.update( + "UPDATE meeting_material SET content_json=?, status=?, audit_node_status=?, audit_aggregate_status=?, submit_remark=?, " + + "version_no=?, is_latest_version=1, updated_at=CURRENT_TIMESTAMP, updated_by=0 WHERE id=?", + contentJson, + status, + "SUBMIT".equals(actionType) ? "PENDING" : existing.getAuditNodeStatus(), + "SUBMIT".equals(actionType) ? "PENDING" : existing.getAuditAggregateStatus(), + remark, + nextVersion, + materialId + ); + } + + jdbcTemplate.update( + "INSERT INTO meeting_material_history (tenant_id, meeting_id, module_code, version_no, action_type, content_json, remark, created_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, 0)", + tenantId(), + meetingId, + moduleCode, + nextVersion, + actionType, + contentJson, + remark + ); + + if ("EXPERT_LIST".equals(moduleCode)) { + syncInvoiceStructuredData(meetingId, materialId, contentJson); + } + + List result = jdbcTemplate.query( + "SELECT id, meeting_id, module_code, content_json, status, " + + "audit_node_status, audit_aggregate_status, submit_remark, reject_count, last_reject_reason, " + + "DATE_FORMAT(resubmit_at, '%Y-%m-%d %H:%i:%s') AS resubmit_at, version_no, is_latest_version, " + + "DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM meeting_material WHERE id=?", + MATERIAL_ROW_MAPPER, + materialId + ); + if (result.isEmpty()) { + throw new BusinessException(10003, "资料不存在"); + } + return result.get(0); + } + + private void validateModule(String moduleCode) { + if (moduleCode == null || !SUPPORTED_MODULES.contains(moduleCode)) { + throw new BusinessException(10001, "不支持的资料模块"); + } + } + + private String resolveStorageModuleCode(String moduleCode) { + if ("EXPERT_PROFILE".equals(moduleCode)) { + return "WRITE_OFF_DOCS"; + } + return moduleCode; + } + + private void validateSubmitContent(String moduleCode, String contentJson) { + try { + Map map = objectMapper.readValue(contentJson, new TypeReference>() {}); + if ("BASIC_INFO".equals(moduleCode)) { + requireField(map, "chairmanExpertIds"); + requireField(map, "speakerExpertIds"); + requireField(map, "hostExpertIds"); + requireField(map, "discussionGuestExpertIds"); + requireField(map, "guestCount"); + requireField(map, "attendeeCount"); + requireField(map, "attendeeActualCount"); + requireField(map, "targetAudience"); + requireField(map, "mainAgenda"); + requireField(map, "improvementSuggestion"); + requireField(map, "meetingEffect"); + requireIdList(map.get("chairmanExpertIds"), "chairmanExpertIds"); + requireIdList(map.get("speakerExpertIds"), "speakerExpertIds"); + requireIdList(map.get("hostExpertIds"), "hostExpertIds"); + requireIdList(map.get("discussionGuestExpertIds"), "discussionGuestExpertIds"); + } else if ("WRITE_OFF_DOCS".equals(moduleCode)) { + requireField(map, "agenda"); + requireField(map, "signInSheet"); + requireField(map, "invitation"); + requireAgendaField(map.get("agenda")); + requireNestedField(map, "signInSheet", "ossKey"); + requireOptionalNestedField(map, "themePhoto", "ossKey"); + requireInvitationList(map.get("invitation")); + } else if ("EXPERT_LIST".equals(moduleCode)) { + requireField(map, "onsitePhoto"); + requireField(map, "laborProtocol"); + requireField(map, "invoiceDetail"); + Map onsitePhoto = asObjectMap(map.get("onsitePhoto"), "onsitePhoto"); + Map laborProtocol = asObjectMap(map.get("laborProtocol"), "laborProtocol"); + Map invoiceDetail = asObjectMap(map.get("invoiceDetail"), "invoiceDetail"); + requireField(onsitePhoto, "photos"); + requirePhotoList(onsitePhoto.get("photos")); + requireField(laborProtocol, "details"); + requireLaborDetailList(laborProtocol.get("details")); + requireField(invoiceDetail, "invoices"); + requireInvoiceList(invoiceDetail.get("invoices")); + } else if ("MEETING_INVOICE".equals(moduleCode)) { + if (map.get("sections") instanceof Collection) { + requireMeetingInvoiceSections(map.get("sections")); + } else { + requireField(map, "attachments"); + requireMeetingInvoiceAttachmentList(map.get("attachments")); + } + } + } catch (Exception e) { + throw new BusinessException(10001, "资料内容格式错误或缺少必填字段"); + } + } + + private List> buildReviewItems(String moduleCode, String contentJson) { + List> items = new ArrayList<>(); + Map root; + try { + root = objectMapper.readValue(contentJson, new TypeReference>() {}); + } catch (Exception e) { + return items; + } + if ("BASIC_INFO".equals(moduleCode)) { + addReviewItem(items, "discussionGuestExpertIds", "讨论嘉宾"); + addReviewItem(items, "chairmanExpertIds", "大会主席"); + addReviewItem(items, "speakerExpertIds", "会议讲者"); + addReviewItem(items, "hostExpertIds", "会议主持"); + addReviewItem(items, "guestCount", "嘉宾人数"); + addReviewItem(items, "attendeeCount", "计划参会人数"); + addReviewItem(items, "attendeeActualCount", "实到人数"); + addReviewItem(items, "targetAudience", "主要参会对象"); + addReviewItem(items, "mainAgenda", "主要议程"); + addReviewItem(items, "improvementSuggestion", "不足/改进建议"); + addReviewItem(items, "meetingEffect", "会议效果"); + } else if ("WRITE_OFF_DOCS".equals(moduleCode)) { + Object agendaObj = root.get("agenda"); + if (agendaObj instanceof Collection) { + int idx = 1; + for (Object ignored : (Collection) agendaObj) { + addReviewItem(items, "agenda:" + idx, "会议日程#" + idx); + idx++; + } + } else { + addReviewItem(items, "agenda", "会议日程"); + } + addReviewItem(items, "signInSheet", "签到表"); + Map themePhoto = asObjectMapOrEmpty(root.get("themePhoto")); + if (!stringValue(themePhoto.get("ossKey")).isEmpty()) { + addReviewItem(items, "themePhoto", "主题照片"); + } + Object invitationObj = root.get("invitation"); + if (invitationObj instanceof Collection) { + int idx = 1; + for (Object invitationItem : (Collection) invitationObj) { + if (invitationItem == null) { + // keep index continuity for malformed rows + } + addReviewItem(items, "invitation:" + idx, "邀请函/通知#" + idx); + idx++; + } + } + } else if ("EXPERT_PROFILE".equals(moduleCode)) { + Map profileFile = asObjectMapOrEmpty(root.get("profileFile")); + String ossKey = stringValue(firstNonNull(profileFile.get("ossKey"), root.get("ossKey"))); + if (ossKey != null && !ossKey.isEmpty()) { + addReviewItem(items, "expert_profile_file", "涓撳绠€浠?涓插満鏂囦欢"); + } + } else if ("EXPERT_LIST".equals(moduleCode)) { + Map onsiteRoot = asObjectMapOrEmpty(root.get("onsitePhoto")); + Map laborRoot = asObjectMapOrEmpty(root.get("laborProtocol")); + Map invoiceRoot = asObjectMapOrEmpty(root.get("invoiceDetail")); + Object photosObj = onsiteRoot.get("photos"); + if (photosObj instanceof Collection) { + int idx = 1; + for (Object one : (Collection) photosObj) { + if (one instanceof Map) { + String ossKey = stringValue(((Map) one).get("ossKey")); + addReviewItem(items, "photo:" + (ossKey == null || ossKey.isEmpty() ? idx : ossKey), "现场照片#" + idx); + } else { + addReviewItem(items, "photo:" + idx, "现场照片#" + idx); + } + idx++; + } + } + Object expertSummariesObj = onsiteRoot.get("expertSummaries"); + if (expertSummariesObj instanceof Collection) { + for (Object one : (Collection) expertSummariesObj) { + if (one instanceof Map) { + Map summaryMap = (Map) one; + String expertId = stringValue(summaryMap.get("expertId")); + String expertName = stringValue(summaryMap.get("expertName")); + if (expertId != null && !expertId.isEmpty()) { + String key = "onsite_summary:" + expertId; + String label = "现场说明-" + (expertName == null || expertName.isEmpty() ? "讲者" : expertName); + addReviewItem(items, key, label); + } + } + } + } else { + addReviewItem(items, "onsite_summary", "现场说明"); + } + Object detailsObj = laborRoot.get("details"); + if (detailsObj instanceof Collection) { + int idx = 1; + for (Object one : (Collection) detailsObj) { + if (one instanceof Map) { + Map detail = (Map) one; + String expertId = stringValue(detail.get("expertId")); + String expertName = stringValue(detail.get("expertName")); + String key; + if (expertId == null || expertId.isEmpty()) { + key = "labor:" + idx; + } else { + key = "labor:" + expertId; + } + String label = "劳务协议-" + (expertName == null || expertName.isEmpty() ? ("条目#" + idx) : expertName); + addReviewItem(items, key, label); + } else { + addReviewItem(items, "labor:" + idx, "劳务协议-条目#" + idx); + } + idx++; + } + } + Object invoicesObj = invoiceRoot.get("invoices"); + if (invoicesObj instanceof Collection) { + int idx = 1; + for (Object one : (Collection) invoicesObj) { + if (one instanceof Map) { + Map invoice = (Map) one; + String invoiceNo = stringValue(invoice.get("invoiceNo")); + String expertName = stringValue(invoice.get("expertName")); + String key = "invoice:" + (invoiceNo == null || invoiceNo.isEmpty() ? idx : invoiceNo); + String label = "发票明细-" + + (expertName == null || expertName.isEmpty() ? "条目" : expertName) + + "#" + (invoiceNo == null || invoiceNo.isEmpty() ? idx : invoiceNo); + addReviewItem(items, key, label); + } else { + addReviewItem(items, "invoice:" + idx, "发票明细-条目#" + idx); + } + idx++; + } + } + addReviewItem(items, "invoice_summary", "发票明细汇总说明"); + } else if ("MEETING_INVOICE".equals(moduleCode) && root.get("sections") instanceof Collection) { + Object sectionsObj = root.get("sections"); + for (Object one : (Collection) sectionsObj) { + if (!(one instanceof Map)) { + continue; + } + Map section = asObjectMapOrEmpty(one); + String sectionCode = stringValue(firstNonNull(section.get("sectionCode"), section.get("code"))); + if (sectionCode == null || sectionCode.isEmpty() || !MEETING_INVOICE_SECTION_CODES.contains(sectionCode)) { + continue; + } + List fieldKeys = MEETING_INVOICE_SECTION_FIELD_KEYS.get(sectionCode); + if (fieldKeys != null) { + for (String fieldKey : fieldKeys) { + if (hasNonEmptyMeetingInvoiceFieldFile(section, fieldKey)) { + addReviewItem(items, buildMeetingInvoiceFieldReviewKey(sectionCode, fieldKey), buildMeetingInvoiceFieldReviewKey(sectionCode, fieldKey)); + } + } + } + if (MEETING_INVOICE_AMOUNT_SECTION_CODES.contains(sectionCode)) { + Object amountObj = firstNonNull(section.get("totalAmountCent"), section.get("totalAmountYuan")); + if (amountObj != null && !String.valueOf(amountObj).trim().isEmpty()) { + addReviewItem(items, buildMeetingInvoiceAmountReviewKey(sectionCode), buildMeetingInvoiceAmountReviewKey(sectionCode)); + } + } + } + } else if ("MEETING_INVOICE".equals(moduleCode)) { + Object attachmentsObj = root.get("attachments"); + if (attachmentsObj instanceof Collection) { + int idx = 1; + for (Object one : (Collection) attachmentsObj) { + if (one instanceof Map) { + Map item = (Map) one; + String attachmentName = stringValue(item.get("attachmentName")); + String ossKey = stringValue(item.get("ossKey")); + String key = "meeting_invoice_file:" + (ossKey == null || ossKey.isEmpty() ? idx : ossKey); + String label = "会议发票附件-" + (attachmentName == null || attachmentName.isEmpty() ? ("条目#" + idx) : attachmentName); + addReviewItem(items, key, label); + } else { + addReviewItem(items, "meeting_invoice_file:" + idx, "会议发票附件-条目#" + idx); + } + idx++; + } + } + addReviewItem(items, "meeting_invoice_summary", "会议发票说明"); + } + return items; + } + + private void addReviewItem(List> items, String itemKey, String itemLabel) { + Map row = new LinkedHashMap<>(); + row.put("itemKey", itemKey); + row.put("itemLabel", itemLabel); + items.add(row); + } + + private String buildMeetingInvoiceFieldReviewKey(String sectionCode, String fieldKey) { + return "meeting_invoice:" + sectionCode + ":" + fieldKey; + } + + private String buildMeetingInvoiceAmountReviewKey(String sectionCode) { + return "meeting_invoice:" + sectionCode + ":amount"; + } + + private boolean hasNonEmptyMeetingInvoiceFieldFile(Map section, String fieldKey) { + if (section == null || fieldKey == null || fieldKey.trim().isEmpty()) { + return false; + } + Object filesObj = section.get("files"); + if (filesObj instanceof Collection) { + for (Object fileObj : (Collection) filesObj) { + if (!(fileObj instanceof Map)) { + continue; + } + Map fileMap = (Map) fileObj; + String currentFieldKey = stringValue(firstNonNull(fileMap.get("fieldKey"), fileMap.get("key"))); + String ossKey = stringValue(firstNonNull(fileMap.get("ossKey"), fileMap.get("objectKey"))); + if (fieldKey.equals(currentFieldKey) && ossKey != null && !ossKey.isEmpty()) { + return true; + } + } + } + Object legacyObj = section.get(fieldKey); + if (legacyObj instanceof Collection) { + for (Object fileObj : (Collection) legacyObj) { + if (!(fileObj instanceof Map)) { + continue; + } + String ossKey = stringValue(firstNonNull(((Map) fileObj).get("ossKey"), ((Map) fileObj).get("objectKey"))); + if (ossKey != null && !ossKey.isEmpty()) { + return true; + } + } + return false; + } + if (legacyObj instanceof Map) { + String ossKey = stringValue(firstNonNull(((Map) legacyObj).get("ossKey"), ((Map) legacyObj).get("objectKey"))); + return ossKey != null && !ossKey.isEmpty(); + } + return false; + } + + private void requireField(Map map, String key) { + Object value = map.get(key); + if (value == null || String.valueOf(value).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: " + key); + } + } + + private void requireNestedField(Map map, String key, String nestedKey) { + Object target = map.get(key); + if (!(target instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: " + key); + } + Map nested = (Map) target; + Object nestedValue = nested.get(nestedKey); + if (nestedValue == null || String.valueOf(nestedValue).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: " + key + "." + nestedKey); + } + } + + private void requireOptionalNestedField(Map map, String key, String nestedKey) { + Object target = map.get(key); + if (target == null) { + return; + } + if (!(target instanceof Map)) { + throw new BusinessException(10001, "invalid nested field " + key); + } + Map nested = (Map) target; + Object nestedValue = nested.get(nestedKey); + if (nestedValue == null || String.valueOf(nestedValue).trim().isEmpty()) { + return; + } + } + + private void requireAgendaField(Object value) { + if (value instanceof Map) { + Map map = (Map) value; + Object ossKey = map.get("ossKey"); + if (ossKey == null || String.valueOf(ossKey).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: agenda.ossKey"); + } + return; + } + if (value instanceof Collection) { + Collection list = (Collection) value; + if (list.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: agenda"); + } + for (Object item : list) { + if (!(item instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: agenda"); + } + Map row = (Map) item; + Object ossKey = row.get("ossKey"); + if (ossKey == null || String.valueOf(ossKey).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: agenda.ossKey"); + } + } + return; + } + throw new BusinessException(10001, "提交失败,字段格式错误: agenda"); + } + + private Map asObjectMap(Object value, String fieldName) { + if (!(value instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: " + fieldName); + } + @SuppressWarnings("unchecked") + Map target = (Map) value; + return target; + } + + private Map asObjectMapOrEmpty(Object value) { + if (!(value instanceof Map)) { + return new LinkedHashMap<>(); + } + @SuppressWarnings("unchecked") + Map target = (Map) value; + return target; + } + + private void requireInvitationList(Object value) { + if (!(value instanceof Collection)) { + throw new BusinessException(10001, "提交失败,字段格式错误: invitation"); + } + Collection list = (Collection) value; + if (list.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invitation"); + } + for (Object item : list) { + if (!(item instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: invitation"); + } + Map invitation = (Map) item; + Object ossKey = invitation.get("ossKey"); + if (ossKey == null || String.valueOf(ossKey).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invitation.ossKey"); + } + } + } + + private void requirePhotoList(Object value) { + if (!(value instanceof Collection)) { + throw new BusinessException(10001, "提交失败,字段格式错误: photos"); + } + Collection list = (Collection) value; + if (list.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: photos"); + } + for (Object item : list) { + if (!(item instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: photos"); + } + Map photo = (Map) item; + Object ossKey = photo.get("ossKey"); + if (ossKey == null || String.valueOf(ossKey).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: photos.ossKey"); + } + } + } + + private void requireLaborDetailList(Object value) { + if (!(value instanceof Collection)) { + throw new BusinessException(10001, "提交失败,字段格式错误: details"); + } + Collection list = (Collection) value; + if (list.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: details"); + } + for (Object item : list) { + if (!(item instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: details"); + } + Map detail = (Map) item; + Object expertName = detail.get("expertName"); + Object amountCent = detail.get("amountCent"); + if (expertName == null || String.valueOf(expertName).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: details.expertName"); + } + if (amountCent == null || String.valueOf(amountCent).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: details.amountCent"); + } + } + } + + private void requireInvoiceList(Object value) { + if (!(value instanceof Collection)) { + throw new BusinessException(10001, "提交失败,字段格式错误: invoices"); + } + Collection list = (Collection) value; + if (list.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invoices"); + } + for (Object item : list) { + if (!(item instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: invoices"); + } + Map invoice = (Map) item; + Object expenseType = invoice.get("expenseType"); + Object invoiceNo = invoice.get("invoiceNo"); + Object amountCent = firstNonNull(invoice.get("invoiceAmountCent"), invoice.get("amountCent")); + Object taxCent = firstNonNull(invoice.get("taxAmountCent"), invoice.get("taxCent")); + Object detailCent = firstNonNull(invoice.get("detailAmountCent"), invoice.get("amountCent")); + Object files = firstNonNull(invoice.get("files"), invoice.get("file")); + if (expenseType == null || String.valueOf(expenseType).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invoices.expenseType"); + } + if (invoiceNo == null || String.valueOf(invoiceNo).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invoices.invoiceNo"); + } + if (amountCent == null || String.valueOf(amountCent).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invoices.amountCent"); + } + if (taxCent == null || String.valueOf(taxCent).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invoices.taxCent"); + } + if (detailCent == null || String.valueOf(detailCent).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invoices.detailAmountCent"); + } + + long invoiceAmount = safeLong(amountCent, "invoices.invoiceAmountCent"); + long taxAmount = safeLong(taxCent, "invoices.taxAmountCent"); + long detailAmount = safeLong(detailCent, "invoices.detailAmountCent"); + if (invoiceAmount < 0 || taxAmount < 0 || detailAmount < 0) { + throw new BusinessException(10001, "提交失败,金额字段必须大于等于0"); + } + + if (files instanceof Collection) { + requireInvoiceFileList((Collection) files); + } else if (files instanceof Map) { + requireSingleInvoiceFile((Map) files, "invoices.file"); + } else { + throw new BusinessException(10001, "提交失败,字段格式错误: invoices.files"); + } + } + } + + private void requireInvoiceFileList(Collection files) { + if (files.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: invoices.files"); + } + for (Object file : files) { + if (!(file instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: invoices.files"); + } + requireSingleInvoiceFile((Map) file, "invoices.files"); + } + } + + private void requireMeetingInvoiceAttachmentList(Object value) { + if (!(value instanceof Collection)) { + throw new BusinessException(10001, "提交失败,字段格式错误: attachments"); + } + Collection list = (Collection) value; + if (list.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: attachments"); + } + for (Object item : list) { + if (!(item instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: attachments"); + } + Map file = (Map) item; + Object attachmentName = file.get("attachmentName"); + Object ossKey = file.get("ossKey"); + if (attachmentName == null || String.valueOf(attachmentName).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: attachments.attachmentName"); + } + if (ossKey == null || String.valueOf(ossKey).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: attachments.ossKey"); + } + } + } + + private void requireSingleInvoiceFile(Map file, String path) { + Object fileType = file.get("fileType"); + Object fileName = file.get("fileName"); + Object ossKey = file.get("ossKey"); + if (fileType == null || String.valueOf(fileType).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: " + path + ".fileType"); + } + if (fileName == null || String.valueOf(fileName).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: " + path + ".fileName"); + } + if (ossKey == null || String.valueOf(ossKey).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: " + path + ".ossKey"); + } + } + + private void requireMeetingInvoiceSections(Object value) { + if (!(value instanceof Collection)) { + throw new BusinessException(10001, "提交失败,字段格式错误: sections"); + } + Collection sections = (Collection) value; + if (sections.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: sections"); + } + for (Object sectionObj : sections) { + if (!(sectionObj instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: sections"); + } + Map section = (Map) sectionObj; + String sectionCode = stringValue(firstNonNull(section.get("sectionCode"), section.get("code"))); + if (sectionCode == null || sectionCode.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: sections.sectionCode"); + } + Object filesObj = section.get("files"); + if (!(filesObj instanceof Collection)) { + throw new BusinessException(10001, "提交失败,字段格式错误: sections.files"); + } + Collection files = (Collection) filesObj; + if (!"OTHER".equals(sectionCode) && files.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: sections.files"); + } + for (Object fileObj : files) { + if (!(fileObj instanceof Map)) { + throw new BusinessException(10001, "提交失败,字段格式错误: sections.files"); + } + Map fileMap = (Map) fileObj; + Object ossKey = fileMap.get("ossKey"); + if (ossKey == null || String.valueOf(ossKey).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: sections.files.ossKey"); + } + } + if (MEETING_INVOICE_AMOUNT_SECTION_CODES.contains(sectionCode)) { + Object amountObj = firstNonNull(section.get("totalAmountCent"), section.get("totalAmountYuan")); + if (amountObj == null || String.valueOf(amountObj).trim().isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: sections.totalAmount"); + } + } + } + } + + private void validateMeetingBudgetLimit(Long meetingId, String moduleCode, String currentContentJson) { + validateModule(moduleCode); + Meeting meeting = meetingService.getById(meetingId); + long budgetCent = Math.max(0L, meeting.getBudgetCent()); + String expertListContentJson = "EXPERT_LIST".equals(moduleCode) + ? currentContentJson + : getMaterialContentJsonOrEmpty(meetingId, "EXPERT_LIST"); + String meetingInvoiceContentJson = "MEETING_INVOICE".equals(moduleCode) + ? currentContentJson + : getMaterialContentJsonOrEmpty(meetingId, "MEETING_INVOICE"); + long laborTotalCent = extractLaborTotalCent(expertListContentJson); + long expertInvoiceTotalCent = extractExpertInvoiceTotalCent(expertListContentJson); + long meetingInvoiceTotalCent = extractMeetingInvoiceTotalCent(meetingInvoiceContentJson); + long usedTotalCent = laborTotalCent + expertInvoiceTotalCent + meetingInvoiceTotalCent; + if (usedTotalCent <= budgetCent) { + return; + } + long overCent = usedTotalCent - budgetCent; + throw new BusinessException(10001, "预算校验不通过:当前会议预算" + formatYuan(budgetCent) + + "元,已用" + formatYuan(usedTotalCent) + "元,超预算" + formatYuan(overCent) + "元"); + } + + private String getMaterialContentJsonOrEmpty(Long meetingId, String moduleCode) { + List rows = jdbcTemplate.query( + "SELECT content_json FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND module_code=? AND is_deleted=0 LIMIT 1", + (rs, n) -> rs.getString("content_json"), + tenantId(), + meetingId, + moduleCode + ); + if (rows.isEmpty()) { + return ""; + } + return rows.get(0) == null ? "" : rows.get(0); + } + + private long extractLaborTotalCent(String contentJson) { + if (contentJson == null || contentJson.trim().isEmpty()) { + return 0L; + } + try { + Map root = objectMapper.readValue(contentJson, new TypeReference>() {}); + Map laborProtocol = asObjectMapOrEmpty(root.get("laborProtocol")); + Object detailObj = laborProtocol.get("details"); + if (!(detailObj instanceof Collection)) { + return 0L; + } + long total = 0L; + for (Object one : (Collection) detailObj) { + if (!(one instanceof Map)) { + continue; + } + Map row = (Map) one; + total += Math.max(0L, parseCentValue(row.get("amountCent"))); + } + return total; + } catch (Exception e) { + return 0L; + } + } + + private long extractExpertInvoiceTotalCent(String contentJson) { + if (contentJson == null || contentJson.trim().isEmpty()) { + return 0L; + } + try { + Map root = objectMapper.readValue(contentJson, new TypeReference>() {}); + Map invoiceDetail = asObjectMapOrEmpty(root.get("invoiceDetail")); + Object invoiceObj = invoiceDetail.get("invoices"); + if (!(invoiceObj instanceof Collection)) { + return 0L; + } + long total = 0L; + for (Object one : (Collection) invoiceObj) { + if (!(one instanceof Map)) { + continue; + } + Map row = (Map) one; + Object amountObj = firstNonNull(row.get("invoiceAmountCent"), row.get("amountCent")); + total += Math.max(0L, parseCentValue(amountObj)); + } + return total; + } catch (Exception e) { + return 0L; + } + } + + private long extractMeetingInvoiceTotalCent(String contentJson) { + if (contentJson == null || contentJson.trim().isEmpty()) { + return 0L; + } + try { + Map root = objectMapper.readValue(contentJson, new TypeReference>() {}); + Object sectionsObj = root.get("sections"); + if (!(sectionsObj instanceof Collection)) { + return 0L; + } + long total = 0L; + for (Object one : (Collection) sectionsObj) { + if (!(one instanceof Map)) { + continue; + } + Map section = (Map) one; + String sectionCode = stringValue(firstNonNull(section.get("sectionCode"), section.get("code"))); + if (sectionCode == null || !MEETING_INVOICE_AMOUNT_SECTION_CODES.contains(sectionCode)) { + continue; + } + Object centObj = firstNonNull(section.get("totalAmountCent"), section.get("totalFeeCent")); + if (centObj != null && !String.valueOf(centObj).trim().isEmpty()) { + total += Math.max(0L, parseCentValue(centObj)); + continue; + } + Object yuanObj = firstNonNull(section.get("totalAmountYuan"), section.get("totalFeeYuan")); + total += Math.max(0L, parseYuanToCent(yuanObj)); + } + return total; + } catch (Exception e) { + return 0L; + } + } + + private long parseCentValue(Object value) { + if (value == null || String.valueOf(value).trim().isEmpty()) { + return 0L; + } + try { + BigDecimal decimal = new BigDecimal(String.valueOf(value).trim()); + return decimal.setScale(0, RoundingMode.HALF_UP).longValue(); + } catch (Exception e) { + throw new BusinessException(10001, "提交失败,金额字段不是合法数字"); + } + } + + private long parseYuanToCent(Object value) { + if (value == null || String.valueOf(value).trim().isEmpty()) { + return 0L; + } + try { + BigDecimal decimal = new BigDecimal(String.valueOf(value).trim()); + return decimal.multiply(new BigDecimal("100")).setScale(0, RoundingMode.HALF_UP).longValue(); + } catch (Exception e) { + throw new BusinessException(10001, "提交失败,金额字段不是合法数字"); + } + } + + private String formatYuan(long cent) { + BigDecimal yuan = new BigDecimal(cent).divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP); + return yuan.toPlainString(); + } + + private void requireIdList(Object value, String key) { + if (!(value instanceof Collection)) { + throw new BusinessException(10001, "提交失败,字段格式错误: " + key); + } + Collection list = (Collection) value; + if (list.isEmpty()) { + throw new BusinessException(10001, "提交失败,缺少必填字段: " + key); + } + for (Object item : list) { + if (!(item instanceof Number) && !String.valueOf(item).matches("^\\d+$")) { + throw new BusinessException(10001, "提交失败,字段格式错误: " + key); + } + } + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private void syncInvoiceStructuredData(Long meetingId, Long materialId, String contentJson) { + try { + Map root = objectMapper.readValue(contentJson, new TypeReference>() {}); + Map invoiceDetail = asObjectMapOrEmpty(root.get("invoiceDetail")); + Object invoiceNode = firstNonNull(invoiceDetail.get("invoices"), invoiceDetail.get("items")); + if (!(invoiceNode instanceof Collection)) { + return; + } + + Collection invoices = (Collection) invoiceNode; + jdbcTemplate.update("DELETE FROM meeting_material_invoice_file WHERE tenant_id=? AND meeting_id=?", tenantId(), meetingId); + jdbcTemplate.update("DELETE FROM meeting_material_invoice_item WHERE tenant_id=? AND meeting_id=?", tenantId(), meetingId); + + Map categoryAmount = new LinkedHashMap<>(); + long total = 0L; + Meeting meeting = meetingService.getById(meetingId); + + for (Object obj : invoices) { + if (!(obj instanceof Map)) { + continue; + } + Map invoice = (Map) obj; + String expenseType = stringValue(invoice.get("expenseType")); + String invoiceNo = stringValue(invoice.get("invoiceNo")); + long invoiceAmountCent = safeLong(firstNonNull(invoice.get("invoiceAmountCent"), invoice.get("amountCent")), "invoiceAmountCent"); + long taxAmountCent = safeLong(firstNonNull(invoice.get("taxAmountCent"), invoice.get("taxCent")), "taxAmountCent"); + long detailAmountCent = safeLong(firstNonNull(invoice.get("detailAmountCent"), invoice.get("amountCent")), "detailAmountCent"); + String vendorName = stringValue(invoice.get("vendorName")); + String occurDate = stringValue(invoice.get("occurDate")); + String itemRemark = stringValue(invoice.get("remark")); + + jdbcTemplate.update( + "INSERT INTO meeting_material_invoice_item (tenant_id, meeting_id, material_id, expense_type, invoice_no, invoice_amount_cent, tax_amount_cent, detail_amount_cent, vendor_name, occur_date, remark, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, STR_TO_DATE(?, '%Y-%m-%d'), ?, 0, 0)", + tenantId(), + meetingId, + materialId, + expenseType, + invoiceNo, + invoiceAmountCent, + taxAmountCent, + detailAmountCent, + vendorName, + occurDate, + itemRemark + ); + Long invoiceItemId = jdbcTemplate.queryForObject( + "SELECT LAST_INSERT_ID()", + Long.class + ); + + Object fileNode = firstNonNull(invoice.get("files"), invoice.get("file")); + if (fileNode instanceof Collection) { + for (Object fileObj : (Collection) fileNode) { + if (fileObj instanceof Map) { + insertInvoiceFile(meetingId, invoiceItemId, (Map) fileObj); + } + } + } else if (fileNode instanceof Map) { + insertInvoiceFile(meetingId, invoiceItemId, (Map) fileNode); + } + + categoryAmount.put(expenseType, categoryAmount.getOrDefault(expenseType, 0L) + detailAmountCent); + total += detailAmountCent; + } + + String categoryAmountJson = objectMapper.writeValueAsString(categoryAmount); + int isOverBudget = total > meeting.getBudgetCent() ? 1 : 0; + + jdbcTemplate.update( + "INSERT INTO meeting_invoice_summary (tenant_id, meeting_id, category_amount_cent_json, meeting_total_amount_cent, is_over_budget, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, 0, 0) " + + "ON DUPLICATE KEY UPDATE category_amount_cent_json=VALUES(category_amount_cent_json), meeting_total_amount_cent=VALUES(meeting_total_amount_cent), " + + "is_over_budget=VALUES(is_over_budget), updated_at=CURRENT_TIMESTAMP, updated_by=0", + tenantId(), + meetingId, + categoryAmountJson, + total, + isOverBudget + ); + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + throw new BusinessException(10001, "发票结构化数据处理失败"); + } + } + + private void insertInvoiceFile(Long meetingId, Long invoiceItemId, Map file) { + String fileType = stringValue(file.get("fileType")); + String fileName = stringValue(file.get("fileName")); + String ossKey = stringValue(file.get("ossKey")); + String contentType = stringValue(file.get("contentType")); + long size = safeLong(file.get("size"), "files.size"); + if (size < 0) { + throw new BusinessException(10001, "提交失败,文件大小必须大于等于0"); + } + jdbcTemplate.update( + "INSERT INTO meeting_material_invoice_file (tenant_id, meeting_id, invoice_item_id, file_type, file_name, oss_key, content_type, size, created_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)", + tenantId(), + meetingId, + invoiceItemId, + fileType, + fileName, + ossKey, + contentType, + size + ); + } + + private Object firstNonNull(Object first, Object second) { + return first != null ? first : second; + } + + + private long safeLong(Object value, String fieldName) { + if (value == null || String.valueOf(value).trim().isEmpty()) { + return 0L; + } + try { + return Long.parseLong(String.valueOf(value).trim()); + } catch (Exception e) { + throw new BusinessException(10001, "提交失败,字段不是合法数字: " + fieldName); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/service/MeetingService.java b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingService.java new file mode 100644 index 0000000..916863e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingService.java @@ -0,0 +1,744 @@ +package com.writeoff.module.meeting.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.module.audit.model.AuditNode; +import com.writeoff.module.audit.model.AuditTask; +import com.writeoff.module.audit.model.AuditTaskStatus; +import com.writeoff.module.audit.repository.AuditTaskRepository; +import com.writeoff.module.audit.service.AuditFlowConfigService; +import com.writeoff.module.expert.service.ExpertSnapshotService; +import com.writeoff.module.meeting.dto.CreateMeetingRequest; +import com.writeoff.module.meeting.dto.MeetingQueryRequest; +import com.writeoff.module.meeting.dto.SubmitMeetingRequest; +import com.writeoff.module.meeting.dto.WithdrawMeetingRequest; +import com.writeoff.module.meeting.model.Meeting; +import com.writeoff.module.meeting.model.MeetingAuditStatus; +import com.writeoff.module.meeting.model.MeetingStatus; +import com.writeoff.module.meeting.repository.MeetingRepository; +import com.writeoff.module.project.model.Project; +import com.writeoff.module.project.service.ProjectService; +import com.writeoff.module.scheduler.service.AsyncJobService; +import com.writeoff.module.system.model.BizChangeLogInfo; +import com.writeoff.module.system.service.BizChangeLogService; +import com.writeoff.module.system.service.DataPermissionService; +import com.writeoff.security.AuthContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.List; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.Map; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +@Service +public class MeetingService { + private static final DateTimeFormatter SQL_ISO_SECOND_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"); + private static final Set LOCATION_OPTIONS = new HashSet(); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + static { + LOCATION_OPTIONS.add("线上"); + LOCATION_OPTIONS.add("线下"); + LOCATION_OPTIONS.add("线上+线下"); + } + private final MeetingRepository meetingRepository; + private final ProjectService projectService; + private final AuditTaskRepository auditTaskRepository; + private final AsyncJobService asyncJobService; + private final AuditFlowConfigService auditFlowConfigService; + private final DataPermissionService dataPermissionService; + private final ExpertSnapshotService expertSnapshotService; + private final BizChangeLogService bizChangeLogService; + private final Map submitIdempotency = new ConcurrentHashMap<>(); + private final Map withdrawIdempotency = new ConcurrentHashMap<>(); + + @Autowired + public MeetingService(MeetingRepository meetingRepository, ProjectService projectService, AuditTaskRepository auditTaskRepository, AsyncJobService asyncJobService, AuditFlowConfigService auditFlowConfigService, DataPermissionService dataPermissionService, ExpertSnapshotService expertSnapshotService, BizChangeLogService bizChangeLogService) { + this.meetingRepository = meetingRepository; + this.projectService = projectService; + this.auditTaskRepository = auditTaskRepository; + this.asyncJobService = asyncJobService; + this.auditFlowConfigService = auditFlowConfigService; + this.dataPermissionService = dataPermissionService; + this.expertSnapshotService = expertSnapshotService; + this.bizChangeLogService = bizChangeLogService; + } + + public MeetingService(MeetingRepository meetingRepository, ProjectService projectService, AuditTaskRepository auditTaskRepository, AsyncJobService asyncJobService) { + this(meetingRepository, projectService, auditTaskRepository, asyncJobService, null, null, null, null); + } + + public PageResult list(MeetingQueryRequest query) { + boolean includeDeleted = query != null && Boolean.TRUE.equals(query.getIncludeDeleted()); + List list = meetingRepository.findAll(includeDeleted); + if (dataPermissionService != null) { + DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope(); + Set meetingIds = list.stream().map(Meeting::getId).collect(Collectors.toCollection(HashSet::new)); + Map meetingCreatorMap = dataPermissionService.listMeetingCreators(meetingIds); + Map meetingProjectMap = dataPermissionService.listMeetingProjectIds(meetingIds); + Set projectIds = new HashSet<>(meetingProjectMap.values()); + Map projectCreatorMap = dataPermissionService.listProjectCreators(projectIds); + list = list.stream() + .filter(meeting -> { + Long projectId = meetingProjectMap.get(meeting.getId()); + Long meetingCreatedBy = meetingCreatorMap.get(meeting.getId()); + Long projectCreatedBy = projectId == null ? null : projectCreatorMap.get(projectId); + return dataPermissionService.canAccessMeeting(meeting.getId(), meeting.getProjectId(), meetingCreatedBy, projectCreatedBy, scope); + }) + .collect(Collectors.toList()); + } + list.forEach(this::applyEffectiveStatus); + list = applyFilters(list, query); + return new PageResult<>(list, list.size(), 1, 20); + } + + private List applyFilters(List source, MeetingQueryRequest query) { + if (query == null) { + return source; + } + final String projectName = normalize(query.getProjectName()); + final String topic = normalize(query.getTopic()); + final String meetingStatus = normalize(query.getMeetingStatus()); + final String auditStatus = normalize(query.getAuditStatus()); + final String currentNode = normalize(query.getCurrentAuditNode()); + final LocalDateTime meetingStartFrom = parseDateTime(query.getMeetingStartFrom()); + final LocalDateTime meetingStartTo = parseDateTime(query.getMeetingStartTo()); + final LocalDateTime lastSubmitFrom = parseDateTime(query.getLastSubmitFrom()); + final LocalDateTime lastSubmitTo = parseDateTime(query.getLastSubmitTo()); + + return source.stream() + .filter(m -> query.getProjectId() == null || query.getProjectId().equals(m.getProjectId())) + .filter(m -> projectName == null || containsIgnoreCase(m.getProjectName(), projectName)) + .filter(m -> topic == null || containsIgnoreCase(m.getTopic(), topic)) + .filter(m -> meetingStatus == null || (m.getStatus() != null && m.getStatus().name().equalsIgnoreCase(meetingStatus))) + .filter(m -> auditStatus == null || (m.getAuditStatus() != null && m.getAuditStatus().name().equalsIgnoreCase(auditStatus))) + .filter(m -> currentNode == null || containsIgnoreCase(m.getCurrentAuditNode(), currentNode)) + .filter(m -> query.getCurrentAuditorUserId() == null || query.getCurrentAuditorUserId().equals(m.getCurrentAuditorUserId())) + .filter(m -> inRange(parseDateTime(m.getStartTime()), meetingStartFrom, meetingStartTo)) + .filter(m -> inRange(parseDateTime(m.getLastSubmitAt()), lastSubmitFrom, lastSubmitTo)) + .collect(Collectors.toList()); + } + + private boolean inRange(LocalDateTime value, LocalDateTime from, LocalDateTime to) { + if (from == null && to == null) { + return true; + } + if (value == null) { + return false; + } + if (from != null && value.isBefore(from)) { + return false; + } + return to == null || !value.isAfter(to); + } + + private String normalize(String val) { + if (val == null) { + return null; + } + String trimmed = val.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private boolean containsIgnoreCase(String origin, String keyword) { + if (origin == null) { + return false; + } + return origin.toLowerCase().contains(keyword.toLowerCase()); + } + + private LocalDateTime parseDateTime(String value) { + if (value == null || value.trim().isEmpty()) { + return null; + } + String trimmed = value.trim(); + DateTimeFormatter[] patterns = new DateTimeFormatter[] { + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), + DateTimeFormatter.ISO_LOCAL_DATE_TIME + }; + for (DateTimeFormatter pattern : patterns) { + try { + return LocalDateTime.parse(trimmed, pattern); + } catch (DateTimeParseException ignore) { + } + } + return null; + } + + public Meeting create(CreateMeetingRequest request) { + Project project = projectService.getById(request.getProjectId()); + validateProjectForMeetingCreate(project); + validateMeetingTimeInProjectCycle(project, request); + int existingMeetingCount = countMeetingsByProjectId(project.getId()); + if (existingMeetingCount >= project.getMeetingTotal()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "项目可创建的会议数量已达上限"); + } + validateLocation(request.getLocation()); + long defaultBudgetCent = calculateDefaultMeetingBudgetCent(project); + if (defaultBudgetCent <= 0L) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "默认会议预算必须大于 0"); + } + String now = nowIsoSeconds(); + Meeting meeting = new Meeting( + null, + request.getProjectId(), + request.getTopic(), + request.getMeetingCategory(), + request.getMeetingForm(), + request.getLocation(), + request.getStartTime(), + request.getEndTime(), + defaultBudgetCent, + request.getLaborRatio() == null ? 0d : request.getLaborRatio(), + request.getCateringRatio() == null ? 0d : request.getCateringRatio(), + MeetingStatus.NOT_STARTED, + MeetingAuditStatus.PENDING, + null, + null, + null, + 0, + null, + false, + null, + null, + null, + 0, + null, + now, + safeUserId(), + null, + null, + null, + 0, + null, + null, + null + ); + Meeting saved = meetingRepository.save(meeting); + projectService.markInProgress(request.getProjectId()); + logMeetingCreate(saved); + return saved; + } + + private void validateProjectForMeetingCreate(Project project) { + if (project.getMeetingTotal() <= 0) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "创建会议前请先配置项目会议场次"); + } + LocalDate startDate = project.getStartDate(); + LocalDate endDate = project.getEndDate(); + if (startDate == null || endDate == null || endDate.isBefore(startDate)) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "创建会议前请先配置有效的项目起止日期"); + } + } + + private int countMeetingsByProjectId(Long projectId) { + return (int) meetingRepository.findAll(false).stream() + .filter(item -> !item.isDeleted()) + .filter(item -> projectId.equals(item.getProjectId())) + .count(); + } + + private long calculateDefaultMeetingBudgetCent(Project project) { + long projectBudgetCent = Math.max(0L, project.getBudgetCent()); + ProjectFeeSummary feeSummary = parseProjectFeeSummary(project.getProjectFeeJson()); + long distributableBudgetCent = projectBudgetCent - feeSummary.managementFeeCent - feeSummary.taxFeeCent - feeSummary.customFeeTotalCent; + if (project.getMeetingTotal() <= 0) { + return 0L; + } + return distributableBudgetCent / project.getMeetingTotal(); + } + + private ProjectFeeSummary parseProjectFeeSummary(String projectFeeJson) { + if (projectFeeJson == null || projectFeeJson.trim().isEmpty()) { + return new ProjectFeeSummary(0L, 0L, 0L); + } + try { + JsonNode root = OBJECT_MAPPER.readTree(projectFeeJson); + long managementFeeCent = readNonNegativeLong(root, "managementFeeCent"); + long taxFeeCent = readNonNegativeLong(root, "taxFeeCent"); + long customFeeTotalCent = 0L; + JsonNode customFees = root.path("customFees"); + if (customFees.isArray()) { + for (JsonNode feeNode : customFees) { + if (feeNode == null || !feeNode.isObject()) { + continue; + } + customFeeTotalCent += readNonNegativeLong(feeNode, "amountCent"); + } + } + return new ProjectFeeSummary(managementFeeCent, taxFeeCent, customFeeTotalCent); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "项目费用配置格式不正确"); + } + } + + private long readNonNegativeLong(JsonNode node, String fieldName) { + if (node == null || node.isNull()) { + return 0L; + } + JsonNode valueNode = node.get(fieldName); + if (valueNode == null || valueNode.isNull()) { + return 0L; + } + if (!valueNode.isNumber()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "项目费用字段格式不正确:" + fieldName); + } + long value = valueNode.asLong(); + if (value < 0L) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "项目费用字段不能为负数:" + fieldName); + } + return value; + } + + private static class ProjectFeeSummary { + private final long managementFeeCent; + private final long taxFeeCent; + private final long customFeeTotalCent; + + private ProjectFeeSummary(long managementFeeCent, long taxFeeCent, long customFeeTotalCent) { + this.managementFeeCent = managementFeeCent; + this.taxFeeCent = taxFeeCent; + this.customFeeTotalCent = customFeeTotalCent; + } + } + + public Meeting update(Long meetingId, CreateMeetingRequest request) { + Meeting existing = getById(meetingId); + if (request.getProjectId() != null && !existing.getProjectId().equals(request.getProjectId())) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "编辑会议时不能修改所属项目"); + } + Project project = projectService.getById(existing.getProjectId()); + validateProjectForMeetingCreate(project); + validateMeetingTimeInProjectCycle(project, request); + validateLocation(request.getLocation()); + Meeting updated = new Meeting( + existing.getId(), + existing.getProjectId(), + request.getTopic(), + request.getMeetingCategory(), + request.getMeetingForm(), + request.getLocation(), + request.getStartTime(), + request.getEndTime(), + request.getBudgetCent(), + request.getLaborRatio() == null ? 0d : request.getLaborRatio(), + request.getCateringRatio() == null ? 0d : request.getCateringRatio(), + existing.getStatus(), + existing.getAuditStatus(), + existing.getCurrentAuditNode(), + existing.getLastSubmitAt(), + existing.getLastRejectReason(), + existing.getOverdueDays(), + existing.getRiskFlagsJson(), + existing.isFrozen(), + existing.getFreezeReason(), + existing.getCurrentAuditorUserId(), + existing.getNodeDeadlineAt(), + existing.getRejectCount(), + nowIsoSeconds(), + nowIsoSeconds(), + safeUserId(), + existing.getCancelReason(), + existing.getPostponeReason(), + existing.getWithdrawReason(), + existing.getLockVersion() + 1, + nowIsoSeconds(), + safeUserId(), + existing.getInvoiceConfigJson() + ); + updated.setProjectName(existing.getProjectName()); + Meeting saved = meetingRepository.save(updated); + logMeetingUpdate(existing, saved); + return saved; + } + + private void validateLocation(String location) { + if (location == null || location.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "\u4f1a\u8bae\u5730\u70b9\u4e0d\u80fd\u4e3a\u7a7a"); + } + } + + private void validateLocationLegacy(String location) { + validateLocation(location); + } + + + private void validateMeetingTimeInProjectCycle(Project project, CreateMeetingRequest request) { + LocalDateTime startTime = parseDateTime(request.getStartTime()); + LocalDateTime endTime = parseDateTime(request.getEndTime()); + if (startTime == null || endTime == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议时间格式必须为 yyyy-MM-dd HH:mm:ss"); + } + if (endTime.isBefore(startTime)) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议结束时间不能早于开始时间"); + } + LocalDate projectStartDate = project.getStartDate(); + LocalDate projectEndDate = project.getEndDate(); + if (projectStartDate == null || projectEndDate == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "项目日期范围配置不完整"); + } + LocalDate meetingStartDate = startTime.toLocalDate(); + LocalDate meetingEndDate = endTime.toLocalDate(); + if (meetingStartDate.isBefore(projectStartDate) || meetingEndDate.isAfter(projectEndDate)) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "会议时间必须在项目周期内"); + } + } + + public Map submit(Long meetingId, SubmitMeetingRequest request) { + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在")); + + if (submitIdempotency.containsKey(request.getIdempotencyKey())) { + throw new BusinessException(ErrorCodes.IDEMPOTENCY_CONFLICT, "请求重复,请勿重复提交"); + } + submitIdempotency.put(request.getIdempotencyKey(), meetingId); + + MeetingStatus effectiveStatus = resolveEffectiveStatus(meeting); + if (effectiveStatus != MeetingStatus.COMPLETED) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "会议未完成,不能提交审核"); + } + + if (meeting.getAuditStatus() == MeetingAuditStatus.APPROVED) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "会议已终审通过,不能重复提交"); + } + if (meeting.getAuditStatus() == MeetingAuditStatus.IN_REVIEW) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "会议正在审核中"); + } + if (expertSnapshotService != null) { + expertSnapshotService.snapshotOnMeetingSubmit(meetingId); + } + meeting.setAuditStatus(MeetingAuditStatus.IN_REVIEW); + meetingRepository.save(meeting); + + Long tenantId = tenantId(); + AuditNode firstNode = auditFlowConfigService == null ? AuditNode.INIT_REVIEW : auditFlowConfigService.firstNode(tenantId); + Long assigneeUserId = auditFlowConfigService == null ? null : auditFlowConfigService.resolveAssigneeUserId(tenantId, firstNode); + meeting.setCurrentAuditNode(firstNode.name()); + meeting.setLastSubmitAt(nowIsoSeconds()); + meeting.setLastActionAt(nowIsoSeconds()); + meeting.setCurrentAuditorUserId(assigneeUserId); + meetingRepository.save(meeting); + if (bizChangeLogService != null) { + bizChangeLogService.logAction("MEETING", meetingId, "MEETING_SUBMIT", request.getRemark()); + } + auditTaskRepository.save(new AuditTask( + null, + meetingId, + firstNode, + assigneeUserId, + AuditTaskStatus.PENDING, + request.getRemark() + )); + asyncJobService.enqueue( + "AUDIT_REMIND", + "meetingId=" + meetingId, + "job-audit-remind-" + meetingId + "-" + request.getIdempotencyKey() + ); + + Map result = new LinkedHashMap<>(); + result.put("meetingId", meetingId); + result.put("auditStatus", meeting.getAuditStatus().name()); + result.put("currentNode", firstNode.name()); + return result; + } + + public Map withdraw(Long meetingId, WithdrawMeetingRequest request) { + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在")); + + if (withdrawIdempotency.containsKey(request.getIdempotencyKey())) { + throw new BusinessException(ErrorCodes.IDEMPOTENCY_CONFLICT, "请求重复,请勿重复提交"); + } + withdrawIdempotency.put(request.getIdempotencyKey(), meetingId); + + if (meeting.getAuditStatus() != MeetingAuditStatus.IN_REVIEW) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "只有审核中的会议才可撤回"); + } + + int closedTaskCount = auditTaskRepository.withdrawPendingByMeetingId(meetingId, request.getReason(), safeUserId()); + meeting.setAuditStatus(MeetingAuditStatus.PENDING); + meeting.setCurrentAuditorUserId(null); + meeting.setWithdrawReason(request.getReason()); + meeting.setLastActionAt(nowIsoSeconds()); + meetingRepository.save(meeting); + if (bizChangeLogService != null) { + bizChangeLogService.logAction("MEETING", meetingId, "MEETING_WITHDRAW", request.getReason()); + } + + Map result = new LinkedHashMap(); + result.put("meetingId", meetingId); + result.put("auditStatus", meeting.getAuditStatus().name()); + result.put("closedTaskCount", closedTaskCount); + return result; + } + + /** Soft-delete a draft meeting. */ + + public void deleteDraft(Long meetingId) { + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在")); + + MeetingStatus effectiveStatus = resolveEffectiveStatus(meeting); + // 婵炲濮撮幊搴g礊鐎n兘鍋撻崗澶婂⒉闁绘濞婇幃鈺呮嚋绾版ê浜惧ù锝囨焿缁€?PENDING闂佹寧绋戦悧鍡楃暤閸℃顩烽幖娣灪瀵捇鏌熺紒妯哄婵﹫闄勫濠氬炊妞嬪海顦繛鎴炴尰濮婄懓锕㈤鐘冲仏妞ゆ劑鍨归弸娆戠磽娴h灏版俊鐐插€垮畷妤佹媴缁涘鏅犻梺鍛婂笧婵炩偓婵? + if (meeting.getAuditStatus() != MeetingAuditStatus.PENDING) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "只有待审核的草稿会议才可删除"); + } + if (meeting.getLastSubmitAt() != null) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "已提交过的会议不能直接删除"); + } + meetingRepository.softDelete(meetingId); + if (bizChangeLogService != null) { + bizChangeLogService.logAction("MEETING", meetingId, "MEETING_DELETE_DRAFT", null); + } + } + + /** Cancel a meeting before it starts. */ + + public Map cancel(Long meetingId, String reason) { + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在")); + + if (reason == null || reason.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "取消原因不能为空"); + } + + MeetingStatus effectiveStatus = resolveEffectiveStatus(meeting); + if (effectiveStatus != MeetingStatus.NOT_STARTED) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "只有未开始的会议才可取消"); + } + if (meeting.getAuditStatus() == MeetingAuditStatus.IN_REVIEW) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "请先撤回审核,再取消会议"); + } + if (meeting.getAuditStatus() == MeetingAuditStatus.APPROVED) { + throw new BusinessException(ErrorCodes.INVALID_STATE, "已审核通过的会议不能取消"); + } + + meeting.setStatus(MeetingStatus.CANCELED); + meeting.setCancelReason(reason.trim()); + meeting.setLastActionAt(nowIsoSeconds()); + meetingRepository.save(meeting); + if (bizChangeLogService != null) { + bizChangeLogService.logAction("MEETING", meetingId, "MEETING_CANCEL", reason.trim()); + } + + Map result = new LinkedHashMap<>(); + result.put("meetingId", meetingId); + result.put("status", MeetingStatus.CANCELED.name()); + result.put("reason", reason.trim()); + return result; + } + + public Meeting getById(Long meetingId) { + Meeting meeting = meetingRepository.findById(meetingId) + .orElseThrow(() -> new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在")); + applyEffectiveStatus(meeting); + return meeting; + } + + /** Resolve the effective meeting status from start/end time and current time. */ + + public MeetingStatus resolveEffectiveStatus(Meeting meeting) { + MeetingStatus dbStatus = meeting.getStatus(); + if (dbStatus == MeetingStatus.CANCELED + || dbStatus == MeetingStatus.FROZEN + || dbStatus == MeetingStatus.DELAYED) { + return dbStatus; + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime start = parseDateTime(meeting.getStartTime()); + LocalDateTime end = parseDateTime(meeting.getEndTime()); + if (start == null || end == null) { + return dbStatus != null ? dbStatus : MeetingStatus.NOT_STARTED; + } + if (now.isBefore(start)) { + return MeetingStatus.NOT_STARTED; + } else if (now.isAfter(end)) { + return MeetingStatus.COMPLETED; + } else { + return MeetingStatus.IN_PROGRESS; + } + } + + private void applyEffectiveStatus(Meeting meeting) { + meeting.setStatus(resolveEffectiveStatus(meeting)); + } + + public void updateAuditStatus(Long meetingId, MeetingAuditStatus status) { + Meeting meeting = getById(meetingId); + meeting.setAuditStatus(status); + meetingRepository.save(meeting); + } + + /** Update only the current audit node and current auditor. */ + + public void updateCurrentAuditNode(Long meetingId, String node, Long auditorUserId) { + Meeting meeting = getById(meetingId); + meeting.setCurrentAuditNode(node); + meeting.setCurrentAuditorUserId(auditorUserId); + meeting.setLastActionAt(nowIsoSeconds()); + meetingRepository.save(meeting); + } + + public Meeting updateInvoiceConfig(Long meetingId, com.writeoff.module.meeting.dto.MeetingInvoiceConfigRequest request) { + Meeting meeting = getById(meetingId); + String beforeConfigJson = meeting.getInvoiceConfigJson(); + String configJson = null; + if (request.getInvoiceModules() != null) { + try { + configJson = OBJECT_MAPPER.writeValueAsString(request.getInvoiceModules()); + } catch (Exception e) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "发票配置序列化失败"); + } + } + meeting.setInvoiceConfigJson(configJson); + Meeting saved = meetingRepository.save(meeting); + if (bizChangeLogService != null) { + bizChangeLogService.logFieldChange("MEETING", meetingId, "MEETING_INVOICE_CONFIG_UPDATE", "invoiceConfigJson", "发票模块配置", beforeConfigJson, configJson, null, null); + } + return saved; + } + + public List> listChangeLogs(Long meetingId) { + getById(meetingId); + if (bizChangeLogService == null) { + return new ArrayList>(); + } + return bizChangeLogService.listByBiz("MEETING", meetingId).stream() + .map(this::toMeetingChangeLogRow) + .collect(Collectors.toList()); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private String nowIsoSeconds() { + return LocalDateTime.now().format(SQL_ISO_SECOND_FORMATTER); + } + + private void logMeetingCreate(Meeting meeting) { + if (bizChangeLogService == null) { + return; + } + bizChangeLogService.logAction("MEETING", meeting.getId(), "MEETING_CREATE", null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "topic", "会议主题", null, meeting.getTopic(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "meetingCategory", "会议类别", null, meeting.getMeetingCategory(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "meetingForm", "会议形式", null, meeting.getMeetingForm(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "location", "会议地点", null, meeting.getLocation(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "startTime", "会议开始时间", null, meeting.getStartTime(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "endTime", "会议结束时间", null, meeting.getEndTime(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "budgetCent", "会议预算(分)", null, meeting.getBudgetCent(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "laborRatio", "劳务占比", null, meeting.getLaborRatio(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "cateringRatio", "餐费占比", null, meeting.getCateringRatio(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "status", "会议状态", null, meeting.getStatus(), null); + logMeetingFieldChange(meeting.getId(), "MEETING_CREATE", "auditStatus", "审核状态", null, meeting.getAuditStatus(), null); + } + + private void logMeetingUpdate(Meeting before, Meeting after) { + if (bizChangeLogService == null) { + return; + } + String batchId = bizChangeLogService.newBatchId(); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "topic", "会议主题", before.getTopic(), after.getTopic(), batchId); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "meetingCategory", "会议类别", before.getMeetingCategory(), after.getMeetingCategory(), batchId); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "meetingForm", "会议形式", before.getMeetingForm(), after.getMeetingForm(), batchId); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "location", "会议地点", before.getLocation(), after.getLocation(), batchId); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "startTime", "会议开始时间", before.getStartTime(), after.getStartTime(), batchId); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "endTime", "会议结束时间", before.getEndTime(), after.getEndTime(), batchId); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "budgetCent", "会议预算(分)", before.getBudgetCent(), after.getBudgetCent(), batchId); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "laborRatio", "劳务占比", before.getLaborRatio(), after.getLaborRatio(), batchId); + logMeetingFieldChange(after.getId(), "MEETING_UPDATE", "cateringRatio", "餐费占比", before.getCateringRatio(), after.getCateringRatio(), batchId); + } + + private void logMeetingFieldChange(Long meetingId, + String changeType, + String fieldCode, + String fieldName, + Object beforeValue, + Object afterValue, + String batchId) { + if (bizChangeLogService == null) { + return; + } + bizChangeLogService.logFieldChange( + "MEETING", + meetingId, + changeType, + fieldCode, + fieldName, + normalizeMeetingFieldValue(beforeValue), + normalizeMeetingFieldValue(afterValue), + batchId, + null + ); + } + + private Object normalizeMeetingFieldValue(Object value) { + if (value instanceof Enum) { + return ((Enum) value).name(); + } + return value; + } + + private Map toMeetingChangeLogRow(BizChangeLogInfo item) { + Map row = new LinkedHashMap(); + row.put("id", item.getId()); + row.put("fieldCode", item.getFieldCode()); + row.put("fieldName", resolveMeetingFieldName(item)); + row.put("beforeValue", item.getBeforeValue()); + row.put("afterValue", item.getAfterValue()); + row.put("remark", item.getRemark()); + row.put("changeType", item.getChangeType()); + row.put("operatorUserId", item.getOperatorUserId()); + row.put("operatorUserName", item.getOperatorUserName()); + row.put("relatedUserId", item.getRelatedUserId()); + row.put("relatedUserName", item.getRelatedUserName()); + row.put("batchId", item.getBatchId()); + row.put("createdAt", item.getCreatedAt()); + return row; + } + + private String resolveMeetingFieldName(BizChangeLogInfo item) { + String fieldName = item.getFieldName() == null ? "" : item.getFieldName().trim(); + if (!fieldName.isEmpty()) { + return fieldName; + } + if ("MEETING_CREATE".equals(item.getChangeType())) { + return "会议创建"; + } + if ("MEETING_SUBMIT".equals(item.getChangeType())) { + return "提交审核"; + } + if ("MEETING_WITHDRAW".equals(item.getChangeType())) { + return "撤回审核"; + } + if ("MEETING_DELETE_DRAFT".equals(item.getChangeType())) { + return "删除草稿"; + } + if ("MEETING_CANCEL".equals(item.getChangeType())) { + return "取消会议"; + } + if ("MEETING_INVOICE_CONFIG_UPDATE".equals(item.getChangeType())) { + return "发票模块配置"; + } + return "会议变更"; + } +} diff --git a/backend/src/main/java/com/writeoff/module/meeting/service/MeetingSummaryExportService.java b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingSummaryExportService.java new file mode 100644 index 0000000..8e38c1f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/meeting/service/MeetingSummaryExportService.java @@ -0,0 +1,996 @@ +package com.writeoff.module.meeting.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.file.service.OssService; +import org.apache.poi.ooxml.POIXMLException; +import org.apache.poi.openxml4j.exceptions.InvalidFormatException; +import org.apache.poi.util.Units; +import org.apache.poi.xwpf.usermodel.ParagraphAlignment; +import org.apache.poi.xwpf.usermodel.XWPFDocument; +import org.apache.poi.xwpf.usermodel.XWPFParagraph; +import org.apache.poi.xwpf.usermodel.XWPFRun; +import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; +import org.apache.poi.xwpf.usermodel.XWPFTableRow; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTTc; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +@Service +public class MeetingSummaryExportService { + private static final String TEMPLATE_PATH = "templates/meeting-summary-template.docx"; + private static final String THEME_IMAGE_MARKER = "${themeCaption}"; + private static final String EXPERT_IMAGE_MARKER = "${chairCaption}"; + private static final String MATERIAL_IMAGE_MARKER = "${materialCaption}"; + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter DOC_DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy年M月d日"); + private static final DateTimeFormatter DOC_TIME_FORMATTER = DateTimeFormatter.ofPattern("H:mm"); + private static final int SECTION_IMAGE_MAX_WIDTH = 520; + private static final int SECTION_IMAGE_MAX_HEIGHT = 360; + private static final int EXPERT_IMAGE_MAX_WIDTH = 440; + private static final int EXPERT_IMAGE_MAX_HEIGHT = 320; + + private final JdbcTemplate jdbcTemplate; + private final OssService ossService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public MeetingSummaryExportService(JdbcTemplate jdbcTemplate, OssService ossService) { + this.jdbcTemplate = jdbcTemplate; + this.ossService = ossService; + } + + public byte[] buildDocx(Long tenantId, Long meetingId) { + SummaryContext context = loadSummaryContext(tenantId, meetingId); + try (XWPFDocument document = openTemplateDocument(); + ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + AnchorCells anchorCells = locateAnchorCells(document); + replacePlaceholders(document, buildPlaceholderValues(context)); + renderImageSection(anchorCells.themeCell, context.themeImages, false, SECTION_IMAGE_MAX_WIDTH, SECTION_IMAGE_MAX_HEIGHT); + renderImageSection(anchorCells.expertCell, context.expertImages, true, EXPERT_IMAGE_MAX_WIDTH, EXPERT_IMAGE_MAX_HEIGHT); + renderImageSection(anchorCells.materialCell, context.materialImages, false, SECTION_IMAGE_MAX_WIDTH, SECTION_IMAGE_MAX_HEIGHT); + document.write(outputStream); + return outputStream.toByteArray(); + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "会议总结生成失败"); + } + } + + private SummaryContext loadSummaryContext(Long tenantId, Long meetingId) { + Map meeting = findMeeting(tenantId, meetingId); + Map materialJsonByCode = loadMaterialJsonByCode(tenantId, meetingId); + String basicInfoJson = materialJsonByCode.get("BASIC_INFO"); + if (basicInfoJson == null || basicInfoJson.trim().isEmpty()) { + throw new BusinessException( + ErrorCodes.VALIDATION_ERROR, + "会议基本信息未保存,请先在会议资料-会议基本信息中保存后再生成总结" + ); + } + + Map basicInfo = parseJsonMap(basicInfoJson); + Map writeOffDocs = parseJsonMap(materialJsonByCode.get("WRITE_OFF_DOCS")); + Map expertList = parseJsonMap(materialJsonByCode.get("EXPERT_LIST")); + Map meetingInvoice = parseJsonMap(materialJsonByCode.get("MEETING_INVOICE")); + List> expertBindings = jdbcTemplate.queryForList( + "SELECT expert_id, expert_name, title, organization FROM meeting_expert_binding " + + "WHERE tenant_id=? AND meeting_id=? ORDER BY id ASC", + tenantId, + meetingId + ); + + Map expertProfileById = new LinkedHashMap(); + Map expertProfileByName = new LinkedHashMap(); + Map expertNameById = new LinkedHashMap(); + for (Map expertBinding : expertBindings) { + Long expertId = toLong(expertBinding.get("expert_id")); + String expertName = stringValue(expertBinding.get("expert_name")); + String organization = firstNonEmptyText( + stringValue(expertBinding.get("organization")), + stringValue(expertBinding.get("title")) + ); + ExpertProfile profile = new ExpertProfile(expertId, expertName, organization); + if (expertId != null && expertId > 0L) { + expertNameById.put(expertId, expertName); + expertProfileById.put(expertId, profile); + } + if (!expertName.isEmpty()) { + expertProfileByName.put(normalizeKey(expertName), profile); + } + } + + List chairmanIds = parseIdList(basicInfo.get("chairmanExpertIds")); + List speakerIds = parseIdList(basicInfo.get("speakerExpertIds")); + List hostIds = parseIdList(basicInfo.get("hostExpertIds")); + List discussionGuestIds = parseIdList(basicInfo.get("discussionGuestExpertIds")); + + List chairmanNames = resolveExpertNames(chairmanIds, expertNameById); + List speakerNames = resolveExpertNames(speakerIds, expertNameById); + List hostNames = resolveExpertNames(hostIds, expertNameById); + List discussionGuestNames = resolveExpertNames(discussionGuestIds, expertNameById); + markExpertRoles(chairmanIds, expertProfileById, "大会主席"); + markExpertRoles(hostIds, expertProfileById, "会议主持"); + markExpertRoles(speakerIds, expertProfileById, "会议讲者"); + + markExpertRoles(discussionGuestIds, expertProfileById, "讨论嘉宾"); + + String meetingTopic = stringValue(meeting.get("topic")); + String projectName = stringValue(meeting.get("project_name")); + String organizationName = firstNonEmptyText( + stringValue(meeting.get("host_enterprise_name")), + projectName, + queryTenantName(tenantId) + ); + String meetingCategory = stringValue(meeting.get("meeting_category")); + String location = stringValue(meeting.get("location")); + String startTime = stringValue(meeting.get("start_time")); + String endTime = stringValue(meeting.get("end_time")); + String guestCountText = formatNumber(basicInfo.get("guestCount")); + if ("-".equals(guestCountText) && !expertBindings.isEmpty()) { + guestCountText = String.valueOf(expertBindings.size()); + } + + SummaryContext context = new SummaryContext(); + context.summaryTitle = firstNonEmptyText(meetingTopic, projectName, "会议") + "-会议总结"; + context.organizationName = firstNonEmptyText(organizationName, "-"); + context.meetingCategory = firstNonEmptyText(meetingCategory, "-"); + context.meetingTime = formatMeetingTime(startTime, endTime); + context.meetingLocation = firstNonEmptyText(location, "-"); + context.meetingTopic = firstNonEmptyText(meetingTopic, "-"); + context.chairAndSpeaker = firstNonEmptyText( + buildRoleSummaryText(chairmanNames, hostNames, speakerNames, discussionGuestNames), + "-" + ); + context.guests = firstNonEmptyText(guestCountText, "-"); + context.attendeePlan = formatNumber(basicInfo.get("attendeeCount")); + context.attendeeActual = formatNumber(basicInfo.get("attendeeActualCount")); + context.targetAudience = firstNonEmptyText(normalizePlainText(stringValue(basicInfo.get("targetAudience"))), "-"); + context.mainAgenda = firstNonEmptyText(normalizePlainText(stringValue(basicInfo.get("mainAgenda"))), "-"); + context.meetingEffect = firstNonEmptyText(normalizePlainText(stringValue(basicInfo.get("meetingEffect"))), "-"); + context.improvementSuggestion = firstNonEmptyText( + normalizePlainText(stringValue(basicInfo.get("improvementSuggestion"))), + "无" + ); + context.themeInstruction = "(含" + firstNonEmptyText(meetingTopic, projectName, "会议主题") + ")画面或会议横幅"; + context.footerDate = formatFooterDate(firstNonEmptyText(endTime, startTime)); + context.themeImages = collectThemeImages(writeOffDocs); + context.expertImages = collectExpertImages(expertList, expertNameById, expertProfileById, expertProfileByName); + context.materialImages = collectMaterialImages(meetingInvoice); + return context; + } + + private Map findMeeting(Long tenantId, Long meetingId) { + List> rows = jdbcTemplate.queryForList( + "SELECT m.id, m.topic, m.meeting_category, m.location, " + + "DATE_FORMAT(m.start_time, '%Y-%m-%d %H:%i:%s') AS start_time, " + + "DATE_FORMAT(m.end_time, '%Y-%m-%d %H:%i:%s') AS end_time, " + + "p.project_name, p.host_enterprise_name " + + "FROM meeting m " + + "LEFT JOIN project p ON m.tenant_id=p.tenant_id AND m.project_id=p.id " + + "WHERE m.tenant_id=? AND m.id=? AND m.is_deleted=0 LIMIT 1", + tenantId, + meetingId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "会议不存在"); + } + return rows.get(0); + } + + private Map loadMaterialJsonByCode(Long tenantId, Long meetingId) { + List> rows = jdbcTemplate.queryForList( + "SELECT module_code, content_json FROM meeting_material WHERE tenant_id=? AND meeting_id=? AND is_deleted=0", + tenantId, + meetingId + ); + Map result = new LinkedHashMap(); + for (Map row : rows) { + String moduleCode = stringValue(row.get("module_code")); + if (!moduleCode.isEmpty()) { + result.put(moduleCode, stringValue(row.get("content_json"))); + } + } + return result; + } + + private Map parseJsonMap(String contentJson) { + if (contentJson == null || contentJson.trim().isEmpty()) { + return new LinkedHashMap(); + } + try { + return objectMapper.readValue(contentJson, new TypeReference>() {}); + } catch (Exception ex) { + return new LinkedHashMap(); + } + } + + private String queryTenantName(Long tenantId) { + try { + String name = jdbcTemplate.queryForObject( + "SELECT tenant_name FROM tenant WHERE id=? AND is_deleted=0 LIMIT 1", + String.class, + tenantId + ); + return name == null ? "" : name.trim(); + } catch (Exception ex) { + return ""; + } + } + + private List parseIdList(Object value) { + if (!(value instanceof List)) { + return Collections.emptyList(); + } + List list = (List) value; + List result = new ArrayList(); + for (Object item : list) { + Long id = toLong(item); + if (id != null && id > 0L) { + result.add(id); + } + } + return result; + } + + private List resolveExpertNames(List expertIds, Map expertNameById) { + Set dedup = new LinkedHashSet(); + for (Long expertId : expertIds) { + String name = expertNameById.get(expertId); + if (name != null && !name.trim().isEmpty()) { + dedup.add(name.trim()); + } + } + return new ArrayList(dedup); + } + + private List collectThemeImages(Map writeOffDocs) { + List result = new ArrayList(); + Set usedKeys = new LinkedHashSet(); + Map themePhoto = mapValue(writeOffDocs.get("themePhoto")); + String themePhotoKey = stringValue(themePhoto.get("ossKey")); + String themePhotoName = firstNonEmptyText( + stringValue(themePhoto.get("name")), + stringValue(themePhoto.get("fileName")), + fileNameFromObjectKey(themePhotoKey) + ); + if (!themePhotoKey.isEmpty() && isImageFile(themePhotoName, themePhotoKey) && usedKeys.add(themePhotoKey)) { + result.add(new ImageSource(themePhotoKey, themePhotoName, "")); + } + if (result.isEmpty()) { + for (Map agenda : listOfMap(writeOffDocs.get("agenda"))) { + String objectKey = stringValue(agenda.get("ossKey")); + String fileName = firstNonEmptyText( + stringValue(agenda.get("name")), + fileNameFromObjectKey(objectKey) + ); + if (objectKey.isEmpty() || !isImageFile(fileName, objectKey) || !usedKeys.add(objectKey)) { + continue; + } + result.add(new ImageSource(objectKey, fileName, "")); + } + } + return result; + } + + private List collectExpertImages(Map expertList, Map expertNameById) { + List result = new ArrayList(); + Set usedKeys = new LinkedHashSet(); + Map onsitePhoto = mapValue(expertList.get("onsitePhoto")); + for (Map photo : listOfMap(onsitePhoto.get("photos"))) { + String objectKey = stringValue(photo.get("ossKey")); + String fileName = firstNonEmptyText( + stringValue(photo.get("name")), + fileNameFromObjectKey(objectKey) + ); + if (objectKey.isEmpty() || !isImageFile(fileName, objectKey) || !usedKeys.add(objectKey)) { + continue; + } + Long expertId = toLong(photo.get("expertId")); + String expertName = firstNonEmptyText( + stringValue(photo.get("expertName")), + expertId == null ? "" : stringValue(expertNameById.get(expertId)), + "未命名专家" + ); + result.add(new ImageSource(objectKey, fileName, expertName)); + } + return result; + } + + private List collectExpertImages(Map expertList, + Map expertNameById, + Map expertProfileById, + Map expertProfileByName) { + List result = new ArrayList(); + Set usedKeys = new LinkedHashSet(); + Map onsitePhoto = mapValue(expertList.get("onsitePhoto")); + for (Map photo : listOfMap(onsitePhoto.get("photos"))) { + String objectKey = stringValue(photo.get("ossKey")); + String fileName = firstNonEmptyText( + stringValue(photo.get("name")), + fileNameFromObjectKey(objectKey) + ); + if (objectKey.isEmpty() || !isImageFile(fileName, objectKey) || !usedKeys.add(objectKey)) { + continue; + } + Long expertId = toLong(photo.get("expertId")); + String expertName = firstNonEmptyText( + stringValue(photo.get("expertName")), + expertId == null ? "" : stringValue(expertNameById.get(expertId)), + "未命名专家" + ); + ExpertProfile profile = resolveExpertProfile(expertId, expertName, expertProfileById, expertProfileByName); + result.add(new ImageSource(objectKey, fileName, buildExpertPhotoCaption(expertName, profile))); + } + return result; + } + + private void markExpertRoles(List expertIds, Map expertProfileById, String roleLabel) { + for (Long expertId : expertIds == null ? Collections.emptyList() : expertIds) { + if (expertId == null) { + continue; + } + ExpertProfile profile = expertProfileById.get(expertId); + if (profile != null) { + profile.addRole(roleLabel); + } + } + } + + private ExpertProfile resolveExpertProfile(Long expertId, + String expertName, + Map expertProfileById, + Map expertProfileByName) { + if (expertId != null) { + ExpertProfile profile = expertProfileById.get(expertId); + if (profile != null) { + return profile; + } + } + if (expertName == null || expertName.trim().isEmpty()) { + return null; + } + return expertProfileByName.get(normalizeKey(expertName)); + } + + private String buildExpertPhotoCaption(String fallbackName, ExpertProfile profile) { + String expertName = firstNonEmptyText( + profile == null ? "" : profile.name, + fallbackName, + "未命名专家" + ); + String roles = profile == null ? "" : joinDisplayNames(new ArrayList(profile.roles)); + String organization = profile == null ? "" : profile.organization; + List parts = new ArrayList(); + parts.add(expertName); + if (!roles.isEmpty()) { + parts.add(roles); + } + if (!organization.isEmpty()) { + parts.add(organization); + } + return String.join(" / ", parts); + } + + private List collectMaterialImages(Map meetingInvoice) { + List result = new ArrayList(); + Set usedKeys = new LinkedHashSet(); + for (Map section : listOfMap(meetingInvoice.get("sections"))) { + if (!"MATERIAL_DETAIL".equalsIgnoreCase(stringValue(section.get("sectionCode")))) { + continue; + } + for (Map file : listOfMap(section.get("files"))) { + String objectKey = stringValue(file.get("ossKey")); + String fileName = firstNonEmptyText( + stringValue(file.get("fileName")), + stringValue(file.get("name")), + fileNameFromObjectKey(objectKey) + ); + if (objectKey.isEmpty() || !isImageFile(fileName, objectKey) || !usedKeys.add(objectKey)) { + continue; + } + result.add(new ImageSource(objectKey, fileName, "")); + } + } + return result; + } + + private XWPFDocument openTemplateDocument() { + ClassPathResource resource = new ClassPathResource(TEMPLATE_PATH); + try (InputStream inputStream = resource.getInputStream()) { + return new XWPFDocument(inputStream); + } catch (IOException ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "会议总结模板损坏"); + } catch (POIXMLException ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "会议总结模板损坏"); + } + } + + private AnchorCells locateAnchorCells(XWPFDocument document) { + XWPFTableCell themeCell = null; + XWPFTableCell expertCell = null; + XWPFTableCell materialCell = null; + for (XWPFTable table : document.getTables()) { + for (XWPFTableRow row : table.getRows()) { + for (XWPFTableCell cell : row.getTableCells()) { + String text = stringValue(cell.getText()); + if (themeCell == null && text.contains(THEME_IMAGE_MARKER)) { + themeCell = cell; + } + if (expertCell == null && text.contains(EXPERT_IMAGE_MARKER)) { + expertCell = cell; + } + if (materialCell == null && text.contains(MATERIAL_IMAGE_MARKER)) { + materialCell = cell; + } + } + } + } + if (themeCell == null || expertCell == null || materialCell == null) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "会议总结模板损坏"); + } + return new AnchorCells(themeCell, expertCell, materialCell); + } + + private Map buildPlaceholderValues(SummaryContext context) { + Map values = new LinkedHashMap(); + values.put("${summaryTitle}", context.summaryTitle); + values.put("${organizationName}", context.organizationName); + values.put("${meetingCategory}", context.meetingCategory); + values.put("${meetingTime}", context.meetingTime); + values.put("${meetingLocation}", context.meetingLocation); + values.put("${meetingTopic}", context.meetingTopic); + values.put("${chairAndSpeaker}", context.chairAndSpeaker); + values.put("${guests}", context.guests); + values.put("${attendeePlan}", context.attendeePlan); + values.put("${attendeeActual}", context.attendeeActual); + values.put("${targetAudience}", context.targetAudience); + values.put("${mainAgenda}", context.mainAgenda); + values.put("${meetingEffect}", context.meetingEffect); + values.put("${improvementSuggestion}", context.improvementSuggestion); + values.put("${themeInstruction}", context.themeInstruction); + values.put("${themeCaption}", ""); + values.put("${chairCaption}", ""); + values.put("${hostCaption}", ""); + values.put("${speakerCaption}", ""); + values.put("${discussionCaption}", ""); + values.put("${materialCaption}", ""); + values.put("${footerDate}", context.footerDate); + return values; + } + + private void replacePlaceholders(XWPFDocument document, Map placeholders) { + for (XWPFParagraph paragraph : document.getParagraphs()) { + replacePlaceholders(paragraph, placeholders); + } + for (XWPFTable table : document.getTables()) { + replacePlaceholders(table, placeholders); + } + } + + private void replacePlaceholders(XWPFTable table, Map placeholders) { + for (XWPFTableRow row : table.getRows()) { + for (XWPFTableCell cell : row.getTableCells()) { + for (XWPFParagraph paragraph : cell.getParagraphs()) { + replacePlaceholders(paragraph, placeholders); + } + for (XWPFTable nestedTable : cell.getTables()) { + replacePlaceholders(nestedTable, placeholders); + } + } + } + } + + private void replacePlaceholders(XWPFParagraph paragraph, Map placeholders) { + String original = paragraph.getParagraphText(); + if (original == null || original.isEmpty()) { + return; + } + String replaced = original; + for (Map.Entry entry : placeholders.entrySet()) { + if (replaced.contains(entry.getKey())) { + replaced = replaced.replace(entry.getKey(), safeValue(entry.getValue())); + } + } + if (!original.equals(replaced)) { + rewriteParagraph(paragraph, replaced); + } + } + + private void rewriteParagraph(XWPFParagraph paragraph, String text) { + CTRPr style = null; + if (!paragraph.getRuns().isEmpty() && paragraph.getRuns().get(0).getCTR().isSetRPr()) { + style = (CTRPr) paragraph.getRuns().get(0).getCTR().getRPr().copy(); + } + for (int i = paragraph.getRuns().size() - 1; i >= 0; i--) { + paragraph.removeRun(i); + } + XWPFRun run = paragraph.createRun(); + if (style != null) { + run.getCTR().setRPr(style); + } + setRunText(run, safeValue(text)); + } + + private void renderImageSection(XWPFTableCell cell, + List sources, + boolean showCaption, + int maxWidthPx, + int maxHeightPx) throws IOException, InvalidFormatException { + List images = loadRenderableImages(sources, maxWidthPx, maxHeightPx); + int skippedCount = Math.max(0, sources.size() - images.size()); + XWPFParagraph firstParagraph = clearCell(cell); + if (images.isEmpty()) { + String message = sources.isEmpty() ? "暂无图片" : "图片加载失败或格式暂不支持"; + writeParagraph(firstParagraph, message, ParagraphAlignment.CENTER, 11, false); + return; + } + + boolean useFirstParagraph = true; + for (RenderableImage image : images) { + XWPFParagraph imageParagraph = useFirstParagraph ? firstParagraph : cell.addParagraph(); + useFirstParagraph = false; + imageParagraph.setAlignment(ParagraphAlignment.CENTER); + imageParagraph.setSpacingAfter(80); + XWPFRun imageRun = imageParagraph.createRun(); + imageRun.addPicture( + new ByteArrayInputStream(image.bytes), + image.pictureType, + image.fileName, + Units.pixelToEMU(image.widthPx), + Units.pixelToEMU(image.heightPx) + ); + + if (showCaption && !image.caption.isEmpty()) { + XWPFParagraph captionParagraph = cell.addParagraph(); + captionParagraph.setAlignment(ParagraphAlignment.CENTER); + captionParagraph.setSpacingAfter(180); + writeParagraph(captionParagraph, image.caption, ParagraphAlignment.CENTER, 10, false); + } else { + imageParagraph.setSpacingAfter(180); + } + } + + if (skippedCount > 0) { + XWPFParagraph noteParagraph = cell.addParagraph(); + writeParagraph(noteParagraph, "部分图片因格式不支持或文件损坏未导入。", ParagraphAlignment.CENTER, 9, true); + } + } + + private List loadRenderableImages(List sources, int maxWidthPx, int maxHeightPx) { + List result = new ArrayList(); + for (ImageSource source : sources == null ? Collections.emptyList() : sources) { + RenderableImage image = loadRenderableImage(source, maxWidthPx, maxHeightPx); + if (image != null) { + result.add(image); + } + } + return result; + } + + private RenderableImage loadRenderableImage(ImageSource source, int maxWidthPx, int maxHeightPx) { + if (source == null || source.objectKey.isEmpty()) { + return null; + } + try { + byte[] objectBytes = ossService.getObjectBytes(source.objectKey); + if (objectBytes == null || objectBytes.length == 0) { + return null; + } + return buildRenderableImage(objectBytes, source.fileName, source.caption, maxWidthPx, maxHeightPx); + } catch (Exception ex) { + return null; + } + } + + private RenderableImage buildRenderableImage(byte[] sourceBytes, + String fileName, + String caption, + int maxWidthPx, + int maxHeightPx) throws IOException { + String effectiveFileName = firstNonEmptyText(fileName, "image"); + Integer pictureType = resolvePictureType(effectiveFileName); + byte[] effectiveBytes = sourceBytes; + + if (pictureType == null) { + BufferedImage image = ImageIO.read(new ByteArrayInputStream(sourceBytes)); + if (image == null) { + return null; + } + byte[] converted = writeImageBytes(image, "png"); + if (converted == null || converted.length == 0) { + return null; + } + effectiveBytes = converted; + effectiveFileName = replaceFileExtension(effectiveFileName, "png"); + pictureType = XWPFDocument.PICTURE_TYPE_PNG; + } + + BufferedImage effectiveImage = ImageIO.read(new ByteArrayInputStream(effectiveBytes)); + if (effectiveImage == null) { + return null; + } + ImageSize imageSize = scaleImage(effectiveImage.getWidth(), effectiveImage.getHeight(), maxWidthPx, maxHeightPx); + return new RenderableImage( + effectiveBytes, + pictureType.intValue(), + effectiveFileName, + safeValue(caption), + imageSize.width, + imageSize.height + ); + } + + private byte[] writeImageBytes(BufferedImage image, String formatName) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + if (!ImageIO.write(image, formatName, outputStream)) { + return null; + } + return outputStream.toByteArray(); + } + + private ImageSize scaleImage(int widthPx, int heightPx, int maxWidthPx, int maxHeightPx) { + if (widthPx <= 0 || heightPx <= 0) { + return new ImageSize(Math.max(1, maxWidthPx), Math.max(1, maxHeightPx)); + } + double ratio = Math.min( + Math.min((double) maxWidthPx / (double) widthPx, (double) maxHeightPx / (double) heightPx), + 1D + ); + int scaledWidth = Math.max(1, (int) Math.round(widthPx * ratio)); + int scaledHeight = Math.max(1, (int) Math.round(heightPx * ratio)); + return new ImageSize(scaledWidth, scaledHeight); + } + + private XWPFParagraph clearCell(XWPFTableCell cell) { + int paragraphCount = cell.getParagraphs().size(); + for (int i = paragraphCount - 1; i >= 0; i--) { + cell.removeParagraph(i); + } + CTTc ctTc = cell.getCTTc(); + for (int i = ctTc.sizeOfTblArray() - 1; i >= 0; i--) { + ctTc.removeTbl(i); + } + return cell.addParagraph(); + } + + private void writeParagraph(XWPFParagraph paragraph, + String text, + ParagraphAlignment alignment, + int fontSize, + boolean italic) { + paragraph.setAlignment(alignment); + XWPFRun run = paragraph.createRun(); + if (fontSize > 0) { + run.setFontSize(fontSize); + } + run.setItalic(italic); + setRunText(run, text); + } + + private void setRunText(XWPFRun run, String text) { + String[] lines = safeValue(text).split("\n", -1); + if (lines.length == 0) { + run.setText("", 0); + return; + } + run.setText(lines[0], 0); + for (int i = 1; i < lines.length; i++) { + run.addBreak(); + run.setText(lines[i]); + } + } + + private Integer resolvePictureType(String fileName) { + String suffix = fileExtension(fileName); + if ("png".equals(suffix)) { + return Integer.valueOf(XWPFDocument.PICTURE_TYPE_PNG); + } + if ("jpg".equals(suffix) || "jpeg".equals(suffix)) { + return Integer.valueOf(XWPFDocument.PICTURE_TYPE_JPEG); + } + if ("gif".equals(suffix)) { + return Integer.valueOf(XWPFDocument.PICTURE_TYPE_GIF); + } + if ("bmp".equals(suffix)) { + return Integer.valueOf(XWPFDocument.PICTURE_TYPE_BMP); + } + return null; + } + + private String replaceFileExtension(String fileName, String targetExtension) { + String cleanName = firstNonEmptyText(fileName, "image"); + int dotIndex = cleanName.lastIndexOf('.'); + String baseName = dotIndex >= 0 ? cleanName.substring(0, dotIndex) : cleanName; + return baseName + "." + targetExtension; + } + + private String fileExtension(String fileName) { + String cleanName = stringValue(fileName).toLowerCase(Locale.ROOT); + int dotIndex = cleanName.lastIndexOf('.'); + if (dotIndex < 0 || dotIndex >= cleanName.length() - 1) { + return ""; + } + return cleanName.substring(dotIndex + 1); + } + + private String formatMeetingTime(String startTimeText, String endTimeText) { + LocalDateTime start = parseDateTime(startTimeText); + LocalDateTime end = parseDateTime(endTimeText); + if (start == null && end == null) { + return "-"; + } + if (start != null && end != null) { + if (start.toLocalDate().equals(end.toLocalDate())) { + return DOC_DATE_FORMATTER.format(start.toLocalDate()) + " " + + DOC_TIME_FORMATTER.format(start) + "~" + DOC_TIME_FORMATTER.format(end); + } + return DOC_DATE_FORMATTER.format(start.toLocalDate()) + " " + DOC_TIME_FORMATTER.format(start) + + " ~ " + + DOC_DATE_FORMATTER.format(end.toLocalDate()) + " " + DOC_TIME_FORMATTER.format(end); + } + LocalDateTime only = start != null ? start : end; + return DOC_DATE_FORMATTER.format(only.toLocalDate()) + " " + DOC_TIME_FORMATTER.format(only); + } + + private String formatFooterDate(String dateTimeText) { + LocalDateTime dateTime = parseDateTime(dateTimeText); + LocalDate date = dateTime == null ? LocalDate.now() : dateTime.toLocalDate(); + return DOC_DATE_FORMATTER.format(date); + } + + private LocalDateTime parseDateTime(String text) { + if (text == null || text.trim().isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(text.trim(), DATE_TIME_FORMATTER); + } catch (Exception ex) { + return null; + } + } + + private String normalizePlainText(String value) { + if (value == null || value.trim().isEmpty()) { + return ""; + } + String normalized = value.replace("\r\n", "\n").replace("\r", "\n"); + String[] parts = normalized.split("\n"); + List items = new ArrayList(); + for (String part : parts) { + String text = part == null ? "" : part.trim(); + if (!text.isEmpty()) { + items.add(text); + } + } + return items.isEmpty() ? "" : String.join(";", items); + } + + private String buildRoleSummaryText(List chairmanNames, + List hostNames, + List speakerNames, + List discussionGuestNames) { + List parts = new ArrayList(); + String chairmanText = joinDisplayNames(chairmanNames); + String hostText = joinDisplayNames(hostNames); + String speakerText = joinDisplayNames(speakerNames); + String discussionGuestText = joinDisplayNames(discussionGuestNames); + if (!chairmanText.isEmpty()) { + parts.add("主席:" + chairmanText); + } + if (!hostText.isEmpty()) { + parts.add("主持:" + hostText); + } + if (!speakerText.isEmpty()) { + parts.add("讲者:" + speakerText); + } + if (!discussionGuestText.isEmpty()) { + parts.add("讨论嘉宾:" + discussionGuestText); + } + return parts.isEmpty() ? "" : String.join(";", parts); + } + + private String joinDisplayNames(List values) { + List filtered = new ArrayList(); + for (String value : values == null ? Collections.emptyList() : values) { + if (value != null && !value.trim().isEmpty()) { + filtered.add(value.trim()); + } + } + return filtered.isEmpty() ? "" : String.join("、", filtered); + } + + private String normalizeKey(String value) { + return stringValue(value).replace(" ", "").toLowerCase(Locale.ROOT); + } + + private String firstNonEmptyText(String... values) { + for (String value : values) { + if (value != null && !value.trim().isEmpty()) { + return value.trim(); + } + } + return ""; + } + + @SuppressWarnings("unchecked") + private List> listOfMap(Object value) { + if (!(value instanceof List)) { + return Collections.emptyList(); + } + List list = (List) value; + List> result = new ArrayList>(); + for (Object item : list) { + if (item instanceof Map) { + result.add((Map) item); + } + } + return result; + } + + @SuppressWarnings("unchecked") + private Map mapValue(Object value) { + if (value instanceof Map) { + return (Map) value; + } + return Collections.emptyMap(); + } + + private boolean isImageFile(String fileName, String objectKey) { + String name = stringValue(fileName).toLowerCase(Locale.ROOT); + String key = stringValue(objectKey).toLowerCase(Locale.ROOT); + return name.endsWith(".png") || name.endsWith(".jpg") || name.endsWith(".jpeg") + || name.endsWith(".gif") || name.endsWith(".bmp") || name.endsWith(".webp") + || key.endsWith(".png") || key.endsWith(".jpg") || key.endsWith(".jpeg") + || key.endsWith(".gif") || key.endsWith(".bmp") || key.endsWith(".webp"); + } + + private String fileNameFromObjectKey(String objectKey) { + String key = stringValue(objectKey); + if (key.isEmpty()) { + return ""; + } + int idx = key.lastIndexOf('/'); + return idx >= 0 ? key.substring(idx + 1) : key; + } + + private String formatNumber(Object value) { + Long number = toLong(value); + return number == null ? "-" : String.valueOf(number); + } + + private String stringValue(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String safeValue(String value) { + return value == null ? "" : value; + } + + private Long toLong(Object value) { + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + String text = stringValue(value); + return text.isEmpty() ? null : Long.valueOf(text); + } catch (Exception ex) { + return null; + } + } + + private static final class SummaryContext { + private String summaryTitle; + private String organizationName; + private String meetingCategory; + private String meetingTime; + private String meetingLocation; + private String meetingTopic; + private String chairAndSpeaker; + private String guests; + private String attendeePlan; + private String attendeeActual; + private String targetAudience; + private String mainAgenda; + private String meetingEffect; + private String improvementSuggestion; + private String themeInstruction; + private String footerDate; + private List themeImages = Collections.emptyList(); + private List expertImages = Collections.emptyList(); + private List materialImages = Collections.emptyList(); + } + + private static final class AnchorCells { + private final XWPFTableCell themeCell; + private final XWPFTableCell expertCell; + private final XWPFTableCell materialCell; + + private AnchorCells(XWPFTableCell themeCell, XWPFTableCell expertCell, XWPFTableCell materialCell) { + this.themeCell = themeCell; + this.expertCell = expertCell; + this.materialCell = materialCell; + } + } + + private static final class ImageSource { + private final String objectKey; + private final String fileName; + private final String caption; + + private ImageSource(String objectKey, String fileName, String caption) { + this.objectKey = objectKey == null ? "" : objectKey.trim(); + this.fileName = fileName == null ? "" : fileName.trim(); + this.caption = caption == null ? "" : caption.trim(); + } + } + + private static final class ExpertProfile { + private final Long expertId; + private final String name; + private final String organization; + private final LinkedHashSet roles = new LinkedHashSet(); + + private ExpertProfile(Long expertId, String name, String organization) { + this.expertId = expertId; + this.name = name == null ? "" : name.trim(); + this.organization = organization == null ? "" : organization.trim(); + } + + private void addRole(String roleLabel) { + if (roleLabel != null && !roleLabel.trim().isEmpty()) { + roles.add(roleLabel.trim()); + } + } + } + + private static final class RenderableImage { + private final byte[] bytes; + private final int pictureType; + private final String fileName; + private final String caption; + private final int widthPx; + private final int heightPx; + + private RenderableImage(byte[] bytes, int pictureType, String fileName, String caption, int widthPx, int heightPx) { + this.bytes = bytes; + this.pictureType = pictureType; + this.fileName = fileName; + this.caption = caption; + this.widthPx = widthPx; + this.heightPx = heightPx; + } + } + + private static final class ImageSize { + private final int width; + private final int height; + + private ImageSize(int width, int height) { + this.width = width; + this.height = height; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/controller/InAppNotificationController.java b/backend/src/main/java/com/writeoff/module/notification/controller/InAppNotificationController.java new file mode 100644 index 0000000..208a038 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/controller/InAppNotificationController.java @@ -0,0 +1,44 @@ +package com.writeoff.module.notification.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.notification.model.InAppNotificationInfo; +import com.writeoff.module.notification.service.InAppNotificationService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import java.util.LinkedHashMap; +import java.util.Map; + +@RestController +@RequestMapping("/api/in-app-notifications") +public class InAppNotificationController { + private final InAppNotificationService inAppNotificationService; + + public InAppNotificationController(InAppNotificationService inAppNotificationService) { + this.inAppNotificationService = inAppNotificationService; + } + + @GetMapping + @RequirePermission(value = "notification.inapp.read", dataScope = DataScopeType.TENANT, auditAction = "IN_APP_NOTIFICATION_LIST") + public ApiResponse> listMine() { + return ApiResponse.success(inAppNotificationService.listMine()); + } + + @PostMapping("/{id}/read") + @RequirePermission(value = "notification.inapp.mark-read", dataScope = DataScopeType.TENANT, auditAction = "IN_APP_NOTIFICATION_MARK_READ") + public ApiResponse markRead(@PathVariable("id") Long id) { + inAppNotificationService.markRead(id); + return ApiResponse.success(null); + } + + @PostMapping("/read-all") + @RequirePermission(value = "notification.inapp.mark-read", dataScope = DataScopeType.TENANT, auditAction = "IN_APP_NOTIFICATION_MARK_ALL_READ") + public ApiResponse> markAllRead() { + int affected = inAppNotificationService.markAllRead(); + Map data = new LinkedHashMap(); + data.put("affected", affected); + return ApiResponse.success(data); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/controller/NotificationDispatchController.java b/backend/src/main/java/com/writeoff/module/notification/controller/NotificationDispatchController.java new file mode 100644 index 0000000..68f191a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/controller/NotificationDispatchController.java @@ -0,0 +1,133 @@ +package com.writeoff.module.notification.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.module.export.dto.CreateExportTaskRequest; +import com.writeoff.module.export.service.ExportTaskService; +import com.writeoff.module.notification.dto.DispatchNotificationRequest; +import com.writeoff.module.notification.dto.AliyunSmsReceiptRequest; +import com.writeoff.module.notification.dto.NotificationReceiptRequest; +import com.writeoff.module.notification.model.NotificationTaskInfo; +import com.writeoff.module.notification.service.NotificationDispatchService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import javax.servlet.http.HttpServletRequest; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/notifications") +public class NotificationDispatchController { + private final NotificationDispatchService notificationDispatchService; + private final ExportTaskService exportTaskService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public NotificationDispatchController(NotificationDispatchService notificationDispatchService, + ExportTaskService exportTaskService) { + this.notificationDispatchService = notificationDispatchService; + this.exportTaskService = exportTaskService; + } + + @PostMapping("/tasks/export") + @RequirePermission(value = "notification.task.read", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_TASK_EXPORT") + public ApiResponse> exportTasks(@RequestBody @Valid CreateExportTaskRequest request) { + request.setTaskCode("NOTIFICATION_TASK_EXPORT"); + request.setBizType("NOTIFICATION_TASK"); + return ApiResponse.success(exportTaskService.create(request)); + } + + @PostMapping("/dispatch") + @RequirePermission(value = "notification.dispatch", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_DISPATCH") + public ApiResponse> dispatch(@RequestBody @Valid DispatchNotificationRequest request) { + return ApiResponse.success(notificationDispatchService.dispatch(request)); + } + + @GetMapping("/tasks") + @RequirePermission(value = "notification.task.read", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_TASK_LIST") + public ApiResponse> listTasks( + @RequestParam(value = "pageNo", required = false) Integer pageNo, + @RequestParam(value = "pageSize", required = false) Integer pageSize) { + return ApiResponse.success(notificationDispatchService.listTasks(pageNo, pageSize)); + } + + @PostMapping("/receipts") + @RequirePermission(value = "notification.dispatch", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_RECEIPT_INGEST") + public ApiResponse> receipt(@RequestBody @Valid NotificationReceiptRequest request) { + return ApiResponse.success(notificationDispatchService.ingestReceipt(request)); + } + + @PostMapping("/receipts/webhook") + public ApiResponse> receiptWebhook(@RequestBody @Valid NotificationReceiptRequest request, + @RequestHeader(value = "X-Receipt-Timestamp", required = false) String timestamp, + @RequestHeader(value = "X-Receipt-Signature", required = false) String signature) { + return ApiResponse.success(notificationDispatchService.ingestReceiptWebhook(request, timestamp, signature)); + } + + @PostMapping("/receipts/providers/aliyun-sms") + public ApiResponse> aliyunSmsReceipt(@RequestBody(required = false) String body, + HttpServletRequest httpServletRequest) { + List requests = parseAliyunSmsReceiptRequests(body, httpServletRequest); + return ApiResponse.success(notificationDispatchService.ingestAliyunSmsReceipts(requests)); + } + + private List parseAliyunSmsReceiptRequests(String body, HttpServletRequest request) { + String raw = body == null ? "" : body.trim(); + try { + if (!raw.isEmpty()) { + if (raw.startsWith("[")) { + List list = objectMapper.readValue(raw, new TypeReference>() {}); + return list == null ? new ArrayList() : list; + } + if (raw.startsWith("{")) { + AliyunSmsReceiptRequest single = objectMapper.readValue(raw, AliyunSmsReceiptRequest.class); + List list = new ArrayList(); + list.add(single); + return list; + } + } + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "阿里云短信回执请求体格式非法"); + } + + Map parameterMap = request == null ? new LinkedHashMap() : request.getParameterMap(); + if (parameterMap == null || parameterMap.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "阿里云短信回执内容不能为空"); + } + AliyunSmsReceiptRequest single = new AliyunSmsReceiptRequest(); + single.setBizId(firstParam(parameterMap, "biz_id", "bizId")); + single.setOutId(firstParam(parameterMap, "out_id", "outId")); + single.setPhoneNumber(firstParam(parameterMap, "phone_number", "phoneNumber")); + single.setErrCode(firstParam(parameterMap, "err_code", "errCode")); + single.setErrMsg(firstParam(parameterMap, "err_msg", "errMsg")); + String success = firstParam(parameterMap, "success", "Success"); + if (success != null && success.trim().length() > 0) { + single.setSuccess(Boolean.valueOf(success.trim())); + } + single.setReceiveDate(firstParam(parameterMap, "report_time", "receiveDate", "receive_time")); + List list = new ArrayList(); + list.add(single); + return list; + } + + private String firstParam(Map parameterMap, String... names) { + if (parameterMap == null || names == null) { + return null; + } + for (String name : names) { + String[] values = parameterMap.get(name); + if (values != null && values.length > 0 && values[0] != null) { + return values[0]; + } + } + return null; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/controller/NotificationPolicyController.java b/backend/src/main/java/com/writeoff/module/notification/controller/NotificationPolicyController.java new file mode 100644 index 0000000..aabfc3b --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/controller/NotificationPolicyController.java @@ -0,0 +1,72 @@ +package com.writeoff.module.notification.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.notification.dto.BindNotificationPolicyEventsRequest; +import com.writeoff.module.notification.dto.CreateNotificationPolicyRequest; +import com.writeoff.module.notification.dto.UpdateNotificationPolicyRequest; +import com.writeoff.module.notification.model.NotificationPolicyInfo; +import com.writeoff.module.notification.service.NotificationPolicyService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.bind.annotation.RequestParam; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/notification-policies") +public class NotificationPolicyController { + private final NotificationPolicyService notificationPolicyService; + + public NotificationPolicyController(NotificationPolicyService notificationPolicyService) { + this.notificationPolicyService = notificationPolicyService; + } + + @GetMapping + @RequirePermission(value = "notification.policy.read", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_POLICY_LIST") + public ApiResponse> list( + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return ApiResponse.success(notificationPolicyService.list(pageNo, pageSize)); + } + + @PostMapping + @RequirePermission(value = "notification.policy.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_POLICY_CREATE") + public ApiResponse create(@RequestBody @Valid CreateNotificationPolicyRequest request) { + return ApiResponse.success(notificationPolicyService.create(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "notification.policy.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_POLICY_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, + @RequestBody @Valid UpdateNotificationPolicyRequest request) { + return ApiResponse.success(notificationPolicyService.update(id, request)); + } + + @PostMapping("/{id}/events") + @RequirePermission(value = "notification.policy.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_POLICY_BIND_EVENTS") + public ApiResponse bindEvents(@PathVariable("id") Long id, + @RequestBody @Valid BindNotificationPolicyEventsRequest request) { + return ApiResponse.success(notificationPolicyService.bindEvents(id, request.getEventCode())); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "notification.policy.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_POLICY_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + return ApiResponse.success(notificationPolicyService.enable(id)); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "notification.policy.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_POLICY_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + return ApiResponse.success(notificationPolicyService.disable(id)); + } + + @PostMapping("/{id}/delete") + @RequirePermission(value = "notification.policy.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_POLICY_DELETE") + public ApiResponse delete(@PathVariable("id") Long id) { + notificationPolicyService.softDelete(id); + return ApiResponse.success("OK"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/controller/NotificationTextTemplateController.java b/backend/src/main/java/com/writeoff/module/notification/controller/NotificationTextTemplateController.java new file mode 100644 index 0000000..ab21947 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/controller/NotificationTextTemplateController.java @@ -0,0 +1,63 @@ +package com.writeoff.module.notification.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.notification.dto.CreateNotificationTextTemplateRequest; +import com.writeoff.module.notification.dto.UpdateNotificationTextTemplateRequest; +import com.writeoff.module.notification.model.NotificationTextTemplateInfo; +import com.writeoff.module.notification.service.NotificationTextTemplateService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/notification-text-templates") +public class NotificationTextTemplateController { + private final NotificationTextTemplateService notificationTextTemplateService; + + public NotificationTextTemplateController(NotificationTextTemplateService notificationTextTemplateService) { + this.notificationTextTemplateService = notificationTextTemplateService; + } + + @GetMapping + @RequirePermission(value = "notification.text-template.read", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_TEXT_TEMPLATE_LIST") + public ApiResponse> list( + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return ApiResponse.success(notificationTextTemplateService.list(pageNo, pageSize)); + } + + @PostMapping + @RequirePermission(value = "notification.text-template.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_TEXT_TEMPLATE_CREATE") + public ApiResponse create(@RequestBody @Valid CreateNotificationTextTemplateRequest request) { + return ApiResponse.success(notificationTextTemplateService.create(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "notification.text-template.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_TEXT_TEMPLATE_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, + @RequestBody @Valid UpdateNotificationTextTemplateRequest request) { + return ApiResponse.success(notificationTextTemplateService.update(id, request)); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "notification.text-template.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_TEXT_TEMPLATE_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + return ApiResponse.success(notificationTextTemplateService.enable(id)); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "notification.text-template.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_TEXT_TEMPLATE_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + return ApiResponse.success(notificationTextTemplateService.disable(id)); + } + + @PostMapping("/{id}/delete") + @RequirePermission(value = "notification.text-template.manage", dataScope = DataScopeType.TENANT, auditAction = "NOTIFICATION_TEXT_TEMPLATE_DELETE") + public ApiResponse delete(@PathVariable("id") Long id) { + notificationTextTemplateService.softDelete(id); + return ApiResponse.success("OK"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/controller/PlatformNotifyGatewayController.java b/backend/src/main/java/com/writeoff/module/notification/controller/PlatformNotifyGatewayController.java new file mode 100644 index 0000000..13f143c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/controller/PlatformNotifyGatewayController.java @@ -0,0 +1,55 @@ +package com.writeoff.module.notification.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.notification.dto.SavePlatformNotifyGatewayRequest; +import com.writeoff.module.notification.dto.TestPlatformNotifyGatewayRequest; +import com.writeoff.module.notification.model.PlatformNotifyGatewayInfo; +import com.writeoff.module.notification.service.PlatformNotifyGatewayService; +import com.writeoff.module.notification.service.PlatformNotifyGatewayTestService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/platform/notify-gateways") +public class PlatformNotifyGatewayController { + private final PlatformNotifyGatewayService gatewayService; + private final PlatformNotifyGatewayTestService testService; + + public PlatformNotifyGatewayController(PlatformNotifyGatewayService gatewayService, + PlatformNotifyGatewayTestService testService) { + this.gatewayService = gatewayService; + this.testService = testService; + } + + @GetMapping + @RequirePermission(value = "platform.notify-gateway.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_NOTIFY_GATEWAY_LIST") + public ApiResponse> list() { + return ApiResponse.success(gatewayService.list()); + } + + @PutMapping("/{channelCode}") + @RequirePermission(value = "platform.notify-gateway.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_NOTIFY_GATEWAY_SAVE") + public ApiResponse save(@PathVariable("channelCode") String channelCode, + @RequestBody SavePlatformNotifyGatewayRequest request) { + return ApiResponse.success(gatewayService.save(channelCode, request)); + } + + @PostMapping("/{channelCode}/test") + @RequirePermission(value = "platform.notify-gateway.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_NOTIFY_GATEWAY_TEST") + public ApiResponse> test(@PathVariable("channelCode") String channelCode, + @RequestBody @Valid TestPlatformNotifyGatewayRequest request) { + return ApiResponse.success(testService.test(channelCode, request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/AliyunSmsReceiptRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/AliyunSmsReceiptRequest.java new file mode 100644 index 0000000..0c39a51 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/AliyunSmsReceiptRequest.java @@ -0,0 +1,67 @@ +package com.writeoff.module.notification.dto; + +public class AliyunSmsReceiptRequest { + private String bizId; + private String outId; + private String phoneNumber; + private String errCode; + private String errMsg; + private Boolean success; + private String receiveDate; + + public String getBizId() { + return bizId; + } + + public void setBizId(String bizId) { + this.bizId = bizId; + } + + public String getOutId() { + return outId; + } + + public void setOutId(String outId) { + this.outId = outId; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public String getErrCode() { + return errCode; + } + + public void setErrCode(String errCode) { + this.errCode = errCode; + } + + public String getErrMsg() { + return errMsg; + } + + public void setErrMsg(String errMsg) { + this.errMsg = errMsg; + } + + public Boolean getSuccess() { + return success; + } + + public void setSuccess(Boolean success) { + this.success = success; + } + + public String getReceiveDate() { + return receiveDate; + } + + public void setReceiveDate(String receiveDate) { + this.receiveDate = receiveDate; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/BindNotificationPolicyEventsRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/BindNotificationPolicyEventsRequest.java new file mode 100644 index 0000000..2152cf5 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/BindNotificationPolicyEventsRequest.java @@ -0,0 +1,16 @@ +package com.writeoff.module.notification.dto; + +import javax.validation.constraints.NotBlank; + +public class BindNotificationPolicyEventsRequest { + @NotBlank(message = "事件编码不能为空") + private String eventCode; + + public String getEventCode() { + return eventCode; + } + + public void setEventCode(String eventCode) { + this.eventCode = eventCode; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/CreateNotificationPolicyRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/CreateNotificationPolicyRequest.java new file mode 100644 index 0000000..b943d2f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/CreateNotificationPolicyRequest.java @@ -0,0 +1,75 @@ +package com.writeoff.module.notification.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class CreateNotificationPolicyRequest { + @NotBlank(message = "策略名称不能为空") + private String policyName; + @NotBlank(message = "事件编码不能为空") + private String eventCode; + @NotBlank(message = "通知渠道不能为空") + private String channel; + @NotBlank(message = "接收对象不能为空") + private String receiverType; + @NotNull(message = "文案模板ID不能为空") + private Long templateId; + private String variablesJson; + private String status; + + public String getPolicyName() { + return policyName; + } + + public void setPolicyName(String policyName) { + this.policyName = policyName; + } + + public String getEventCode() { + return eventCode; + } + + public void setEventCode(String eventCode) { + this.eventCode = eventCode; + } + + public String getChannel() { + return channel; + } + + public void setChannel(String channel) { + this.channel = channel; + } + + public String getReceiverType() { + return receiverType; + } + + public void setReceiverType(String receiverType) { + this.receiverType = receiverType; + } + + public Long getTemplateId() { + return templateId; + } + + public void setTemplateId(Long templateId) { + this.templateId = templateId; + } + + public String getVariablesJson() { + return variablesJson; + } + + public void setVariablesJson(String variablesJson) { + this.variablesJson = variablesJson; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/CreateNotificationTextTemplateRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/CreateNotificationTextTemplateRequest.java new file mode 100644 index 0000000..3b52df0 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/CreateNotificationTextTemplateRequest.java @@ -0,0 +1,53 @@ +package com.writeoff.module.notification.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateNotificationTextTemplateRequest { + @NotBlank(message = "文案模板名称不能为空") + private String templateName; + private String subjectTemplate; + private String titleTemplate; + @NotBlank(message = "正文模板不能为空") + private String contentTemplate; + private String status; + + public String getTemplateName() { + return templateName; + } + + public void setTemplateName(String templateName) { + this.templateName = templateName; + } + + public String getSubjectTemplate() { + return subjectTemplate; + } + + public void setSubjectTemplate(String subjectTemplate) { + this.subjectTemplate = subjectTemplate; + } + + public String getTitleTemplate() { + return titleTemplate; + } + + public void setTitleTemplate(String titleTemplate) { + this.titleTemplate = titleTemplate; + } + + public String getContentTemplate() { + return contentTemplate; + } + + public void setContentTemplate(String contentTemplate) { + this.contentTemplate = contentTemplate; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/DispatchNotificationRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/DispatchNotificationRequest.java new file mode 100644 index 0000000..c846724 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/DispatchNotificationRequest.java @@ -0,0 +1,62 @@ +package com.writeoff.module.notification.dto; + +import javax.validation.constraints.NotBlank; + +public class DispatchNotificationRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + @NotBlank(message = "事件编码不能为空") + private String eventCode; + private String bizType; + private String bizId; + private String variablesJson; + private Long policyId; + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public void setIdempotencyKey(String idempotencyKey) { + this.idempotencyKey = idempotencyKey; + } + + public String getEventCode() { + return eventCode; + } + + public void setEventCode(String eventCode) { + this.eventCode = eventCode; + } + + public String getBizType() { + return bizType; + } + + public void setBizType(String bizType) { + this.bizType = bizType; + } + + public String getBizId() { + return bizId; + } + + public void setBizId(String bizId) { + this.bizId = bizId; + } + + public String getVariablesJson() { + return variablesJson; + } + + public void setVariablesJson(String variablesJson) { + this.variablesJson = variablesJson; + } + + public Long getPolicyId() { + return policyId; + } + + public void setPolicyId(Long policyId) { + this.policyId = policyId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/NotificationReceiptRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/NotificationReceiptRequest.java new file mode 100644 index 0000000..e07965e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/NotificationReceiptRequest.java @@ -0,0 +1,55 @@ +package com.writeoff.module.notification.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class NotificationReceiptRequest { + @NotNull(message = "任务ID不能为空") + private Long taskId; + @NotBlank(message = "供应商消息ID不能为空") + private String providerMessageId; + @NotBlank(message = "回执码不能为空") + private String receiptCode; + private String receiptMessage; + private Boolean delivered; + + public Long getTaskId() { + return taskId; + } + + public void setTaskId(Long taskId) { + this.taskId = taskId; + } + + public String getProviderMessageId() { + return providerMessageId; + } + + public void setProviderMessageId(String providerMessageId) { + this.providerMessageId = providerMessageId; + } + + public String getReceiptCode() { + return receiptCode; + } + + public void setReceiptCode(String receiptCode) { + this.receiptCode = receiptCode; + } + + public String getReceiptMessage() { + return receiptMessage; + } + + public void setReceiptMessage(String receiptMessage) { + this.receiptMessage = receiptMessage; + } + + public Boolean getDelivered() { + return delivered; + } + + public void setDelivered(Boolean delivered) { + this.delivered = delivered; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/SavePlatformNotifyGatewayRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/SavePlatformNotifyGatewayRequest.java new file mode 100644 index 0000000..219f37a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/SavePlatformNotifyGatewayRequest.java @@ -0,0 +1,51 @@ +package com.writeoff.module.notification.dto; + +import java.util.Map; + +public class SavePlatformNotifyGatewayRequest { + private String gatewayName; + private String providerCode; + private String status; + private String remark; + private Map config; + + public String getGatewayName() { + return gatewayName; + } + + public void setGatewayName(String gatewayName) { + this.gatewayName = gatewayName; + } + + public String getProviderCode() { + return providerCode; + } + + public void setProviderCode(String providerCode) { + this.providerCode = providerCode; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } + + public Map getConfig() { + return config; + } + + public void setConfig(Map config) { + this.config = config; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/TestPlatformNotifyGatewayRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/TestPlatformNotifyGatewayRequest.java new file mode 100644 index 0000000..9c05191 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/TestPlatformNotifyGatewayRequest.java @@ -0,0 +1,34 @@ +package com.writeoff.module.notification.dto; + +import javax.validation.constraints.NotBlank; + +public class TestPlatformNotifyGatewayRequest { + @NotBlank(message = "测试接收目标不能为空") + private String receiverRef; + private String subject; + private String content; + + public String getReceiverRef() { + return receiverRef; + } + + public void setReceiverRef(String receiverRef) { + this.receiverRef = receiverRef; + } + + public String getSubject() { + return subject; + } + + public void setSubject(String subject) { + this.subject = subject; + } + + public String getContent() { + return content; + } + + public void setContent(String content) { + this.content = content; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/UpdateNotificationPolicyRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/UpdateNotificationPolicyRequest.java new file mode 100644 index 0000000..f6f8a1c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/UpdateNotificationPolicyRequest.java @@ -0,0 +1,4 @@ +package com.writeoff.module.notification.dto; + +public class UpdateNotificationPolicyRequest extends CreateNotificationPolicyRequest { +} diff --git a/backend/src/main/java/com/writeoff/module/notification/dto/UpdateNotificationTextTemplateRequest.java b/backend/src/main/java/com/writeoff/module/notification/dto/UpdateNotificationTextTemplateRequest.java new file mode 100644 index 0000000..2da0bd1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/dto/UpdateNotificationTextTemplateRequest.java @@ -0,0 +1,4 @@ +package com.writeoff.module.notification.dto; + +public class UpdateNotificationTextTemplateRequest extends CreateNotificationTextTemplateRequest { +} diff --git a/backend/src/main/java/com/writeoff/module/notification/model/InAppNotificationInfo.java b/backend/src/main/java/com/writeoff/module/notification/model/InAppNotificationInfo.java new file mode 100644 index 0000000..ee24044 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/model/InAppNotificationInfo.java @@ -0,0 +1,43 @@ +package com.writeoff.module.notification.model; + +public class InAppNotificationInfo { + private Long id; + private String title; + private String content; + private String status; + private String createdAt; + private String readAt; + + public InAppNotificationInfo(Long id, String title, String content, String status, String createdAt, String readAt) { + this.id = id; + this.title = title; + this.content = content; + this.status = status; + this.createdAt = createdAt; + this.readAt = readAt; + } + + public Long getId() { + return id; + } + + public String getTitle() { + return title; + } + + public String getContent() { + return content; + } + + public String getStatus() { + return status; + } + + public String getCreatedAt() { + return createdAt; + } + + public String getReadAt() { + return readAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/model/NotificationPolicyInfo.java b/backend/src/main/java/com/writeoff/module/notification/model/NotificationPolicyInfo.java new file mode 100644 index 0000000..de2a06f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/model/NotificationPolicyInfo.java @@ -0,0 +1,55 @@ +package com.writeoff.module.notification.model; + +public class NotificationPolicyInfo { + private Long id; + private String policyName; + private String eventCode; + private String channel; + private String receiverType; + private Long templateId; + private String variablesJson; + private String status; + + public NotificationPolicyInfo(Long id, String policyName, String eventCode, String channel, String receiverType, Long templateId, String variablesJson, String status) { + this.id = id; + this.policyName = policyName; + this.eventCode = eventCode; + this.channel = channel; + this.receiverType = receiverType; + this.templateId = templateId; + this.variablesJson = variablesJson; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getPolicyName() { + return policyName; + } + + public String getEventCode() { + return eventCode; + } + + public String getChannel() { + return channel; + } + + public String getReceiverType() { + return receiverType; + } + + public Long getTemplateId() { + return templateId; + } + + public String getVariablesJson() { + return variablesJson; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/model/NotificationTaskInfo.java b/backend/src/main/java/com/writeoff/module/notification/model/NotificationTaskInfo.java new file mode 100644 index 0000000..4b8313c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/model/NotificationTaskInfo.java @@ -0,0 +1,103 @@ +package com.writeoff.module.notification.model; + +public class NotificationTaskInfo { + private Long id; + private Long policyId; + private String eventCode; + private String channel; + private String receiverType; + private String receiverRef; + private String receiverResolveSource; + private String status; + private Integer retryCount; + private String providerMessageId; + private String receiptCode; + private String receiptMessage; + private String errorMessage; + private String createdAt; + private String sentAt; + private String receiptAt; + + public NotificationTaskInfo(Long id, Long policyId, String eventCode, String channel, String receiverType, String receiverRef, String receiverResolveSource, String status, Integer retryCount, String providerMessageId, String receiptCode, String receiptMessage, String errorMessage, String createdAt, String sentAt, String receiptAt) { + this.id = id; + this.policyId = policyId; + this.eventCode = eventCode; + this.channel = channel; + this.receiverType = receiverType; + this.receiverRef = receiverRef; + this.receiverResolveSource = receiverResolveSource; + this.status = status; + this.retryCount = retryCount; + this.providerMessageId = providerMessageId; + this.receiptCode = receiptCode; + this.receiptMessage = receiptMessage; + this.errorMessage = errorMessage; + this.createdAt = createdAt; + this.sentAt = sentAt; + this.receiptAt = receiptAt; + } + + public Long getId() { + return id; + } + + public Long getPolicyId() { + return policyId; + } + + public String getEventCode() { + return eventCode; + } + + public String getChannel() { + return channel; + } + + public String getReceiverType() { + return receiverType; + } + + public String getReceiverRef() { + return receiverRef; + } + + public String getReceiverResolveSource() { + return receiverResolveSource; + } + + public String getStatus() { + return status; + } + + public Integer getRetryCount() { + return retryCount; + } + + public String getProviderMessageId() { + return providerMessageId; + } + + public String getReceiptCode() { + return receiptCode; + } + + public String getReceiptMessage() { + return receiptMessage; + } + + public String getErrorMessage() { + return errorMessage; + } + + public String getCreatedAt() { + return createdAt; + } + + public String getSentAt() { + return sentAt; + } + + public String getReceiptAt() { + return receiptAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/model/NotificationTextTemplateInfo.java b/backend/src/main/java/com/writeoff/module/notification/model/NotificationTextTemplateInfo.java new file mode 100644 index 0000000..0d02ea2 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/model/NotificationTextTemplateInfo.java @@ -0,0 +1,43 @@ +package com.writeoff.module.notification.model; + +public class NotificationTextTemplateInfo { + private Long id; + private String templateName; + private String subjectTemplate; + private String titleTemplate; + private String contentTemplate; + private String status; + + public NotificationTextTemplateInfo(Long id, String templateName, String subjectTemplate, String titleTemplate, String contentTemplate, String status) { + this.id = id; + this.templateName = templateName; + this.subjectTemplate = subjectTemplate; + this.titleTemplate = titleTemplate; + this.contentTemplate = contentTemplate; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getTemplateName() { + return templateName; + } + + public String getSubjectTemplate() { + return subjectTemplate; + } + + public String getTitleTemplate() { + return titleTemplate; + } + + public String getContentTemplate() { + return contentTemplate; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/model/PlatformNotifyGatewayInfo.java b/backend/src/main/java/com/writeoff/module/notification/model/PlatformNotifyGatewayInfo.java new file mode 100644 index 0000000..2a63202 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/model/PlatformNotifyGatewayInfo.java @@ -0,0 +1,71 @@ +package com.writeoff.module.notification.model; + +import java.util.Map; + +public class PlatformNotifyGatewayInfo { + private final Long id; + private final String channelCode; + private final String gatewayName; + private final String providerCode; + private final String status; + private final String remark; + private final boolean configured; + private final String updatedAt; + private final Map config; + + public PlatformNotifyGatewayInfo(Long id, + String channelCode, + String gatewayName, + String providerCode, + String status, + String remark, + boolean configured, + String updatedAt, + Map config) { + this.id = id; + this.channelCode = channelCode; + this.gatewayName = gatewayName; + this.providerCode = providerCode; + this.status = status; + this.remark = remark; + this.configured = configured; + this.updatedAt = updatedAt; + this.config = config; + } + + public Long getId() { + return id; + } + + public String getChannelCode() { + return channelCode; + } + + public String getGatewayName() { + return gatewayName; + } + + public String getProviderCode() { + return providerCode; + } + + public String getStatus() { + return status; + } + + public String getRemark() { + return remark; + } + + public boolean isConfigured() { + return configured; + } + + public String getUpdatedAt() { + return updatedAt; + } + + public Map getConfig() { + return config; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/model/PlatformNotifyGatewayResolvedConfig.java b/backend/src/main/java/com/writeoff/module/notification/model/PlatformNotifyGatewayResolvedConfig.java new file mode 100644 index 0000000..88ed694 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/model/PlatformNotifyGatewayResolvedConfig.java @@ -0,0 +1,57 @@ +package com.writeoff.module.notification.model; + +import java.util.Map; + +public class PlatformNotifyGatewayResolvedConfig { + private final Long id; + private final String channelCode; + private final String gatewayName; + private final String providerCode; + private final String status; + private final String remark; + private final Map config; + + public PlatformNotifyGatewayResolvedConfig(Long id, + String channelCode, + String gatewayName, + String providerCode, + String status, + String remark, + Map config) { + this.id = id; + this.channelCode = channelCode; + this.gatewayName = gatewayName; + this.providerCode = providerCode; + this.status = status; + this.remark = remark; + this.config = config; + } + + public Long getId() { + return id; + } + + public String getChannelCode() { + return channelCode; + } + + public String getGatewayName() { + return gatewayName; + } + + public String getProviderCode() { + return providerCode; + } + + public String getStatus() { + return status; + } + + public String getRemark() { + return remark; + } + + public Map getConfig() { + return config; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/provider/EmailNotificationProvider.java b/backend/src/main/java/com/writeoff/module/notification/provider/EmailNotificationProvider.java new file mode 100644 index 0000000..60b1266 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/provider/EmailNotificationProvider.java @@ -0,0 +1,141 @@ +package com.writeoff.module.notification.provider; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig; +import com.writeoff.module.notification.service.PlatformNotifyGatewayService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.Properties; + +@Component +public class EmailNotificationProvider implements NotificationChannelProvider { + private static final Logger log = LoggerFactory.getLogger(EmailNotificationProvider.class); + private final PlatformNotifyGatewayService gatewayService; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final String defaultSubject; + + public EmailNotificationProvider(PlatformNotifyGatewayService gatewayService, + @Value("${app.notification.mail.default-subject:绯荤粺閫氱煡}") String defaultSubject) { + this.gatewayService = gatewayService; + this.defaultSubject = defaultSubject == null ? "绯荤粺閫氱煡" : defaultSubject.trim(); + } + + @Override + public String channel() { + return "EMAIL"; + } + + @Override + public NotificationSendResult send(String receiverRef, String payloadJson, Map context) { + if (receiverRef == null || receiverRef.trim().isEmpty()) { + return new NotificationSendResult(false, null, "INVALID_RECEIVER", "閭鍦板潃涓嶈兘涓虹┖"); + } + long start = System.currentTimeMillis(); + try { + PlatformNotifyGatewayResolvedConfig gatewayConfig = gatewayService.resolveChannelConfig("EMAIL", true); + JavaMailSenderImpl sender = resolveMailSender(gatewayConfig); + Map runtimeConfig = gatewayConfig.getConfig(); + String subject = textOr(runtimeConfig.get("defaultSubject"), defaultSubject); + String content = payloadJson == null ? "" : payloadJson; + if (payloadJson != null && payloadJson.trim().startsWith("{")) { + Map payload = objectMapper.readValue(payloadJson, new TypeReference>() {}); + Object sub = payload.get("subject"); + Object body = payload.get("content"); + if (sub != null && String.valueOf(sub).trim().length() > 0) { + subject = String.valueOf(sub).trim(); + } + if (body != null) { + content = String.valueOf(body); + } + } + SimpleMailMessage message = new SimpleMailMessage(); + String runtimeFrom = text(runtimeConfig.get("fromAddress")); + if (!runtimeFrom.isEmpty()) { + message.setFrom(runtimeFrom); + } + message.setTo(receiverRef.trim()); + message.setSubject(subject); + message.setText(content == null ? "" : content); + log.info("email sending start, to={}, subject={}", receiverRef.trim(), subject); + sender.send(message); + String id = "EMAIL-" + System.currentTimeMillis(); + log.info("email sending success, to={}, messageId={}, elapsedMs={}", receiverRef.trim(), id, System.currentTimeMillis() - start); + return new NotificationSendResult(true, id, "SENT", "閭欢鍙戦€佹垚鍔?"); + } catch (Exception ex) { + log.error("email sending failed, to={}, elapsedMs={}, reason={}", receiverRef.trim(), System.currentTimeMillis() - start, ex.getMessage(), ex); + return new NotificationSendResult(false, null, "SEND_FAILED", ex.getMessage()); + } + } + + private JavaMailSenderImpl resolveMailSender(PlatformNotifyGatewayResolvedConfig gatewayConfig) { + if (gatewayConfig == null || gatewayConfig.getConfig() == null) { + throw new IllegalStateException("邮件网关未启用或未配置"); + } + Map config = gatewayConfig.getConfig(); + if (text(config.get("host")).isEmpty()) { + throw new IllegalStateException("邮件网关缺少 SMTP Host 配置"); + } + if (text(config.get("fromAddress")).isEmpty()) { + throw new IllegalStateException("邮件网关缺少发件邮箱配置"); + } + JavaMailSenderImpl sender = new JavaMailSenderImpl(); + sender.setHost(text(config.get("host"))); + sender.setPort(intValue(config.get("port"), 587)); + sender.setProtocol(textOr(config.get("protocol"), "smtp")); + sender.setUsername(text(config.get("username"))); + sender.setPassword(text(config.get("password"))); + Properties props = sender.getJavaMailProperties(); + props.put("mail.smtp.auth", String.valueOf(boolValue(config.get("smtpAuth"), true))); + props.put("mail.smtp.starttls.enable", String.valueOf(boolValue(config.get("starttlsEnable"), true))); + props.put("mail.smtp.starttls.required", String.valueOf(boolValue(config.get("starttlsRequired"), false))); + props.put("mail.smtp.ssl.enable", String.valueOf(boolValue(config.get("sslEnable"), false))); + props.put("mail.smtp.connectiontimeout", String.valueOf(intValue(config.get("connectTimeoutMs"), 5000))); + props.put("mail.smtp.timeout", String.valueOf(intValue(config.get("timeoutMs"), 5000))); + props.put("mail.smtp.writetimeout", String.valueOf(intValue(config.get("writeTimeoutMs"), 5000))); + return sender; + } + + private String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String textOr(Object value, String fallback) { + String text = text(value); + return text.isEmpty() ? fallback : text; + } + + private int intValue(Object value, int fallback) { + if (value == null) { + return fallback; + } + try { + return value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(String.valueOf(value).trim()); + } catch (Exception ex) { + return fallback; + } + } + + private boolean boolValue(Object value, boolean fallback) { + if (value == null) { + return fallback; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + String text = String.valueOf(value).trim(); + if ("true".equalsIgnoreCase(text) || "1".equals(text)) { + return true; + } + if ("false".equalsIgnoreCase(text) || "0".equals(text)) { + return false; + } + return fallback; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/provider/InAppNotificationProvider.java b/backend/src/main/java/com/writeoff/module/notification/provider/InAppNotificationProvider.java new file mode 100644 index 0000000..9e19af4 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/provider/InAppNotificationProvider.java @@ -0,0 +1,118 @@ +package com.writeoff.module.notification.provider; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.module.notification.ws.NotificationWebSocketPushService; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +public class InAppNotificationProvider implements NotificationChannelProvider { + private final JdbcTemplate jdbcTemplate; + private final NotificationWebSocketPushService webSocketPushService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public InAppNotificationProvider(JdbcTemplate jdbcTemplate, NotificationWebSocketPushService webSocketPushService) { + this.jdbcTemplate = jdbcTemplate; + this.webSocketPushService = webSocketPushService; + } + + @Override + public String channel() { + return "IN_APP"; + } + + @Override + public NotificationSendResult send(String receiverRef, String payloadJson, Map context) { + if (receiverRef == null || receiverRef.trim().isEmpty()) { + return new NotificationSendResult(false, null, "INVALID_RECEIVER", "站内通知接收人不能为空"); + } + String normalizedReceiver = receiverRef.trim(); + String title = "系统通知"; + String content = payloadJson == null ? "" : payloadJson; + Long receiverUserId = parseReceiverUserId(normalizedReceiver); + try { + if (payloadJson != null && payloadJson.trim().startsWith("{")) { + Map payload = objectMapper.readValue(payloadJson, new TypeReference>() {}); + Object titleVal = payload.get("title"); + if (titleVal == null) { + titleVal = payload.get("subject"); + } + Object contentVal = payload.get("content"); + if (contentVal == null) { + contentVal = payload.get("message"); + } + if (titleVal != null && String.valueOf(titleVal).trim().length() > 0) { + title = String.valueOf(titleVal).trim(); + } + if (contentVal != null) { + content = String.valueOf(contentVal); + } + } + Map payload = new LinkedHashMap(); + if (payloadJson != null && payloadJson.trim().startsWith("{")) { + payload = objectMapper.readValue(payloadJson, new TypeReference>() {}); + } + if (context != null) { + payload.putAll(context); + } + String mergedPayloadJson = payloadJson; + if (!payload.isEmpty()) { + mergedPayloadJson = objectMapper.writeValueAsString(payload); + } + jdbcTemplate.update( + "INSERT INTO in_app_notification (tenant_id, receiver_ref, receiver_user_id, title, content, payload_json, status, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, 'UNREAD', ?, ?)", + tenantId(), + normalizedReceiver, + receiverUserId, + title, + content, + mergedPayloadJson, + safeUserId(), + safeUserId() + ); + webSocketPushService.pushInAppNotification(tenantId(), normalizedReceiver, receiverUserId); + String id = "INAPP-" + System.currentTimeMillis(); + return new NotificationSendResult(true, id, "SENT", "站内通知已送达"); + } catch (Exception ex) { + return new NotificationSendResult(false, null, "SEND_FAILED", ex.getMessage()); + } + } + + private Long parseReceiverUserId(String receiverRef) { + if (receiverRef == null) { + return null; + } + String val = receiverRef.trim(); + if (val.length() == 0) { + return null; + } + if (val.startsWith("user-")) { + String idPart = val.substring("user-".length()); + try { + return Long.valueOf(idPart); + } catch (Exception ex) { + return null; + } + } + try { + return Long.valueOf(val); + } catch (Exception ex) { + return null; + } + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/provider/NotificationChannelProvider.java b/backend/src/main/java/com/writeoff/module/notification/provider/NotificationChannelProvider.java new file mode 100644 index 0000000..1c85e02 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/provider/NotificationChannelProvider.java @@ -0,0 +1,9 @@ +package com.writeoff.module.notification.provider; + +import java.util.Map; + +public interface NotificationChannelProvider { + String channel(); + + NotificationSendResult send(String receiverRef, String payloadJson, Map context); +} diff --git a/backend/src/main/java/com/writeoff/module/notification/provider/NotificationSendResult.java b/backend/src/main/java/com/writeoff/module/notification/provider/NotificationSendResult.java new file mode 100644 index 0000000..939e781 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/provider/NotificationSendResult.java @@ -0,0 +1,31 @@ +package com.writeoff.module.notification.provider; + +public class NotificationSendResult { + private final boolean accepted; + private final String providerMessageId; + private final String providerCode; + private final String providerMessage; + + public NotificationSendResult(boolean accepted, String providerMessageId, String providerCode, String providerMessage) { + this.accepted = accepted; + this.providerMessageId = providerMessageId; + this.providerCode = providerCode; + this.providerMessage = providerMessage; + } + + public boolean isAccepted() { + return accepted; + } + + public String getProviderMessageId() { + return providerMessageId; + } + + public String getProviderCode() { + return providerCode; + } + + public String getProviderMessage() { + return providerMessage; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/provider/SmsNotificationProvider.java b/backend/src/main/java/com/writeoff/module/notification/provider/SmsNotificationProvider.java new file mode 100644 index 0000000..71d5f0c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/provider/SmsNotificationProvider.java @@ -0,0 +1,267 @@ +package com.writeoff.module.notification.provider; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig; +import com.writeoff.module.notification.service.PlatformNotifyGatewayService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.BufferedReader; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collections; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.SimpleTimeZone; +import java.util.UUID; + +@Component +public class SmsNotificationProvider implements NotificationChannelProvider { + private static final Logger log = LoggerFactory.getLogger(SmsNotificationProvider.class); + private final PlatformNotifyGatewayService gatewayService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public SmsNotificationProvider(PlatformNotifyGatewayService gatewayService) { + this.gatewayService = gatewayService; + } + + @Override + public String channel() { + return "SMS"; + } + + @Override + public NotificationSendResult send(String receiverRef, String payloadJson, Map context) { + if (receiverRef == null || receiverRef.trim().isEmpty()) { + return new NotificationSendResult(false, null, "INVALID_RECEIVER", "短信接收人不能为空"); + } + PlatformNotifyGatewayResolvedConfig gatewayConfig = gatewayService.resolveChannelConfig("SMS", true); + if (gatewayConfig == null) { + String legacyId = "SMS-" + System.currentTimeMillis(); + log.info("sms gateway config not enabled, fallback to legacy mock accept, receiver={}", receiverRef.trim()); + return new NotificationSendResult(true, legacyId, "LEGACY_ACCEPTED", "短信通道已按兼容模式受理"); + } + if ("ALIYUN_SMS".equalsIgnoreCase(gatewayConfig.getProviderCode())) { + return sendByAliyun(receiverRef.trim(), payloadJson, context, gatewayConfig); + } + boolean mockEnabled = boolValue(gatewayConfig.getConfig().get("mockEnabled"), "MOCK".equalsIgnoreCase(gatewayConfig.getProviderCode())); + String providerCode = gatewayConfig.getProviderCode() == null ? "SMS" : gatewayConfig.getProviderCode().trim().toUpperCase(); + String id = providerCode + "-" + System.currentTimeMillis(); + log.info("sms sending accepted, provider={}, receiver={}, mockEnabled={}", providerCode, receiverRef.trim(), mockEnabled); + return new NotificationSendResult(true, id, mockEnabled ? "MOCK_ACCEPTED" : "ACCEPTED", mockEnabled ? "短信网关已模拟受理" : "短信网关已受理"); + } + + private NotificationSendResult sendByAliyun(String receiverRef, + String payloadJson, + Map context, + PlatformNotifyGatewayResolvedConfig gatewayConfig) { + Map config = gatewayConfig.getConfig(); + boolean mockEnabled = boolValue(config.get("mockEnabled"), false); + if (mockEnabled) { + String mockId = "ALIYUN_SMS-" + System.currentTimeMillis(); + return new NotificationSendResult(true, mockId, "MOCK_ACCEPTED", "阿里云短信已按模拟模式受理"); + } + String endpoint = text(config.get("endpoint")); + if (endpoint.isEmpty()) { + endpoint = "https://dysmsapi.aliyuncs.com/"; + } else if (!endpoint.startsWith("http://") && !endpoint.startsWith("https://")) { + endpoint = "https://" + endpoint; + } + String accessKeyId = text(config.get("accessKeyId")); + String accessKeySecret = text(config.get("accessKeySecret")); + String signName = text(config.get("signName")); + String templateCode = text(config.get("templateCode")); + String regionId = textOr(config.get("regionId"), "cn-hangzhou"); + if (accessKeyId.isEmpty() || accessKeySecret.isEmpty()) { + return new NotificationSendResult(false, null, "CONFIG_ERROR", "阿里云短信 AccessKey 配置不完整"); + } + if (signName.isEmpty() || templateCode.isEmpty()) { + return new NotificationSendResult(false, null, "CONFIG_ERROR", "阿里云短信签名或模板编码未配置"); + } + try { + Map payload = parsePayload(payloadJson); + Map templateParams = buildAliyunTemplateParams(payload, context); + String outId = resolveOutId(context); + Map params = new LinkedHashMap(); + params.put("AccessKeyId", accessKeyId); + params.put("Action", "SendSms"); + params.put("Format", "JSON"); + params.put("PhoneNumbers", receiverRef); + params.put("RegionId", regionId); + params.put("SignName", signName); + params.put("SignatureMethod", "HMAC-SHA1"); + params.put("SignatureNonce", UUID.randomUUID().toString()); + params.put("SignatureVersion", "1.0"); + params.put("TemplateCode", templateCode); + params.put("TemplateParam", objectMapper.writeValueAsString(templateParams)); + params.put("Timestamp", utcTimestamp()); + params.put("Version", "2017-05-25"); + if (!outId.isEmpty()) { + params.put("OutId", outId); + } + String signature = signAliyunRpc(params, accessKeySecret); + params.put("Signature", signature); + String responseText = doGet(endpoint, params); + Map responseMap = parsePayload(responseText); + String code = text(responseMap.get("Code")); + String message = text(responseMap.get("Message")); + String bizId = text(responseMap.get("BizId")); + if (!"OK".equalsIgnoreCase(code)) { + return new NotificationSendResult(false, bizId.isEmpty() ? null : bizId, code.isEmpty() ? "SEND_FAILED" : code, message.isEmpty() ? "阿里云短信发送失败" : message); + } + return new NotificationSendResult(true, bizId.isEmpty() ? "ALIYUN_SMS-" + System.currentTimeMillis() : bizId, code, message.isEmpty() ? "阿里云短信发送成功" : message); + } catch (Exception ex) { + log.error("aliyun sms sending failed, receiver={}, err={}", receiverRef, ex.getMessage(), ex); + return new NotificationSendResult(false, null, "SEND_FAILED", ex.getMessage()); + } + } + + private Map parsePayload(String payloadJson) throws Exception { + if (payloadJson == null || payloadJson.trim().isEmpty() || !payloadJson.trim().startsWith("{")) { + return new LinkedHashMap(); + } + Map payload = objectMapper.readValue(payloadJson, new TypeReference>() {}); + return payload == null ? new LinkedHashMap() : new LinkedHashMap(payload); + } + + private Map buildAliyunTemplateParams(Map payload, Map context) { + Map params = new LinkedHashMap(); + if (payload != null) { + params.putAll(payload); + } + if (context != null) { + Object taskId = context.get("taskId"); + if (taskId != null) { + params.put("taskId", String.valueOf(taskId)); + } + } + if (!params.containsKey("content") && params.containsKey("message")) { + params.put("content", params.get("message")); + } + if (!params.containsKey("message") && params.containsKey("content")) { + params.put("message", params.get("content")); + } + return params; + } + + private String resolveOutId(Map context) { + if (context == null) { + return ""; + } + Object outId = context.get("outId"); + if (outId != null && String.valueOf(outId).trim().length() > 0) { + return String.valueOf(outId).trim(); + } + Object taskId = context.get("taskId"); + if (taskId != null && String.valueOf(taskId).trim().length() > 0) { + return "task-" + String.valueOf(taskId).trim(); + } + return ""; + } + + private String utcTimestamp() { + SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"); + sdf.setTimeZone(new SimpleTimeZone(0, "UTC")); + return sdf.format(new Date()); + } + + private String signAliyunRpc(Map params, String accessKeySecret) throws Exception { + List keys = new ArrayList(params.keySet()); + Collections.sort(keys); + StringBuilder canonicalized = new StringBuilder(); + for (String key : keys) { + if (canonicalized.length() > 0) { + canonicalized.append("&"); + } + canonicalized.append(percentEncode(key)).append("=").append(percentEncode(params.get(key))); + } + String stringToSign = "GET&%2F&" + percentEncode(canonicalized.toString()); + Mac mac = Mac.getInstance("HmacSHA1"); + mac.init(new SecretKeySpec((accessKeySecret + "&").getBytes(StandardCharsets.UTF_8), "HmacSHA1")); + return Base64.getEncoder().encodeToString(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8))); + } + + private String doGet(String endpoint, Map params) throws Exception { + StringBuilder query = new StringBuilder(); + List keys = new ArrayList(params.keySet()); + Collections.sort(keys); + for (String key : keys) { + if (query.length() > 0) { + query.append("&"); + } + query.append(URLEncoder.encode(key, "UTF-8")).append("=").append(URLEncoder.encode(text(params.get(key)), "UTF-8")); + } + String url = endpoint + (endpoint.contains("?") ? "&" : "?") + query; + HttpURLConnection connection = (HttpURLConnection) new java.net.URL(url).openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(5000); + connection.setReadTimeout(10000); + connection.setDoInput(true); + int code = connection.getResponseCode(); + InputStream stream = code >= 200 && code < 300 ? connection.getInputStream() : connection.getErrorStream(); + String response = readStream(stream); + if (code < 200 || code >= 300) { + throw new IllegalStateException("HTTP " + code + ": " + response); + } + return response; + } + + private String readStream(InputStream stream) throws Exception { + if (stream == null) { + return ""; + } + BufferedReader reader = new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line); + } + return sb.toString(); + } + + private String percentEncode(String value) throws Exception { + return URLEncoder.encode(value == null ? "" : value, "UTF-8") + .replace("+", "%20") + .replace("*", "%2A") + .replace("%7E", "~"); + } + + private boolean boolValue(Object value, boolean fallback) { + if (value == null) { + return fallback; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + String text = String.valueOf(value).trim(); + if ("true".equalsIgnoreCase(text) || "1".equals(text)) { + return true; + } + if ("false".equalsIgnoreCase(text) || "0".equals(text)) { + return false; + } + return fallback; + } + + private String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String textOr(Object value, String fallback) { + String text = text(value); + return text.isEmpty() ? fallback : text; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/service/InAppNotificationService.java b/backend/src/main/java/com/writeoff/module/notification/service/InAppNotificationService.java new file mode 100644 index 0000000..c966d0b --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/service/InAppNotificationService.java @@ -0,0 +1,95 @@ +package com.writeoff.module.notification.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.notification.model.InAppNotificationInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class InAppNotificationService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper ROW_MAPPER = (rs, n) -> new InAppNotificationInfo( + rs.getLong("id"), + rs.getString("title"), + rs.getString("content"), + rs.getString("status"), + rs.getString("created_at"), + rs.getString("read_at") + ); + + public InAppNotificationService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult listMine() { + Long userId = AuthContext.userId(); + String userRef = userId == null ? "" : ("user-" + userId); + List list = jdbcTemplate.query( + "SELECT id, title, content, status, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, " + + "DATE_FORMAT(read_at, '%Y-%m-%d %H:%i:%s') AS read_at " + + "FROM in_app_notification " + + "WHERE tenant_id=? AND is_deleted=0 AND (receiver_ref='ALL' OR receiver_ref=? OR receiver_user_id=?) " + + "ORDER BY id DESC LIMIT 200", + ROW_MAPPER, + tenantId(), + userRef, + userId + ); + return new PageResult(list, list.size(), 1, 200); + } + + @Transactional(rollbackFor = Exception.class) + public void markRead(Long id) { + Long userId = AuthContext.userId(); + String userRef = userId == null ? "" : ("user-" + userId); + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM in_app_notification " + + "WHERE tenant_id=? AND id=? AND is_deleted=0 AND (receiver_ref='ALL' OR receiver_ref=? OR receiver_user_id=?)", + Integer.class, + tenantId(), + id, + userRef, + userId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "站内通知不存在"); + } + jdbcTemplate.update( + "UPDATE in_app_notification SET status='READ', read_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + id + ); + } + + @Transactional(rollbackFor = Exception.class) + public int markAllRead() { + Long userId = AuthContext.userId(); + String userRef = userId == null ? "" : ("user-" + userId); + return jdbcTemplate.update( + "UPDATE in_app_notification SET status='READ', read_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP, updated_by=? " + + "WHERE tenant_id=? AND is_deleted=0 AND status='UNREAD' AND (receiver_ref='ALL' OR receiver_ref=? OR receiver_user_id=?)", + safeUserId(), + tenantId(), + userRef, + userId + ); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/service/NotificationDeliveryProtectionService.java b/backend/src/main/java/com/writeoff/module/notification/service/NotificationDeliveryProtectionService.java new file mode 100644 index 0000000..feef0c4 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/service/NotificationDeliveryProtectionService.java @@ -0,0 +1,266 @@ +package com.writeoff.module.notification.service; + +import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +public class NotificationDeliveryProtectionService { + private final JdbcTemplate jdbcTemplate; + private final PlatformNotifyGatewayService gatewayService; + + public NotificationDeliveryProtectionService(JdbcTemplate jdbcTemplate, + PlatformNotifyGatewayService gatewayService) { + this.jdbcTemplate = jdbcTemplate; + this.gatewayService = gatewayService; + } + + public GuardDecision checkBeforeSend(String channelCode, String receiverRef) { + String channel = normalizeChannel(channelCode); + if (!isProtectedChannel(channel)) { + return GuardDecision.allow(); + } + BreakerState breakerState = loadBreakerState(channel); + if (breakerState.breakerUntil != null && LocalDateTime.now().isBefore(breakerState.breakerUntil)) { + String untilText = breakerState.breakerUntil.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + return GuardDecision.block("BREAKER_OPEN", "渠道熔断中,请在 " + untilText + " 后重试"); + } + if ("SMS".equals(channel)) { + SmsGuardConfig config = resolveSmsConfig(channel); + GuardRecord record = loadGuardRecord(channel, normalizeReceiver(receiverRef)); + LocalDateTime now = LocalDateTime.now(); + if (record.lastSentAt != null && config.quietPeriodSeconds > 0 && now.isBefore(record.lastSentAt.plusSeconds(config.quietPeriodSeconds))) { + return GuardDecision.block("SMS_RATE_LIMIT_WINDOW", "同一手机号发送过于频繁,请稍后再试"); + } + if (config.dailyLimit > 0 && record.dailyCount >= config.dailyLimit) { + return GuardDecision.block("SMS_RATE_LIMIT_DAILY", "同一手机号当日发送次数已达上限"); + } + } + return GuardDecision.allow(); + } + + public void recordSuccess(String channelCode, String receiverRef) { + String channel = normalizeChannel(channelCode); + if (!isProtectedChannel(channel)) { + return; + } + if ("SMS".equals(channel)) { + String normalizedReceiver = normalizeReceiver(receiverRef); + jdbcTemplate.update( + "INSERT INTO platform_notify_delivery_guard (channel_code, receiver_ref, stat_date, daily_count, last_sent_at) " + + "VALUES (?, ?, CURRENT_DATE(), 1, CURRENT_TIMESTAMP) " + + "ON DUPLICATE KEY UPDATE daily_count=daily_count+1, last_sent_at=VALUES(last_sent_at), updated_at=CURRENT_TIMESTAMP", + channel, + normalizedReceiver + ); + } + jdbcTemplate.update( + "UPDATE platform_notify_circuit_breaker SET consecutive_failures=0, breaker_until=NULL, last_failure_message=NULL, updated_at=CURRENT_TIMESTAMP WHERE channel_code=?", + channel + ); + } + + public void recordFailure(String channelCode, String failureMessage) { + String channel = normalizeChannel(channelCode); + if (!isProtectedChannel(channel)) { + return; + } + BreakerState state = loadBreakerState(channel); + int threshold = resolveFailureThreshold(channel); + int cooldownSeconds = resolveBreakerCooldownSeconds(channel); + int nextFailures = state.consecutiveFailures + 1; + LocalDateTime breakerUntil = nextFailures >= threshold + ? LocalDateTime.now().plusSeconds(Math.max(cooldownSeconds, 1)) + : null; + if (state.exists) { + jdbcTemplate.update( + "UPDATE platform_notify_circuit_breaker SET consecutive_failures=?, breaker_until=?, last_failure_message=?, updated_at=CURRENT_TIMESTAMP WHERE channel_code=?", + nextFailures, + toTimestamp(breakerUntil), + trimMessage(failureMessage), + channel + ); + return; + } + jdbcTemplate.update( + "INSERT INTO platform_notify_circuit_breaker (channel_code, consecutive_failures, breaker_until, last_failure_message) VALUES (?, ?, ?, ?)", + channel, + nextFailures, + toTimestamp(breakerUntil), + trimMessage(failureMessage) + ); + } + + private GuardRecord loadGuardRecord(String channelCode, String receiverRef) { + List> rows = jdbcTemplate.queryForList( + "SELECT daily_count, last_sent_at FROM platform_notify_delivery_guard WHERE channel_code=? AND receiver_ref=? AND stat_date=CURRENT_DATE() LIMIT 1", + channelCode, + receiverRef + ); + if (rows.isEmpty()) { + return new GuardRecord(0, null); + } + Map row = rows.get(0); + return new GuardRecord(intValue(row.get("daily_count"), 0), toLocalDateTime(row.get("last_sent_at"))); + } + + private BreakerState loadBreakerState(String channelCode) { + List> rows = jdbcTemplate.queryForList( + "SELECT consecutive_failures, breaker_until FROM platform_notify_circuit_breaker WHERE channel_code=? LIMIT 1", + channelCode + ); + if (rows.isEmpty()) { + return new BreakerState(false, 0, null); + } + Map row = rows.get(0); + return new BreakerState(true, intValue(row.get("consecutive_failures"), 0), toLocalDateTime(row.get("breaker_until"))); + } + + private SmsGuardConfig resolveSmsConfig(String channelCode) { + PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(channelCode, false); + Map config = resolved == null ? null : resolved.getConfig(); + return new SmsGuardConfig( + intValue(config == null ? null : config.get("quietPeriodSeconds"), 30), + intValue(config == null ? null : config.get("dailyLimit"), 10) + ); + } + + private int resolveFailureThreshold(String channelCode) { + PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(channelCode, false); + Map config = resolved == null ? null : resolved.getConfig(); + return Math.max(intValue(config == null ? null : config.get("failureThreshold"), 3), 1); + } + + private int resolveBreakerCooldownSeconds(String channelCode) { + PlatformNotifyGatewayResolvedConfig resolved = gatewayService.resolveChannelConfig(channelCode, false); + Map config = resolved == null ? null : resolved.getConfig(); + return Math.max(intValue(config == null ? null : config.get("breakerCooldownSeconds"), 300), 1); + } + + private boolean isProtectedChannel(String channelCode) { + return "EMAIL".equals(channelCode) || "SMS".equals(channelCode); + } + + private String normalizeChannel(String channelCode) { + return channelCode == null ? "" : channelCode.trim().toUpperCase(Locale.ROOT); + } + + private String normalizeReceiver(String receiverRef) { + return receiverRef == null ? "" : receiverRef.trim(); + } + + private String trimMessage(String value) { + String text = value == null ? "" : value.trim(); + return text.length() > 500 ? text.substring(0, 500) : text; + } + + private int intValue(Object value, int fallback) { + if (value == null) { + return fallback; + } + try { + return value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(String.valueOf(value).trim()); + } catch (Exception ex) { + return fallback; + } + } + + private Timestamp toTimestamp(LocalDateTime value) { + return value == null ? null : Timestamp.valueOf(value); + } + + private LocalDateTime toLocalDateTime(Object value) { + if (value == null) { + return null; + } + if (value instanceof Timestamp) { + return ((Timestamp) value).toLocalDateTime(); + } + if (value instanceof java.util.Date) { + return new Timestamp(((java.util.Date) value).getTime()).toLocalDateTime(); + } + if (value instanceof LocalDateTime) { + return (LocalDateTime) value; + } + if (value instanceof LocalDate) { + return ((LocalDate) value).atStartOfDay(); + } + try { + return LocalDateTime.parse(String.valueOf(value).trim().replace(' ', 'T')); + } catch (Exception ex) { + return null; + } + } + + public static final class GuardDecision { + private final boolean allowed; + private final String code; + private final String message; + + private GuardDecision(boolean allowed, String code, String message) { + this.allowed = allowed; + this.code = code; + this.message = message; + } + + public static GuardDecision allow() { + return new GuardDecision(true, "", ""); + } + + public static GuardDecision block(String code, String message) { + return new GuardDecision(false, code, message); + } + + public boolean isAllowed() { + return allowed; + } + + public String getCode() { + return code; + } + + public String getMessage() { + return message; + } + } + + private static final class GuardRecord { + private final int dailyCount; + private final LocalDateTime lastSentAt; + + private GuardRecord(int dailyCount, LocalDateTime lastSentAt) { + this.dailyCount = dailyCount; + this.lastSentAt = lastSentAt; + } + } + + private static final class BreakerState { + private final boolean exists; + private final int consecutiveFailures; + private final LocalDateTime breakerUntil; + + private BreakerState(boolean exists, int consecutiveFailures, LocalDateTime breakerUntil) { + this.exists = exists; + this.consecutiveFailures = consecutiveFailures; + this.breakerUntil = breakerUntil; + } + } + + private static final class SmsGuardConfig { + private final int quietPeriodSeconds; + private final int dailyLimit; + + private SmsGuardConfig(int quietPeriodSeconds, int dailyLimit) { + this.quietPeriodSeconds = quietPeriodSeconds; + this.dailyLimit = dailyLimit; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/service/NotificationDispatchService.java b/backend/src/main/java/com/writeoff/module/notification/service/NotificationDispatchService.java new file mode 100644 index 0000000..2ba5797 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/service/NotificationDispatchService.java @@ -0,0 +1,888 @@ +package com.writeoff.module.notification.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.notification.dto.DispatchNotificationRequest; +import com.writeoff.module.notification.dto.AliyunSmsReceiptRequest; +import com.writeoff.module.notification.dto.NotificationReceiptRequest; +import com.writeoff.module.notification.model.NotificationTaskInfo; +import com.writeoff.module.notification.provider.NotificationChannelProvider; +import com.writeoff.module.notification.provider.NotificationSendResult; +import com.writeoff.module.scheduler.service.AsyncJobService; +import com.writeoff.security.AuthContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Locale; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.HashMap; +import java.util.Collections; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +public class NotificationDispatchService { + private static final Logger log = LoggerFactory.getLogger(NotificationDispatchService.class); + private final JdbcTemplate jdbcTemplate; + private final AsyncJobService asyncJobService; + private final NotificationDeliveryProtectionService deliveryProtectionService; + private final Map providerMap; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final String webhookSecret; + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\$\\{([^}]+)}"); + + private static final RowMapper TASK_ROW_MAPPER = (rs, n) -> new NotificationTaskInfo( + rs.getLong("id"), + rs.getLong("policy_id"), + rs.getString("event_code"), + rs.getString("channel"), + rs.getString("receiver_type"), + rs.getString("receiver_ref"), + rs.getString("receiver_resolve_source"), + rs.getString("status"), + rs.getInt("retry_count"), + rs.getString("provider_message_id"), + rs.getString("receipt_code"), + rs.getString("receipt_message"), + rs.getString("error_message"), + rs.getString("created_at"), + rs.getString("sent_at"), + rs.getString("receipt_at") + ); + + public NotificationDispatchService(JdbcTemplate jdbcTemplate, + AsyncJobService asyncJobService, + NotificationDeliveryProtectionService deliveryProtectionService, + List providers, + @Value("${app.notification.webhook-secret:change-me}") String webhookSecret) { + this.jdbcTemplate = jdbcTemplate; + this.asyncJobService = asyncJobService; + this.deliveryProtectionService = deliveryProtectionService; + this.webhookSecret = webhookSecret == null ? "change-me" : webhookSecret; + this.providerMap = new HashMap(); + if (providers != null) { + for (NotificationChannelProvider provider : providers) { + providerMap.put(provider.channel().toUpperCase(), provider); + } + } + } + + public PageResult listTasks(Integer pageNo, Integer pageSize) { + int resolvedPageNo = normalizePageNo(pageNo); + int resolvedPageSize = normalizePageSize(pageSize); + int offset = (resolvedPageNo - 1) * resolvedPageSize; + Long tenantId = tenantId(); + + String whereSql = " FROM notification_task WHERE tenant_id=? AND is_deleted=0"; + List args = new ArrayList(); + args.add(tenantId); + + Long total = jdbcTemplate.queryForObject("SELECT COUNT(1)" + whereSql, Long.class, args.toArray()); + String dataSql = + "SELECT id, policy_id, event_code, channel, receiver_type, receiver_ref, " + + "JSON_UNQUOTE(JSON_EXTRACT(payload_json, '$.receiverResolveSource')) AS receiver_resolve_source, " + + "status, retry_count, provider_message_id, receipt_code, receipt_message, error_message, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, DATE_FORMAT(sent_at, '%Y-%m-%d %H:%i:%s') AS sent_at, DATE_FORMAT(receipt_at, '%Y-%m-%d %H:%i:%s') AS receipt_at " + + whereSql + + " ORDER BY id DESC LIMIT ? OFFSET ?"; + List dataArgs = new ArrayList(args); + dataArgs.add(resolvedPageSize); + dataArgs.add(offset); + List list = jdbcTemplate.query(dataSql, TASK_ROW_MAPPER, dataArgs.toArray()); + return new PageResult(list, total == null ? 0L : total, resolvedPageNo, resolvedPageSize); + } + + @Transactional(rollbackFor = Exception.class) + public Map dispatch(DispatchNotificationRequest request) { + Long filterPolicyId = request.getPolicyId(); + List> policies; + if (filterPolicyId != null && filterPolicyId > 0) { + policies = jdbcTemplate.queryForList( + "SELECT p.id, p.channel, p.receiver_type, p.template_id, p.variables_json, p.policy_name, " + + "tt.template_name, tt.subject_template, tt.title_template, tt.content_template " + + "FROM notification_policy p " + + "JOIN notification_text_template tt ON tt.tenant_id=p.tenant_id AND tt.id=p.template_id AND tt.is_deleted=0 AND tt.status='ENABLED' " + + "WHERE p.tenant_id=? AND p.id=? AND p.event_code=? AND p.status='ENABLED' AND p.is_deleted=0", + tenantId(), + filterPolicyId, + request.getEventCode() + ); + } else { + policies = jdbcTemplate.queryForList( + "SELECT p.id, p.channel, p.receiver_type, p.template_id, p.variables_json, p.policy_name, " + + "tt.template_name, tt.subject_template, tt.title_template, tt.content_template " + + "FROM notification_policy p " + + "JOIN notification_text_template tt ON tt.tenant_id=p.tenant_id AND tt.id=p.template_id AND tt.is_deleted=0 AND tt.status='ENABLED' " + + "WHERE p.tenant_id=? AND p.event_code=? AND p.status='ENABLED' AND p.is_deleted=0", + tenantId(), + request.getEventCode() + ); + } + if (policies.isEmpty()) { + if (filterPolicyId != null && filterPolicyId > 0) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "未找到可用通知策略(策略不存在、未启用或事件不匹配)"); + } + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "未找到可用通知策略"); + } + Map requestVars = parseVariables(request.getVariablesJson()); + int created = 0; + for (Map p : policies) { + Long policyId = ((Number) p.get("id")).longValue(); + String channel = String.valueOf(p.get("channel")); + String receiverType = String.valueOf(p.get("receiver_type")); + ReceiverResolution receiverResolution = resolveReceiver(channel, receiverType, request, requestVars); + String payloadJson = buildPolicyPayload(request, p, requestVars, receiverResolution.resolveSource, receiverResolution.receiverRef); + jdbcTemplate.update( + "INSERT INTO notification_task (tenant_id, policy_id, event_code, channel, receiver_type, receiver_ref, payload_json, status, retry_count, max_retry, idempotency_key, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, 'PENDING', 0, 3, ?, ?, ?)", + tenantId(), + policyId, + request.getEventCode(), + channel, + receiverType, + receiverResolution.receiverRef, + payloadJson, + request.getIdempotencyKey() + "-" + policyId, + safeUserId(), + safeUserId() + ); + Long taskId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM notification_task WHERE tenant_id=?", Long.class, tenantId()); + Map payload = new LinkedHashMap(); + payload.put("taskId", taskId == null ? 0L : taskId); + payload.put("eventCode", request.getEventCode()); + try { + asyncJobService.enqueue("NOTIFICATION_DISPATCH", objectMapper.writeValueAsString(payload), "notify-task-" + taskId); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "通知任务入队失败"); + } + created++; + } + Map data = new LinkedHashMap(); + data.put("eventCode", request.getEventCode()); + data.put("taskCount", created); + return data; + } + + private ReceiverResolution resolveReceiver(String channel, String receiverType, DispatchNotificationRequest request, Map requestVars) { + String ch = normalizeUpper(channel); + String bizType = request == null ? "" : String.valueOf(request.getBizType() == null ? "" : request.getBizType()); + String bizId = request == null ? "" : String.valueOf(request.getBizId() == null ? "" : request.getBizId()); + String input = bizId.trim(); + if (!input.isEmpty() && isExplicitReceiverRef(ch, input)) { + return new ReceiverResolution(normalizeExplicitReceiverRef(ch, input), "EXPLICIT_INPUT"); + } + ReceiverTarget receiverTarget = resolveTargetUser(receiverType, bizType, input, requestVars); + String receiverRef = resolveReceiverByUser(channel, receiverTarget.userId); + return new ReceiverResolution(receiverRef, receiverTarget.source); + } + + private ReceiverTarget resolveTargetUser(String receiverType, String bizType, String bizId, Map requestVars) { + String type = normalizeUpper(receiverType); + Long directUserId = resolveUserId(bizType, bizId, requestVars); + if ("TARGET_USER".equals(type) && directUserId != null && directUserId > 0) { + return new ReceiverTarget(directUserId, "RECEIVER_TYPE_TARGET_USER"); + } + Long meetingId = resolveMeetingId(bizType, bizId, requestVars); + if ("SUBMITTER".equals(type)) { + Long submitterUserId = findMeetingSubmitterUserId(meetingId); + if (submitterUserId != null && submitterUserId > 0) { + return new ReceiverTarget(submitterUserId, "RECEIVER_TYPE_SUBMITTER"); + } + } else if ("AUDITOR".equals(type)) { + Long auditorUserId = findMeetingAuditorUserId(meetingId); + if (auditorUserId != null && auditorUserId > 0) { + return new ReceiverTarget(auditorUserId, "RECEIVER_TYPE_AUDITOR"); + } + } else if ("FINANCE_ROLE".equals(type)) { + Long financeUserId = findMeetingFinanceApproverUserId(meetingId); + if (financeUserId == null || financeUserId <= 0) { + financeUserId = findFirstFinanceRoleUserId(); + } + if (financeUserId != null && financeUserId > 0) { + return new ReceiverTarget(financeUserId, "RECEIVER_TYPE_FINANCE_ROLE"); + } + } + return new ReceiverTarget(safeUserId(), "FALLBACK_CURRENT_USER"); + } + + private Long resolveUserId(String bizType, String bizId, Map requestVars) { + if (requestVars != null) { + Long userIdFromVars = toLong(requestVars.get("targetUserId")); + if (userIdFromVars != null && userIdFromVars > 0) { + return userIdFromVars; + } + userIdFromVars = toLong(requestVars.get("userId")); + if (userIdFromVars != null && userIdFromVars > 0) { + return userIdFromVars; + } + } + String normalizedBizType = normalizeUpper(bizType); + if (!normalizedBizType.isEmpty() && !"USER".equals(normalizedBizType)) { + return null; + } + String value = bizId == null ? "" : bizId.trim(); + if (value.isEmpty()) { + return null; + } + if (value.startsWith("user-")) { + return toLong(value.substring("user-".length())); + } + return toLong(value); + } + + private String resolveReceiverByUser(String channel, Long userId) { + String ch = normalizeUpper(channel); + if ("EMAIL".equals(ch)) { + String email = userEmail(userId); + if (email.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "目标用户未配置邮箱,无法发送邮件通知"); + } + return email; + } + if ("SMS".equals(ch)) { + String phone = userPhone(userId); + if (phone.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "目标用户未配置手机号,无法发送短信通知"); + } + return phone; + } + return "user-" + (userId == null ? 0L : userId); + } + + private String userEmail(Long userId) { + if (userId == null || userId <= 0) { + return ""; + } + List> rows = jdbcTemplate.queryForList( + "SELECT email FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + tenantId(), + userId + ); + if (rows.isEmpty()) { + return ""; + } + Object val = rows.get(0).get("email"); + return val == null ? "" : String.valueOf(val).trim(); + } + + private String userPhone(Long userId) { + if (userId == null || userId <= 0) { + return ""; + } + List> rows = jdbcTemplate.queryForList( + "SELECT phone FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + tenantId(), + userId + ); + if (rows.isEmpty()) { + return ""; + } + Object val = rows.get(0).get("phone"); + return val == null ? "" : String.valueOf(val).trim(); + } + + private Long resolveMeetingId(String bizType, String bizId, Map requestVars) { + if (requestVars != null) { + Object meetingId = requestVars.get("meetingId"); + Long parsedFromVars = toLong(meetingId); + if (parsedFromVars != null && parsedFromVars > 0) { + return parsedFromVars; + } + } + String type = normalizeUpper(bizType); + if (!type.isEmpty() && !"MEETING".equals(type)) { + return null; + } + String val = bizId == null ? "" : bizId.trim(); + if (val.isEmpty()) { + return null; + } + if (val.startsWith("meeting-")) { + return toLong(val.substring("meeting-".length())); + } + return toLong(val); + } + + private Long findMeetingSubmitterUserId(Long meetingId) { + if (meetingId == null || meetingId <= 0) { + return null; + } + List> rows = jdbcTemplate.queryForList( + "SELECT created_by FROM meeting WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + tenantId(), + meetingId + ); + if (rows.isEmpty()) { + return null; + } + return toLong(rows.get(0).get("created_by")); + } + + private Long findMeetingAuditorUserId(Long meetingId) { + if (meetingId == null || meetingId <= 0) { + return null; + } + List> rows = jdbcTemplate.queryForList( + "SELECT current_auditor_user_id FROM meeting WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + tenantId(), + meetingId + ); + if (rows.isEmpty()) { + return null; + } + return toLong(rows.get(0).get("current_auditor_user_id")); + } + + private Long findMeetingFinanceApproverUserId(Long meetingId) { + if (meetingId == null || meetingId <= 0) { + return null; + } + List> rows = jdbcTemplate.queryForList( + "SELECT p.finance_approver_user_id AS finance_user_id " + + "FROM meeting m JOIN project p ON m.tenant_id=p.tenant_id AND m.project_id=p.id " + + "WHERE m.tenant_id=? AND m.id=? AND m.is_deleted=0 AND p.is_deleted=0 LIMIT 1", + tenantId(), + meetingId + ); + if (rows.isEmpty()) { + return null; + } + return toLong(rows.get(0).get("finance_user_id")); + } + + private Long findFirstFinanceRoleUserId() { + List> rows = jdbcTemplate.queryForList( + "SELECT u.id AS user_id " + + "FROM sys_user u " + + "JOIN user_role ur ON u.tenant_id=ur.tenant_id AND u.id=ur.user_id AND ur.is_deleted=0 " + + "JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id AND r.is_deleted=0 " + + "WHERE u.tenant_id=? AND u.is_deleted=0 AND u.status='ENABLED' " + + "AND r.role_code IN ('FINANCE', 'FINANCE_ROLE', 'FINANCE_APPROVER') " + + "ORDER BY u.id ASC LIMIT 1", + tenantId() + ); + if (rows.isEmpty()) { + return null; + } + return toLong(rows.get(0).get("user_id")); + } + + private String normalizeUpper(String val) { + if (val == null) { + return ""; + } + return val.trim().toUpperCase(Locale.ROOT); + } + + private boolean isExplicitReceiverRef(String channel, String value) { + if (value == null || value.trim().isEmpty()) { + return false; + } + String val = value.trim(); + if ("IN_APP".equals(channel)) { + return "ALL".equalsIgnoreCase(val) || val.startsWith("user-") || isDigits(val); + } + if ("EMAIL".equals(channel)) { + return val.contains("@"); + } + if ("SMS".equals(channel)) { + return isPhoneLike(val); + } + return true; + } + + private String normalizeExplicitReceiverRef(String channel, String value) { + String val = value == null ? "" : value.trim(); + if ("IN_APP".equals(channel) && isDigits(val)) { + return "user-" + val; + } + return val; + } + + private boolean isDigits(String val) { + if (val == null || val.isEmpty()) { + return false; + } + for (int i = 0; i < val.length(); i++) { + if (!Character.isDigit(val.charAt(i))) { + return false; + } + } + return true; + } + + private boolean isPhoneLike(String val) { + if (val == null) { + return false; + } + String normalized = val.trim(); + if (normalized.startsWith("+")) { + normalized = normalized.substring(1); + } + if (normalized.length() < 6 || normalized.length() > 20) { + return false; + } + for (int i = 0; i < normalized.length(); i++) { + if (!Character.isDigit(normalized.charAt(i))) { + return false; + } + } + return true; + } + + private Long toLong(Object val) { + if (val == null) { + return null; + } + try { + if (val instanceof Number) { + return ((Number) val).longValue(); + } + String text = String.valueOf(val).trim(); + if (text.isEmpty()) { + return null; + } + return Long.valueOf(text); + } catch (Exception ex) { + return null; + } + } + + private String buildPolicyPayload(DispatchNotificationRequest request, Map policy, Map requestVars, String receiverResolveSource, String receiverResolvedRef) { + Map policyVars = parseVariables(policy.get("variables_json") == null ? null : String.valueOf(policy.get("variables_json"))); + Map merged = new LinkedHashMap(); + merged.putAll(policyVars); + merged.putAll(requestVars); + merged.put("eventCode", request.getEventCode()); + merged.put("bizType", request.getBizType()); + merged.put("bizId", request.getBizId()); + merged.put("receiverResolveSource", receiverResolveSource == null ? "UNKNOWN" : receiverResolveSource); + merged.put("receiverResolvedRef", receiverResolvedRef == null ? "" : receiverResolvedRef); + merged.put("policyId", policy.get("id")); + merged.put("policyName", policy.get("policy_name")); + merged.put("templateId", policy.get("template_id")); + merged.put("templateName", policy.get("template_name")); + + String policyName = policy.get("policy_name") == null ? "系统通知" : String.valueOf(policy.get("policy_name")); + String eventCode = request.getEventCode() == null ? "" : request.getEventCode().trim(); + String defaultTitle = policyName; + if (!eventCode.isEmpty()) { + defaultTitle = policyName + "(" + eventCode + ")"; + } + String defaultContent = "事件[" + eventCode + "]触发通知"; + + String templateSubject = policy.get("subject_template") == null ? null : String.valueOf(policy.get("subject_template")); + String templateTitle = policy.get("title_template") == null ? null : String.valueOf(policy.get("title_template")); + String templateContent = policy.get("content_template") == null ? null : String.valueOf(policy.get("content_template")); + + String overrideSubject = pickText(merged, "subject", "title"); + String overrideTitle = pickText(merged, "title", "subject"); + String overrideContent = pickText(merged, "content", "message"); + + String finalSubject = resolvePlaceholders( + chooseText(overrideSubject, templateSubject, defaultTitle), + merged + ); + String finalTitle = resolvePlaceholders( + chooseText(overrideTitle, templateTitle, defaultTitle), + merged + ); + String finalContent = resolvePlaceholders( + chooseText(overrideContent, templateContent, defaultContent), + merged + ); + + Map payload = new LinkedHashMap(); + payload.putAll(merged); + payload.put("subject", finalSubject); + payload.put("title", finalTitle); + payload.put("content", finalContent); + payload.put("message", finalContent); + try { + return objectMapper.writeValueAsString(payload); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "通知变量格式非法"); + } + } + + private Map parseVariables(String variablesJson) { + if (variablesJson == null || variablesJson.trim().isEmpty()) { + return new LinkedHashMap(); + } + String raw = variablesJson.trim(); + if (!raw.startsWith("{")) { + Map simple = new LinkedHashMap(); + simple.put("content", raw); + return simple; + } + try { + Map map = objectMapper.readValue(raw, new TypeReference>() {}); + if (map == null) { + return new LinkedHashMap(); + } + return new LinkedHashMap(map); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "variablesJson必须为JSON对象"); + } + } + + private String pickText(Map map, String preferredKey, String fallbackKey) { + if (map == null) { + return null; + } + Object preferred = map.get(preferredKey); + if (preferred != null && String.valueOf(preferred).trim().length() > 0) { + return String.valueOf(preferred); + } + Object fallback = map.get(fallbackKey); + if (fallback != null && String.valueOf(fallback).trim().length() > 0) { + return String.valueOf(fallback); + } + return null; + } + + private String chooseText(String first, String second, String fallback) { + if (first != null && first.trim().length() > 0) { + return first; + } + if (second != null && second.trim().length() > 0) { + return second; + } + return fallback; + } + + private String resolvePlaceholders(String template, Map vars) { + if (template == null) { + return ""; + } + Map safeVars = vars == null ? Collections.emptyMap() : vars; + Matcher matcher = PLACEHOLDER_PATTERN.matcher(template); + StringBuffer buffer = new StringBuffer(); + while (matcher.find()) { + String key = matcher.group(1) == null ? "" : matcher.group(1).trim(); + Object val = safeVars.get(key); + String replacement = val == null ? "" : String.valueOf(val); + matcher.appendReplacement(buffer, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(buffer); + return buffer.toString(); + } + + @Transactional(rollbackFor = Exception.class) + public void processTask(String payload) { + Long taskId = parseTaskId(payload); + Map task = findTask(taskId); + String status = String.valueOf(task.get("status")); + if ("SENT".equalsIgnoreCase(status) || "FAILED".equalsIgnoreCase(status) || "DELIVERED".equalsIgnoreCase(status)) { + return; + } + int retryCount = ((Number) task.get("retry_count")).intValue(); + int maxRetry = ((Number) task.get("max_retry")).intValue(); + String channel = String.valueOf(task.get("channel")); + String receiverRef = String.valueOf(task.get("receiver_ref")); + try { + if (receiverRef == null || receiverRef.trim().isEmpty()) { + throw new IllegalStateException("接收人为空"); + } + NotificationDeliveryProtectionService.GuardDecision guardDecision = deliveryProtectionService.checkBeforeSend(channel, receiverRef); + if (!guardDecision.isAllowed()) { + jdbcTemplate.update( + "UPDATE notification_task SET status='FAILED', retry_count=max_retry, error_message=?, receipt_code=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + guardDecision.getMessage(), + guardDecision.getCode(), + safeUserId(), + tenantId(), + taskId + ); + log.warn("notification task blocked by delivery protection, taskId={}, channel={}, receiver={}, code={}", taskId, channel, receiverRef, guardDecision.getCode()); + return; + } + NotificationChannelProvider provider = providerMap.get(channel == null ? "" : channel.toUpperCase()); + if (provider == null) { + throw new IllegalStateException("渠道适配器不存在"); + } + Map sendContext = new LinkedHashMap(); + sendContext.put("taskId", taskId); + sendContext.put("outId", "task-" + taskId); + NotificationSendResult sendResult = provider.send( + receiverRef, + task.get("payload_json") == null ? null : String.valueOf(task.get("payload_json")), + sendContext + ); + if (!sendResult.isAccepted()) { + throw new IllegalStateException("渠道未受理: " + sendResult.getProviderCode()); + } + jdbcTemplate.update( + "UPDATE notification_task SET status='SENT', provider_message_id=?, receipt_code=?, receipt_message=?, error_message=NULL, sent_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + sendResult.getProviderMessageId(), + sendResult.getProviderCode(), + sendResult.getProviderMessage(), + safeUserId(), + tenantId(), + taskId + ); + try { + deliveryProtectionService.recordSuccess(channel, receiverRef); + } catch (Exception guardEx) { + log.warn("notification delivery protection success bookkeeping failed, taskId={}, err={}", taskId, guardEx.getMessage()); + } + } catch (Exception ex) { + try { + deliveryProtectionService.recordFailure(channel, ex.getMessage()); + } catch (Exception guardEx) { + log.warn("notification delivery protection failure bookkeeping failed, taskId={}, err={}", taskId, guardEx.getMessage()); + } + int nextRetry = retryCount + 1; + String nextStatus = nextRetry >= maxRetry ? "FAILED" : "PENDING"; + jdbcTemplate.update( + "UPDATE notification_task SET status=?, retry_count=?, error_message=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + nextStatus, + nextRetry, + ex.getMessage(), + safeUserId(), + tenantId(), + taskId + ); + if ("FAILED".equals(nextStatus)) { + log.warn("notification task exhausted retries, taskId={}, channel={}, receiver={}, err={}", taskId, channel, receiverRef, ex.getMessage()); + return; + } + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "通知发送失败"); + } + } + + @Transactional(rollbackFor = Exception.class) + public Map ingestReceipt(NotificationReceiptRequest request) { + Long tenantId = tenantId(); + return ingestReceiptInternal(request, tenantId, true); + } + + public Map ingestReceiptWebhook(NotificationReceiptRequest request, String timestamp, String signature) { + if (!verifySignature(request, timestamp, signature)) { + throw new BusinessException(ErrorCodes.NO_DATA_PERMISSION, "回执签名校验失败"); + } + return ingestReceiptInternal(request, null, false); + } + + public Map ingestAliyunSmsReceipt(AliyunSmsReceiptRequest request) { + NotificationReceiptRequest receiptRequest = new NotificationReceiptRequest(); + Long taskId = parseTaskIdFromOutId(request == null ? null : request.getOutId()); + if (taskId == null || taskId <= 0) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "阿里云短信回执缺少可识别的任务标识"); + } + receiptRequest.setTaskId(taskId); + receiptRequest.setProviderMessageId(request == null ? null : request.getBizId()); + String errCode = request == null ? "" : String.valueOf(request.getErrCode() == null ? "" : request.getErrCode()).trim(); + boolean delivered = request != null && Boolean.TRUE.equals(request.getSuccess()) && (errCode.isEmpty() || "DELIVERED".equalsIgnoreCase(errCode) || "OK".equalsIgnoreCase(errCode)); + receiptRequest.setDelivered(delivered); + receiptRequest.setReceiptCode(errCode.isEmpty() ? (delivered ? "DELIVERED" : "FAILED") : errCode); + StringBuilder message = new StringBuilder(); + if (request != null && request.getPhoneNumber() != null && request.getPhoneNumber().trim().length() > 0) { + message.append("手机号=").append(request.getPhoneNumber().trim()); + } + if (request != null && request.getReceiveDate() != null && request.getReceiveDate().trim().length() > 0) { + if (message.length() > 0) { + message.append(";"); + } + message.append("回执时间=").append(request.getReceiveDate().trim()); + } + if (request != null && request.getErrMsg() != null && request.getErrMsg().trim().length() > 0) { + if (message.length() > 0) { + message.append(";"); + } + message.append(request.getErrMsg().trim()); + } + receiptRequest.setReceiptMessage(message.toString()); + return ingestReceiptInternal(receiptRequest, null, false); + } + + public Map ingestAliyunSmsReceipts(List requests) { + if (requests == null || requests.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "阿里云短信回执内容不能为空"); + } + int deliveredCount = 0; + int failedCount = 0; + List> items = new ArrayList>(); + for (AliyunSmsReceiptRequest request : requests) { + Map item = ingestAliyunSmsReceipt(request); + items.add(item); + String status = item.get("status") == null ? "" : String.valueOf(item.get("status")); + if ("DELIVERED".equalsIgnoreCase(status)) { + deliveredCount++; + } else if ("FAILED".equalsIgnoreCase(status)) { + failedCount++; + } + } + Map data = new LinkedHashMap(); + data.put("count", items.size()); + data.put("deliveredCount", deliveredCount); + data.put("failedCount", failedCount); + data.put("items", items); + return data; + } + + @Transactional(rollbackFor = Exception.class) + private Map ingestReceiptInternal(NotificationReceiptRequest request, Long tenantIdHint, boolean requireTenantAuth) { + Map task = requireTenantAuth ? findTask(request.getTaskId()) : findTaskAnyTenant(request.getTaskId()); + Long taskTenantId = tenantIdHint != null ? tenantIdHint : toLong(task.get("tenant_id")); + if (taskTenantId == null || taskTenantId <= 0) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "通知任务不存在"); + } + String providerMessageId = task.get("provider_message_id") == null ? "" : String.valueOf(task.get("provider_message_id")); + if (!providerMessageId.equals(request.getProviderMessageId())) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "供应商消息ID不匹配"); + } + boolean delivered = request.getDelivered() != null && request.getDelivered(); + String nextStatus = delivered ? "DELIVERED" : "FAILED"; + jdbcTemplate.update( + "UPDATE notification_task SET status=?, receipt_code=?, receipt_message=?, receipt_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + nextStatus, + request.getReceiptCode(), + request.getReceiptMessage(), + safeUserId(), + taskTenantId, + request.getTaskId() + ); + jdbcTemplate.update( + "INSERT INTO notification_receipt_log (tenant_id, task_id, provider_message_id, receipt_code, receipt_message, receipt_status, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)", + taskTenantId, + request.getTaskId(), + request.getProviderMessageId(), + request.getReceiptCode(), + request.getReceiptMessage(), + nextStatus, + safeUserId() + ); + Map data = new LinkedHashMap(); + data.put("taskId", request.getTaskId()); + data.put("status", nextStatus); + return data; + } + + private Long parseTaskId(String payload) { + try { + Map map = objectMapper.readValue(payload, new TypeReference>() {}); + Object val = map.get("taskId"); + if (val == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "通知任务ID缺失"); + } + return Long.valueOf(String.valueOf(val)); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "通知任务参数非法"); + } + } + + private Map findTask(Long taskId) { + List> list = jdbcTemplate.queryForList( + "SELECT id, tenant_id, status, retry_count, max_retry, channel, receiver_ref, provider_message_id, payload_json FROM notification_task WHERE tenant_id=? AND id=? AND is_deleted=0", + tenantId(), + taskId + ); + if (list.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "通知任务不存在"); + } + return list.get(0); + } + + private Map findTaskAnyTenant(Long taskId) { + List> list = jdbcTemplate.queryForList( + "SELECT id, tenant_id, status, retry_count, max_retry, channel, receiver_ref, provider_message_id, payload_json FROM notification_task WHERE id=? AND is_deleted=0", + taskId + ); + if (list.isEmpty()) { + throw new BusinessException(ErrorCodes.RESOURCE_NOT_FOUND, "通知任务不存在"); + } + return list.get(0); + } + + private Long parseTaskIdFromOutId(String outId) { + if (outId == null || outId.trim().isEmpty()) { + return null; + } + String raw = outId.trim(); + if (raw.startsWith("task-")) { + raw = raw.substring("task-".length()); + } + return toLong(raw); + } + + private boolean verifySignature(NotificationReceiptRequest request, String timestamp, String signature) { + if (timestamp == null || signature == null || signature.trim().isEmpty()) { + return false; + } + String plain = String.valueOf(request.getTaskId()) + "|" + request.getProviderMessageId() + "|" + request.getReceiptCode() + "|" + timestamp; + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(webhookSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + byte[] bytes = mac.doFinal(plain.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + String hex = Integer.toHexString(b & 0xff); + if (hex.length() == 1) { + sb.append('0'); + } + sb.append(hex); + } + return sb.toString().equalsIgnoreCase(signature.trim()); + } catch (Exception ex) { + return false; + } + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private int normalizePageNo(Integer pageNo) { + if (pageNo == null || pageNo < 1) { + return 1; + } + return pageNo; + } + + private int normalizePageSize(Integer pageSize) { + if (pageSize == null || pageSize < 1) { + return 20; + } + return Math.min(pageSize, 200); + } + + private static final class ReceiverTarget { + private final Long userId; + private final String source; + + private ReceiverTarget(Long userId, String source) { + this.userId = userId; + this.source = source; + } + } + + private static final class ReceiverResolution { + private final String receiverRef; + private final String resolveSource; + + private ReceiverResolution(String receiverRef, String resolveSource) { + this.receiverRef = receiverRef; + this.resolveSource = resolveSource; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/service/NotificationGatewayCryptoService.java b/backend/src/main/java/com/writeoff/module/notification/service/NotificationGatewayCryptoService.java new file mode 100644 index 0000000..b590f41 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/service/NotificationGatewayCryptoService.java @@ -0,0 +1,59 @@ +package com.writeoff.module.notification.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; + +@Service +public class NotificationGatewayCryptoService { + private final SecretKeySpec secretKeySpec; + + public NotificationGatewayCryptoService(@Value("${app.notification.gateway-crypto-secret:change-me}") String secret) { + this.secretKeySpec = new SecretKeySpec(deriveKey(secret == null ? "change-me" : secret), "AES"); + } + + public String encrypt(String plainText) { + if (plainText == null || plainText.trim().isEmpty()) { + return ""; + } + try { + Cipher cipher = Cipher.getInstance("AES"); + cipher.init(Cipher.ENCRYPT_MODE, secretKeySpec); + byte[] encrypted = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + return Base64.getEncoder().encodeToString(encrypted); + } catch (Exception ex) { + throw new IllegalStateException("通知网关敏感配置加密失败", ex); + } + } + + public String decrypt(String cipherText) { + if (cipherText == null || cipherText.trim().isEmpty()) { + return ""; + } + try { + Cipher cipher = Cipher.getInstance("AES"); + cipher.init(Cipher.DECRYPT_MODE, secretKeySpec); + byte[] decoded = Base64.getDecoder().decode(cipherText.trim()); + return new String(cipher.doFinal(decoded), StandardCharsets.UTF_8); + } catch (Exception ex) { + throw new IllegalStateException("通知网关敏感配置解密失败", ex); + } + } + + private byte[] deriveKey(String secret) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] bytes = digest.digest(secret.getBytes(StandardCharsets.UTF_8)); + byte[] key = new byte[16]; + System.arraycopy(bytes, 0, key, 0, key.length); + return key; + } catch (Exception ex) { + throw new IllegalStateException("通知网关密钥初始化失败", ex); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/service/NotificationPolicyService.java b/backend/src/main/java/com/writeoff/module/notification/service/NotificationPolicyService.java new file mode 100644 index 0000000..102c18a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/service/NotificationPolicyService.java @@ -0,0 +1,239 @@ +package com.writeoff.module.notification.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.notification.dto.CreateNotificationPolicyRequest; +import com.writeoff.module.notification.dto.UpdateNotificationPolicyRequest; +import com.writeoff.module.notification.model.NotificationPolicyInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class NotificationPolicyService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper POLICY_ROW_MAPPER = (rs, n) -> new NotificationPolicyInfo( + rs.getLong("id"), + rs.getString("policy_name"), + rs.getString("event_code"), + rs.getString("channel"), + rs.getString("receiver_type"), + rs.getLong("template_id"), + rs.getString("variables_json"), + rs.getString("status") + ); + + public NotificationPolicyService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult list(int pageNo, int pageSize) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + int offset = (safePage - 1) * safeSize; + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_policy WHERE tenant_id=? AND is_deleted=0", + Integer.class, + tenantId() + ); + long totalCount = total == null ? 0 : total; + + List list = jdbcTemplate.query( + "SELECT * FROM notification_policy WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC LIMIT ? OFFSET ?", + POLICY_ROW_MAPPER, + tenantId(), + safeSize, + offset + ); + return new PageResult(list, totalCount, safePage, safeSize); + } + + @Transactional(rollbackFor = Exception.class) + public NotificationPolicyInfo create(CreateNotificationPolicyRequest request) { + validateTextTemplateExists(request.getTemplateId()); + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "INSERT INTO notification_policy (tenant_id, policy_name, event_code, channel, receiver_type, template_id, variables_json, status, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tenantId(), + request.getPolicyName(), + request.getEventCode(), + request.getChannel(), + request.getReceiverType(), + request.getTemplateId(), + request.getVariablesJson(), + status, + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM notification_policy WHERE tenant_id=?", Long.class, tenantId()); + Long policyId = id == null ? 0L : id; + upsertPolicyEvent(policyId, request.getEventCode(), status); + return findById(policyId); + } + + @Transactional(rollbackFor = Exception.class) + public NotificationPolicyInfo update(Long id, UpdateNotificationPolicyRequest request) { + assertExists(id); + validateTextTemplateExists(request.getTemplateId()); + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "UPDATE notification_policy SET policy_name=?, event_code=?, channel=?, receiver_type=?, template_id=?, variables_json=?, status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? " + + "WHERE tenant_id=? AND id=?", + request.getPolicyName(), + request.getEventCode(), + request.getChannel(), + request.getReceiverType(), + request.getTemplateId(), + request.getVariablesJson(), + status, + safeUserId(), + tenantId(), + id + ); + upsertPolicyEvent(id, request.getEventCode(), status); + return findById(id); + } + + @Transactional(rollbackFor = Exception.class) + public NotificationPolicyInfo bindEvents(Long id, String eventCode) { + assertExists(id); + String normalizedEventCode = eventCode == null ? "" : eventCode.trim(); + if (normalizedEventCode.isEmpty()) { + throw new BusinessException(10001, "事件编码不能为空"); + } + NotificationPolicyInfo policy = findById(id); + jdbcTemplate.update( + "UPDATE notification_policy SET event_code=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + normalizedEventCode, + safeUserId(), + tenantId(), + id + ); + upsertPolicyEvent(id, normalizedEventCode, policy.getStatus()); + return findById(id); + } + + public NotificationPolicyInfo enable(Long id) { + return updateStatus(id, "ENABLED"); + } + + public NotificationPolicyInfo disable(Long id) { + return updateStatus(id, "DISABLED"); + } + + @Transactional(rollbackFor = Exception.class) + public void softDelete(Long id) { + assertExists(id); + NotificationPolicyInfo policy = findById(id); + if ("ENABLED".equals(policy.getStatus())) { + throw new BusinessException(10001, "请先停用通知策略再删除"); + } + jdbcTemplate.update( + "UPDATE notification_policy SET is_deleted=1, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + safeUserId(), tenantId(), id); + // 同步清除策略事件绑定 + jdbcTemplate.update( + "UPDATE notification_policy_event SET status='DISABLED', updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND policy_id=?", + safeUserId(), tenantId(), id); + } + + private NotificationPolicyInfo updateStatus(Long id, String status) { + assertExists(id); + jdbcTemplate.update( + "UPDATE notification_policy SET status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + status, + safeUserId(), + tenantId(), + id + ); + jdbcTemplate.update( + "UPDATE notification_policy_event SET status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND policy_id=?", + status, + safeUserId(), + tenantId(), + id + ); + return findById(id); + } + + private void upsertPolicyEvent(Long policyId, String eventCode, String status) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_policy_event WHERE tenant_id=? AND policy_id=?", + Integer.class, + tenantId(), + policyId + ); + if (count == null || count == 0) { + jdbcTemplate.update( + "INSERT INTO notification_policy_event (tenant_id, policy_id, event_code, status, created_by, updated_by) VALUES (?, ?, ?, ?, ?, ?)", + tenantId(), policyId, eventCode, status, safeUserId(), safeUserId() + ); + } else { + jdbcTemplate.update( + "UPDATE notification_policy_event SET event_code=?, status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND policy_id=?", + eventCode, status, safeUserId(), tenantId(), policyId + ); + } + } + + private String normalizeStatus(String status) { + String val = status == null ? "ENABLED" : status.trim().toUpperCase(); + if (!"ENABLED".equals(val) && !"DISABLED".equals(val)) { + throw new BusinessException(10001, "状态仅支持 ENABLED/DISABLED"); + } + return val; + } + + private void validateTextTemplateExists(Long templateId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_text_template WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + templateId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "文案模板不存在"); + } + } + + private NotificationPolicyInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT * FROM notification_policy WHERE tenant_id=? AND id=? AND is_deleted=0", + POLICY_ROW_MAPPER, + tenantId(), + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "通知策略不存在"); + } + return list.get(0); + } + + private void assertExists(Long id) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_policy WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + id + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "通知策略不存在"); + } + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/service/NotificationTextTemplateService.java b/backend/src/main/java/com/writeoff/module/notification/service/NotificationTextTemplateService.java new file mode 100644 index 0000000..58992cc --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/service/NotificationTextTemplateService.java @@ -0,0 +1,212 @@ +package com.writeoff.module.notification.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.notification.dto.CreateNotificationTextTemplateRequest; +import com.writeoff.module.notification.dto.UpdateNotificationTextTemplateRequest; +import com.writeoff.module.notification.model.NotificationTextTemplateInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class NotificationTextTemplateService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper ROW_MAPPER = (rs, n) -> new NotificationTextTemplateInfo( + rs.getLong("id"), + rs.getString("template_name"), + rs.getString("subject_template"), + rs.getString("title_template"), + rs.getString("content_template"), + rs.getString("status") + ); + + public NotificationTextTemplateService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult list(int pageNo, int pageSize) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 200); + int offset = (safePage - 1) * safeSize; + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_text_template WHERE tenant_id=? AND is_deleted=0", + Integer.class, + tenantId() + ); + long totalCount = total == null ? 0 : total; + + List list = jdbcTemplate.query( + "SELECT id, template_name, subject_template, title_template, content_template, status " + + "FROM notification_text_template WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC LIMIT ? OFFSET ?", + ROW_MAPPER, + tenantId(), + safeSize, + offset + ); + return new PageResult(list, totalCount, safePage, safeSize); + } + + @Transactional(rollbackFor = Exception.class) + public NotificationTextTemplateInfo create(CreateNotificationTextTemplateRequest request) { + assertTemplateNameUnique(request.getTemplateName().trim(), null); + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "INSERT INTO notification_text_template (tenant_id, template_name, subject_template, title_template, content_template, status, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + tenantId(), + request.getTemplateName().trim(), + trimText(request.getSubjectTemplate()), + trimText(request.getTitleTemplate()), + request.getContentTemplate().trim(), + status, + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM notification_text_template WHERE tenant_id=?", Long.class, tenantId()); + return findById(id == null ? 0L : id); + } + + @Transactional(rollbackFor = Exception.class) + public NotificationTextTemplateInfo update(Long id, UpdateNotificationTextTemplateRequest request) { + assertExists(id); + assertTemplateNameUnique(request.getTemplateName().trim(), id); + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "UPDATE notification_text_template SET template_name=?, subject_template=?, title_template=?, content_template=?, status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? " + + "WHERE tenant_id=? AND id=?", + request.getTemplateName().trim(), + trimText(request.getSubjectTemplate()), + trimText(request.getTitleTemplate()), + request.getContentTemplate().trim(), + status, + safeUserId(), + tenantId(), + id + ); + return findById(id); + } + + public NotificationTextTemplateInfo enable(Long id) { + return updateStatus(id, "ENABLED"); + } + + public NotificationTextTemplateInfo disable(Long id) { + return updateStatus(id, "DISABLED"); + } + + @Transactional(rollbackFor = Exception.class) + public void softDelete(Long id) { + assertExists(id); + NotificationTextTemplateInfo tpl = findById(id); + if ("ENABLED".equals(tpl.getStatus())) { + throw new BusinessException(10001, "请先停用文案模板再删除"); + } + // 检查是否被活跃策略引用 + Integer refCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_policy WHERE tenant_id=? AND template_id=? AND is_deleted=0", + Integer.class, tenantId(), id); + if (refCount != null && refCount > 0) { + throw new BusinessException(10001, "该文案模板仍被通知策略引用,请先调整策略"); + } + jdbcTemplate.update( + "UPDATE notification_text_template SET is_deleted=1, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + safeUserId(), tenantId(), id); + } + + private NotificationTextTemplateInfo updateStatus(Long id, String status) { + assertExists(id); + jdbcTemplate.update( + "UPDATE notification_text_template SET status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + status, + safeUserId(), + tenantId(), + id + ); + return findById(id); + } + + private NotificationTextTemplateInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, template_name, subject_template, title_template, content_template, status " + + "FROM notification_text_template WHERE tenant_id=? AND id=? AND is_deleted=0", + ROW_MAPPER, + tenantId(), + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "通知文案模板不存在"); + } + return list.get(0); + } + + private void assertExists(Long id) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_text_template WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + id + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "通知文案模板不存在"); + } + } + + private String normalizeStatus(String status) { + String val = status == null ? "ENABLED" : status.trim().toUpperCase(); + if (!"ENABLED".equals(val) && !"DISABLED".equals(val)) { + throw new BusinessException(10001, "状态仅支持 ENABLED/DISABLED"); + } + return val; + } + + private void assertTemplateNameUnique(String templateName, Long excludeId) { + String normalized = templateName == null ? "" : templateName.trim(); + if (normalized.length() == 0) { + throw new BusinessException(10001, "文案模板名称不能为空"); + } + Integer count; + if (excludeId == null) { + count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_text_template WHERE tenant_id=? AND template_name=? AND is_deleted=0", + Integer.class, + tenantId(), + normalized + ); + } else { + count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM notification_text_template WHERE tenant_id=? AND template_name=? AND is_deleted=0 AND id<>?", + Integer.class, + tenantId(), + normalized, + excludeId + ); + } + if (count != null && count > 0) { + throw new BusinessException(10001, "文案模板名称已存在,请换一个名称"); + } + } + + private String trimText(String text) { + if (text == null) { + return null; + } + String val = text.trim(); + return val.length() == 0 ? null : val; + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/service/PlatformNotifyGatewayService.java b/backend/src/main/java/com/writeoff/module/notification/service/PlatformNotifyGatewayService.java new file mode 100644 index 0000000..bd68332 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/service/PlatformNotifyGatewayService.java @@ -0,0 +1,314 @@ +package com.writeoff.module.notification.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.notification.dto.SavePlatformNotifyGatewayRequest; +import com.writeoff.module.notification.model.PlatformNotifyGatewayInfo; +import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +public class PlatformNotifyGatewayService { + private final JdbcTemplate jdbcTemplate; + private final NotificationGatewayCryptoService cryptoService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public PlatformNotifyGatewayService(JdbcTemplate jdbcTemplate, NotificationGatewayCryptoService cryptoService) { + this.jdbcTemplate = jdbcTemplate; + this.cryptoService = cryptoService; + } + + public List list() { + List> rows = jdbcTemplate.queryForList( + "SELECT id, channel_code, gateway_name, provider_code, status, config_json, secret_config_cipher, remark, " + + "DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM platform_notify_gateway WHERE is_deleted=0 ORDER BY id ASC" + ); + List list = new ArrayList(); + for (Map row : rows) { + list.add(toInfo(row)); + } + return list; + } + + public PlatformNotifyGatewayInfo save(String channelCode, SavePlatformNotifyGatewayRequest request) { + String normalizedChannel = normalizeChannel(channelCode); + Map existing = findRow(normalizedChannel); + if (existing.isEmpty()) { + throw new BusinessException(10003, "通知网关不存在"); + } + SaveConfigBundle bundle = buildConfigBundle( + normalizedChannel, + request == null ? null : request.getConfig(), + request == null ? null : request.getProviderCode(), + request == null ? null : request.getStatus() + ); + String gatewayName = normalizeText( + request == null ? null : request.getGatewayName(), + normalizedChannel.equals("EMAIL") ? "邮件网关" : "短信网关" + ); + String remark = request == null ? null : normalizeNullableText(request.getRemark()); + jdbcTemplate.update( + "UPDATE platform_notify_gateway SET gateway_name=?, provider_code=?, status=?, config_json=?, secret_config_cipher=?, remark=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE channel_code=? AND is_deleted=0", + gatewayName, + bundle.providerCode, + bundle.status, + toJson(bundle.publicConfig), + cryptoService.encrypt(toJson(bundle.secretConfig)), + remark, + safeUserId(), + normalizedChannel + ); + return toInfo(findRow(normalizedChannel)); + } + + public PlatformNotifyGatewayResolvedConfig resolveChannelConfig(String channelCode, boolean requireEnabled) { + String normalizedChannel = normalizeChannel(channelCode); + Map row = findRow(normalizedChannel); + if (row.isEmpty()) { + return null; + } + String status = String.valueOf(row.get("status")); + if (requireEnabled && !"ENABLED".equalsIgnoreCase(status)) { + return null; + } + Map mergedConfig = mergeConfig(row); + return new PlatformNotifyGatewayResolvedConfig( + toLong(row.get("id")), + normalizedChannel, + String.valueOf(row.get("gateway_name")), + String.valueOf(row.get("provider_code")), + status, + row.get("remark") == null ? "" : String.valueOf(row.get("remark")), + mergedConfig + ); + } + + private PlatformNotifyGatewayInfo toInfo(Map row) { + Map mergedConfig = mergeConfig(row); + return new PlatformNotifyGatewayInfo( + toLong(row.get("id")), + String.valueOf(row.get("channel_code")), + String.valueOf(row.get("gateway_name")), + String.valueOf(row.get("provider_code")), + String.valueOf(row.get("status")), + row.get("remark") == null ? "" : String.valueOf(row.get("remark")), + isConfigured(String.valueOf(row.get("channel_code")), mergedConfig), + row.get("updated_at") == null ? "" : String.valueOf(row.get("updated_at")), + mergedConfig + ); + } + + private SaveConfigBundle buildConfigBundle(String channelCode, Map input, String providerCodeRaw, String statusRaw) { + Map safeInput = input == null ? new LinkedHashMap() : input; + String status = normalizeStatus(statusRaw); + if ("EMAIL".equals(channelCode)) { + String providerCode = normalizeText(providerCodeRaw, "SMTP").toUpperCase(Locale.ROOT); + Map publicConfig = new LinkedHashMap(); + Map secretConfig = new LinkedHashMap(); + publicConfig.put("host", normalizeNullableText(safeInput.get("host"))); + publicConfig.put("port", normalizeInt(safeInput.get("port"), 587)); + publicConfig.put("protocol", normalizeText(safeInput.get("protocol"), "smtp")); + publicConfig.put("fromAddress", normalizeNullableText(safeInput.get("fromAddress"))); + publicConfig.put("defaultSubject", normalizeText(safeInput.get("defaultSubject"), "系统通知")); + publicConfig.put("smtpAuth", normalizeBoolean(safeInput.get("smtpAuth"), true)); + publicConfig.put("starttlsEnable", normalizeBoolean(safeInput.get("starttlsEnable"), true)); + publicConfig.put("starttlsRequired", normalizeBoolean(safeInput.get("starttlsRequired"), false)); + publicConfig.put("sslEnable", normalizeBoolean(safeInput.get("sslEnable"), false)); + publicConfig.put("connectTimeoutMs", normalizeInt(safeInput.get("connectTimeoutMs"), 5000)); + publicConfig.put("timeoutMs", normalizeInt(safeInput.get("timeoutMs"), 5000)); + publicConfig.put("writeTimeoutMs", normalizeInt(safeInput.get("writeTimeoutMs"), 5000)); + publicConfig.put("failureThreshold", normalizeInt(safeInput.get("failureThreshold"), 3)); + publicConfig.put("breakerCooldownSeconds", normalizeInt(safeInput.get("breakerCooldownSeconds"), 300)); + secretConfig.put("username", normalizeNullableText(safeInput.get("username"))); + secretConfig.put("password", normalizeNullableText(safeInput.get("password"))); + if ("ENABLED".equals(status)) { + assertRequired(publicConfig.get("host"), "SMTP 主机不能为空"); + assertRequired(publicConfig.get("fromAddress"), "发件邮箱不能为空"); + if (Boolean.TRUE.equals(publicConfig.get("smtpAuth"))) { + assertRequired(secretConfig.get("username"), "SMTP 用户名不能为空"); + assertRequired(secretConfig.get("password"), "SMTP 密码不能为空"); + } + } + return new SaveConfigBundle(providerCode, status, publicConfig, secretConfig); + } + + if ("SMS".equals(channelCode)) { + String providerCode = normalizeText(providerCodeRaw, "MOCK").toUpperCase(Locale.ROOT); + Map publicConfig = new LinkedHashMap(); + Map secretConfig = new LinkedHashMap(); + boolean mockEnabled = normalizeBoolean(safeInput.get("mockEnabled"), "MOCK".equals(providerCode)); + publicConfig.put("endpoint", normalizeNullableText(safeInput.get("endpoint"))); + publicConfig.put("signName", normalizeNullableText(safeInput.get("signName"))); + publicConfig.put("templateCode", normalizeNullableText(safeInput.get("templateCode"))); + publicConfig.put("regionId", normalizeText(safeInput.get("regionId"), "cn-hangzhou")); + publicConfig.put("mockEnabled", mockEnabled); + publicConfig.put("quietPeriodSeconds", normalizeInt(safeInput.get("quietPeriodSeconds"), 30)); + publicConfig.put("dailyLimit", normalizeInt(safeInput.get("dailyLimit"), 10)); + publicConfig.put("failureThreshold", normalizeInt(safeInput.get("failureThreshold"), 3)); + publicConfig.put("breakerCooldownSeconds", normalizeInt(safeInput.get("breakerCooldownSeconds"), 300)); + secretConfig.put("accessKeyId", normalizeNullableText(safeInput.get("accessKeyId"))); + secretConfig.put("accessKeySecret", normalizeNullableText(safeInput.get("accessKeySecret"))); + if ("ENABLED".equals(status) && !mockEnabled) { + assertRequired(publicConfig.get("endpoint"), "短信服务地址不能为空"); + assertRequired(publicConfig.get("signName"), "短信签名不能为空"); + assertRequired(secretConfig.get("accessKeyId"), "短信 AccessKeyId 不能为空"); + assertRequired(secretConfig.get("accessKeySecret"), "短信 AccessKeySecret 不能为空"); + } + return new SaveConfigBundle(providerCode, status, publicConfig, secretConfig); + } + + throw new BusinessException(10001, "暂不支持的通知网关渠道"); + } + + private Map mergeConfig(Map row) { + Map merged = new LinkedHashMap(); + merged.putAll(parseJsonObject(row.get("config_json") == null ? null : String.valueOf(row.get("config_json")))); + String secretJson = cryptoService.decrypt(row.get("secret_config_cipher") == null ? null : String.valueOf(row.get("secret_config_cipher"))); + merged.putAll(parseJsonObject(secretJson)); + return merged; + } + + private Map findRow(String channelCode) { + List> rows = jdbcTemplate.queryForList( + "SELECT id, channel_code, gateway_name, provider_code, status, config_json, secret_config_cipher, remark, " + + "DATE_FORMAT(updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM platform_notify_gateway WHERE channel_code=? AND is_deleted=0 LIMIT 1", + channelCode + ); + return rows.isEmpty() ? new LinkedHashMap() : rows.get(0); + } + + private Map parseJsonObject(String json) { + if (json == null || json.trim().isEmpty()) { + return new LinkedHashMap(); + } + try { + Map map = objectMapper.readValue(json, new TypeReference>() {}); + return map == null ? new LinkedHashMap() : new LinkedHashMap(map); + } catch (Exception ex) { + throw new BusinessException(10001, "通知网关配置格式非法"); + } + } + + private String toJson(Map map) { + try { + return objectMapper.writeValueAsString(map == null ? new LinkedHashMap() : map); + } catch (Exception ex) { + throw new BusinessException(10001, "通知网关配置序列化失败"); + } + } + + private boolean isConfigured(String channelCode, Map config) { + if ("EMAIL".equals(channelCode)) { + return !normalizeNullableText(config.get("host")).isEmpty() && !normalizeNullableText(config.get("fromAddress")).isEmpty(); + } + if ("SMS".equals(channelCode)) { + return normalizeBoolean(config.get("mockEnabled"), false) || !normalizeNullableText(config.get("endpoint")).isEmpty(); + } + return !config.isEmpty(); + } + + private void assertRequired(Object value, String message) { + if (normalizeNullableText(value).isEmpty()) { + throw new BusinessException(10001, message); + } + } + + private String normalizeChannel(String channelCode) { + String value = normalizeText(channelCode, "").toUpperCase(Locale.ROOT); + if (!"EMAIL".equals(value) && !"SMS".equals(value)) { + throw new BusinessException(10001, "通知网关渠道仅支持 EMAIL 或 SMS"); + } + return value; + } + + private String normalizeStatus(String status) { + String value = normalizeText(status, "DISABLED").toUpperCase(Locale.ROOT); + if (!"ENABLED".equals(value) && !"DISABLED".equals(value)) { + throw new BusinessException(10001, "网关状态仅支持 ENABLED 或 DISABLED"); + } + return value; + } + + private String normalizeText(Object value, String defaultValue) { + String text = normalizeNullableText(value); + return text.isEmpty() ? defaultValue : text; + } + + private String normalizeNullableText(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private Integer normalizeInt(Object value, int defaultValue) { + if (value == null) { + return defaultValue; + } + try { + int parsed = value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(String.valueOf(value).trim()); + return parsed <= 0 ? defaultValue : parsed; + } catch (Exception ex) { + return defaultValue; + } + } + + private Boolean normalizeBoolean(Object value, boolean defaultValue) { + if (value == null) { + return defaultValue; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + String text = String.valueOf(value).trim(); + if ("true".equalsIgnoreCase(text) || "1".equals(text) || "Y".equalsIgnoreCase(text)) { + return true; + } + if ("false".equalsIgnoreCase(text) || "0".equals(text) || "N".equalsIgnoreCase(text)) { + return false; + } + return defaultValue; + } + + private Long toLong(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + return Long.valueOf(String.valueOf(value).trim()); + } catch (Exception ex) { + return null; + } + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private static final class SaveConfigBundle { + private final String providerCode; + private final String status; + private final Map publicConfig; + private final Map secretConfig; + + private SaveConfigBundle(String providerCode, String status, Map publicConfig, Map secretConfig) { + this.providerCode = providerCode; + this.status = status; + this.publicConfig = publicConfig; + this.secretConfig = secretConfig; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/service/PlatformNotifyGatewayTestService.java b/backend/src/main/java/com/writeoff/module/notification/service/PlatformNotifyGatewayTestService.java new file mode 100644 index 0000000..9a41548 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/service/PlatformNotifyGatewayTestService.java @@ -0,0 +1,212 @@ +package com.writeoff.module.notification.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.notification.dto.TestPlatformNotifyGatewayRequest; +import com.writeoff.module.notification.model.PlatformNotifyGatewayResolvedConfig; +import com.writeoff.module.notification.provider.SmsNotificationProvider; +import com.writeoff.module.notification.provider.NotificationSendResult; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSenderImpl; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Properties; + +@Service +public class PlatformNotifyGatewayTestService { + private final PlatformNotifyGatewayService gatewayService; + private final SmsNotificationProvider smsNotificationProvider; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public PlatformNotifyGatewayTestService(PlatformNotifyGatewayService gatewayService, + SmsNotificationProvider smsNotificationProvider) { + this.gatewayService = gatewayService; + this.smsNotificationProvider = smsNotificationProvider; + } + + public Map test(String channelCode, TestPlatformNotifyGatewayRequest request) { + PlatformNotifyGatewayResolvedConfig gateway = gatewayService.resolveChannelConfig(channelCode, false); + if (gateway == null) { + throw new BusinessException(10003, "通知网关不存在"); + } + if ("EMAIL".equalsIgnoreCase(gateway.getChannelCode())) { + return testEmail(gateway, request); + } + if ("SMS".equalsIgnoreCase(gateway.getChannelCode())) { + return testSms(gateway, request); + } + throw new BusinessException(10001, "暂不支持的通知网关测试渠道"); + } + + private Map testEmail(PlatformNotifyGatewayResolvedConfig gateway, TestPlatformNotifyGatewayRequest request) { + Map config = gateway.getConfig(); + String host = text(config.get("host")); + String fromAddress = text(config.get("fromAddress")); + boolean smtpAuth = boolValue(config.get("smtpAuth"), true); + String username = text(config.get("username")); + String password = text(config.get("password")); + if (host.isEmpty()) { + throw new BusinessException(10001, "SMTP 主机不能为空"); + } + if (fromAddress.isEmpty()) { + throw new BusinessException(10001, "发件邮箱不能为空"); + } + if (smtpAuth) { + if (username.isEmpty()) { + throw new BusinessException(10001, "SMTP 用户名不能为空"); + } + if (password.isEmpty()) { + throw new BusinessException(10001, "SMTP 密码不能为空"); + } + } + try { + JavaMailSenderImpl sender = new JavaMailSenderImpl(); + sender.setHost(host); + sender.setPort(intValue(config.get("port"), 587)); + sender.setProtocol(textOr(config.get("protocol"), "smtp")); + sender.setUsername(username); + sender.setPassword(password); + Properties props = sender.getJavaMailProperties(); + props.put("mail.smtp.auth", String.valueOf(smtpAuth)); + props.put("mail.smtp.starttls.enable", String.valueOf(boolValue(config.get("starttlsEnable"), true))); + props.put("mail.smtp.starttls.required", String.valueOf(boolValue(config.get("starttlsRequired"), false))); + props.put("mail.smtp.ssl.enable", String.valueOf(boolValue(config.get("sslEnable"), false))); + props.put("mail.smtp.connectiontimeout", String.valueOf(intValue(config.get("connectTimeoutMs"), 5000))); + props.put("mail.smtp.timeout", String.valueOf(intValue(config.get("timeoutMs"), 5000))); + props.put("mail.smtp.writetimeout", String.valueOf(intValue(config.get("writeTimeoutMs"), 5000))); + + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(fromAddress); + message.setTo(request.getReceiverRef().trim()); + message.setSubject(textOr(request.getSubject(), "通知网关测试")); + message.setText(textOr(request.getContent(), "这是一封来自平台通知网关配置中心的测试邮件。")); + sender.send(message); + + Map data = new LinkedHashMap(); + data.put("channelCode", gateway.getChannelCode()); + data.put("providerCode", gateway.getProviderCode()); + data.put("receiverRef", request.getReceiverRef().trim()); + data.put("accepted", Boolean.TRUE); + data.put("message", "测试邮件发送成功"); + return data; + } catch (Exception ex) { + throw new BusinessException(10001, "测试邮件发送失败: " + ex.getMessage()); + } + } + + private Map testSms(PlatformNotifyGatewayResolvedConfig gateway, TestPlatformNotifyGatewayRequest request) { + Map config = gateway.getConfig(); + boolean mockEnabled = boolValue(config.get("mockEnabled"), "MOCK".equalsIgnoreCase(gateway.getProviderCode())); + if (!mockEnabled) { + if (text(config.get("endpoint")).isEmpty()) { + throw new BusinessException(10001, "短信服务地址不能为空"); + } + if (text(config.get("signName")).isEmpty()) { + throw new BusinessException(10001, "短信签名不能为空"); + } + if (text(config.get("accessKeyId")).isEmpty()) { + throw new BusinessException(10001, "短信 AccessKeyId 不能为空"); + } + if (text(config.get("accessKeySecret")).isEmpty()) { + throw new BusinessException(10001, "短信 AccessKeySecret 不能为空"); + } + } + if (!isPhoneLike(request.getReceiverRef())) { + throw new BusinessException(10001, "短信测试接收目标必须为手机号"); + } + Map payload = new LinkedHashMap(); + payload.put("subject", textOr(request.getSubject(), "通知网关测试")); + payload.put("content", textOr(request.getContent(), "这是一条来自平台通知网关配置中心的测试短信。")); + payload.put("signName", text(config.get("signName"))); + payload.put("templateCode", text(config.get("templateCode"))); + try { + if ("ALIYUN_SMS".equalsIgnoreCase(gateway.getProviderCode()) && !mockEnabled) { + Map sendContext = new LinkedHashMap(); + sendContext.put("outId", "test-" + System.currentTimeMillis()); + NotificationSendResult result = smsNotificationProvider.send( + request.getReceiverRef().trim(), + objectMapper.writeValueAsString(payload), + sendContext + ); + if (result == null || !result.isAccepted()) { + throw new BusinessException(10001, result == null ? "测试短信发送失败" : result.getProviderMessage()); + } + Map data = new LinkedHashMap(); + data.put("channelCode", gateway.getChannelCode()); + data.put("providerCode", gateway.getProviderCode()); + data.put("receiverRef", request.getReceiverRef().trim()); + data.put("accepted", Boolean.TRUE); + data.put("providerMessageId", result.getProviderMessageId()); + data.put("providerResultCode", result.getProviderCode()); + data.put("message", result.getProviderMessage()); + return data; + } + Map data = new LinkedHashMap(); + data.put("channelCode", gateway.getChannelCode()); + data.put("providerCode", gateway.getProviderCode()); + data.put("receiverRef", request.getReceiverRef().trim()); + data.put("accepted", Boolean.TRUE); + data.put("mockEnabled", mockEnabled); + data.put("payloadJson", objectMapper.writeValueAsString(payload)); + data.put("message", mockEnabled ? "测试短信已模拟受理" : "测试短信参数校验通过,当前版本按模拟模式返回受理"); + return data; + } catch (Exception ex) { + throw new BusinessException(10001, "测试短信构造失败"); + } + } + + private boolean isPhoneLike(String value) { + String normalized = text(value); + if (normalized.startsWith("+")) { + normalized = normalized.substring(1); + } + if (normalized.length() < 6 || normalized.length() > 20) { + return false; + } + for (int i = 0; i < normalized.length(); i++) { + if (!Character.isDigit(normalized.charAt(i))) { + return false; + } + } + return true; + } + + private String text(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private String textOr(Object value, String fallback) { + String text = text(value); + return text.isEmpty() ? fallback : text; + } + + private int intValue(Object value, int fallback) { + if (value == null) { + return fallback; + } + try { + return value instanceof Number ? ((Number) value).intValue() : Integer.parseInt(String.valueOf(value).trim()); + } catch (Exception ex) { + return fallback; + } + } + + private boolean boolValue(Object value, boolean fallback) { + if (value == null) { + return fallback; + } + if (value instanceof Boolean) { + return (Boolean) value; + } + String text = String.valueOf(value).trim(); + if ("true".equalsIgnoreCase(text) || "1".equals(text)) { + return true; + } + if ("false".equalsIgnoreCase(text) || "0".equals(text)) { + return false; + } + return fallback; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketConfig.java b/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketConfig.java new file mode 100644 index 0000000..64ba1c5 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketConfig.java @@ -0,0 +1,22 @@ +package com.writeoff.module.notification.ws; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.socket.config.annotation.EnableWebSocket; +import org.springframework.web.socket.config.annotation.WebSocketConfigurer; +import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; + +@Configuration +@EnableWebSocket +public class NotificationWebSocketConfig implements WebSocketConfigurer { + private final NotificationWebSocketHandler notificationWebSocketHandler; + + public NotificationWebSocketConfig(NotificationWebSocketHandler notificationWebSocketHandler) { + this.notificationWebSocketHandler = notificationWebSocketHandler; + } + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(notificationWebSocketHandler, "/ws/notifications") + .setAllowedOrigins("*"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketHandler.java b/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketHandler.java new file mode 100644 index 0000000..346d082 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketHandler.java @@ -0,0 +1,100 @@ +package com.writeoff.module.notification.ws; + +import com.writeoff.security.AuthScope; +import com.writeoff.security.JwtTokenService; +import io.jsonwebtoken.Claims; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; + +import java.net.URI; +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +public class NotificationWebSocketHandler extends TextWebSocketHandler { + private final JwtTokenService jwtTokenService; + private final NotificationWebSocketPushService pushService; + + public NotificationWebSocketHandler(JwtTokenService jwtTokenService, NotificationWebSocketPushService pushService) { + this.jwtTokenService = jwtTokenService; + this.pushService = pushService; + } + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + String token = resolveToken(session); + if (token == null || token.trim().isEmpty()) { + session.close(CloseStatus.POLICY_VIOLATION); + return; + } + try { + Claims claims = jwtTokenService.parse(token.trim()); + AuthScope scope = AuthScope.fromClaim(claims.get("scope", String.class)); + if (scope != AuthScope.TENANT) { + session.close(CloseStatus.POLICY_VIOLATION); + return; + } + Number uid = claims.get("uid", Number.class); + Number tid = claims.get("tid", Number.class); + if (uid == null || tid == null) { + session.close(CloseStatus.POLICY_VIOLATION); + return; + } + Long userId = uid.longValue(); + Long tenantId = tid.longValue(); + session.getAttributes().put("uid", userId); + session.getAttributes().put("tid", tenantId); + pushService.register(session, tenantId, userId); + Map hello = new LinkedHashMap(); + hello.put("type", "WS_CONNECTED"); + hello.put("tenantId", tenantId); + hello.put("userId", userId); + session.sendMessage(new TextMessage(new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(hello))); + } catch (Exception ex) { + session.close(CloseStatus.POLICY_VIOLATION); + } + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) { + // no-op: server push only + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) { + pushService.unregister(session); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + pushService.unregister(session); + } + + private String resolveToken(WebSocketSession session) { + URI uri = session.getUri(); + if (uri == null || uri.getQuery() == null) { + return null; + } + String[] parts = uri.getQuery().split("&"); + for (String item : parts) { + int idx = item.indexOf('='); + if (idx <= 0) { + continue; + } + String key = item.substring(0, idx); + if (!"token".equals(key)) { + continue; + } + String val = item.substring(idx + 1); + try { + return java.net.URLDecoder.decode(val, "UTF-8"); + } catch (Exception ex) { + return val; + } + } + return null; + } +} diff --git a/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketPushService.java b/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketPushService.java new file mode 100644 index 0000000..2f44fa3 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/notification/ws/NotificationWebSocketPushService.java @@ -0,0 +1,155 @@ +package com.writeoff.module.notification.ws; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; + +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArraySet; + +@Service +public class NotificationWebSocketPushService { + private final JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + private final Map> userSessions = new ConcurrentHashMap>(); + private final Map> tenantSessions = new ConcurrentHashMap>(); + private final Map sessionUserKey = new ConcurrentHashMap(); + private final Map sessionTenantKey = new ConcurrentHashMap(); + + public NotificationWebSocketPushService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public void register(WebSocketSession session, Long tenantId, Long userId) { + if (session == null || tenantId == null || userId == null) { + return; + } + String userKey = userKey(tenantId, userId); + userSessions.computeIfAbsent(userKey, k -> new CopyOnWriteArraySet()).add(session); + tenantSessions.computeIfAbsent(tenantId, k -> new CopyOnWriteArraySet()).add(session); + sessionUserKey.put(session.getId(), userKey); + sessionTenantKey.put(session.getId(), tenantId); + } + + public void unregister(WebSocketSession session) { + if (session == null) { + return; + } + String sid = session.getId(); + String userKey = sessionUserKey.remove(sid); + Long tenantId = sessionTenantKey.remove(sid); + if (userKey != null) { + Set set = userSessions.get(userKey); + if (set != null) { + set.remove(session); + if (set.isEmpty()) { + userSessions.remove(userKey); + } + } + } + if (tenantId != null) { + Set set = tenantSessions.get(tenantId); + if (set != null) { + set.remove(session); + if (set.isEmpty()) { + tenantSessions.remove(tenantId); + } + } + } + } + + public void pushInAppNotification(Long tenantId, String receiverRef, Long receiverUserId) { + if (tenantId == null || tenantId <= 0) { + return; + } + Long targetUserId = receiverUserId; + if (targetUserId == null && receiverRef != null) { + String ref = receiverRef.trim(); + if (ref.startsWith("user-")) { + try { + targetUserId = Long.valueOf(ref.substring("user-".length())); + } catch (Exception ignored) { + } + } else { + try { + targetUserId = Long.valueOf(ref); + } catch (Exception ignored) { + } + } + } + if ("ALL".equalsIgnoreCase(receiverRef == null ? "" : receiverRef.trim())) { + pushToTenant(tenantId); + return; + } + if (targetUserId != null && targetUserId > 0) { + pushToUser(tenantId, targetUserId); + return; + } + pushToTenant(tenantId); + } + + private void pushToUser(Long tenantId, Long userId) { + String key = userKey(tenantId, userId); + Set sessions = userSessions.get(key); + if (sessions == null || sessions.isEmpty()) { + return; + } + Map data = new LinkedHashMap(); + data.put("type", "IN_APP_UNREAD_CHANGED"); + data.put("tenantId", tenantId); + data.put("userId", userId); + data.put("unreadCount", countUnread(tenantId, userId)); + broadcast(sessions, data); + } + + private void pushToTenant(Long tenantId) { + Set sessions = tenantSessions.get(tenantId); + if (sessions == null || sessions.isEmpty()) { + return; + } + Map data = new LinkedHashMap(); + data.put("type", "IN_APP_NOTIFICATION_NEW"); + data.put("tenantId", tenantId); + broadcast(sessions, data); + } + + private int countUnread(Long tenantId, Long userId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM in_app_notification " + + "WHERE tenant_id=? AND is_deleted=0 AND status='UNREAD' AND (receiver_ref='ALL' OR receiver_ref=? OR receiver_user_id=?)", + Integer.class, + tenantId, + "user-" + userId, + userId + ); + return count == null ? 0 : count; + } + + private void broadcast(Set sessions, Map payload) { + String message; + try { + message = objectMapper.writeValueAsString(payload); + } catch (Exception ex) { + return; + } + for (WebSocketSession session : sessions) { + if (session == null || !session.isOpen()) { + continue; + } + try { + session.sendMessage(new TextMessage(message)); + } catch (IOException ignored) { + } + } + } + + private String userKey(Long tenantId, Long userId) { + return String.valueOf(tenantId) + ":" + String.valueOf(userId); + } +} diff --git a/backend/src/main/java/com/writeoff/module/observability/controller/ObservabilityController.java b/backend/src/main/java/com/writeoff/module/observability/controller/ObservabilityController.java new file mode 100644 index 0000000..124de56 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/observability/controller/ObservabilityController.java @@ -0,0 +1,76 @@ +package com.writeoff.module.observability.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.observability.dto.CreateAlertRuleRequest; +import com.writeoff.module.observability.dto.UpdateAlertRuleRequest; +import com.writeoff.module.observability.model.AlertEventInfo; +import com.writeoff.module.observability.model.AlertRuleInfo; +import com.writeoff.module.observability.model.ObservabilityMetricPoint; +import com.writeoff.module.observability.service.ObservabilityService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/observability") +public class ObservabilityController { + private final ObservabilityService observabilityService; + + public ObservabilityController(ObservabilityService observabilityService) { + this.observabilityService = observabilityService; + } + + @GetMapping("/metrics") + @RequirePermission(value = "observability.read", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OBSERVABILITY_METRICS") + public ApiResponse> metrics(@RequestParam("metricCode") String metricCode, + @RequestParam(value = "minutes", required = false, defaultValue = "60") int minutes) { + return ApiResponse.success(observabilityService.queryMetric(metricCode, minutes)); + } + + @GetMapping("/metrics/export") + @RequirePermission(value = "observability.read", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OBSERVABILITY_METRICS_EXPORT") + public ApiResponse> exportMetrics(@RequestParam(value = "minutes", required = false, defaultValue = "60") int minutes) { + return ApiResponse.success(observabilityService.exportMetricSummary(minutes)); + } + + @GetMapping("/alert-rules") + @RequirePermission(value = "observability.read", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OBSERVABILITY_RULE_LIST") + public ApiResponse> rules() { + return ApiResponse.success(observabilityService.listRules()); + } + + @PostMapping("/alert-rules") + @RequirePermission(value = "observability.manage", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OBSERVABILITY_RULE_CREATE") + public ApiResponse createRule(@RequestBody @Valid CreateAlertRuleRequest request) { + return ApiResponse.success(observabilityService.createRule(request)); + } + + @PutMapping("/alert-rules/{id}") + @RequirePermission(value = "observability.manage", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OBSERVABILITY_RULE_UPDATE") + public ApiResponse updateRule(@PathVariable("id") Long id, + @RequestBody @Valid UpdateAlertRuleRequest request) { + return ApiResponse.success(observabilityService.updateRule(id, request)); + } + + @PostMapping("/alert-rules/evaluate") + @RequirePermission(value = "observability.manage", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OBSERVABILITY_RULE_EVALUATE") + public ApiResponse> evaluateRules() { + return ApiResponse.success(observabilityService.evaluateRules()); + } + + @PostMapping("/alert-rules/evaluate/auto") + @RequirePermission(value = "observability.manage", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OBSERVABILITY_RULE_EVALUATE_AUTO") + public ApiResponse> evaluateRulesAuto(@RequestParam(value = "recoveryWindowMinute", required = false, defaultValue = "10") int recoveryWindowMinute) { + return ApiResponse.success(observabilityService.evaluateRulesAuto(recoveryWindowMinute)); + } + + @GetMapping("/alert-events") + @RequirePermission(value = "observability.read", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OBSERVABILITY_EVENT_LIST") + public ApiResponse> events() { + return ApiResponse.success(observabilityService.listEvents()); + } +} diff --git a/backend/src/main/java/com/writeoff/module/observability/dto/CreateAlertRuleRequest.java b/backend/src/main/java/com/writeoff/module/observability/dto/CreateAlertRuleRequest.java new file mode 100644 index 0000000..a3c05fa --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/observability/dto/CreateAlertRuleRequest.java @@ -0,0 +1,75 @@ +package com.writeoff.module.observability.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class CreateAlertRuleRequest { + @NotBlank(message = "规则编码不能为空") + private String ruleCode; + @NotBlank(message = "规则名称不能为空") + private String ruleName; + @NotBlank(message = "比较符不能为空") + private String compareOp; + @NotNull(message = "阈值不能为空") + private Double thresholdValue; + @NotNull(message = "窗口分钟不能为空") + private Integer windowMinute; + private Integer suppressWindowMinute; + private String status; + + public String getRuleCode() { + return ruleCode; + } + + public void setRuleCode(String ruleCode) { + this.ruleCode = ruleCode; + } + + public String getRuleName() { + return ruleName; + } + + public void setRuleName(String ruleName) { + this.ruleName = ruleName; + } + + public String getCompareOp() { + return compareOp; + } + + public void setCompareOp(String compareOp) { + this.compareOp = compareOp; + } + + public Double getThresholdValue() { + return thresholdValue; + } + + public void setThresholdValue(Double thresholdValue) { + this.thresholdValue = thresholdValue; + } + + public Integer getWindowMinute() { + return windowMinute; + } + + public void setWindowMinute(Integer windowMinute) { + this.windowMinute = windowMinute; + } + + public Integer getSuppressWindowMinute() { + return suppressWindowMinute; + } + + public void setSuppressWindowMinute(Integer suppressWindowMinute) { + this.suppressWindowMinute = suppressWindowMinute; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/observability/dto/UpdateAlertRuleRequest.java b/backend/src/main/java/com/writeoff/module/observability/dto/UpdateAlertRuleRequest.java new file mode 100644 index 0000000..28bd921 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/observability/dto/UpdateAlertRuleRequest.java @@ -0,0 +1,4 @@ +package com.writeoff.module.observability.dto; + +public class UpdateAlertRuleRequest extends CreateAlertRuleRequest { +} diff --git a/backend/src/main/java/com/writeoff/module/observability/job/AlertRuleAutoEvaluateJob.java b/backend/src/main/java/com/writeoff/module/observability/job/AlertRuleAutoEvaluateJob.java new file mode 100644 index 0000000..0c05a5a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/observability/job/AlertRuleAutoEvaluateJob.java @@ -0,0 +1,47 @@ +package com.writeoff.module.observability.job; + +import com.writeoff.module.observability.service.ObservabilityService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import java.util.List; + +@Component +public class AlertRuleAutoEvaluateJob { + private final ObservabilityService observabilityService; + private final JdbcTemplate jdbcTemplate; + private final boolean enabled; + private final int recoveryWindowMinute; + + public AlertRuleAutoEvaluateJob(ObservabilityService observabilityService, + JdbcTemplate jdbcTemplate, + @Value("${app.observability.auto-evaluate.enabled:true}") boolean enabled, + @Value("${app.observability.recovery-window-minute:10}") int recoveryWindowMinute) { + this.observabilityService = observabilityService; + this.jdbcTemplate = jdbcTemplate; + this.enabled = enabled; + this.recoveryWindowMinute = recoveryWindowMinute; + } + + @Scheduled(fixedDelayString = "${app.observability.auto-evaluate.interval-ms:60000}") + public void run() { + if (!enabled) { + return; + } + List tenantIds = jdbcTemplate.queryForList( + "SELECT id FROM tenant WHERE is_deleted=0 AND status='ENABLED' ORDER BY id ASC", + Long.class + ); + for (Long tenantId : tenantIds) { + try { + AuthContext.set(0L, tenantId, AuthScope.TENANT); + observabilityService.evaluateRulesAuto(recoveryWindowMinute); + } finally { + AuthContext.clear(); + } + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/observability/model/AlertEventInfo.java b/backend/src/main/java/com/writeoff/module/observability/model/AlertEventInfo.java new file mode 100644 index 0000000..de6913b --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/observability/model/AlertEventInfo.java @@ -0,0 +1,61 @@ +package com.writeoff.module.observability.model; + +public class AlertEventInfo { + private Long id; + private String ruleCode; + private String metricCode; + private Double currentValue; + private Double thresholdValue; + private String alertLevel; + private String status; + private String createdAt; + private String recoveredAt; + + public AlertEventInfo(Long id, String ruleCode, String metricCode, Double currentValue, Double thresholdValue, String alertLevel, String status, String createdAt, String recoveredAt) { + this.id = id; + this.ruleCode = ruleCode; + this.metricCode = metricCode; + this.currentValue = currentValue; + this.thresholdValue = thresholdValue; + this.alertLevel = alertLevel; + this.status = status; + this.createdAt = createdAt; + this.recoveredAt = recoveredAt; + } + + public Long getId() { + return id; + } + + public String getRuleCode() { + return ruleCode; + } + + public String getMetricCode() { + return metricCode; + } + + public Double getCurrentValue() { + return currentValue; + } + + public Double getThresholdValue() { + return thresholdValue; + } + + public String getAlertLevel() { + return alertLevel; + } + + public String getStatus() { + return status; + } + + public String getCreatedAt() { + return createdAt; + } + + public String getRecoveredAt() { + return recoveredAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/observability/model/AlertRuleInfo.java b/backend/src/main/java/com/writeoff/module/observability/model/AlertRuleInfo.java new file mode 100644 index 0000000..321cf08 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/observability/model/AlertRuleInfo.java @@ -0,0 +1,55 @@ +package com.writeoff.module.observability.model; + +public class AlertRuleInfo { + private Long id; + private String ruleCode; + private String ruleName; + private String compareOp; + private Double thresholdValue; + private Integer windowMinute; + private Integer suppressWindowMinute; + private String status; + + public AlertRuleInfo(Long id, String ruleCode, String ruleName, String compareOp, Double thresholdValue, Integer windowMinute, Integer suppressWindowMinute, String status) { + this.id = id; + this.ruleCode = ruleCode; + this.ruleName = ruleName; + this.compareOp = compareOp; + this.thresholdValue = thresholdValue; + this.windowMinute = windowMinute; + this.suppressWindowMinute = suppressWindowMinute; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getRuleCode() { + return ruleCode; + } + + public String getRuleName() { + return ruleName; + } + + public String getCompareOp() { + return compareOp; + } + + public Double getThresholdValue() { + return thresholdValue; + } + + public Integer getWindowMinute() { + return windowMinute; + } + + public Integer getSuppressWindowMinute() { + return suppressWindowMinute; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/observability/model/ObservabilityMetricPoint.java b/backend/src/main/java/com/writeoff/module/observability/model/ObservabilityMetricPoint.java new file mode 100644 index 0000000..88dbdb8 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/observability/model/ObservabilityMetricPoint.java @@ -0,0 +1,25 @@ +package com.writeoff.module.observability.model; + +public class ObservabilityMetricPoint { + private String minuteSlot; + private String metricCode; + private Double metricValue; + + public ObservabilityMetricPoint(String minuteSlot, String metricCode, Double metricValue) { + this.minuteSlot = minuteSlot; + this.metricCode = metricCode; + this.metricValue = metricValue; + } + + public String getMinuteSlot() { + return minuteSlot; + } + + public String getMetricCode() { + return metricCode; + } + + public Double getMetricValue() { + return metricValue; + } +} diff --git a/backend/src/main/java/com/writeoff/module/observability/service/ObservabilityService.java b/backend/src/main/java/com/writeoff/module/observability/service/ObservabilityService.java new file mode 100644 index 0000000..8778884 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/observability/service/ObservabilityService.java @@ -0,0 +1,429 @@ +package com.writeoff.module.observability.service; + +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.observability.dto.CreateAlertRuleRequest; +import com.writeoff.module.observability.dto.UpdateAlertRuleRequest; +import com.writeoff.module.observability.model.AlertEventInfo; +import com.writeoff.module.observability.model.AlertRuleInfo; +import com.writeoff.module.observability.model.ObservabilityMetricPoint; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class ObservabilityService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper RULE_ROW_MAPPER = (rs, n) -> new AlertRuleInfo( + rs.getLong("id"), + rs.getString("rule_code"), + rs.getString("rule_name"), + rs.getString("compare_op"), + rs.getDouble("threshold_value"), + rs.getInt("window_minute"), + rs.getInt("suppress_window_minute"), + rs.getString("status") + ); + + private static final RowMapper EVENT_ROW_MAPPER = (rs, n) -> new AlertEventInfo( + rs.getLong("id"), + rs.getString("rule_code"), + rs.getString("metric_code"), + rs.getDouble("current_value"), + rs.getDouble("threshold_value"), + rs.getString("alert_level"), + rs.getString("status"), + rs.getString("created_at"), + rs.getString("recovered_at") + ); + + public ObservabilityService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public void recordApiMetric(String apiPath, int statusCode, long durationMs) { + String statusGroup = statusCode >= 500 ? "5XX" : (statusCode >= 400 ? "4XX" : "2XX"); + insertMetric("API_TOTAL", "status", statusGroup, 1D); + insertMetric("API_DURATION_MS", "path", normalize(apiPath), (double) durationMs); + } + + public void recordAsyncMetric(String jobType, String resultStatus) { + insertMetric("ASYNC_JOB_TOTAL", "jobType", normalize(jobType), 1D); + if ("FAILED".equalsIgnoreCase(resultStatus)) { + insertMetric("ASYNC_JOB_FAILED", "jobType", normalize(jobType), 1D); + } + } + + public void recordExportMetric(String exportCode, String resultStatus) { + insertMetric("EXPORT_TOTAL", "exportCode", normalize(exportCode), 1D); + if (!"SUCCESS".equalsIgnoreCase(resultStatus)) { + insertMetric("EXPORT_FAILED", "exportCode", normalize(exportCode), 1D); + } + } + + public List queryMetric(String metricCode, int minutes) { + int safeMinutes = minutes <= 0 ? 60 : minutes; + return jdbcTemplate.query( + "SELECT DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:00') AS minute_slot, metric_code, SUM(metric_value) AS metric_value " + + "FROM observability_metric WHERE tenant_id=? AND metric_code=? AND created_at>=DATE_SUB(NOW(), INTERVAL ? MINUTE) " + + "GROUP BY DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:00'), metric_code ORDER BY minute_slot ASC", + (rs, n) -> new ObservabilityMetricPoint( + rs.getString("minute_slot"), + rs.getString("metric_code"), + rs.getDouble("metric_value") + ), + tenantId(), + metricCode, + safeMinutes + ); + } + + public List listRules() { + return jdbcTemplate.query( + "SELECT * FROM alert_rule WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC", + RULE_ROW_MAPPER, + tenantId() + ); + } + + @Transactional(rollbackFor = Exception.class) + public AlertRuleInfo createRule(CreateAlertRuleRequest request) { + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "INSERT INTO alert_rule (tenant_id, rule_code, rule_name, compare_op, threshold_value, window_minute, suppress_window_minute, status, created_by, updated_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tenantId(), + request.getRuleCode(), + request.getRuleName(), + request.getCompareOp(), + request.getThresholdValue(), + request.getWindowMinute(), + normalizeSuppressMinute(request.getSuppressWindowMinute()), + status, + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM alert_rule WHERE tenant_id=?", Long.class, tenantId()); + return findRuleById(id == null ? 0L : id); + } + + @Transactional(rollbackFor = Exception.class) + public AlertRuleInfo updateRule(Long id, UpdateAlertRuleRequest request) { + assertRuleExists(id); + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "UPDATE alert_rule SET rule_code=?, rule_name=?, compare_op=?, threshold_value=?, window_minute=?, suppress_window_minute=?, status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? " + + "WHERE tenant_id=? AND id=?", + request.getRuleCode(), + request.getRuleName(), + request.getCompareOp(), + request.getThresholdValue(), + request.getWindowMinute(), + normalizeSuppressMinute(request.getSuppressWindowMinute()), + status, + safeUserId(), + tenantId(), + id + ); + return findRuleById(id); + } + + public Map evaluateRules() { + return evaluateRulesInternal(false, 10); + } + + public Map evaluateRulesAuto(int recoveryWindowMinute) { + int safeRecovery = recoveryWindowMinute <= 0 ? 10 : recoveryWindowMinute; + return evaluateRulesInternal(true, safeRecovery); + } + + private Map evaluateRulesInternal(boolean autoMode, int recoveryWindowMinute) { + List rules = listRules(); + int triggerCount = 0; + int recoverCount = 0; + for (AlertRuleInfo rule : rules) { + if (!"ENABLED".equalsIgnoreCase(rule.getStatus())) { + continue; + } + double current = calculateRuleValue(rule); + Long activeId = latestActiveEventId(rule.getRuleCode()); + if (hit(rule.getCompareOp(), current, rule.getThresholdValue())) { + clearRecoverCandidate(activeId); + if (inSuppressWindow(rule.getRuleCode(), rule.getSuppressWindowMinute())) { + continue; + } + triggerCount++; + jdbcTemplate.update( + "INSERT INTO alert_event (tenant_id, rule_code, metric_code, current_value, threshold_value, alert_level, status, message, created_by) VALUES (?, ?, ?, ?, ?, ?, 'ACTIVE', ?, ?)", + tenantId(), + rule.getRuleCode(), + metricCodeByRule(rule.getRuleCode()), + current, + rule.getThresholdValue(), + "WARN", + "触发告警规则: " + rule.getRuleName(), + safeUserId() + ); + } else if (activeId != null) { + if (markOrCheckRecoveryReady(activeId, recoveryWindowMinute)) { + recoverCount++; + jdbcTemplate.update( + "UPDATE alert_event SET status='RECOVERED', recovered_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + tenantId(), + activeId + ); + } + } + } + Map data = new LinkedHashMap(); + data.put("ruleTotal", rules.size()); + data.put("triggerCount", triggerCount); + data.put("recoverCount", recoverCount); + data.put("autoMode", autoMode); + data.put("recoveryWindowMinute", recoveryWindowMinute); + return data; + } + + public List listEvents() { + return jdbcTemplate.query( + "SELECT id, rule_code, metric_code, current_value, threshold_value, alert_level, status, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, " + + "DATE_FORMAT(recovered_at, '%Y-%m-%d %H:%i:%s') AS recovered_at " + + "FROM alert_event WHERE tenant_id=? ORDER BY id DESC LIMIT 200", + EVENT_ROW_MAPPER, + tenantId() + ); + } + + public Map exportMetricSummary(int minutes) { + int safeMinutes = minutes <= 0 ? 60 : minutes; + Double total = jdbcTemplate.queryForObject( + "SELECT IFNULL(SUM(metric_value),0) FROM observability_metric WHERE tenant_id=? AND metric_code='EXPORT_TOTAL' AND created_at>=DATE_SUB(NOW(), INTERVAL ? MINUTE)", + Double.class, + tenantId(), + safeMinutes + ); + Double failed = jdbcTemplate.queryForObject( + "SELECT IFNULL(SUM(metric_value),0) FROM observability_metric WHERE tenant_id=? AND metric_code='EXPORT_FAILED' AND created_at>=DATE_SUB(NOW(), INTERVAL ? MINUTE)", + Double.class, + tenantId(), + safeMinutes + ); + List points = queryMetric("EXPORT_TOTAL", safeMinutes); + Map data = new LinkedHashMap(); + data.put("minutes", safeMinutes); + data.put("total", total == null ? 0D : total); + data.put("failed", failed == null ? 0D : failed); + data.put("points", points); + return data; + } + + private void insertMetric(String metricCode, String labelKey, String labelValue, Double value) { + jdbcTemplate.update( + "INSERT INTO observability_metric (tenant_id, metric_code, label_key, label_value, metric_value, created_by) VALUES (?, ?, ?, ?, ?, ?)", + tenantId(), + metricCode, + labelKey, + labelValue, + value, + safeUserId() + ); + } + + private double calculateRuleValue(AlertRuleInfo rule) { + String code = rule.getRuleCode(); + int minutes = rule.getWindowMinute() == null || rule.getWindowMinute() <= 0 ? 5 : rule.getWindowMinute(); + if ("API_5XX_RATE".equalsIgnoreCase(code)) { + Double total = jdbcTemplate.queryForObject( + "SELECT IFNULL(SUM(metric_value),0) FROM observability_metric WHERE tenant_id=? AND metric_code='API_TOTAL' AND created_at>=DATE_SUB(NOW(), INTERVAL ? MINUTE)", + Double.class, + tenantId(), + minutes + ); + Double f5xx = jdbcTemplate.queryForObject( + "SELECT IFNULL(SUM(metric_value),0) FROM observability_metric WHERE tenant_id=? AND metric_code='API_TOTAL' AND label_key='status' AND label_value='5XX' AND created_at>=DATE_SUB(NOW(), INTERVAL ? MINUTE)", + Double.class, + tenantId(), + minutes + ); + double all = total == null ? 0D : total; + double bad = f5xx == null ? 0D : f5xx; + return all == 0 ? 0D : (bad * 100D / all); + } + if ("ASYNC_BACKLOG".equalsIgnoreCase(code)) { + Integer cnt = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM async_job WHERE tenant_id=? AND status IN ('READY','RUNNING')", + Integer.class, + tenantId() + ); + return cnt == null ? 0D : cnt.doubleValue(); + } + if ("ASYNC_FAILED_RATE".equalsIgnoreCase(code)) { + Double total = jdbcTemplate.queryForObject( + "SELECT IFNULL(SUM(metric_value),0) FROM observability_metric WHERE tenant_id=? AND metric_code='ASYNC_JOB_TOTAL' AND created_at>=DATE_SUB(NOW(), INTERVAL ? MINUTE)", + Double.class, + tenantId(), + minutes + ); + Double failed = jdbcTemplate.queryForObject( + "SELECT IFNULL(SUM(metric_value),0) FROM observability_metric WHERE tenant_id=? AND metric_code='ASYNC_JOB_FAILED' AND created_at>=DATE_SUB(NOW(), INTERVAL ? MINUTE)", + Double.class, + tenantId(), + minutes + ); + double all = total == null ? 0D : total; + double bad = failed == null ? 0D : failed; + return all == 0 ? 0D : (bad * 100D / all); + } + return 0D; + } + + private boolean hit(String op, double current, Double threshold) { + double th = threshold == null ? 0D : threshold; + String compare = op == null ? ">=" : op.trim(); + if (">".equals(compare)) { + return current > th; + } + if ("<".equals(compare)) { + return current < th; + } + if ("<=".equals(compare)) { + return current <= th; + } + return current >= th; + } + + private String metricCodeByRule(String ruleCode) { + if ("API_5XX_RATE".equalsIgnoreCase(ruleCode)) { + return "API_TOTAL"; + } + if ("ASYNC_BACKLOG".equalsIgnoreCase(ruleCode)) { + return "ASYNC_BACKLOG"; + } + if ("ASYNC_FAILED_RATE".equalsIgnoreCase(ruleCode)) { + return "ASYNC_JOB_FAILED"; + } + return "UNKNOWN"; + } + + private AlertRuleInfo findRuleById(Long id) { + List list = jdbcTemplate.query( + "SELECT * FROM alert_rule WHERE tenant_id=? AND id=? AND is_deleted=0", + RULE_ROW_MAPPER, + tenantId(), + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "告警规则不存在"); + } + return list.get(0); + } + + private void assertRuleExists(Long id) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM alert_rule WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + id + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "告警规则不存在"); + } + } + + private String normalizeStatus(String status) { + String val = status == null ? "ENABLED" : status.trim().toUpperCase(); + if (!"ENABLED".equals(val) && !"DISABLED".equals(val)) { + throw new BusinessException(10001, "状态仅支持 ENABLED/DISABLED"); + } + return val; + } + + private String normalize(String v) { + return v == null ? "" : v.trim(); + } + + private int normalizeSuppressMinute(Integer m) { + if (m == null || m < 0) { + return 0; + } + return m; + } + + private boolean inSuppressWindow(String ruleCode, Integer suppressWindowMinute) { + int m = suppressWindowMinute == null ? 0 : suppressWindowMinute; + if (m <= 0) { + return false; + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM alert_event WHERE tenant_id=? AND rule_code=? AND status='ACTIVE' AND created_at>=DATE_SUB(NOW(), INTERVAL ? MINUTE)", + Integer.class, + tenantId(), + ruleCode, + m + ); + return count != null && count > 0; + } + + private Long latestActiveEventId(String ruleCode) { + List ids = jdbcTemplate.queryForList( + "SELECT id FROM alert_event WHERE tenant_id=? AND rule_code=? AND status='ACTIVE' ORDER BY id DESC LIMIT 1", + Long.class, + tenantId(), + ruleCode + ); + return ids.isEmpty() ? null : ids.get(0); + } + + private void clearRecoverCandidate(Long activeId) { + if (activeId == null) { + return; + } + jdbcTemplate.update( + "UPDATE alert_event SET recover_candidate_at=NULL WHERE tenant_id=? AND id=? AND status='ACTIVE'", + tenantId(), + activeId + ); + } + + private boolean markOrCheckRecoveryReady(Long activeId, int recoveryWindowMinute) { + List> list = jdbcTemplate.queryForList( + "SELECT recover_candidate_at FROM alert_event WHERE tenant_id=? AND id=? AND status='ACTIVE' LIMIT 1", + tenantId(), + activeId + ); + if (list.isEmpty()) { + return false; + } + Object val = list.get(0).get("recover_candidate_at"); + if (val == null) { + jdbcTemplate.update( + "UPDATE alert_event SET recover_candidate_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=? AND status='ACTIVE'", + tenantId(), + activeId + ); + return false; + } + Integer ready = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM alert_event WHERE tenant_id=? AND id=? AND status='ACTIVE' AND recover_candidate_at IS NOT NULL AND recover_candidate_at<=DATE_SUB(NOW(), INTERVAL ? MINUTE)", + Integer.class, + tenantId(), + activeId, + recoveryWindowMinute + ); + return ready != null && ready > 0; + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/config/BaiduOcrProperties.java b/backend/src/main/java/com/writeoff/module/ocr/config/BaiduOcrProperties.java new file mode 100644 index 0000000..332606a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/config/BaiduOcrProperties.java @@ -0,0 +1,117 @@ +package com.writeoff.module.ocr.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "app.baidu-ocr") +public class BaiduOcrProperties { + private String apiKey; + private String secretKey; + private String tokenUrl; + private String multipleInvoiceUrl; + private String idCardUrl; + private String bankCardUrl; + private String documentExtractTaskUrl; + private String documentExtractQueryUrl; + private int connectTimeoutMs; + private int readTimeoutMs; + private long maxBytes; + private long documentExtractMaxBytes; + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getSecretKey() { + return secretKey; + } + + public void setSecretKey(String secretKey) { + this.secretKey = secretKey; + } + + public String getTokenUrl() { + return tokenUrl; + } + + public void setTokenUrl(String tokenUrl) { + this.tokenUrl = tokenUrl; + } + + public String getMultipleInvoiceUrl() { + return multipleInvoiceUrl; + } + + public void setMultipleInvoiceUrl(String multipleInvoiceUrl) { + this.multipleInvoiceUrl = multipleInvoiceUrl; + } + + public String getIdCardUrl() { + return idCardUrl; + } + + public void setIdCardUrl(String idCardUrl) { + this.idCardUrl = idCardUrl; + } + + public String getBankCardUrl() { + return bankCardUrl; + } + + public void setBankCardUrl(String bankCardUrl) { + this.bankCardUrl = bankCardUrl; + } + + public String getDocumentExtractTaskUrl() { + return documentExtractTaskUrl; + } + + public void setDocumentExtractTaskUrl(String documentExtractTaskUrl) { + this.documentExtractTaskUrl = documentExtractTaskUrl; + } + + public String getDocumentExtractQueryUrl() { + return documentExtractQueryUrl; + } + + public void setDocumentExtractQueryUrl(String documentExtractQueryUrl) { + this.documentExtractQueryUrl = documentExtractQueryUrl; + } + + public int getConnectTimeoutMs() { + return connectTimeoutMs; + } + + public void setConnectTimeoutMs(int connectTimeoutMs) { + this.connectTimeoutMs = connectTimeoutMs; + } + + public int getReadTimeoutMs() { + return readTimeoutMs; + } + + public void setReadTimeoutMs(int readTimeoutMs) { + this.readTimeoutMs = readTimeoutMs; + } + + public long getMaxBytes() { + return maxBytes; + } + + public void setMaxBytes(long maxBytes) { + this.maxBytes = maxBytes; + } + + public long getDocumentExtractMaxBytes() { + return documentExtractMaxBytes; + } + + public void setDocumentExtractMaxBytes(long documentExtractMaxBytes) { + this.documentExtractMaxBytes = documentExtractMaxBytes; + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/controller/OcrController.java b/backend/src/main/java/com/writeoff/module/ocr/controller/OcrController.java new file mode 100644 index 0000000..b8c0b4d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/controller/OcrController.java @@ -0,0 +1,75 @@ +package com.writeoff.module.ocr.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.ocr.dto.BankCardOcrRequest; +import com.writeoff.module.ocr.dto.BankCardOcrResponse; +import com.writeoff.module.ocr.dto.DocumentExtractTaskQueryRequest; +import com.writeoff.module.ocr.dto.DocumentExtractTaskQueryResponse; +import com.writeoff.module.ocr.dto.DocumentExtractTaskSubmitRequest; +import com.writeoff.module.ocr.dto.DocumentExtractTaskSubmitResponse; +import com.writeoff.module.ocr.dto.IdCardOcrRequest; +import com.writeoff.module.ocr.dto.IdCardOcrResponse; +import com.writeoff.module.ocr.dto.MultipleInvoiceOcrRequest; +import com.writeoff.module.ocr.dto.MultipleInvoiceOcrResponse; +import com.writeoff.module.ocr.service.BaiduBankCardOcrService; +import com.writeoff.module.ocr.service.BaiduDocumentExtractService; +import com.writeoff.module.ocr.service.BaiduIdCardOcrService; +import com.writeoff.module.ocr.service.BaiduMultipleInvoiceOcrService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/ocr") +public class OcrController { + private final BaiduMultipleInvoiceOcrService multipleInvoiceOcrService; + private final BaiduIdCardOcrService idCardOcrService; + private final BaiduBankCardOcrService bankCardOcrService; + private final BaiduDocumentExtractService documentExtractService; + + public OcrController( + BaiduMultipleInvoiceOcrService multipleInvoiceOcrService, + BaiduIdCardOcrService idCardOcrService, + BaiduBankCardOcrService bankCardOcrService, + BaiduDocumentExtractService documentExtractService + ) { + this.multipleInvoiceOcrService = multipleInvoiceOcrService; + this.idCardOcrService = idCardOcrService; + this.bankCardOcrService = bankCardOcrService; + this.documentExtractService = documentExtractService; + } + + @PostMapping("/multiple-invoice") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING_MODULE, auditAction = "OCR_MULTIPLE_INVOICE") + public ApiResponse multipleInvoice(@RequestBody @Validated MultipleInvoiceOcrRequest request) { + return ApiResponse.success(multipleInvoiceOcrService.recognize(request.getObjectKey())); + } + + @PostMapping("/id-card") + @RequirePermission(value = "ocr.idcard", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OCR_ID_CARD") + public ApiResponse idCard(@RequestBody @Validated IdCardOcrRequest request) { + return ApiResponse.success(idCardOcrService.recognize(request.getObjectKey(), request.getIdCardSide())); + } + + @PostMapping("/bank-card") + @RequirePermission(value = "ocr.bankcard", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "OCR_BANK_CARD") + public ApiResponse bankCard(@RequestBody @Validated BankCardOcrRequest request) { + return ApiResponse.success(bankCardOcrService.recognize(request.getObjectKey())); + } + + @PostMapping("/document-extract/task") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING_MODULE, auditAction = "OCR_DOCUMENT_EXTRACT_SUBMIT") + public ApiResponse submitDocumentExtractTask(@RequestBody @Validated DocumentExtractTaskSubmitRequest request) { + return ApiResponse.success(documentExtractService.submitTask(request)); + } + + @PostMapping("/document-extract/query-task") + @RequirePermission(value = "meeting.material.save", dataScope = DataScopeType.MEETING_MODULE, auditAction = "OCR_DOCUMENT_EXTRACT_QUERY") + public ApiResponse queryDocumentExtractTask(@RequestBody @Validated DocumentExtractTaskQueryRequest request) { + return ApiResponse.success(documentExtractService.queryTask(request.getTaskId())); + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/controller/PlatformOcrController.java b/backend/src/main/java/com/writeoff/module/ocr/controller/PlatformOcrController.java new file mode 100644 index 0000000..0632269 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/controller/PlatformOcrController.java @@ -0,0 +1,52 @@ +package com.writeoff.module.ocr.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.ocr.dto.BankCardOcrRequest; +import com.writeoff.module.ocr.dto.BankCardOcrResponse; +import com.writeoff.module.ocr.dto.IdCardOcrRequest; +import com.writeoff.module.ocr.dto.IdCardOcrResponse; +import com.writeoff.module.ocr.service.BaiduBankCardOcrService; +import com.writeoff.module.ocr.service.BaiduIdCardOcrService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/platform/ocr") +public class PlatformOcrController { + private final BaiduIdCardOcrService idCardOcrService; + private final BaiduBankCardOcrService bankCardOcrService; + + public PlatformOcrController(BaiduIdCardOcrService idCardOcrService, BaiduBankCardOcrService bankCardOcrService) { + this.idCardOcrService = idCardOcrService; + this.bankCardOcrService = bankCardOcrService; + } + + @PostMapping("/id-card") + @RequirePermission( + value = "platform.ocr.idcard", + domain = PermissionDomain.PLATFORM, + dataScope = DataScopeType.GLOBAL_READONLY, + auditAction = "PLATFORM_OCR_ID_CARD" + ) + public ApiResponse idCard(@RequestBody @Validated IdCardOcrRequest request) { + return ApiResponse.success(idCardOcrService.recognize(request.getObjectKey(), request.getIdCardSide())); + } + + @PostMapping("/bank-card") + @RequirePermission( + value = "platform.ocr.bankcard", + domain = PermissionDomain.PLATFORM, + dataScope = DataScopeType.GLOBAL_READONLY, + auditAction = "PLATFORM_OCR_BANK_CARD" + ) + public ApiResponse bankCard(@RequestBody @Validated BankCardOcrRequest request) { + return ApiResponse.success(bankCardOcrService.recognize(request.getObjectKey())); + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/BankCardOcrRequest.java b/backend/src/main/java/com/writeoff/module/ocr/dto/BankCardOcrRequest.java new file mode 100644 index 0000000..01988fa --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/BankCardOcrRequest.java @@ -0,0 +1,17 @@ +package com.writeoff.module.ocr.dto; + +import javax.validation.constraints.NotBlank; + +public class BankCardOcrRequest { + @NotBlank(message = "objectKey不能为空") + private String objectKey; + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/BankCardOcrResponse.java b/backend/src/main/java/com/writeoff/module/ocr/dto/BankCardOcrResponse.java new file mode 100644 index 0000000..f702278 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/BankCardOcrResponse.java @@ -0,0 +1,73 @@ +package com.writeoff.module.ocr.dto; + +import java.util.Map; + +public class BankCardOcrResponse { + private Map raw; + private Normalized normalized; + + public Map getRaw() { + return raw; + } + + public void setRaw(Map raw) { + this.raw = raw; + } + + public Normalized getNormalized() { + return normalized; + } + + public void setNormalized(Normalized normalized) { + this.normalized = normalized; + } + + public static class Normalized { + private String bankCardNumber; + private String validDate; + private Integer bankCardType; + private String bankName; + private String holderName; + + public String getBankCardNumber() { + return bankCardNumber; + } + + public void setBankCardNumber(String bankCardNumber) { + this.bankCardNumber = bankCardNumber; + } + + public String getValidDate() { + return validDate; + } + + public void setValidDate(String validDate) { + this.validDate = validDate; + } + + public Integer getBankCardType() { + return bankCardType; + } + + public void setBankCardType(Integer bankCardType) { + this.bankCardType = bankCardType; + } + + public String getBankName() { + return bankName; + } + + public void setBankName(String bankName) { + this.bankName = bankName; + } + + public String getHolderName() { + return holderName; + } + + public void setHolderName(String holderName) { + this.holderName = holderName; + } + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskQueryRequest.java b/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskQueryRequest.java new file mode 100644 index 0000000..1322479 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskQueryRequest.java @@ -0,0 +1,16 @@ +package com.writeoff.module.ocr.dto; + +import javax.validation.constraints.NotBlank; + +public class DocumentExtractTaskQueryRequest { + @NotBlank(message = "taskId must not be blank") + private String taskId; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskQueryResponse.java b/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskQueryResponse.java new file mode 100644 index 0000000..ea4cc4c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskQueryResponse.java @@ -0,0 +1,87 @@ +package com.writeoff.module.ocr.dto; + +import java.util.Map; + +public class DocumentExtractTaskQueryResponse { + private String taskId; + private String status; + private String reason; + private String createdAt; + private String startedAt; + private String finishedAt; + private Long duration; + private String logId; + private Map raw; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public String getReason() { + return reason; + } + + public void setReason(String reason) { + this.reason = reason; + } + + public String getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(String createdAt) { + this.createdAt = createdAt; + } + + public String getStartedAt() { + return startedAt; + } + + public void setStartedAt(String startedAt) { + this.startedAt = startedAt; + } + + public String getFinishedAt() { + return finishedAt; + } + + public void setFinishedAt(String finishedAt) { + this.finishedAt = finishedAt; + } + + public Long getDuration() { + return duration; + } + + public void setDuration(Long duration) { + this.duration = duration; + } + + public String getLogId() { + return logId; + } + + public void setLogId(String logId) { + this.logId = logId; + } + + public Map getRaw() { + return raw; + } + + public void setRaw(Map raw) { + this.raw = raw; + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskSubmitRequest.java b/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskSubmitRequest.java new file mode 100644 index 0000000..62a8300 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskSubmitRequest.java @@ -0,0 +1,133 @@ +package com.writeoff.module.ocr.dto; + +import javax.validation.Valid; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; +import java.util.List; + +public class DocumentExtractTaskSubmitRequest { + private String objectKey; + private String fileName; + + @Size(max = 1, message = "fileUrls supports only one url") + private List<@NotBlank(message = "file url must not be blank") String> fileUrls; + + private String manifestVersionId; + + @Valid + @Size(max = 100, message = "manifest supports at most 100 fields") + private List manifest; + + private Boolean removeDuplicates; + private String pageRange; + private Boolean extractSeal; + private Boolean eraseWatermark; + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public List getFileUrls() { + return fileUrls; + } + + public void setFileUrls(List fileUrls) { + this.fileUrls = fileUrls; + } + + public String getManifestVersionId() { + return manifestVersionId; + } + + public void setManifestVersionId(String manifestVersionId) { + this.manifestVersionId = manifestVersionId; + } + + public List getManifest() { + return manifest; + } + + public void setManifest(List manifest) { + this.manifest = manifest; + } + + public Boolean getRemoveDuplicates() { + return removeDuplicates; + } + + public void setRemoveDuplicates(Boolean removeDuplicates) { + this.removeDuplicates = removeDuplicates; + } + + public String getPageRange() { + return pageRange; + } + + public void setPageRange(String pageRange) { + this.pageRange = pageRange; + } + + public Boolean getExtractSeal() { + return extractSeal; + } + + public void setExtractSeal(Boolean extractSeal) { + this.extractSeal = extractSeal; + } + + public Boolean getEraseWatermark() { + return eraseWatermark; + } + + public void setEraseWatermark(Boolean eraseWatermark) { + this.eraseWatermark = eraseWatermark; + } + + public static class ManifestField { + @NotBlank(message = "manifest key must not be blank") + @Size(max = 30, message = "manifest key length must be <= 30") + private String key; + + @Size(max = 30, message = "manifest parentKey length must be <= 30") + private String parentKey; + + @Size(max = 100, message = "manifest description length must be <= 100") + private String description; + + public String getKey() { + return key; + } + + public void setKey(String key) { + this.key = key; + } + + public String getParentKey() { + return parentKey; + } + + public void setParentKey(String parentKey) { + this.parentKey = parentKey; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskSubmitResponse.java b/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskSubmitResponse.java new file mode 100644 index 0000000..7735311 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/DocumentExtractTaskSubmitResponse.java @@ -0,0 +1,33 @@ +package com.writeoff.module.ocr.dto; + +import java.util.Map; + +public class DocumentExtractTaskSubmitResponse { + private String taskId; + private String logId; + private Map raw; + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getLogId() { + return logId; + } + + public void setLogId(String logId) { + this.logId = logId; + } + + public Map getRaw() { + return raw; + } + + public void setRaw(Map raw) { + this.raw = raw; + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/IdCardOcrRequest.java b/backend/src/main/java/com/writeoff/module/ocr/dto/IdCardOcrRequest.java new file mode 100644 index 0000000..4d7fb01 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/IdCardOcrRequest.java @@ -0,0 +1,30 @@ +package com.writeoff.module.ocr.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Pattern; + +public class IdCardOcrRequest { + @NotBlank(message = "objectKey不能为空") + private String objectKey; + + @NotBlank(message = "idCardSide不能为空") + @Pattern(regexp = "front|back", message = "idCardSide仅支持front或back") + private String idCardSide; + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } + + public String getIdCardSide() { + return idCardSide; + } + + public void setIdCardSide(String idCardSide) { + this.idCardSide = idCardSide; + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/IdCardOcrResponse.java b/backend/src/main/java/com/writeoff/module/ocr/dto/IdCardOcrResponse.java new file mode 100644 index 0000000..61605f5 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/IdCardOcrResponse.java @@ -0,0 +1,118 @@ +package com.writeoff.module.ocr.dto; + +import java.util.Map; + +public class IdCardOcrResponse { + private Map raw; + private Normalized normalized; + + public Map getRaw() { + return raw; + } + + public void setRaw(Map raw) { + this.raw = raw; + } + + public Normalized getNormalized() { + return normalized; + } + + public void setNormalized(Normalized normalized) { + this.normalized = normalized; + } + + public static class Normalized { + private String idCardSide; + private String name; + private String idNo; + private String gender; + private String ethnicity; + private String birth; + private String address; + private String issueAuthority; + private String signDate; + private String expiryDate; + + public String getIdCardSide() { + return idCardSide; + } + + public void setIdCardSide(String idCardSide) { + this.idCardSide = idCardSide; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getIdNo() { + return idNo; + } + + public void setIdNo(String idNo) { + this.idNo = idNo; + } + + public String getGender() { + return gender; + } + + public void setGender(String gender) { + this.gender = gender; + } + + public String getEthnicity() { + return ethnicity; + } + + public void setEthnicity(String ethnicity) { + this.ethnicity = ethnicity; + } + + public String getBirth() { + return birth; + } + + public void setBirth(String birth) { + this.birth = birth; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public String getIssueAuthority() { + return issueAuthority; + } + + public void setIssueAuthority(String issueAuthority) { + this.issueAuthority = issueAuthority; + } + + public String getSignDate() { + return signDate; + } + + public void setSignDate(String signDate) { + this.signDate = signDate; + } + + public String getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(String expiryDate) { + this.expiryDate = expiryDate; + } + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/MultipleInvoiceOcrRequest.java b/backend/src/main/java/com/writeoff/module/ocr/dto/MultipleInvoiceOcrRequest.java new file mode 100644 index 0000000..d62c317 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/MultipleInvoiceOcrRequest.java @@ -0,0 +1,17 @@ +package com.writeoff.module.ocr.dto; + +import javax.validation.constraints.NotBlank; + +public class MultipleInvoiceOcrRequest { + @NotBlank(message = "objectKey不能为空") + private String objectKey; + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/dto/MultipleInvoiceOcrResponse.java b/backend/src/main/java/com/writeoff/module/ocr/dto/MultipleInvoiceOcrResponse.java new file mode 100644 index 0000000..994c797 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/dto/MultipleInvoiceOcrResponse.java @@ -0,0 +1,91 @@ +package com.writeoff.module.ocr.dto; + +import java.util.Map; + +public class MultipleInvoiceOcrResponse { + private Map raw; + private Normalized normalized; + private String detectedType; + private Double probability; + + public Map getRaw() { + return raw; + } + + public void setRaw(Map raw) { + this.raw = raw; + } + + public Normalized getNormalized() { + return normalized; + } + + public void setNormalized(Normalized normalized) { + this.normalized = normalized; + } + + public String getDetectedType() { + return detectedType; + } + + public void setDetectedType(String detectedType) { + this.detectedType = detectedType; + } + + public Double getProbability() { + return probability; + } + + public void setProbability(Double probability) { + this.probability = probability; + } + + public static class Normalized { + private String invoiceCode; + private String invoiceNum; + private String name; + private Long totalAmountCent; + private Long taxCent; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getInvoiceCode() { + return invoiceCode; + } + + public void setInvoiceCode(String invoiceCode) { + this.invoiceCode = invoiceCode; + } + + public String getInvoiceNum() { + return invoiceNum; + } + + public void setInvoiceNum(String invoiceNum) { + this.invoiceNum = invoiceNum; + } + + public Long getTotalAmountCent() { + return totalAmountCent; + } + + public void setTotalAmountCent(Long totalAmountCent) { + this.totalAmountCent = totalAmountCent; + } + + public Long getTaxCent() { + return taxCent; + } + + public void setTaxCent(Long taxCent) { + this.taxCent = taxCent; + } + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/service/BaiduBankCardOcrService.java b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduBankCardOcrService.java new file mode 100644 index 0000000..eb49a21 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduBankCardOcrService.java @@ -0,0 +1,178 @@ +package com.writeoff.module.ocr.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.ocr.config.BaiduOcrProperties; +import com.writeoff.module.ocr.dto.BankCardOcrResponse; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +@Service +public class BaiduBankCardOcrService { + private final BaiduOcrProperties props; + private final BaiduOcrTokenService tokenService; + private final OssService ossService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public BaiduBankCardOcrService( + BaiduOcrProperties props, + BaiduOcrTokenService tokenService, + OssService ossService + ) { + this.props = props; + this.tokenService = tokenService; + this.ossService = ossService; + } + + public BankCardOcrResponse recognize(String objectKey) { + String key = String.valueOf(objectKey == null ? "" : objectKey).trim(); + if (key.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "objectKey不能为空"); + } + if (key.toLowerCase().endsWith(".pdf")) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "银行卡OCR仅支持图片文件"); + } + byte[] bytes = downloadFromOss(key); + Map raw = callBankCard(bytes); + BankCardOcrResponse response = new BankCardOcrResponse(); + response.setRaw(raw); + response.setNormalized(normalize(raw)); + return response; + } + + private Map callBankCard(byte[] bytes) { + try { + String token = tokenService.getAccessToken(); + String apiUrl = props.getBankCardUrl(); + if (apiUrl == null || apiUrl.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR bankCardUrl未配置"); + } + String endpoint = apiUrl + "?access_token=" + URLEncoder.encode(token, "UTF-8"); + HttpURLConnection connection = (HttpURLConnection) new URL(endpoint).openConnection(); + connection.setRequestMethod("POST"); + connection.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + connection.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + connection.setDoOutput(true); + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + String imageBase64 = Base64.getEncoder().encodeToString(bytes); + String body = "image=" + URLEncoder.encode(imageBase64, "UTF-8"); + try (OutputStream os = connection.getOutputStream()) { + os.write(body.getBytes(StandardCharsets.UTF_8)); + } + int code = connection.getResponseCode(); + InputStream stream = code >= 200 && code < 300 ? connection.getInputStream() : connection.getErrorStream(); + String text = new String(readAllBytesWithLimit(stream, 2 * 1024 * 1024), StandardCharsets.UTF_8); + JsonNode json = objectMapper.readTree(text == null ? "" : text); + if (json.has("error_code")) { + String errMsg = json.has("error_msg") ? json.get("error_msg").asText() : "unknown"; + String logId = json.has("log_id") ? json.get("log_id").asText() : ""; + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "银行卡OCR识别失败: " + errMsg + (logId.isEmpty() ? "" : " (log_id=" + logId + ")")); + } + if (code < 200 || code >= 300) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "银行卡OCR识别失败: HTTP " + code); + } + return objectMapper.convertValue(json, new TypeReference>() {}); + } catch (Exception e) { + if (e instanceof BusinessException) { + throw (BusinessException) e; + } + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "调用百度银行卡OCR失败: " + e.getMessage()); + } + } + + @SuppressWarnings("unchecked") + private BankCardOcrResponse.Normalized normalize(Map raw) { + BankCardOcrResponse.Normalized n = new BankCardOcrResponse.Normalized(); + Object resultObj = raw.get("result"); + if (!(resultObj instanceof Map)) { + return n; + } + Map result = (Map) resultObj; + n.setBankCardNumber(asText(result.get("bank_card_number"))); + n.setValidDate(asText(result.get("valid_date"))); + n.setBankCardType(asInt(result.get("bank_card_type"))); + n.setBankName(asText(result.get("bank_name"))); + n.setHolderName(asText(result.get("holder_name"))); + return n; + } + + private static String asText(Object value) { + return value == null ? null : String.valueOf(value).trim(); + } + + private static Integer asInt(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + try { + return Integer.parseInt(String.valueOf(value).trim()); + } catch (Exception ignored) { + return null; + } + } + + private byte[] downloadFromOss(String objectKey) { + String signedUrl = ossService.generateDownloadUrl(objectKey); + if (signedUrl == null || signedUrl.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "生成OSS下载链接失败"); + } + try { + URL url = new URL(signedUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + conn.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + int status = conn.getResponseCode(); + InputStream is = status >= 200 && status < 300 ? conn.getInputStream() : conn.getErrorStream(); + byte[] data = readAllBytesWithLimit(is, props.getMaxBytes()); + if (status < 200 || status >= 300) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "拉取OSS文件失败: HTTP " + status); + } + return data; + } catch (BusinessException be) { + throw be; + } catch (Exception e) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "拉取OSS文件异常: " + e.getMessage()); + } + } + + private static byte[] readAllBytesWithLimit(InputStream is, long maxBytes) throws java.io.IOException { + if (is == null) { + return new byte[0]; + } + long limit = maxBytes <= 0 ? 0 : maxBytes; + try (InputStream in = is; ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + long total = 0; + int n; + while ((n = in.read(buffer)) >= 0) { + if (n == 0) { + continue; + } + total += n; + if (limit > 0 && total > limit) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "文件过大,超过限制"); + } + bos.write(buffer, 0, n); + } + return bos.toByteArray(); + } + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/service/BaiduDocumentExtractService.java b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduDocumentExtractService.java new file mode 100644 index 0000000..ec4dfbd --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduDocumentExtractService.java @@ -0,0 +1,366 @@ +package com.writeoff.module.ocr.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.ocr.config.BaiduOcrProperties; +import com.writeoff.module.ocr.dto.DocumentExtractTaskQueryResponse; +import com.writeoff.module.ocr.dto.DocumentExtractTaskSubmitRequest; +import com.writeoff.module.ocr.dto.DocumentExtractTaskSubmitResponse; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class BaiduDocumentExtractService { + private static final long RESPONSE_MAX_BYTES = 8L * 1024 * 1024; + + private final BaiduOcrProperties props; + private final BaiduOcrTokenService tokenService; + private final OssService ossService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public BaiduDocumentExtractService( + BaiduOcrProperties props, + BaiduOcrTokenService tokenService, + OssService ossService + ) { + this.props = props; + this.tokenService = tokenService; + this.ossService = ossService; + } + + public DocumentExtractTaskSubmitResponse submitTask(DocumentExtractTaskSubmitRequest request) { + validateSubmitRequest(request); + + String objectKey = trimToNull(request.getObjectKey()); + List fileUrls = sanitizeFileUrls(request.getFileUrls()); + + List formParts = new ArrayList(); + if (objectKey != null) { + byte[] bytes = downloadFromOss(objectKey); + String resolvedFileName = resolveFileName(request.getFileName(), objectKey); + formParts.add(formParam("file", Base64.getEncoder().encodeToString(bytes))); + formParts.add(formParam("fileName", resolvedFileName)); + } else { + formParts.add(formParam("fileURLs", toJson(fileUrls))); + } + + String manifestVersionId = trimToNull(request.getManifestVersionId()); + if (manifestVersionId != null) { + formParts.add(formParam("manifestVersionId", manifestVersionId)); + } else { + formParts.add(formParam("manifest", toJson(buildManifestPayload(request.getManifest())))); + } + + if (request.getRemoveDuplicates() != null) { + formParts.add(formParam("removeDuplicates", String.valueOf(request.getRemoveDuplicates()))); + } + if (trimToNull(request.getPageRange()) != null) { + formParts.add(formParam("pageRange", request.getPageRange().trim())); + } + if (request.getExtractSeal() != null) { + formParts.add(formParam("extractSeal", String.valueOf(request.getExtractSeal()))); + } + if (request.getEraseWatermark() != null) { + formParts.add(formParam("eraseWatermark", String.valueOf(request.getEraseWatermark()))); + } + + JsonNode json = doPostForm( + props.getDocumentExtractTaskUrl(), + joinFormParts(formParts), + "Baidu document extract submit failed" + ); + + DocumentExtractTaskSubmitResponse response = new DocumentExtractTaskSubmitResponse(); + response.setTaskId(asText(json.path("result").path("taskId"))); + response.setLogId(asText(json.path("log_id"))); + response.setRaw(toMap(json)); + return response; + } + + public DocumentExtractTaskQueryResponse queryTask(String taskId) { + String normalizedTaskId = trimToNull(taskId); + if (normalizedTaskId == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "taskId must not be blank"); + } + + JsonNode json = doPostForm( + props.getDocumentExtractQueryUrl(), + formParam("taskId", normalizedTaskId), + "Baidu document extract query failed" + ); + + JsonNode result = json.path("result"); + DocumentExtractTaskQueryResponse response = new DocumentExtractTaskQueryResponse(); + response.setTaskId(asText(result.path("taskId"))); + response.setStatus(asText(result.path("status"))); + response.setReason(asText(result.path("reason"))); + response.setCreatedAt(asText(result.path("createdAt"))); + response.setStartedAt(asText(result.path("startedAt"))); + response.setFinishedAt(asText(result.path("finishedAt"))); + response.setDuration(asLong(result.path("duration"))); + response.setLogId(asText(json.path("log_id"))); + response.setRaw(toMap(json)); + return response; + } + + private void validateSubmitRequest(DocumentExtractTaskSubmitRequest request) { + String objectKey = trimToNull(request.getObjectKey()); + List fileUrls = sanitizeFileUrls(request.getFileUrls()); + if (objectKey == null && fileUrls.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "objectKey or fileUrls is required"); + } + if (objectKey != null && !fileUrls.isEmpty()) { + fileUrls.clear(); + } + + String manifestVersionId = trimToNull(request.getManifestVersionId()); + if (manifestVersionId == null) { + List manifest = request.getManifest(); + if (manifest == null || manifest.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "manifestVersionId or manifest is required"); + } + } + } + + private List> buildManifestPayload(List manifest) { + List> payload = new ArrayList>(); + if (manifest == null) { + return payload; + } + for (DocumentExtractTaskSubmitRequest.ManifestField field : manifest) { + if (field == null) { + continue; + } + String key = trimToNull(field.getKey()); + if (key == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "manifest key must not be blank"); + } + Map item = new LinkedHashMap(); + item.put("key", key); + item.put("parentKey", defaultString(field.getParentKey())); + item.put("description", defaultString(field.getDescription())); + payload.add(item); + } + return payload; + } + + private JsonNode doPostForm(String apiUrl, String formBody, String failurePrefix) { + String endpoint = trimToNull(apiUrl); + if (endpoint == null) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, failurePrefix + ": endpoint is not configured"); + } + + try { + String accessToken = tokenService.getAccessToken(); + String requestUrl = endpoint + "?access_token=" + URLEncoder.encode(accessToken, "UTF-8"); + HttpURLConnection connection = (HttpURLConnection) new URL(requestUrl).openConnection(); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); + connection.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + connection.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + + try (OutputStream os = connection.getOutputStream()) { + os.write(formBody.getBytes(StandardCharsets.UTF_8)); + } + + int code = connection.getResponseCode(); + InputStream stream = code >= 200 && code < 300 ? connection.getInputStream() : connection.getErrorStream(); + String text = new String(readAllBytesWithLimit(stream, RESPONSE_MAX_BYTES), StandardCharsets.UTF_8); + JsonNode json = objectMapper.readTree(text == null ? "" : text); + + if (json.has("error_code") && json.path("error_code").asInt() != 0) { + String errorMsg = asText(json.path("error_msg")); + String logId = asText(json.path("log_id")); + throw new BusinessException( + ErrorCodes.INTERNAL_ERROR, + failurePrefix + ": " + defaultString(errorMsg) + (logId == null ? "" : " (log_id=" + logId + ")") + ); + } + if (code < 200 || code >= 300) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, failurePrefix + ": HTTP " + code); + } + return json; + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, failurePrefix + ": " + ex.getMessage()); + } + } + + private byte[] downloadFromOss(String objectKey) { + String signedUrl = ossService.generateDownloadUrl(objectKey); + if (trimToNull(signedUrl) == null) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "Failed to generate OSS download url"); + } + try { + HttpURLConnection connection = (HttpURLConnection) new URL(signedUrl).openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + connection.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + int code = connection.getResponseCode(); + InputStream stream = code >= 200 && code < 300 ? connection.getInputStream() : connection.getErrorStream(); + byte[] bytes = readAllBytesWithLimit(stream, props.getDocumentExtractMaxBytes()); + if (code < 200 || code >= 300) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "Failed to download OSS file: HTTP " + code); + } + if (bytes.length == 0) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "file content is empty"); + } + return bytes; + } catch (BusinessException ex) { + throw ex; + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "Failed to download OSS file: " + ex.getMessage()); + } + } + + private String resolveFileName(String requestedFileName, String objectKey) { + String fileName = trimToNull(requestedFileName); + if (fileName != null) { + return fileName; + } + String inferred = inferFileNameFromObjectKey(objectKey); + if (inferred == null) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "fileName is required when objectKey has no filename"); + } + return inferred; + } + + private String inferFileNameFromObjectKey(String objectKey) { + String text = trimToNull(objectKey); + if (text == null) { + return null; + } + int slash = Math.max(text.lastIndexOf('/'), text.lastIndexOf('\\')); + String fileName = slash >= 0 ? text.substring(slash + 1) : text; + return trimToNull(fileName); + } + + private List sanitizeFileUrls(List fileUrls) { + List result = new ArrayList(); + if (fileUrls == null) { + return result; + } + for (String fileUrl : fileUrls) { + String value = trimToNull(fileUrl); + if (value != null) { + result.add(value); + } + } + return result; + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "Failed to serialize request body: " + ex.getMessage()); + } + } + + private Map toMap(JsonNode json) { + return objectMapper.convertValue(json, new TypeReference>() { + }); + } + + private String joinFormParts(List formParts) { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < formParts.size(); i++) { + if (i > 0) { + builder.append('&'); + } + builder.append(formParts.get(i)); + } + return builder.toString(); + } + + private String formParam(String key, String value) { + try { + return URLEncoder.encode(key, "UTF-8") + "=" + URLEncoder.encode(value == null ? "" : value, "UTF-8"); + } catch (Exception ex) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "Failed to encode request body: " + ex.getMessage()); + } + } + + private static String trimToNull(String value) { + if (value == null) { + return null; + } + String text = value.trim(); + return text.isEmpty() ? null : text; + } + + private static String defaultString(String value) { + return value == null ? "" : value.trim(); + } + + private static String asText(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) { + return null; + } + String text = node.asText(); + if (text == null) { + return null; + } + text = text.trim(); + return text.isEmpty() ? null : text; + } + + private static Long asLong(JsonNode node) { + if (node == null || node.isMissingNode() || node.isNull()) { + return null; + } + if (node.isNumber()) { + return node.asLong(); + } + String text = asText(node); + if (text == null) { + return null; + } + try { + return Long.parseLong(text); + } catch (Exception ex) { + return null; + } + } + + private static byte[] readAllBytesWithLimit(InputStream is, long maxBytes) throws java.io.IOException { + if (is == null) { + return new byte[0]; + } + long limit = maxBytes <= 0 ? 0 : maxBytes; + try (InputStream in = is; ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buffer = new byte[8192]; + long total = 0; + int n; + while ((n = in.read(buffer)) >= 0) { + if (n == 0) { + continue; + } + total += n; + if (limit > 0 && total > limit) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "file exceeds size limit"); + } + bos.write(buffer, 0, n); + } + return bos.toByteArray(); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/service/BaiduIdCardOcrService.java b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduIdCardOcrService.java new file mode 100644 index 0000000..104e80e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduIdCardOcrService.java @@ -0,0 +1,182 @@ +package com.writeoff.module.ocr.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.ocr.config.BaiduOcrProperties; +import com.writeoff.module.ocr.dto.IdCardOcrResponse; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; + +@Service +public class BaiduIdCardOcrService { + private final BaiduOcrProperties props; + private final BaiduOcrTokenService tokenService; + private final OssService ossService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public BaiduIdCardOcrService(BaiduOcrProperties props, BaiduOcrTokenService tokenService, OssService ossService) { + this.props = props; + this.tokenService = tokenService; + this.ossService = ossService; + } + + public IdCardOcrResponse recognize(String objectKey, String idCardSide) { + String key = String.valueOf(objectKey == null ? "" : objectKey).trim(); + String side = String.valueOf(idCardSide == null ? "" : idCardSide).trim().toLowerCase(); + if (key.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "objectKey不能为空"); + } + if (!"front".equals(side) && !"back".equals(side)) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "idCardSide仅支持front或back"); + } + if (key.toLowerCase().endsWith(".pdf")) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "身份证OCR仅支持图片文件"); + } + + byte[] bytes = downloadFromOss(key); + if (bytes.length <= 0) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "文件内容为空"); + } + Map raw = callIdCardOcr(side, bytes); + + IdCardOcrResponse resp = new IdCardOcrResponse(); + resp.setRaw(raw); + resp.setNormalized(normalize(raw, side)); + return resp; + } + + private byte[] downloadFromOss(String objectKey) { + String signedUrl = ossService.generateDownloadUrl(objectKey); + if (signedUrl == null || signedUrl.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "生成OSS下载链接失败"); + } + try { + URL url = new URL(signedUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + conn.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + int status = conn.getResponseCode(); + InputStream is = status >= 200 && status < 300 ? conn.getInputStream() : conn.getErrorStream(); + byte[] data = readAllBytesWithLimit(is, props.getMaxBytes()); + if (status < 200 || status >= 300) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "拉取OSS文件失败: HTTP " + status); + } + return data; + } catch (BusinessException be) { + throw be; + } catch (Exception e) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "拉取OSS文件异常: " + e.getMessage()); + } + } + + private Map callIdCardOcr(String side, byte[] bytes) { + String accessToken = tokenService.getAccessToken(); + String apiUrl = props.getIdCardUrl(); + if (apiUrl == null || apiUrl.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR idCardUrl未配置"); + } + String b64 = Base64.getEncoder().encodeToString(bytes); + try { + String form = "id_card_side=" + URLEncoder.encode(side, "UTF-8") + + "&image=" + URLEncoder.encode(b64, "UTF-8"); + String url = apiUrl + "?access_token=" + URLEncoder.encode(accessToken, "UTF-8"); + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + conn.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + try (OutputStream os = conn.getOutputStream()) { + os.write(form.getBytes(StandardCharsets.UTF_8)); + } + int status = conn.getResponseCode(); + InputStream is = status >= 200 && status < 300 ? conn.getInputStream() : conn.getErrorStream(); + String body = new String(readAllBytesWithLimit(is, 2 * 1024 * 1024), StandardCharsets.UTF_8); + JsonNode json = objectMapper.readTree(body == null ? "" : body); + if (json.has("error_code")) { + String errMsg = json.has("error_msg") ? json.get("error_msg").asText() : "unknown"; + String logId = json.has("log_id") ? json.get("log_id").asText() : ""; + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "身份证OCR识别失败: " + errMsg + (logId.isEmpty() ? "" : " (log_id=" + logId + ")")); + } + if (status < 200 || status >= 300) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "身份证OCR识别失败: HTTP " + status); + } + return objectMapper.convertValue(json, new TypeReference>() { + }); + } catch (BusinessException be) { + throw be; + } catch (Exception e) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "身份证OCR调用异常: " + e.getMessage()); + } + } + + @SuppressWarnings("unchecked") + private IdCardOcrResponse.Normalized normalize(Map raw, String side) { + IdCardOcrResponse.Normalized n = new IdCardOcrResponse.Normalized(); + n.setIdCardSide(side); + Object wr = raw == null ? null : raw.get("words_result"); + Map words = wr instanceof Map ? (Map) wr : null; + if (words == null) { + return n; + } + n.setName(extractWord(words, "姓名")); + n.setIdNo(extractWord(words, "公民身份号码")); + n.setGender(extractWord(words, "性别")); + n.setEthnicity(extractWord(words, "民族")); + n.setBirth(extractWord(words, "出生")); + n.setAddress(extractWord(words, "住址")); + n.setIssueAuthority(extractWord(words, "签发机关")); + n.setSignDate(extractWord(words, "签发日期")); + n.setExpiryDate(extractWord(words, "失效日期")); + return n; + } + + @SuppressWarnings("unchecked") + private static String extractWord(Map wordsResult, String key) { + Object v = wordsResult.get(key); + if (!(v instanceof Map)) { + return null; + } + Object word = ((Map) v).get("words"); + String s = String.valueOf(word == null ? "" : word).trim(); + return s.isEmpty() ? null : s; + } + + private static byte[] readAllBytesWithLimit(InputStream is, long maxBytes) throws java.io.IOException { + if (is == null) { + return new byte[0]; + } + long limit = maxBytes <= 0 ? 0 : maxBytes; + try (InputStream in = is; ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buf = new byte[8192]; + long total = 0; + int n; + while ((n = in.read(buf)) >= 0) { + if (n == 0) { + continue; + } + total += n; + if (limit > 0 && total > limit) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "文件过大,超过限制"); + } + bos.write(buf, 0, n); + } + return bos.toByteArray(); + } + } +} + diff --git a/backend/src/main/java/com/writeoff/module/ocr/service/BaiduMultipleInvoiceOcrService.java b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduMultipleInvoiceOcrService.java new file mode 100644 index 0000000..98bcd43 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduMultipleInvoiceOcrService.java @@ -0,0 +1,462 @@ +package com.writeoff.module.ocr.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.ocr.config.BaiduOcrProperties; +import com.writeoff.module.ocr.dto.MultipleInvoiceOcrResponse; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; + +@Service +public class BaiduMultipleInvoiceOcrService { + private final BaiduOcrProperties props; + private final BaiduOcrTokenService tokenService; + private final OssService ossService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public BaiduMultipleInvoiceOcrService(BaiduOcrProperties props, BaiduOcrTokenService tokenService, OssService ossService) { + this.props = props; + this.tokenService = tokenService; + this.ossService = ossService; + } + + public MultipleInvoiceOcrResponse recognize(String objectKey) { + String key = String.valueOf(objectKey == null ? "" : objectKey).trim(); + if (key.isEmpty()) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "objectKey不能为空"); + } + + byte[] bytes = downloadFromOss(key); + if (bytes.length <= 0) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "文件内容为空"); + } + + Map raw = callBaiduMultipleInvoice(key, bytes); + MultipleInvoiceOcrResponse resp = new MultipleInvoiceOcrResponse(); + resp.setRaw(raw); + + NormalizedPick pick = pickBestResult(raw); + if (pick != null) { + resp.setDetectedType(pick.type); + resp.setProbability(pick.probability); + resp.setNormalized(normalize(pick.result, pick.type)); + } else { + resp.setDetectedType(null); + resp.setProbability(null); + resp.setNormalized(new MultipleInvoiceOcrResponse.Normalized()); + } + return resp; + } + + private byte[] downloadFromOss(String objectKey) { + String signedUrl = ossService.generateDownloadUrl(objectKey); + if (signedUrl == null || signedUrl.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "生成OSS下载链接失败"); + } + try { + URL url = new URL(signedUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + conn.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + int status = conn.getResponseCode(); + InputStream is = status >= 200 && status < 300 ? conn.getInputStream() : conn.getErrorStream(); + byte[] data = readAllBytesWithLimit(is, props.getMaxBytes()); + if (status < 200 || status >= 300) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "拉取OSS文件失败: HTTP " + status); + } + return data; + } catch (BusinessException be) { + throw be; + } catch (Exception e) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "拉取OSS文件异常: " + e.getMessage()); + } + } + + private Map callBaiduMultipleInvoice(String objectKey, byte[] bytes) { + String accessToken = tokenService.getAccessToken(); + String apiUrl = props.getMultipleInvoiceUrl(); + if (apiUrl == null || apiUrl.trim().isEmpty()) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR multipleInvoiceUrl未配置"); + } + + boolean isPdf = objectKey.toLowerCase().endsWith(".pdf"); + String fieldName = isPdf ? "pdf_file" : "image"; + String b64 = Base64.getEncoder().encodeToString(bytes); + + try { + String encoded = URLEncoder.encode(b64, "UTF-8"); + String form = fieldName + "=" + encoded; + String url = apiUrl + "?access_token=" + URLEncoder.encode(accessToken, "UTF-8"); + HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + conn.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + conn.setRequestProperty("Content-Type", "application/x-www-form-urlencoded"); + + try (OutputStream os = conn.getOutputStream()) { + os.write(form.getBytes(StandardCharsets.UTF_8)); + } + + int status = conn.getResponseCode(); + InputStream is = status >= 200 && status < 300 ? conn.getInputStream() : conn.getErrorStream(); + String body = new String(readAllBytesWithLimit(is, 2 * 1024 * 1024), StandardCharsets.UTF_8); + JsonNode json = objectMapper.readTree(body == null ? "" : body); + + if (json.has("error_code")) { + String errMsg = json.has("error_msg") ? json.get("error_msg").asText() : "unknown"; + String logId = json.has("log_id") ? json.get("log_id").asText() : ""; + throw new BusinessException( + ErrorCodes.INTERNAL_ERROR, + "百度OCR识别失败: " + errMsg + (logId.isEmpty() ? "" : " (log_id=" + logId + ")") + ); + } + if (status < 200 || status >= 300) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR识别失败: HTTP " + status); + } + return objectMapper.convertValue(json, new TypeReference>() { + }); + } catch (BusinessException be) { + throw be; + } catch (Exception e) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR调用异常: " + e.getMessage()); + } + } + + private static class NormalizedPick { + final String type; + final Double probability; + final Map result; + + NormalizedPick(String type, Double probability, Map result) { + this.type = type; + this.probability = probability; + this.result = result; + } + } + + @SuppressWarnings("unchecked") + private NormalizedPick pickBestResult(Map raw) { + Object wordsResult = raw == null ? null : raw.get("words_result"); + if (!(wordsResult instanceof Iterable)) { + return null; + } + return ((Iterable) wordsResult).iterator().hasNext() ? streamPick((Iterable) wordsResult) : null; + } + + @SuppressWarnings("unchecked") + private NormalizedPick streamPick(Iterable items) { + return java.util.stream.StreamSupport.stream(items.spliterator(), false) + .filter(x -> x instanceof Map) + .map(x -> (Map) x) + .map(map -> { + String type = firstNonBlank( + trimToNull(map.get("type")), + trimToNull(map.get("detectedType")) + ); + Double prob = null; + Object p = map.get("probability"); + if (p instanceof Number) { + prob = ((Number) p).doubleValue(); + } else if (p != null) { + try { + prob = Double.parseDouble(String.valueOf(p)); + } catch (Exception ignored) { + prob = null; + } + } + Object res = map.get("result"); + Map result = res instanceof Map ? (Map) res : new LinkedHashMap<>(); + return new NormalizedPick(normalizeType(type), prob, result); + }) + .max(Comparator.comparingDouble(p -> p.probability == null ? -1.0 : p.probability)) + .orElse(null); + } + + private MultipleInvoiceOcrResponse.Normalized normalize(Map result, String type) { + MultipleInvoiceOcrResponse.Normalized normalized = new MultipleInvoiceOcrResponse.Normalized(); + if (result == null) { + return normalized; + } + + String normalizedType = normalizeType(type); + if ("vat_invoice".equals(normalizedType)) { + normalizeVatInvoice(normalized, result); + } else if ("air_ticket".equals(normalizedType)) { + normalizeAirTicket(normalized, result); + } + + fillGenericFallback(normalized, result); + return normalized; + } + + private void normalizeVatInvoice(MultipleInvoiceOcrResponse.Normalized normalized, Map result) { + normalized.setInvoiceCode(extractFirstWord( + result, + "InvoiceCode", + "InvoiceCodeConfirm", + "invoice_code", + "fapiao-daima" + )); + normalized.setInvoiceNum(extractFirstWord( + result, + "InvoiceNum", + "InvoiceNumConfirm", + "invoice_num", + "fapiao-haoma", + "invoice_number" + )); + normalized.setName(extractFirstWord( + result, + "SellerName", + "seller_name", + "name" + )); + normalized.setTaxCent(parseMoneyToCent(extractFirstWord( + result, + "TotalTax", + "TotalTaxAmount", + "tax_total", + "tax" + ))); + normalized.setTotalAmountCent(parseMoneyToCent(extractFirstWord( + result, + "AmountInFiguers", + "AmountInFigures", + "amount_in_figuers", + "amount_in_figures", + "price-tax-small", + "small_price", + "price_tax_small" + ))); + } + + private void normalizeAirTicket(MultipleInvoiceOcrResponse.Normalized normalized, Map result) { + normalized.setInvoiceCode(extractFirstWord( + result, + "invoice_code", + "InvoiceCode", + "fapiao-daima" + )); + normalized.setInvoiceNum(extractFirstWord( + result, + "invoice_num", + "InvoiceNum", + "invoice_number", + "ticket_num" + )); + normalized.setName(extractFirstWord( + result, + "name", + "Name", + "PassengName", + "PassengerName" + )); + normalized.setTaxCent(parseMoneyToCent(extractFirstWord( + result, + "commodity_tax", + "CommodityTax", + "tax_total", + "tax" + ))); + normalized.setTotalAmountCent(parseMoneyToCent(extractFirstWord( + result, + "ticket_rates", + "ticket_rates_in_figures", + "invoice_rate", + "invoice_rate_in_figure", + "TotalFare", + "total_fare", + "Fare", + "fare" + ))); + } + + private void fillGenericFallback(MultipleInvoiceOcrResponse.Normalized normalized, Map result) { + if (isBlank(normalized.getInvoiceCode())) { + normalized.setInvoiceCode(extractFirstWord( + result, + "InvoiceCode", + "InvoiceCodeConfirm", + "invoice_code", + "fapiao-daima" + )); + } + if (isBlank(normalized.getInvoiceNum())) { + normalized.setInvoiceNum(extractFirstWord( + result, + "InvoiceNum", + "InvoiceNumConfirm", + "invoice_num", + "fapiao-haoma", + "invoice_number", + "ticket_num" + )); + } + if (isBlank(normalized.getName())) { + normalized.setName(extractFirstWord( + result, + "SellerName", + "seller_name", + "PassengName", + "PassengerName", + "name", + "Name", + "buyer-name" + )); + } + if (normalized.getTaxCent() == null) { + normalized.setTaxCent(parseMoneyToCent(extractFirstWord( + result, + "TotalTax", + "commodity_tax", + "CommodityTax", + "tax", + "TotalTaxAmount", + "tax_total" + ))); + } + if (normalized.getTotalAmountCent() == null) { + normalized.setTotalAmountCent(parseMoneyToCent(extractFirstWord( + result, + "ticket_rates", + "AmountInFiguers", + "AmountInFigures", + "amount_in_figuers", + "amount_in_figures", + "TotalAmount", + "total_amount", + "TotalFare", + "total_fare", + "Fare", + "fare", + "ticket_rates_in_figures", + "invoice_rate", + "invoice_rate_in_figure", + "price-tax-small", + "small_price", + "price_tax_small" + ))); + } + } + + @SuppressWarnings("unchecked") + private static String extractFirstWord(Map result, String... keys) { + for (String key : keys) { + Object val = result.get(key); + if (val == null) { + continue; + } + if (val instanceof Iterable) { + for (Object item : (Iterable) val) { + if (item instanceof Map) { + Object word = ((Map) item).get("word"); + String text = String.valueOf(word == null ? "" : word).trim(); + if (!text.isEmpty()) { + return text; + } + } + } + } else if (val instanceof Map) { + Object word = ((Map) val).get("word"); + String text = String.valueOf(word == null ? "" : word).trim(); + if (!text.isEmpty()) { + return text; + } + } else { + String text = String.valueOf(val).trim(); + if (!text.isEmpty()) { + return text; + } + } + } + return null; + } + + private static String trimToNull(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (value != null && !value.trim().isEmpty()) { + return value.trim(); + } + } + return null; + } + + private static String normalizeType(String type) { + return type == null ? null : type.trim().toLowerCase(Locale.ROOT); + } + + private static boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } + + private static Long parseMoneyToCent(String raw) { + if (raw == null) { + return null; + } + String s = raw.trim(); + if (s.isEmpty()) { + return null; + } + s = s.replaceAll("[,,\\s]", ""); + String cleaned = s.replaceAll("[^0-9.\\-]", ""); + if (cleaned.isEmpty() || cleaned.equals("-") || cleaned.equals(".")) { + return null; + } + try { + java.math.BigDecimal bd = new java.math.BigDecimal(cleaned); + bd = bd.setScale(2, java.math.RoundingMode.HALF_UP); + return bd.multiply(new java.math.BigDecimal("100")).longValueExact(); + } catch (Exception e) { + return null; + } + } + + private static byte[] readAllBytesWithLimit(InputStream is, long maxBytes) throws java.io.IOException { + if (is == null) { + return new byte[0]; + } + long limit = maxBytes <= 0 ? 0 : maxBytes; + try (InputStream in = is; ByteArrayOutputStream bos = new ByteArrayOutputStream()) { + byte[] buf = new byte[8192]; + long total = 0; + int n; + while ((n = in.read(buf)) >= 0) { + if (n == 0) { + continue; + } + total += n; + if (limit > 0 && total > limit) { + throw new BusinessException(ErrorCodes.VALIDATION_ERROR, "文件过大,超过限制"); + } + bos.write(buf, 0, n); + } + return bos.toByteArray(); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/ocr/service/BaiduOcrTokenService.java b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduOcrTokenService.java new file mode 100644 index 0000000..1449e70 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/ocr/service/BaiduOcrTokenService.java @@ -0,0 +1,111 @@ +package com.writeoff.module.ocr.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.module.ocr.config.BaiduOcrProperties; +import org.springframework.stereotype.Service; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Objects; + +@Service +public class BaiduOcrTokenService { + private final BaiduOcrProperties props; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private final Object lock = new Object(); + private volatile String cachedToken; + private volatile long cachedTokenExpireAtMs; + + public BaiduOcrTokenService(BaiduOcrProperties props) { + this.props = props; + } + + public String getAccessToken() { + String token = cachedToken; + long expireAt = cachedTokenExpireAtMs; + long now = System.currentTimeMillis(); + if (token != null && now < expireAt) { + return token; + } + synchronized (lock) { + token = cachedToken; + expireAt = cachedTokenExpireAtMs; + now = System.currentTimeMillis(); + if (token != null && now < expireAt) { + return token; + } + refreshTokenLocked(); + if (cachedToken == null) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "获取百度OCR access_token失败"); + } + return cachedToken; + } + } + + private void refreshTokenLocked() { + String apiKey = String.valueOf(props.getApiKey() == null ? "" : props.getApiKey()).trim(); + String secretKey = String.valueOf(props.getSecretKey() == null ? "" : props.getSecretKey()).trim(); + if (apiKey.isEmpty() || secretKey.isEmpty()) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR未配置 BAIDU_OCR_API_KEY/BAIDU_OCR_SECRET_KEY"); + } + String tokenUrl = Objects.requireNonNull(props.getTokenUrl(), "tokenUrl"); + try { + String query = "grant_type=client_credentials" + + "&client_id=" + URLEncoder.encode(apiKey, "UTF-8") + + "&client_secret=" + URLEncoder.encode(secretKey, "UTF-8"); + URL url = new URL(tokenUrl + "?" + query); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(Math.max(1000, props.getConnectTimeoutMs())); + conn.setReadTimeout(Math.max(1000, props.getReadTimeoutMs())); + + int status = conn.getResponseCode(); + InputStream is = status >= 200 && status < 300 ? conn.getInputStream() : conn.getErrorStream(); + String body = readAll(is); + JsonNode json = objectMapper.readTree(body == null ? "" : body); + if (status < 200 || status >= 300) { + String err = json.has("error_description") ? json.get("error_description").asText() : (body == null ? "" : body); + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR token获取失败: " + err); + } + String accessToken = json.has("access_token") ? json.get("access_token").asText() : null; + long expiresInSec = json.has("expires_in") ? json.get("expires_in").asLong(0) : 0; + if (accessToken == null || accessToken.trim().isEmpty() || expiresInSec <= 0) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR token返回缺少字段"); + } + long now = System.currentTimeMillis(); + long safetyMs = 60_000L; + long expireAtMs = now + Math.max(0, expiresInSec * 1000L - safetyMs); + cachedToken = accessToken.trim(); + cachedTokenExpireAtMs = expireAtMs; + } catch (BusinessException be) { + throw be; + } catch (Exception e) { + throw new BusinessException(ErrorCodes.INTERNAL_ERROR, "百度OCR token获取异常: " + e.getMessage()); + } + } + + private static String readAll(InputStream is) throws IOException { + if (is == null) { + return ""; + } + try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) { + sb.append(line); + } + return sb.toString(); + } + } +} + diff --git a/backend/src/main/java/com/writeoff/module/project/controller/ProjectController.java b/backend/src/main/java/com/writeoff/module/project/controller/ProjectController.java new file mode 100644 index 0000000..919ef9e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/controller/ProjectController.java @@ -0,0 +1,124 @@ +package com.writeoff.module.project.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.project.dto.CreateProjectRequest; +import com.writeoff.module.project.dto.SaveProjectBindingsRequest; +import com.writeoff.module.project.model.Project; +import com.writeoff.module.project.service.ProjectService; +import com.writeoff.module.export.dto.CreateExportTaskRequest; +import com.writeoff.module.export.service.ExportTaskService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import javax.validation.Valid; +import org.springframework.web.bind.annotation.*; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/projects") +public class ProjectController { + private final ProjectService projectService; + private final ExportTaskService exportTaskService; + + public ProjectController(ProjectService projectService, ExportTaskService exportTaskService) { + this.projectService = projectService; + this.exportTaskService = exportTaskService; + } + + @GetMapping + public ApiResponse> list(@RequestParam(value = "parentOnly", required = false) Boolean parentOnly, + @RequestParam(value = "includeDeleted", required = false) Boolean includeDeleted) { + return ApiResponse.success(projectService.list(parentOnly, includeDeleted)); + } + + @GetMapping("/{id}/children") + public ApiResponse> children(@PathVariable("id") Long id, + @RequestParam(value = "includeDeleted", required = false) Boolean includeDeleted) { + return ApiResponse.success(projectService.listChildren(id, includeDeleted)); + } + + @PostMapping + @RequirePermission(value = "project.create", dataScope = DataScopeType.TENANT, auditAction = "PROJECT_CREATE") + public ApiResponse create(@RequestBody @Valid CreateProjectRequest request) { + return ApiResponse.success(projectService.create(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "project.create", dataScope = DataScopeType.PROJECT, auditAction = "PROJECT_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, + @RequestBody @Valid CreateProjectRequest request) { + return ApiResponse.success(projectService.update(id, request)); + } + + @GetMapping("/binding-candidates") + @RequirePermission(value = "project.bind.user", dataScope = DataScopeType.TENANT, auditAction = "PROJECT_BIND_CANDIDATES") + public ApiResponse> bindingCandidates() { + return ApiResponse.success(projectService.listBindingCandidates()); + } + + @GetMapping("/{id}/bindings") + @RequirePermission(value = "project.bind.user", dataScope = DataScopeType.PROJECT, auditAction = "PROJECT_BIND_READ") + public ApiResponse> bindings(@PathVariable("id") Long id) { + return ApiResponse.success(projectService.getBindings(id)); + } + + @GetMapping("/{id}/key-change-logs") + @RequirePermission(value = "project.key-change-log.read", dataScope = DataScopeType.PROJECT, auditAction = "PROJECT_KEY_CHANGE_LOG_LIST") + public ApiResponse>> keyChangeLogs(@PathVariable("id") Long id) { + return ApiResponse.success(projectService.listKeyChangeLogs(id)); + } + + @PostMapping("/{id}/bindings") + @RequirePermission(value = "project.bind.user", dataScope = DataScopeType.PROJECT, auditAction = "PROJECT_BIND_SAVE") + public ApiResponse saveBindings(@PathVariable("id") Long id, + @RequestBody @Valid SaveProjectBindingsRequest request) { + projectService.saveBindings(id, request); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/freeze") + @RequirePermission(value = "project.freeze", dataScope = DataScopeType.PROJECT, auditAction = "PROJECT_FREEZE") + public ApiResponse> freeze(@PathVariable("id") Long id, + @RequestParam String reason) { + Project project = projectService.freeze(id, reason); + Map result = new LinkedHashMap<>(); + result.put("projectId", project.getId()); + result.put("status", project.getStatus().name()); + result.put("reason", reason); + return ApiResponse.success(result); + } + + @PostMapping("/{id}/unfreeze") + @RequirePermission(value = "project.unfreeze", dataScope = DataScopeType.PROJECT, auditAction = "PROJECT_UNFREEZE") + public ApiResponse> unfreeze(@PathVariable("id") Long id, + @RequestBody Map body) { + String reason = body == null ? null : body.get("reason"); + Project project = projectService.unfreeze(id, reason); + Map result = new LinkedHashMap<>(); + result.put("projectId", project.getId()); + result.put("status", project.getStatus().name()); + result.put("reason", reason); + return ApiResponse.success(result); + } + + @PostMapping("/{id}/archive") + @RequirePermission(value = "project.archive", dataScope = DataScopeType.PROJECT, auditAction = "PROJECT_ARCHIVE") + public ApiResponse> archive(@PathVariable("id") Long id) { + Project project = projectService.archive(id); + Map result = new LinkedHashMap<>(); + result.put("projectId", project.getId()); + result.put("status", project.getStatus().name()); + return ApiResponse.success(result); + } + + @PostMapping("/export") + @RequirePermission(value = "project.create", dataScope = DataScopeType.TENANT, auditAction = "PROJECT_EXPORT") + public ApiResponse> exportProjects(@RequestBody @Valid CreateExportTaskRequest request) { + request.setTaskCode("PROJECT_EXPORT"); + request.setBizType("PROJECT"); + return ApiResponse.success(exportTaskService.create(request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/project/dto/CreateProjectRequest.java b/backend/src/main/java/com/writeoff/module/project/dto/CreateProjectRequest.java new file mode 100644 index 0000000..b220c10 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/dto/CreateProjectRequest.java @@ -0,0 +1,153 @@ +package com.writeoff.module.project.dto; + +import javax.validation.constraints.Min; +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; +import java.time.LocalDate; + +public class CreateProjectRequest { + @NotBlank(message = "项目名称不能为空") + private String name; + private Long parentProjectId; + private LocalDate startDate; + private LocalDate endDate; + @NotNull(message = "项目预算不能为空") + @Min(value = 1, message = "项目预算必须大于0") + private Long budgetCent; + @NotNull(message = "会议总期数不能为空") + @Min(value = 1, message = "会议总期数必须大于0") + private Integer meetingTotal; + private Long partnerEnterpriseId; + private Boolean allowMeetingOverBudget; + private Double overBudgetThresholdRatio; + private String overBudgetApprovalChainJson; + + private Double laborFeeRatio; + private Boolean allowProjectOverBudget; + private String invoiceInfo; + private String expenseRatioJson; + private String projectFeeJson; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Long getParentProjectId() { + return parentProjectId; + } + + public void setParentProjectId(Long parentProjectId) { + this.parentProjectId = parentProjectId; + } + + public LocalDate getStartDate() { + return startDate; + } + + public void setStartDate(LocalDate startDate) { + this.startDate = startDate; + } + + public LocalDate getEndDate() { + return endDate; + } + + public void setEndDate(LocalDate endDate) { + this.endDate = endDate; + } + + public Long getBudgetCent() { + return budgetCent; + } + + public void setBudgetCent(Long budgetCent) { + this.budgetCent = budgetCent; + } + + public Integer getMeetingTotal() { + return meetingTotal; + } + + public void setMeetingTotal(Integer meetingTotal) { + this.meetingTotal = meetingTotal; + } + + public Long getPartnerEnterpriseId() { + return partnerEnterpriseId; + } + + public void setPartnerEnterpriseId(Long partnerEnterpriseId) { + this.partnerEnterpriseId = partnerEnterpriseId; + } + + public Boolean getAllowMeetingOverBudget() { + return allowMeetingOverBudget; + } + + public void setAllowMeetingOverBudget(Boolean allowMeetingOverBudget) { + this.allowMeetingOverBudget = allowMeetingOverBudget; + } + + public Double getOverBudgetThresholdRatio() { + return overBudgetThresholdRatio; + } + + public void setOverBudgetThresholdRatio(Double overBudgetThresholdRatio) { + this.overBudgetThresholdRatio = overBudgetThresholdRatio; + } + + public String getOverBudgetApprovalChainJson() { + return overBudgetApprovalChainJson; + } + + public void setOverBudgetApprovalChainJson(String overBudgetApprovalChainJson) { + this.overBudgetApprovalChainJson = overBudgetApprovalChainJson; + } + + + + public Double getLaborFeeRatio() { + return laborFeeRatio; + } + + public void setLaborFeeRatio(Double laborFeeRatio) { + this.laborFeeRatio = laborFeeRatio; + } + + public Boolean getAllowProjectOverBudget() { + return allowProjectOverBudget; + } + + public void setAllowProjectOverBudget(Boolean allowProjectOverBudget) { + this.allowProjectOverBudget = allowProjectOverBudget; + } + + public String getInvoiceInfo() { + return invoiceInfo; + } + + public void setInvoiceInfo(String invoiceInfo) { + this.invoiceInfo = invoiceInfo; + } + + public String getExpenseRatioJson() { + return expenseRatioJson; + } + + public void setExpenseRatioJson(String expenseRatioJson) { + this.expenseRatioJson = expenseRatioJson; + } + + public String getProjectFeeJson() { + return projectFeeJson; + } + + public void setProjectFeeJson(String projectFeeJson) { + this.projectFeeJson = projectFeeJson; + } + +} diff --git a/backend/src/main/java/com/writeoff/module/project/dto/SaveProjectBindingsRequest.java b/backend/src/main/java/com/writeoff/module/project/dto/SaveProjectBindingsRequest.java new file mode 100644 index 0000000..13f2ae4 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/dto/SaveProjectBindingsRequest.java @@ -0,0 +1,33 @@ +package com.writeoff.module.project.dto; + +import java.util.List; + +public class SaveProjectBindingsRequest { + private List ownerUserIds; + private List executorUserIds; + private List legacyExecutorUserIds; + + public List getOwnerUserIds() { + return ownerUserIds; + } + + public void setOwnerUserIds(List ownerUserIds) { + this.ownerUserIds = ownerUserIds; + } + + public List getExecutorUserIds() { + return executorUserIds; + } + + public void setExecutorUserIds(List executorUserIds) { + this.executorUserIds = executorUserIds; + } + + public List getLegacyExecutorUserIds() { + return legacyExecutorUserIds; + } + + public void setLegacyExecutorUserIds(List legacyExecutorUserIds) { + this.legacyExecutorUserIds = legacyExecutorUserIds; + } +} diff --git a/backend/src/main/java/com/writeoff/module/project/model/Project.java b/backend/src/main/java/com/writeoff/module/project/model/Project.java new file mode 100644 index 0000000..ae8e8b2 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/model/Project.java @@ -0,0 +1,434 @@ +package com.writeoff.module.project.model; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +public class Project { + /** 项目ID */ + private Long id; + /** 项目名称 */ + private String name; + /** 子项目名称(可为空) */ + private String subProjectName; + /** 上级项目ID(为空表示一级项目) */ + private Long parentProjectId; + /** 子项目数量 */ + private Integer subProjectCount; + /** 项目开始日期 */ + private LocalDate startDate; + /** 项目结束日期 */ + private LocalDate endDate; + /** 兼容历史字段:合作企业ID */ + private Long enterpriseId; + /** 兼容历史字段:合作企业名称 */ + private String enterpriseName; + /** 主办单位ID */ + private Long hostEnterpriseId; + /** 主办单位名称(当前租户) */ + private String hostEnterpriseName; + /** 合作企业ID */ + private Long partnerEnterpriseId; + /** 主办单位负责人用户ID */ + private Long hostOwnerUserId; + /** 主办单位项目执行人用户ID */ + private Long hostExecutorUserId; + /** 合作企业负责人用户ID */ + private Long partnerOwnerUserId; + /** 合作企业项目执行人用户ID */ + private Long partnerExecutorUserId; + /** 项目总预算(分) */ + private long budgetCent; + /** 项目会议总期数 */ + private int meetingTotal; + /** 已完成核销会议数量 */ + private int meetingCompletedCount; + /** 是否允许单场超支 */ + private boolean allowMeetingOverBudget; + /** 超支阈值比例(0.1=10%) */ + private double overBudgetThresholdRatio; + /** 超支审批链(JSON) */ + private String overBudgetApprovalChainJson; + /** 预算执行率 */ + private double budgetExecutionRatio; + /** 风险标记(JSON) */ + private String riskFlagsJson; + + /** 核销进度-未开始场次 */ + private int writeOffNotStartedCount; + /** 核销进度-核销中场次 */ + private int writeOffInProgressCount; + /** 核销进度-核销完成场次 */ + private int writeOffCompletedCount; + /** 劳务费用占比 */ + private double laborFeeRatio; + /** 是否允许超过项目总费用 */ + private boolean allowProjectOverBudget; + /** 发票信息快照(便于一键复制) */ + private String invoiceInfo; + /** 费用占比(JSON) */ + private String expenseRatioJson; + /** 项目费用设置(JSON) */ + private String projectFeeJson; + /** 主办单位备份执行人用户ID */ + private Long hostBackupExecutorUserId; + /** 合作企业备份执行人用户ID */ + private Long partnerBackupExecutorUserId; + /** 项目负责人审批人用户ID */ + private Long projectOwnerApproverUserId; + /** 财务审批人用户ID */ + private Long financeApproverUserId; + /** 项目中止原因 */ + private String terminatedReason; + /** 项目冻结原因 */ + private String freezeReason; + /** 归档时间 */ + private LocalDateTime archivedAt; + /** 关键变更日志(JSON) */ + private String keyChangeLogJson; + /** 项目状态 */ + private ProjectStatus status; + /** 主办单位负责人(TENANT_ADMIN) */ + private String hostOwnerUsers; + /** 主办单位项目执行人(PROJECT_OWNER) */ + private String hostExecutorUsers; + /** 合作企业负责人(PROJECT_EXECUTOR) */ + private String partnerOwnerUsers; + /** 合作企业项目执行人(EXECUTOR) */ + private String partnerExecutorUsers; + /** 是否已软删除 */ + private boolean deleted; + + public Project(Long id, String name, Long enterpriseId, long budgetCent, int meetingTotal, ProjectStatus status) { + this( + id, name, null, null, null, enterpriseId, null, null, enterpriseId, null, null, null, null, + budgetCent, meetingTotal, 0, false, 0.1d, null, 0d, null, + meetingTotal, 0, 0, + 0d, false, null, null, null, null, null, null, null, null, null, null, status + ); + } + + public Project(Long id, String name, Long enterpriseId, String enterpriseName, long budgetCent, int meetingTotal, ProjectStatus status) { + this( + id, name, null, null, null, enterpriseId, enterpriseName, null, enterpriseId, null, null, null, null, + budgetCent, meetingTotal, 0, false, 0.1d, null, 0d, null, + meetingTotal, 0, 0, + 0d, false, null, null, null, null, null, null, null, null, null, null, status + ); + } + + public Project(Long id, + String name, + String subProjectName, + LocalDate startDate, + LocalDate endDate, + Long enterpriseId, + String enterpriseName, + Long hostEnterpriseId, + Long partnerEnterpriseId, + Long hostOwnerUserId, + Long hostExecutorUserId, + Long partnerOwnerUserId, + Long partnerExecutorUserId, + long budgetCent, + int meetingTotal, + int meetingCompletedCount, + boolean allowMeetingOverBudget, + double overBudgetThresholdRatio, + String overBudgetApprovalChainJson, + double budgetExecutionRatio, + String riskFlagsJson, + + int writeOffNotStartedCount, + int writeOffInProgressCount, + int writeOffCompletedCount, + double laborFeeRatio, + boolean allowProjectOverBudget, + String invoiceInfo, + String expenseRatioJson, + Long hostBackupExecutorUserId, + Long partnerBackupExecutorUserId, + Long projectOwnerApproverUserId, + Long financeApproverUserId, + String terminatedReason, + String freezeReason, + LocalDateTime archivedAt, + String keyChangeLogJson, + ProjectStatus status) { + this.id = id; + this.name = name; + this.subProjectName = subProjectName; + this.startDate = startDate; + this.endDate = endDate; + this.enterpriseId = enterpriseId; + this.enterpriseName = enterpriseName; + this.hostEnterpriseId = hostEnterpriseId; + this.partnerEnterpriseId = partnerEnterpriseId; + this.hostOwnerUserId = hostOwnerUserId; + this.hostExecutorUserId = hostExecutorUserId; + this.partnerOwnerUserId = partnerOwnerUserId; + this.partnerExecutorUserId = partnerExecutorUserId; + this.budgetCent = budgetCent; + this.meetingTotal = meetingTotal; + this.meetingCompletedCount = meetingCompletedCount; + this.allowMeetingOverBudget = allowMeetingOverBudget; + this.overBudgetThresholdRatio = overBudgetThresholdRatio; + this.overBudgetApprovalChainJson = overBudgetApprovalChainJson; + this.budgetExecutionRatio = budgetExecutionRatio; + this.riskFlagsJson = riskFlagsJson; + + this.writeOffNotStartedCount = writeOffNotStartedCount; + this.writeOffInProgressCount = writeOffInProgressCount; + this.writeOffCompletedCount = writeOffCompletedCount; + this.laborFeeRatio = laborFeeRatio; + this.allowProjectOverBudget = allowProjectOverBudget; + this.invoiceInfo = invoiceInfo; + this.expenseRatioJson = expenseRatioJson; + this.hostBackupExecutorUserId = hostBackupExecutorUserId; + this.partnerBackupExecutorUserId = partnerBackupExecutorUserId; + this.projectOwnerApproverUserId = projectOwnerApproverUserId; + this.financeApproverUserId = financeApproverUserId; + this.terminatedReason = terminatedReason; + this.freezeReason = freezeReason; + this.archivedAt = archivedAt; + this.keyChangeLogJson = keyChangeLogJson; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getName() { + return name; + } + + public String getSubProjectName() { + return subProjectName; + } + + public Integer getSubProjectCount() { + return subProjectCount; + } + + public Long getParentProjectId() { + return parentProjectId; + } + + public LocalDate getStartDate() { + return startDate; + } + + public LocalDate getEndDate() { + return endDate; + } + + public Long getEnterpriseId() { + return enterpriseId; + } + + public String getEnterpriseName() { + return enterpriseName; + } + + public Long getHostEnterpriseId() { + return hostEnterpriseId; + } + + public String getHostEnterpriseName() { + return hostEnterpriseName; + } + + public Long getPartnerEnterpriseId() { + return partnerEnterpriseId; + } + + public Long getHostOwnerUserId() { + return hostOwnerUserId; + } + + public Long getHostExecutorUserId() { + return hostExecutorUserId; + } + + public Long getPartnerOwnerUserId() { + return partnerOwnerUserId; + } + + public Long getPartnerExecutorUserId() { + return partnerExecutorUserId; + } + + public long getBudgetCent() { + return budgetCent; + } + + public int getMeetingTotal() { + return meetingTotal; + } + + public int getMeetingCompletedCount() { + return meetingCompletedCount; + } + + public boolean isAllowMeetingOverBudget() { + return allowMeetingOverBudget; + } + + public double getOverBudgetThresholdRatio() { + return overBudgetThresholdRatio; + } + + public String getOverBudgetApprovalChainJson() { + return overBudgetApprovalChainJson; + } + + public double getBudgetExecutionRatio() { + return budgetExecutionRatio; + } + + public String getRiskFlagsJson() { + return riskFlagsJson; + } + + + + public int getWriteOffNotStartedCount() { + return writeOffNotStartedCount; + } + + public int getWriteOffInProgressCount() { + return writeOffInProgressCount; + } + + public int getWriteOffCompletedCount() { + return writeOffCompletedCount; + } + + public double getLaborFeeRatio() { + return laborFeeRatio; + } + + public boolean isAllowProjectOverBudget() { + return allowProjectOverBudget; + } + + public String getInvoiceInfo() { + return invoiceInfo; + } + + public String getExpenseRatioJson() { + return expenseRatioJson; + } + + public String getProjectFeeJson() { + return projectFeeJson; + } + + public Long getHostBackupExecutorUserId() { + return hostBackupExecutorUserId; + } + + public Long getPartnerBackupExecutorUserId() { + return partnerBackupExecutorUserId; + } + + public Long getProjectOwnerApproverUserId() { + return projectOwnerApproverUserId; + } + + public Long getFinanceApproverUserId() { + return financeApproverUserId; + } + + public String getTerminatedReason() { + return terminatedReason; + } + + public String getFreezeReason() { + return freezeReason; + } + + public LocalDateTime getArchivedAt() { + return archivedAt; + } + + public String getKeyChangeLogJson() { + return keyChangeLogJson; + } + + public ProjectStatus getStatus() { + return status; + } + + public String getHostOwnerUsers() { + return hostOwnerUsers; + } + + public String getHostExecutorUsers() { + return hostExecutorUsers; + } + + public String getPartnerOwnerUsers() { + return partnerOwnerUsers; + } + + public String getPartnerExecutorUsers() { + return partnerExecutorUsers; + } + + public void setStatus(ProjectStatus status) { + this.status = status; + } + + public void setFreezeReason(String freezeReason) { + this.freezeReason = freezeReason; + } + + public void setTerminatedReason(String terminatedReason) { + this.terminatedReason = terminatedReason; + } + + public void setArchivedAt(LocalDateTime archivedAt) { + this.archivedAt = archivedAt; + } + + public void setSubProjectCount(Integer subProjectCount) { + this.subProjectCount = subProjectCount; + } + + public void setParentProjectId(Long parentProjectId) { + this.parentProjectId = parentProjectId; + } + + public void setHostEnterpriseName(String hostEnterpriseName) { + this.hostEnterpriseName = hostEnterpriseName; + } + + public void setHostOwnerUsers(String hostOwnerUsers) { + this.hostOwnerUsers = hostOwnerUsers; + } + + public void setHostExecutorUsers(String hostExecutorUsers) { + this.hostExecutorUsers = hostExecutorUsers; + } + + public void setPartnerOwnerUsers(String partnerOwnerUsers) { + this.partnerOwnerUsers = partnerOwnerUsers; + } + + public void setPartnerExecutorUsers(String partnerExecutorUsers) { + this.partnerExecutorUsers = partnerExecutorUsers; + } + + public void setProjectFeeJson(String projectFeeJson) { + this.projectFeeJson = projectFeeJson; + } + + public boolean isDeleted() { + return deleted; + } + + public void setDeleted(boolean deleted) { + this.deleted = deleted; + } +} diff --git a/backend/src/main/java/com/writeoff/module/project/model/ProjectStatus.java b/backend/src/main/java/com/writeoff/module/project/model/ProjectStatus.java new file mode 100644 index 0000000..6eaef19 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/model/ProjectStatus.java @@ -0,0 +1,10 @@ +package com.writeoff.module.project.model; + +public enum ProjectStatus { + WAITING, + IN_PROGRESS, + COMPLETED, + TERMINATED, + ARCHIVED, + FROZEN +} diff --git a/backend/src/main/java/com/writeoff/module/project/repository/InMemoryProjectRepository.java b/backend/src/main/java/com/writeoff/module/project/repository/InMemoryProjectRepository.java new file mode 100644 index 0000000..530aba7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/repository/InMemoryProjectRepository.java @@ -0,0 +1,97 @@ +package com.writeoff.module.project.repository; + +import com.writeoff.module.project.model.Project; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "in-memory") +public class InMemoryProjectRepository implements ProjectRepository { + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(1000); + + @Override + public Project save(Project project) { + if (project.getId() == null) { + Project newProject = new Project( + idGenerator.incrementAndGet(), + project.getName(), + null, + project.getStartDate(), + project.getEndDate(), + project.getEnterpriseId(), + null, + project.getHostEnterpriseId(), + project.getPartnerEnterpriseId(), + project.getHostOwnerUserId(), + project.getHostExecutorUserId(), + project.getPartnerOwnerUserId(), + project.getPartnerExecutorUserId(), + project.getBudgetCent(), + project.getMeetingTotal(), + project.getMeetingCompletedCount(), + project.isAllowMeetingOverBudget(), + project.getOverBudgetThresholdRatio(), + project.getOverBudgetApprovalChainJson(), + project.getBudgetExecutionRatio(), + project.getRiskFlagsJson(), + + project.getWriteOffNotStartedCount(), + project.getWriteOffInProgressCount(), + project.getWriteOffCompletedCount(), + project.getLaborFeeRatio(), + project.isAllowProjectOverBudget(), + project.getInvoiceInfo(), + project.getExpenseRatioJson(), + project.getHostBackupExecutorUserId(), + project.getPartnerBackupExecutorUserId(), + project.getProjectOwnerApproverUserId(), + project.getFinanceApproverUserId(), + project.getTerminatedReason(), + project.getFreezeReason(), + project.getArchivedAt(), + project.getKeyChangeLogJson(), + project.getStatus() + ); + newProject.setSubProjectCount(project.getSubProjectCount()); + newProject.setParentProjectId(project.getParentProjectId()); + newProject.setHostEnterpriseName(project.getHostEnterpriseName()); + newProject.setHostOwnerUsers(project.getHostOwnerUsers()); + newProject.setHostExecutorUsers(project.getHostExecutorUsers()); + newProject.setPartnerOwnerUsers(project.getPartnerOwnerUsers()); + newProject.setPartnerExecutorUsers(project.getPartnerExecutorUsers()); + newProject.setProjectFeeJson(project.getProjectFeeJson()); + store.put(newProject.getId(), newProject); + return newProject; + } + store.put(project.getId(), project); + return project; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public List findAll(boolean includeDeleted) { + return new ArrayList<>(store.values()); + } + + @Override + public List findByParentProjectId(Long parentProjectId, boolean includeDeleted) { + return store.values().stream() + .filter(project -> { + Long currentParentId = project.getParentProjectId(); + return currentParentId != null && currentParentId.equals(parentProjectId); + }) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/main/java/com/writeoff/module/project/repository/JdbcProjectRepository.java b/backend/src/main/java/com/writeoff/module/project/repository/JdbcProjectRepository.java new file mode 100644 index 0000000..5b2f315 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/repository/JdbcProjectRepository.java @@ -0,0 +1,302 @@ +package com.writeoff.module.project.repository; + +import com.writeoff.module.project.model.Project; +import com.writeoff.module.project.model.ProjectStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import com.writeoff.security.AuthContext; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.util.List; +import java.util.Optional; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "jdbc", matchIfMissing = true) +public class JdbcProjectRepository implements ProjectRepository { + private final JdbcTemplate jdbcTemplate; + private static final RowMapper ROW_MAPPER = (rs, n) -> { + Project p = new Project( + rs.getLong("id"), + rs.getString("project_name"), + null, + rs.getDate("start_date") == null ? null : rs.getDate("start_date").toLocalDate(), + rs.getDate("end_date") == null ? null : rs.getDate("end_date").toLocalDate(), + rs.getObject("enterprise_id") == null ? null : rs.getLong("enterprise_id"), + rs.getString("enterprise_name"), + null, + rs.getObject("partner_enterprise_id") == null ? null : rs.getLong("partner_enterprise_id"), + null, + null, + null, + null, + rs.getLong("budget_cent"), + rs.getInt("meeting_total"), + rs.getInt("meeting_completed_count"), + rs.getInt("allow_meeting_over_budget") == 1, + rs.getBigDecimal("over_budget_threshold_ratio") == null ? 0.1d : rs.getBigDecimal("over_budget_threshold_ratio").doubleValue(), + rs.getString("over_budget_approval_chain_json"), + rs.getBigDecimal("budget_execution_ratio") == null ? 0d : rs.getBigDecimal("budget_execution_ratio").doubleValue(), + rs.getString("risk_flags_json"), + + rs.getInt("write_off_not_started_count"), + rs.getInt("write_off_in_progress_count"), + rs.getInt("write_off_completed_count"), + rs.getBigDecimal("labor_fee_ratio") == null ? 0d : rs.getBigDecimal("labor_fee_ratio").doubleValue(), + rs.getInt("allow_project_over_budget") == 1, + rs.getString("invoice_info"), + rs.getString("expense_ratio_json"), + null, + null, + null, + null, + rs.getString("terminated_reason"), + rs.getString("freeze_reason"), + rs.getTimestamp("archived_at") == null ? null : rs.getTimestamp("archived_at").toLocalDateTime(), + rs.getString("key_change_log_json"), + ProjectStatus.valueOf(rs.getString("status")) + ); + p.setSubProjectCount(rs.getObject("sub_project_count") == null ? 0 : rs.getInt("sub_project_count")); + p.setParentProjectId(rs.getObject("parent_project_id") == null ? null : rs.getLong("parent_project_id")); + p.setHostEnterpriseName(rs.getString("host_enterprise_name")); + p.setHostOwnerUsers(rs.getString("host_owner_users")); + p.setHostExecutorUsers(rs.getString("host_executor_users")); + p.setPartnerOwnerUsers(rs.getString("partner_owner_users")); + p.setPartnerExecutorUsers(rs.getString("partner_executor_users")); + p.setProjectFeeJson(rs.getString("project_fee_json")); + p.setDeleted(rs.getInt("is_deleted") == 1); + return p; + }; + + public JdbcProjectRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public Project save(Project project) { + if (project.getId() == null) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO project (tenant_id, project_name, parent_project_id, start_date, end_date, host_enterprise_name, partner_enterprise_id, budget_cent, meeting_total, meeting_completed_count, allow_meeting_over_budget, over_budget_threshold_ratio, over_budget_approval_chain_json, budget_execution_ratio, risk_flags_json, write_off_not_started_count, write_off_in_progress_count, write_off_completed_count, labor_fee_ratio, allow_project_over_budget, invoice_info, expense_ratio_json, project_fee_json, terminated_reason, freeze_reason, archived_at, key_change_log_json, status, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + Statement.RETURN_GENERATED_KEYS + ); + Long operator = safeUserId(); + ps.setLong(1, tenantId()); + ps.setString(2, project.getName()); + setNullableLong(ps, 3, project.getParentProjectId()); + if (project.getStartDate() == null) { + ps.setNull(4, java.sql.Types.DATE); + } else { + ps.setDate(4, java.sql.Date.valueOf(project.getStartDate())); + } + if (project.getEndDate() == null) { + ps.setNull(5, java.sql.Types.DATE); + } else { + ps.setDate(5, java.sql.Date.valueOf(project.getEndDate())); + } + ps.setString(6, project.getHostEnterpriseName()); + setNullableLong(ps, 7, project.getPartnerEnterpriseId()); + ps.setLong(8, project.getBudgetCent()); + ps.setInt(9, project.getMeetingTotal()); + ps.setInt(10, project.getMeetingCompletedCount()); + ps.setInt(11, project.isAllowMeetingOverBudget() ? 1 : 0); + ps.setBigDecimal(12, java.math.BigDecimal.valueOf(project.getOverBudgetThresholdRatio())); + ps.setString(13, project.getOverBudgetApprovalChainJson()); + ps.setBigDecimal(14, java.math.BigDecimal.valueOf(project.getBudgetExecutionRatio())); + ps.setString(15, project.getRiskFlagsJson()); + ps.setInt(16, project.getWriteOffNotStartedCount()); + ps.setInt(17, project.getWriteOffInProgressCount()); + ps.setInt(18, project.getWriteOffCompletedCount()); + ps.setBigDecimal(19, java.math.BigDecimal.valueOf(project.getLaborFeeRatio())); + ps.setInt(20, project.isAllowProjectOverBudget() ? 1 : 0); + ps.setString(21, project.getInvoiceInfo()); + ps.setString(22, project.getExpenseRatioJson()); + ps.setString(23, project.getProjectFeeJson()); + ps.setString(24, project.getTerminatedReason()); + ps.setString(25, project.getFreezeReason()); + if (project.getArchivedAt() == null) { + ps.setNull(26, java.sql.Types.TIMESTAMP); + } else { + ps.setTimestamp(26, java.sql.Timestamp.valueOf(project.getArchivedAt())); + } + ps.setString(27, project.getKeyChangeLogJson()); + ps.setString(28, project.getStatus().name()); + ps.setLong(29, operator); + ps.setLong(30, operator); + return ps; + }, keyHolder); + Number key = keyHolder.getKey(); + Long id = key == null ? null : key.longValue(); + Project created = new Project( + id, + project.getName(), + null, + project.getStartDate(), + project.getEndDate(), + project.getEnterpriseId(), + null, + null, + project.getPartnerEnterpriseId(), + null, + null, + null, + null, + project.getBudgetCent(), + project.getMeetingTotal(), + project.getMeetingCompletedCount(), + project.isAllowMeetingOverBudget(), + project.getOverBudgetThresholdRatio(), + project.getOverBudgetApprovalChainJson(), + project.getBudgetExecutionRatio(), + project.getRiskFlagsJson(), + + project.getWriteOffNotStartedCount(), + project.getWriteOffInProgressCount(), + project.getWriteOffCompletedCount(), + project.getLaborFeeRatio(), + project.isAllowProjectOverBudget(), + project.getInvoiceInfo(), + project.getExpenseRatioJson(), + null, + null, + null, + null, + project.getTerminatedReason(), + project.getFreezeReason(), + project.getArchivedAt(), + project.getKeyChangeLogJson(), + project.getStatus() + ); + created.setSubProjectCount(project.getSubProjectCount()); + created.setParentProjectId(project.getParentProjectId()); + created.setHostEnterpriseName(project.getHostEnterpriseName()); + created.setProjectFeeJson(project.getProjectFeeJson()); + return created; + } + jdbcTemplate.update( + "UPDATE project SET project_name=?, parent_project_id=?, start_date=?, end_date=?, host_enterprise_name=?, partner_enterprise_id=?, budget_cent=?, meeting_total=?, meeting_completed_count=?, allow_meeting_over_budget=?, " + + "over_budget_threshold_ratio=?, over_budget_approval_chain_json=?, budget_execution_ratio=?, risk_flags_json=?, write_off_not_started_count=?, write_off_in_progress_count=?, write_off_completed_count=?, labor_fee_ratio=?, allow_project_over_budget=?, invoice_info=?, expense_ratio_json=?, project_fee_json=?, terminated_reason=?, freeze_reason=?, archived_at=?, key_change_log_json=?, status=?, updated_by=? WHERE tenant_id=? AND id=?", + project.getName(), + project.getParentProjectId(), + project.getStartDate() == null ? null : java.sql.Date.valueOf(project.getStartDate()), + project.getEndDate() == null ? null : java.sql.Date.valueOf(project.getEndDate()), + project.getHostEnterpriseName(), + project.getPartnerEnterpriseId(), + project.getBudgetCent(), + project.getMeetingTotal(), + project.getMeetingCompletedCount(), + project.isAllowMeetingOverBudget() ? 1 : 0, + java.math.BigDecimal.valueOf(project.getOverBudgetThresholdRatio()), + project.getOverBudgetApprovalChainJson(), + java.math.BigDecimal.valueOf(project.getBudgetExecutionRatio()), + project.getRiskFlagsJson(), + + project.getWriteOffNotStartedCount(), + project.getWriteOffInProgressCount(), + project.getWriteOffCompletedCount(), + java.math.BigDecimal.valueOf(project.getLaborFeeRatio()), + project.isAllowProjectOverBudget() ? 1 : 0, + project.getInvoiceInfo(), + project.getExpenseRatioJson(), + project.getProjectFeeJson(), + project.getTerminatedReason(), + project.getFreezeReason(), + project.getArchivedAt() == null ? null : java.sql.Timestamp.valueOf(project.getArchivedAt()), + project.getKeyChangeLogJson(), + project.getStatus().name(), + safeUserId(), + tenantId(), + project.getId() + ); + return project; + } + + @Override + public Optional findById(Long id) { + List list = jdbcTemplate.query( + "SELECT p.id, p.project_name, p.parent_project_id, " + + "(SELECT COUNT(1) FROM project c WHERE c.tenant_id=p.tenant_id AND c.parent_project_id=p.id AND c.is_deleted=0) AS sub_project_count, " + + "p.start_date, p.end_date, p.partner_enterprise_id AS enterprise_id, e.enterprise_name, p.host_enterprise_name, p.partner_enterprise_id, p.budget_cent, p.meeting_total, p.meeting_completed_count, " + + "p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " + + "p.is_deleted, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM sys_user su JOIN user_role ur ON su.tenant_id=ur.tenant_id AND su.id=ur.user_id JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id WHERE su.tenant_id=p.tenant_id AND su.is_deleted=0 AND r.role_code='TENANT_ADMIN') AS host_owner_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_OWNER' AND b.is_deleted=0 AND su.is_deleted=0) AS host_executor_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_EXECUTOR' AND b.is_deleted=0 AND su.is_deleted=0) AS partner_owner_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='EXECUTOR' AND b.is_deleted=0 AND su.is_deleted=0) AS partner_executor_users " + + "FROM project p LEFT JOIN enterprise e ON p.tenant_id=e.tenant_id AND p.partner_enterprise_id=e.id " + + "WHERE p.tenant_id=? AND p.id=? AND p.is_deleted=0", + ROW_MAPPER, + tenantId(), + id + ); + return list.stream().findFirst(); + } + + @Override + public List findAll(boolean includeDeleted) { + String whereSql = includeDeleted ? "WHERE p.tenant_id=? " : "WHERE p.tenant_id=? AND p.is_deleted=0 "; + return jdbcTemplate.query( + "SELECT p.id, p.project_name, p.parent_project_id, " + + "(SELECT COUNT(1) FROM project c WHERE c.tenant_id=p.tenant_id AND c.parent_project_id=p.id AND c.is_deleted=0) AS sub_project_count, " + + "p.start_date, p.end_date, p.partner_enterprise_id AS enterprise_id, e.enterprise_name, p.host_enterprise_name, p.partner_enterprise_id, p.budget_cent, p.meeting_total, p.meeting_completed_count, " + + "p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " + + "p.is_deleted, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM sys_user su JOIN user_role ur ON su.tenant_id=ur.tenant_id AND su.id=ur.user_id JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id WHERE su.tenant_id=p.tenant_id AND su.is_deleted=0 AND r.role_code='TENANT_ADMIN') AS host_owner_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_OWNER' AND b.is_deleted=0 AND su.is_deleted=0) AS host_executor_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_EXECUTOR' AND b.is_deleted=0 AND su.is_deleted=0) AS partner_owner_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='EXECUTOR' AND b.is_deleted=0 AND su.is_deleted=0) AS partner_executor_users " + + "FROM project p LEFT JOIN enterprise e ON p.tenant_id=e.tenant_id AND p.partner_enterprise_id=e.id " + + whereSql + + "ORDER BY p.id DESC", + ROW_MAPPER, + tenantId() + ); + } + + @Override + public List findByParentProjectId(Long parentProjectId, boolean includeDeleted) { + String whereSql = includeDeleted + ? "WHERE p.tenant_id=? AND p.parent_project_id=? " + : "WHERE p.tenant_id=? AND p.parent_project_id=? AND p.is_deleted=0 "; + return jdbcTemplate.query( + "SELECT p.id, p.project_name, p.parent_project_id, " + + "(SELECT COUNT(1) FROM project c WHERE c.tenant_id=p.tenant_id AND c.parent_project_id=p.id AND c.is_deleted=0) AS sub_project_count, " + + "p.start_date, p.end_date, p.partner_enterprise_id AS enterprise_id, e.enterprise_name, p.host_enterprise_name, p.partner_enterprise_id, p.budget_cent, p.meeting_total, p.meeting_completed_count, " + + "p.allow_meeting_over_budget, p.over_budget_threshold_ratio, p.over_budget_approval_chain_json, p.budget_execution_ratio, p.risk_flags_json, p.write_off_not_started_count, p.write_off_in_progress_count, p.write_off_completed_count, p.labor_fee_ratio, p.allow_project_over_budget, p.invoice_info, p.expense_ratio_json, p.project_fee_json, p.terminated_reason, p.freeze_reason, p.archived_at, p.key_change_log_json, p.status, " + + "p.is_deleted, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM sys_user su JOIN user_role ur ON su.tenant_id=ur.tenant_id AND su.id=ur.user_id JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id WHERE su.tenant_id=p.tenant_id AND su.is_deleted=0 AND r.role_code='TENANT_ADMIN') AS host_owner_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_OWNER' AND b.is_deleted=0 AND su.is_deleted=0) AS host_executor_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='PROJECT_EXECUTOR' AND b.is_deleted=0 AND su.is_deleted=0) AS partner_owner_users, " + + "(SELECT GROUP_CONCAT(DISTINCT su.user_name SEPARATOR '、') FROM project_user_binding b JOIN sys_user su ON b.tenant_id=su.tenant_id AND b.user_id=su.id WHERE b.tenant_id=p.tenant_id AND b.project_id=p.id AND b.bind_role_code='EXECUTOR' AND b.is_deleted=0 AND su.is_deleted=0) AS partner_executor_users " + + "FROM project p LEFT JOIN enterprise e ON p.tenant_id=e.tenant_id AND p.partner_enterprise_id=e.id " + + whereSql + + "ORDER BY p.id DESC", + ROW_MAPPER, + tenantId(), + parentProjectId + ); + } + + private static void setNullableLong(PreparedStatement ps, int index, Long value) throws java.sql.SQLException { + if (value == null) { + ps.setNull(index, java.sql.Types.BIGINT); + } else { + ps.setLong(index, value); + } + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/project/repository/ProjectRepository.java b/backend/src/main/java/com/writeoff/module/project/repository/ProjectRepository.java new file mode 100644 index 0000000..e9b455d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/repository/ProjectRepository.java @@ -0,0 +1,16 @@ +package com.writeoff.module.project.repository; + +import com.writeoff.module.project.model.Project; + +import java.util.List; +import java.util.Optional; + +public interface ProjectRepository { + Project save(Project project); + + Optional findById(Long id); + + List findAll(boolean includeDeleted); + + List findByParentProjectId(Long parentProjectId, boolean includeDeleted); +} diff --git a/backend/src/main/java/com/writeoff/module/project/service/ProjectService.java b/backend/src/main/java/com/writeoff/module/project/service/ProjectService.java new file mode 100644 index 0000000..aa7885f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/project/service/ProjectService.java @@ -0,0 +1,793 @@ +package com.writeoff.module.project.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.writeoff.module.project.dto.CreateProjectRequest; +import com.writeoff.module.project.dto.SaveProjectBindingsRequest; +import com.writeoff.module.project.model.Project; +import com.writeoff.module.project.model.ProjectStatus; +import com.writeoff.module.project.repository.ProjectRepository; +import com.writeoff.module.system.model.BizChangeLogInfo; +import com.writeoff.module.system.service.BizChangeLogService; +import com.writeoff.module.system.service.DataPermissionService; +import com.writeoff.module.system.service.EnterpriseService; +import com.writeoff.security.PermissionService; +import com.writeoff.security.AuthContext; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class ProjectService { + private final ObjectMapper objectMapper = new ObjectMapper(); + private final ProjectRepository projectRepository; + private final DataPermissionService dataPermissionService; + private final EnterpriseService enterpriseService; + private final JdbcTemplate jdbcTemplate; + private final PermissionService permissionService; + private final BizChangeLogService bizChangeLogService; + + @Autowired + public ProjectService(ProjectRepository projectRepository, + DataPermissionService dataPermissionService, + EnterpriseService enterpriseService, + JdbcTemplate jdbcTemplate, + PermissionService permissionService, + BizChangeLogService bizChangeLogService) { + this.projectRepository = projectRepository; + this.dataPermissionService = dataPermissionService; + this.enterpriseService = enterpriseService; + this.jdbcTemplate = jdbcTemplate; + this.permissionService = permissionService; + this.bizChangeLogService = bizChangeLogService; + } + + public ProjectService(ProjectRepository projectRepository) { + this(projectRepository, null, null, null, null, null); + } + + public PageResult list(Boolean parentOnly, Boolean includeDeleted) { + List list = projectRepository.findAll(Boolean.TRUE.equals(includeDeleted)); + if (Boolean.TRUE.equals(parentOnly)) { + list = list.stream() + .filter(project -> project.getParentProjectId() == null) + .collect(Collectors.toList()); + } + if (dataPermissionService != null) { + DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope(); + final Map creatorMap = scope.isProjectOwnerOnly() + ? dataPermissionService.listProjectCreators(list.stream().map(Project::getId).collect(Collectors.toCollection(HashSet::new))) + : new LinkedHashMap(); + list = list.stream() + .filter(project -> dataPermissionService.canAccessProject(project.getId(), creatorMap.get(project.getId()), scope)) + .collect(Collectors.toList()); + } + return new PageResult<>(list, list.size(), 1, 20); + } + + public List listChildren(Long parentProjectId) { + getById(parentProjectId); + List children = projectRepository.findByParentProjectId(parentProjectId, false); + if (dataPermissionService != null) { + DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope(); + final Map creatorMap = scope.isProjectOwnerOnly() + ? dataPermissionService.listProjectCreators(children.stream().map(Project::getId).collect(Collectors.toCollection(HashSet::new))) + : new LinkedHashMap(); + children = children.stream() + .filter(project -> dataPermissionService.canAccessProject(project.getId(), creatorMap.get(project.getId()), scope)) + .collect(Collectors.toList()); + } + return children; + } + + public List listChildren(Long parentProjectId, Boolean includeDeleted) { + getById(parentProjectId); + List children = projectRepository.findByParentProjectId(parentProjectId, Boolean.TRUE.equals(includeDeleted)); + if (dataPermissionService != null) { + DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope(); + final Map creatorMap = scope.isProjectOwnerOnly() + ? dataPermissionService.listProjectCreators(children.stream().map(Project::getId).collect(Collectors.toCollection(HashSet::new))) + : new LinkedHashMap(); + children = children.stream() + .filter(project -> dataPermissionService.canAccessProject(project.getId(), creatorMap.get(project.getId()), scope)) + .collect(Collectors.toList()); + } + return children; + } + + public Project create(CreateProjectRequest request) { + if (enterpriseService != null) { + enterpriseService.assertEnabled(request.getPartnerEnterpriseId()); + } + // 项目周期合法性校验:结束日期不能早于开始日期。 + if (request.getStartDate() != null && request.getEndDate() != null + && request.getEndDate().isBefore(request.getStartDate())) { + throw new BusinessException(10001, "项目结束日期不能早于开始日期"); + } + if (request.getParentProjectId() != null) { + getById(request.getParentProjectId()); + } + ProjectFeeSummary projectFee = buildProjectFeeSummary(request.getProjectFeeJson()); + double budgetExecutionRatio = calculateBudgetExecutionRatio(request.getBudgetCent(), projectFee.totalCent); + assertProjectBudgetConstraint( + request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(), + request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(), + budgetExecutionRatio, + request.getBudgetCent(), + projectFee.totalCent + ); + Project project = new Project( + null, + request.getName(), + null, + request.getStartDate(), + request.getEndDate(), + request.getPartnerEnterpriseId(), + null, + null, + request.getPartnerEnterpriseId(), + null, + null, + null, + null, + request.getBudgetCent(), + request.getMeetingTotal(), + 0, + request.getAllowMeetingOverBudget() != null && request.getAllowMeetingOverBudget(), + request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(), + request.getOverBudgetApprovalChainJson(), + budgetExecutionRatio, + null, + + request.getMeetingTotal(), + 0, + 0, + request.getLaborFeeRatio() == null ? 0d : request.getLaborFeeRatio(), + request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(), + request.getInvoiceInfo(), + request.getExpenseRatioJson(), + null, + null, + null, + null, + null, + null, + null, + null, + ProjectStatus.WAITING + ); + project.setParentProjectId(request.getParentProjectId()); + project.setHostEnterpriseName(resolveCurrentTenantName()); + project.setProjectFeeJson(projectFee.normalizedJson); + Project saved = projectRepository.save(project); + logProjectCreate(saved); + return saved; + } + + public Project update(Long projectId, CreateProjectRequest request) { + Project existing = getById(projectId); + if (enterpriseService != null && request.getPartnerEnterpriseId() != null) { + enterpriseService.assertEnabled(request.getPartnerEnterpriseId()); + } + if (request.getStartDate() != null && request.getEndDate() != null + && request.getEndDate().isBefore(request.getStartDate())) { + throw new BusinessException(10001, "项目结束日期不能早于开始日期"); + } + String requestProjectFeeJson = request.getProjectFeeJson(); + String sourceProjectFeeJson = (requestProjectFeeJson == null || requestProjectFeeJson.trim().isEmpty()) + ? existing.getProjectFeeJson() + : requestProjectFeeJson; + ProjectFeeSummary projectFee = buildProjectFeeSummary(sourceProjectFeeJson); + double budgetExecutionRatio = calculateBudgetExecutionRatio(request.getBudgetCent(), projectFee.totalCent); + assertProjectBudgetConstraint( + request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(), + request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(), + budgetExecutionRatio, + request.getBudgetCent(), + projectFee.totalCent + ); + Project project = new Project( + existing.getId(), + request.getName(), + null, + request.getStartDate(), + request.getEndDate(), + request.getPartnerEnterpriseId(), + existing.getEnterpriseName(), + null, + request.getPartnerEnterpriseId(), + null, + null, + null, + null, + request.getBudgetCent(), + request.getMeetingTotal(), + existing.getMeetingCompletedCount(), + request.getAllowMeetingOverBudget() != null && request.getAllowMeetingOverBudget(), + request.getOverBudgetThresholdRatio() == null ? 0.1d : request.getOverBudgetThresholdRatio(), + request.getOverBudgetApprovalChainJson(), + budgetExecutionRatio, + existing.getRiskFlagsJson(), + + existing.getWriteOffNotStartedCount(), + existing.getWriteOffInProgressCount(), + existing.getWriteOffCompletedCount(), + request.getLaborFeeRatio() == null ? existing.getLaborFeeRatio() : request.getLaborFeeRatio(), + request.getAllowProjectOverBudget() != null && request.getAllowProjectOverBudget(), + request.getInvoiceInfo(), + request.getExpenseRatioJson(), + null, + null, + null, + null, + existing.getTerminatedReason(), + existing.getFreezeReason(), + existing.getArchivedAt(), + existing.getKeyChangeLogJson(), + existing.getStatus() + ); + project.setParentProjectId(request.getParentProjectId() == null ? existing.getParentProjectId() : request.getParentProjectId()); + project.setHostEnterpriseName(resolveCurrentTenantName()); + project.setProjectFeeJson(projectFee.normalizedJson); + Project saved = projectRepository.save(project); + logProjectUpdate(existing, saved); + return saved; + } + + public Project freeze(Long projectId, String reason) { + Project project = projectRepository.findById(projectId) + .orElseThrow(() -> new BusinessException(10003, "项目不存在")); + if (reason == null || reason.trim().isEmpty()) { + throw new BusinessException(10001, "冻结原因不能为空"); + } + project.setStatus(ProjectStatus.FROZEN); + // 冻结原因需要与状态一起落库,满足风控留痕要求。 + project.setFreezeReason(reason.trim()); + Project saved = projectRepository.save(project); + if (bizChangeLogService != null) { + bizChangeLogService.logAction("PROJECT", saved.getId(), "PROJECT_FREEZE", reason.trim()); + } + return saved; + } + + public Project getById(Long projectId) { + return projectRepository.findById(projectId) + .orElseThrow(() -> new BusinessException(10003, "项目不存在")); + } + + public Project unfreeze(Long projectId, String reason) { + Project project = getById(projectId); + if (project.getStatus() != ProjectStatus.FROZEN) { + throw new BusinessException(30001, "仅冻结状态的项目允许解冻"); + } + if (reason == null || reason.trim().isEmpty()) { + throw new BusinessException(10001, "解冻原因不能为空"); + } + project.setStatus(ProjectStatus.IN_PROGRESS); + project.setFreezeReason(null); + Project saved = projectRepository.save(project); + if (bizChangeLogService != null) { + bizChangeLogService.logAction("PROJECT", saved.getId(), "PROJECT_UNFREEZE", reason.trim()); + } + return saved; + } + + public Project archive(Long projectId) { + Project project = getById(projectId); + if (project.getStatus() != ProjectStatus.COMPLETED && project.getStatus() != ProjectStatus.FROZEN) { + throw new BusinessException(30001, "仅已完成或已冻结的项目允许归档"); + } + project.setStatus(ProjectStatus.ARCHIVED); + project.setArchivedAt(java.time.LocalDateTime.now()); + Project saved = projectRepository.save(project); + if (bizChangeLogService != null) { + bizChangeLogService.logAction("PROJECT", saved.getId(), "PROJECT_ARCHIVE", null); + } + return saved; + } + + public void markInProgress(Long projectId) { + Project project = getById(projectId); + if (project.getStatus() == ProjectStatus.WAITING) { + project.setStatus(ProjectStatus.IN_PROGRESS); + projectRepository.save(project); + } + } + + public Map listBindingCandidates() { + ensureJdbcEnabled(); + boolean projectExecutorMode = isCurrentUserProjectExecutor(); + Map result = new LinkedHashMap<>(); + result.put("ownerUsers", projectExecutorMode ? new ArrayList>() : listUsersByRoleCode("PROJECT_OWNER")); + result.put("executorUsers", projectExecutorMode ? new ArrayList>() : listUsersByRoleCode("PROJECT_EXECUTOR")); + result.put("legacyExecutorUsers", (hasExecutorBindingPermission() || projectExecutorMode) ? listUsersByRoleCode("EXECUTOR") : new ArrayList>()); + return result; + } + + public Map getBindings(Long projectId) { + ensureJdbcEnabled(); + getById(projectId); + boolean projectExecutorMode = isCurrentUserProjectExecutor(); + Map result = new LinkedHashMap<>(); + result.put("ownerUsers", projectExecutorMode ? new ArrayList>() : listProjectBoundUsers(projectId, "PROJECT_OWNER")); + result.put("executorUsers", projectExecutorMode ? new ArrayList>() : listProjectBoundUsers(projectId, "PROJECT_EXECUTOR")); + result.put("legacyExecutorUsers", (hasExecutorBindingPermission() || projectExecutorMode) ? listProjectBoundUsers(projectId, "EXECUTOR") : new ArrayList>()); + return result; + } + + public void saveBindings(Long projectId, SaveProjectBindingsRequest request) { + ensureJdbcEnabled(); + getById(projectId); + boolean projectExecutorMode = isCurrentUserProjectExecutor(); + List> beforeOwnerUsers = listProjectBoundUsers(projectId, "PROJECT_OWNER"); + List> beforeExecutorUsers = listProjectBoundUsers(projectId, "PROJECT_EXECUTOR"); + List> beforeLegacyExecutorUsers = listProjectBoundUsers(projectId, "EXECUTOR"); + List ownerUserIds = request.getOwnerUserIds() == null ? new ArrayList() : request.getOwnerUserIds(); + List executorUserIds = request.getExecutorUserIds() == null ? new ArrayList() : request.getExecutorUserIds(); + List legacyExecutorUserIds = request.getLegacyExecutorUserIds() == null ? new ArrayList() : request.getLegacyExecutorUserIds(); + boolean canBindLegacyExecutor = hasExecutorBindingPermission() || projectExecutorMode; + if (projectExecutorMode) { + ownerUserIds = listProjectBoundUserIds(projectId, "PROJECT_OWNER"); + executorUserIds = listProjectBoundUserIds(projectId, "PROJECT_EXECUTOR"); + } + if (!canBindLegacyExecutor && !legacyExecutorUserIds.isEmpty()) { + throw new BusinessException(10001, "无 project.bind.executor_user 权限"); + } + for (Long userId : ownerUserIds) { + assertUserHasRole(userId, "PROJECT_OWNER"); + } + for (Long userId : executorUserIds) { + assertUserHasRole(userId, "PROJECT_EXECUTOR"); + } + if (canBindLegacyExecutor) { + for (Long userId : legacyExecutorUserIds) { + assertUserHasRole(userId, "EXECUTOR"); + } + } else { + legacyExecutorUserIds = listProjectBoundUserIds(projectId, "EXECUTOR"); + } + jdbcTemplate.update( + "DELETE FROM project_user_binding WHERE tenant_id=? AND project_id=?", + tenantId(), + projectId + ); + for (Long userId : ownerUserIds) { + insertBinding(projectId, userId, "PROJECT_OWNER"); + } + for (Long userId : executorUserIds) { + insertBinding(projectId, userId, "PROJECT_EXECUTOR"); + } + for (Long userId : legacyExecutorUserIds) { + insertBinding(projectId, userId, "EXECUTOR"); + } + logProjectBindingChanges(projectId, beforeOwnerUsers, beforeExecutorUsers, beforeLegacyExecutorUsers, ownerUserIds, executorUserIds, legacyExecutorUserIds); + } + + public List> listKeyChangeLogs(Long projectId) { + getById(projectId); + if (bizChangeLogService == null) { + return new ArrayList>(); + } + return bizChangeLogService.listByBiz("PROJECT", projectId).stream() + .map(this::toProjectChangeLogRow) + .collect(Collectors.toList()); + } + + private List> listUsersByRoleCode(String roleCode) { + return jdbcTemplate.query( + "SELECT DISTINCT u.id AS user_id, u.user_name, u.phone, u.created_by " + + "FROM sys_user u " + + "JOIN user_role ur ON u.tenant_id=ur.tenant_id AND u.id=ur.user_id " + + "JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id " + + "WHERE u.tenant_id=? AND u.is_deleted=0 AND u.status='ENABLED' AND r.role_code=? " + + "ORDER BY u.id DESC", + (rs, n) -> { + Map row = new LinkedHashMap<>(); + row.put("userId", rs.getLong("user_id")); + row.put("userName", rs.getString("user_name")); + row.put("phone", rs.getString("phone")); + row.put("createdBy", rs.getLong("created_by")); + return row; + }, + tenantId(), + roleCode + ); + } + + private List> listProjectBoundUsers(Long projectId, String roleCode) { + return jdbcTemplate.query( + "SELECT b.user_id, u.user_name, u.phone, u.created_by " + + "FROM project_user_binding b " + + "JOIN sys_user u ON b.tenant_id=u.tenant_id AND b.user_id=u.id " + + "WHERE b.tenant_id=? AND b.project_id=? AND b.bind_role_code=? AND b.is_deleted=0 AND u.is_deleted=0 " + + "ORDER BY b.id DESC", + (rs, n) -> { + Map row = new LinkedHashMap<>(); + row.put("userId", rs.getLong("user_id")); + row.put("userName", rs.getString("user_name")); + row.put("phone", rs.getString("phone")); + row.put("createdBy", rs.getLong("created_by")); + return row; + }, + tenantId(), + projectId, + roleCode + ); + } + + private List listProjectBoundUserIds(Long projectId, String roleCode) { + return jdbcTemplate.queryForList( + "SELECT user_id FROM project_user_binding WHERE tenant_id=? AND project_id=? AND bind_role_code=? AND is_deleted=0", + Long.class, + tenantId(), + projectId, + roleCode + ); + } + + private void assertUserHasRole(Long userId, String roleCode) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) " + + "FROM user_role ur " + + "JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id " + + "WHERE ur.tenant_id=? AND ur.user_id=? AND r.role_code=?", + Integer.class, + tenantId(), + userId, + roleCode + ); + if (count == null || count == 0) { + throw new BusinessException(10001, "用户未分配角色: " + roleCode); + } + } + + private void insertBinding(Long projectId, Long userId, String roleCode) { + jdbcTemplate.update( + "INSERT INTO project_user_binding (tenant_id, project_id, user_id, bind_role_code, is_deleted, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, 0, 0, 0)", + tenantId(), + projectId, + userId, + roleCode + ); + } + + private void ensureJdbcEnabled() { + if (jdbcTemplate == null) { + throw new BusinessException(10001, "当前仓储模式不支持项目人员绑定"); + } + } + + private boolean hasExecutorBindingPermission() { + Long userId = AuthContext.userId(); + return permissionService != null && userId != null && permissionService.hasPermission(userId, "project.bind.executor_user"); + } + + private boolean isCurrentUserProjectExecutor() { + Long userId = AuthContext.userId(); + if (userId == null) { + return false; + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) " + + "FROM user_role ur " + + "JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id " + + "WHERE ur.tenant_id=? AND ur.user_id=? AND r.role_code='PROJECT_EXECUTOR' AND r.is_deleted=0", + Integer.class, + tenantId(), + userId + ); + return count != null && count > 0; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private String resolveCurrentTenantName() { + if (jdbcTemplate == null) { + return null; + } + List names = jdbcTemplate.query( + "SELECT tenant_name FROM tenant WHERE id=?", + (rs, n) -> rs.getString("tenant_name"), + tenantId() + ); + return names.isEmpty() ? null : names.get(0); + } + + private ProjectFeeSummary buildProjectFeeSummary(String rawProjectFeeJson) { + ObjectNode root; + try { + if (rawProjectFeeJson == null || rawProjectFeeJson.trim().isEmpty()) { + root = objectMapper.createObjectNode(); + } else { + JsonNode parsed = objectMapper.readTree(rawProjectFeeJson); + root = parsed != null && parsed.isObject() ? (ObjectNode) parsed : objectMapper.createObjectNode(); + } + } catch (Exception e) { + throw new BusinessException(10001, "项目费用配置格式不正确"); + } + + long managementFeeCent = readNonNegativeLong(root, "managementFeeCent"); + long taxFeeCent = readNonNegativeLong(root, "taxFeeCent"); + long paidAmountCent = readNonNegativeLong(root, "paidAmountCent"); + + ArrayNode customFeesNode = objectMapper.createArrayNode(); + long customTotalCent = 0L; + JsonNode customFees = root.get("customFees"); + if (customFees != null && customFees.isArray()) { + for (JsonNode feeNode : customFees) { + if (feeNode == null || !feeNode.isObject()) { + continue; + } + String name = feeNode.path("name").asText("").trim(); + if (name.isEmpty()) { + continue; + } + long amountCent = readNonNegativeLong(feeNode, "amountCent"); + String remark = feeNode.path("remark").asText(""); + ObjectNode normalizedFee = objectMapper.createObjectNode(); + normalizedFee.put("name", name); + normalizedFee.put("amountCent", amountCent); + normalizedFee.put("remark", remark); + customFeesNode.add(normalizedFee); + customTotalCent += amountCent; + } + } + + ObjectNode normalizedRoot = objectMapper.createObjectNode(); + normalizedRoot.put("managementFeeCent", managementFeeCent); + normalizedRoot.put("taxFeeCent", taxFeeCent); + normalizedRoot.put("paidAmountCent", paidAmountCent); + normalizedRoot.set("customFees", customFeesNode); + + try { + return new ProjectFeeSummary( + objectMapper.writeValueAsString(normalizedRoot), + managementFeeCent + taxFeeCent + paidAmountCent + customTotalCent + ); + } catch (Exception e) { + throw new BusinessException(10001, "项目费用配置序列化失败"); + } + } + + private long readNonNegativeLong(JsonNode node, String fieldName) { + JsonNode valueNode = node.get(fieldName); + if (valueNode == null || valueNode.isNull()) { + return 0L; + } + if (!valueNode.isNumber()) { + throw new BusinessException(10001, "项目费用字段格式不正确: " + fieldName); + } + long value = valueNode.asLong(); + if (value < 0L) { + throw new BusinessException(10001, "项目费用字段不能为负数: " + fieldName); + } + return value; + } + + private double calculateBudgetExecutionRatio(Long budgetCent, long feeTotalCent) { + long budget = budgetCent == null ? 0L : budgetCent; + if (budget <= 0L) { + return 0d; + } + return (double) feeTotalCent / budget; + } + + private void assertProjectBudgetConstraint(boolean allowProjectOverBudget, + double thresholdRatio, + double budgetExecutionRatio, + Long budgetCent, + long feeTotalCent) { + if (allowProjectOverBudget) { + return; + } + double allowedRatio = 1d + Math.max(0d, thresholdRatio); + if (budgetExecutionRatio > allowedRatio) { + long budget = budgetCent == null ? 0L : budgetCent; + long allowedTotalCent = Math.round(budget * allowedRatio); + long overCent = Math.max(0L, feeTotalCent - allowedTotalCent); + String detail = String.format( + Locale.ROOT, + "项目总费用已超过预算阈值:当前执行率%.2f%%,阈值%.2f%%,超出%.2f元", + budgetExecutionRatio * 100d, + allowedRatio * 100d, + overCent / 100d + ); + throw new BusinessException(10001, detail); + } + } + + private void logProjectCreate(Project project) { + if (bizChangeLogService == null) { + return; + } + bizChangeLogService.logAction("PROJECT", project.getId(), "PROJECT_CREATE", null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "name", "项目名称", null, project.getName(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "parentProjectId", "上级项目ID", null, project.getParentProjectId(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "startDate", "项目开始日期", null, project.getStartDate(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "endDate", "项目结束日期", null, project.getEndDate(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "partnerEnterpriseId", "合作企业ID", null, project.getPartnerEnterpriseId(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "budgetCent", "项目预算(分)", null, project.getBudgetCent(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "meetingTotal", "会议总期数", null, project.getMeetingTotal(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "laborFeeRatio", "劳务费用占比", null, project.getLaborFeeRatio(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "invoiceInfo", "发票信息", null, project.getInvoiceInfo(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "expenseRatioJson", "费用占比配置", null, project.getExpenseRatioJson(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "projectFeeJson", "项目费用配置", null, project.getProjectFeeJson(), null); + logProjectFieldChange(project.getId(), "PROJECT_CREATE", "status", "项目状态", null, project.getStatus(), null); + } + + private void logProjectUpdate(Project before, Project after) { + if (bizChangeLogService == null) { + return; + } + String batchId = bizChangeLogService.newBatchId(); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "name", "项目名称", before.getName(), after.getName(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "parentProjectId", "上级项目ID", before.getParentProjectId(), after.getParentProjectId(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "startDate", "项目开始日期", before.getStartDate(), after.getStartDate(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "endDate", "项目结束日期", before.getEndDate(), after.getEndDate(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "partnerEnterpriseId", "合作企业ID", before.getPartnerEnterpriseId(), after.getPartnerEnterpriseId(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "budgetCent", "项目预算(分)", before.getBudgetCent(), after.getBudgetCent(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "meetingTotal", "会议总期数", before.getMeetingTotal(), after.getMeetingTotal(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "laborFeeRatio", "劳务费用占比", before.getLaborFeeRatio(), after.getLaborFeeRatio(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "invoiceInfo", "发票信息", before.getInvoiceInfo(), after.getInvoiceInfo(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "expenseRatioJson", "费用占比配置", before.getExpenseRatioJson(), after.getExpenseRatioJson(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "projectFeeJson", "项目费用配置", before.getProjectFeeJson(), after.getProjectFeeJson(), batchId); + logProjectFieldChange(after.getId(), "PROJECT_UPDATE", "status", "项目状态", before.getStatus(), after.getStatus(), batchId); + } + + private void logProjectBindingChanges(Long projectId, + List> beforeOwnerUsers, + List> beforeExecutorUsers, + List> beforeLegacyExecutorUsers, + List ownerUserIds, + List executorUserIds, + List legacyExecutorUserIds) { + if (bizChangeLogService == null) { + return; + } + String batchId = bizChangeLogService.newBatchId(); + logBindingDiff(projectId, "PROJECT_OWNER", "项目负责人", beforeOwnerUsers, ownerUserIds, batchId); + logBindingDiff(projectId, "PROJECT_EXECUTOR", "项目执行人", beforeExecutorUsers, executorUserIds, batchId); + logBindingDiff(projectId, "EXECUTOR", "执行人", beforeLegacyExecutorUsers, legacyExecutorUserIds, batchId); + } + + private void logBindingDiff(Long projectId, + String fieldCode, + String fieldName, + List> beforeUsers, + List afterUserIds, + String batchId) { + Map beforeMap = new LinkedHashMap(); + for (Map item : beforeUsers) { + if (item == null) { + continue; + } + Object userIdVal = item.get("userId"); + long userId = userIdVal instanceof Number ? ((Number) userIdVal).longValue() : 0L; + if (userId > 0L) { + beforeMap.put(userId, String.valueOf(item.get("userName") == null ? "" : item.get("userName")).trim()); + } + } + Map afterMap = loadUserNameMap(afterUserIds); + Set allIds = new HashSet(); + allIds.addAll(beforeMap.keySet()); + allIds.addAll(afterMap.keySet()); + List sortedIds = new ArrayList(allIds); + sortedIds.sort(Comparator.naturalOrder()); + for (Long userId : sortedIds) { + boolean beforeExists = beforeMap.containsKey(userId); + boolean afterExists = afterMap.containsKey(userId); + String userName = beforeExists ? beforeMap.get(userId) : afterMap.get(userId); + if (!beforeExists && afterExists) { + bizChangeLogService.logRelationAdd("PROJECT", projectId, "PROJECT_BIND_ADD", fieldCode, fieldName, userId, userName, batchId, null); + } else if (beforeExists && !afterExists) { + bizChangeLogService.logRelationRemove("PROJECT", projectId, "PROJECT_BIND_REMOVE", fieldCode, fieldName, userId, userName, batchId, null); + } + } + } + + private Map loadUserNameMap(List userIds) { + Map map = new HashMap(); + if (bizChangeLogService == null) { + return map; + } + for (BizChangeLogService.UserRef item : bizChangeLogService.loadUserRefs(userIds)) { + map.put(item.getUserId(), item.getUserName()); + } + return map; + } + + private void logProjectFieldChange(Long projectId, + String changeType, + String fieldCode, + String fieldName, + Object beforeValue, + Object afterValue, + String batchId) { + if (bizChangeLogService == null) { + return; + } + bizChangeLogService.logFieldChange( + "PROJECT", + projectId, + changeType, + fieldCode, + fieldName, + normalizeProjectFieldValue(beforeValue), + normalizeProjectFieldValue(afterValue), + batchId, + null + ); + } + + private Object normalizeProjectFieldValue(Object value) { + if (value instanceof Enum) { + return ((Enum) value).name(); + } + return value; + } + + private Map toProjectChangeLogRow(BizChangeLogInfo item) { + Map row = new LinkedHashMap(); + row.put("id", item.getId()); + row.put("fieldCode", item.getFieldCode()); + row.put("fieldName", resolveProjectFieldName(item)); + row.put("beforeValue", item.getBeforeValue()); + row.put("afterValue", item.getAfterValue()); + row.put("changeReason", item.getRemark()); + row.put("handoverAt", null); + row.put("createdBy", item.getOperatorUserId()); + row.put("createdAt", item.getCreatedAt()); + row.put("changeType", item.getChangeType()); + row.put("operatorUserName", item.getOperatorUserName()); + row.put("batchId", item.getBatchId()); + return row; + } + + private String resolveProjectFieldName(BizChangeLogInfo item) { + String fieldName = item.getFieldName() == null ? "" : item.getFieldName().trim(); + if (!fieldName.isEmpty()) { + return fieldName; + } + if ("PROJECT_CREATE".equals(item.getChangeType())) { + return "项目创建"; + } + if ("PROJECT_FREEZE".equals(item.getChangeType())) { + return "项目冻结"; + } + if ("PROJECT_UNFREEZE".equals(item.getChangeType())) { + return "项目解冻"; + } + if ("PROJECT_ARCHIVE".equals(item.getChangeType())) { + return "项目归档"; + } + return "项目变更"; + } + + private static class ProjectFeeSummary { + private final String normalizedJson; + private final long totalCent; + + private ProjectFeeSummary(String normalizedJson, long totalCent) { + this.normalizedJson = normalizedJson; + this.totalCent = totalCent; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/scheduler/job/AsyncJobScheduler.java b/backend/src/main/java/com/writeoff/module/scheduler/job/AsyncJobScheduler.java new file mode 100644 index 0000000..78d7d94 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/scheduler/job/AsyncJobScheduler.java @@ -0,0 +1,77 @@ +package com.writeoff.module.scheduler.job; + +import com.writeoff.module.scheduler.model.AsyncJob; +import com.writeoff.module.scheduler.service.AsyncJobService; +import com.writeoff.module.notification.service.NotificationDispatchService; +import com.writeoff.module.export.service.ExportTaskService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class AsyncJobScheduler { + private final AsyncJobService asyncJobService; + private final NotificationDispatchService notificationDispatchService; + private final ExportTaskService exportTaskService; + private final int batchSize; + private final boolean enabled; + + public AsyncJobScheduler(AsyncJobService asyncJobService, + NotificationDispatchService notificationDispatchService, + ExportTaskService exportTaskService, + @Value("${app.scheduler.batch-size:100}") int batchSize, + @Value("${app.scheduler.enabled:true}") boolean enabled) { + this.asyncJobService = asyncJobService; + this.notificationDispatchService = notificationDispatchService; + this.exportTaskService = exportTaskService; + this.batchSize = batchSize; + this.enabled = enabled; + } + + @Scheduled(fixedDelayString = "${app.scheduler.poll-interval-ms:3000}") + public void schedule() { + if (!enabled) { + return; + } + List jobs = asyncJobService.fetchReadyJobs(batchSize); + for (AsyncJob job : jobs) { + try { + if (job.getTenantId() != null && job.getTenantId() > 0) { + AuthContext.set(0L, job.getTenantId(), AuthScope.TENANT); + } else { + AuthContext.clear(); + } + asyncJobService.markRunning(job, "scheduler-1"); + try { + execute(job); + asyncJobService.markSuccess(job); + } catch (Exception ex) { + asyncJobService.markFailed(job, ex); + } + } finally { + AuthContext.clear(); + } + } + } + + private void execute(AsyncJob job) { + switch (job.getJobType()) { + case "AUDIT_REMIND": + case "TEMPLATE_EXPIRE": + case "EXPORT_REPORT": + return; + case "NOTIFICATION_DISPATCH": + notificationDispatchService.processTask(job.getPayload()); + return; + case "EXPORT_TASK": + exportTaskService.processTask(job.getPayload()); + return; + default: + throw new IllegalArgumentException("Unsupported jobType: " + job.getJobType()); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/scheduler/model/AsyncJob.java b/backend/src/main/java/com/writeoff/module/scheduler/model/AsyncJob.java new file mode 100644 index 0000000..eccda17 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/scheduler/model/AsyncJob.java @@ -0,0 +1,95 @@ +package com.writeoff.module.scheduler.model; + +import java.time.LocalDateTime; + +public class AsyncJob { + private Long id; + private Long tenantId; + private String jobType; + private String payload; + private AsyncJobStatus status; + private LocalDateTime nextRunAt; + private int retryCount; + private int maxRetry; + private String idempotencyKey; + private String lockedBy; + private LocalDateTime lockedAt; + + public AsyncJob(Long id, Long tenantId, String jobType, String payload, AsyncJobStatus status, LocalDateTime nextRunAt, int retryCount, int maxRetry, String idempotencyKey, String lockedBy, LocalDateTime lockedAt) { + this.id = id; + this.tenantId = tenantId; + this.jobType = jobType; + this.payload = payload; + this.status = status; + this.nextRunAt = nextRunAt; + this.retryCount = retryCount; + this.maxRetry = maxRetry; + this.idempotencyKey = idempotencyKey; + this.lockedBy = lockedBy; + this.lockedAt = lockedAt; + } + + public Long getId() { + return id; + } + + public Long getTenantId() { + return tenantId; + } + + public String getJobType() { + return jobType; + } + + public String getPayload() { + return payload; + } + + public AsyncJobStatus getStatus() { + return status; + } + + public LocalDateTime getNextRunAt() { + return nextRunAt; + } + + public int getRetryCount() { + return retryCount; + } + + public int getMaxRetry() { + return maxRetry; + } + + public String getIdempotencyKey() { + return idempotencyKey; + } + + public String getLockedBy() { + return lockedBy; + } + + public LocalDateTime getLockedAt() { + return lockedAt; + } + + public void setStatus(AsyncJobStatus status) { + this.status = status; + } + + public void setNextRunAt(LocalDateTime nextRunAt) { + this.nextRunAt = nextRunAt; + } + + public void setRetryCount(int retryCount) { + this.retryCount = retryCount; + } + + public void setLockedBy(String lockedBy) { + this.lockedBy = lockedBy; + } + + public void setLockedAt(LocalDateTime lockedAt) { + this.lockedAt = lockedAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/scheduler/model/AsyncJobStatus.java b/backend/src/main/java/com/writeoff/module/scheduler/model/AsyncJobStatus.java new file mode 100644 index 0000000..ec31743 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/scheduler/model/AsyncJobStatus.java @@ -0,0 +1,8 @@ +package com.writeoff.module.scheduler.model; + +public enum AsyncJobStatus { + READY, + RUNNING, + SUCCESS, + FAILED +} diff --git a/backend/src/main/java/com/writeoff/module/scheduler/repository/AsyncJobRepository.java b/backend/src/main/java/com/writeoff/module/scheduler/repository/AsyncJobRepository.java new file mode 100644 index 0000000..5c284a6 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/scheduler/repository/AsyncJobRepository.java @@ -0,0 +1,17 @@ +package com.writeoff.module.scheduler.repository; + +import com.writeoff.module.scheduler.model.AsyncJob; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface AsyncJobRepository { + AsyncJob save(AsyncJob job); + + Optional findById(Long id); + + Optional findByIdempotencyKey(String idempotencyKey); + + List findReady(LocalDateTime now, int limit); +} diff --git a/backend/src/main/java/com/writeoff/module/scheduler/repository/InMemoryAsyncJobRepository.java b/backend/src/main/java/com/writeoff/module/scheduler/repository/InMemoryAsyncJobRepository.java new file mode 100644 index 0000000..59fe291 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/scheduler/repository/InMemoryAsyncJobRepository.java @@ -0,0 +1,69 @@ +package com.writeoff.module.scheduler.repository; + +import com.writeoff.module.scheduler.model.AsyncJob; +import com.writeoff.module.scheduler.model.AsyncJobStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "in-memory") +public class InMemoryAsyncJobRepository implements AsyncJobRepository { + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + private final AtomicLong idGenerator = new AtomicLong(10000); + + @Override + public AsyncJob save(AsyncJob job) { + if (job.getId() == null) { + AsyncJob newJob = new AsyncJob( + idGenerator.incrementAndGet(), + job.getTenantId(), + job.getJobType(), + job.getPayload(), + job.getStatus(), + job.getNextRunAt(), + job.getRetryCount(), + job.getMaxRetry(), + job.getIdempotencyKey(), + job.getLockedBy(), + job.getLockedAt() + ); + store.put(newJob.getId(), newJob); + return newJob; + } + store.put(job.getId(), job); + return job; + } + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findByIdempotencyKey(String idempotencyKey) { + if (idempotencyKey == null || idempotencyKey.isEmpty()) { + return Optional.empty(); + } + return store.values().stream() + .filter(j -> idempotencyKey.equals(j.getIdempotencyKey())) + .findFirst(); + } + + @Override + public List findReady(LocalDateTime now, int limit) { + return store.values().stream() + .filter(j -> j.getStatus() == AsyncJobStatus.READY && !j.getNextRunAt().isAfter(now)) + .sorted(Comparator.comparing(AsyncJob::getNextRunAt)) + .limit(limit) + .collect(Collectors.toCollection(ArrayList::new)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/scheduler/repository/JdbcAsyncJobRepository.java b/backend/src/main/java/com/writeoff/module/scheduler/repository/JdbcAsyncJobRepository.java new file mode 100644 index 0000000..4a1dcce --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/scheduler/repository/JdbcAsyncJobRepository.java @@ -0,0 +1,109 @@ +package com.writeoff.module.scheduler.repository; + +import com.writeoff.module.scheduler.model.AsyncJob; +import com.writeoff.module.scheduler.model.AsyncJobStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; +import com.writeoff.security.AuthContext; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +@ConditionalOnProperty(prefix = "app.repository", name = "mode", havingValue = "jdbc", matchIfMissing = true) +public class JdbcAsyncJobRepository implements AsyncJobRepository { + private final JdbcTemplate jdbcTemplate; + private static final RowMapper ROW_MAPPER = (rs, n) -> new AsyncJob( + rs.getLong("id"), + rs.getLong("tenant_id"), + rs.getString("job_type"), + rs.getString("payload"), + AsyncJobStatus.valueOf(rs.getString("status")), + rs.getTimestamp("next_run_at").toLocalDateTime(), + rs.getInt("retry_count"), + rs.getInt("max_retry"), + rs.getString("idempotency_key"), + rs.getString("locked_by"), + rs.getTimestamp("locked_at") == null ? null : rs.getTimestamp("locked_at").toLocalDateTime() + ); + + public JdbcAsyncJobRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Override + public AsyncJob save(AsyncJob job) { + if (job.getId() == null) { + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO async_job (tenant_id, job_type, payload, status, next_run_at, retry_count, max_retry, idempotency_key, locked_by, locked_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + Statement.RETURN_GENERATED_KEYS + ); + Long tenantId = job.getTenantId() == null ? tenantId() : job.getTenantId(); + ps.setLong(1, tenantId); + ps.setString(2, job.getJobType()); + ps.setString(3, job.getPayload()); + ps.setString(4, job.getStatus().name()); + ps.setObject(5, job.getNextRunAt()); + ps.setInt(6, job.getRetryCount()); + ps.setInt(7, job.getMaxRetry()); + ps.setString(8, job.getIdempotencyKey()); + ps.setString(9, job.getLockedBy()); + ps.setObject(10, job.getLockedAt()); + return ps; + }, keyHolder); + Number key = keyHolder.getKey(); + Long id = key == null ? null : key.longValue(); + return new AsyncJob(id, job.getTenantId(), job.getJobType(), job.getPayload(), job.getStatus(), job.getNextRunAt(), job.getRetryCount(), job.getMaxRetry(), job.getIdempotencyKey(), job.getLockedBy(), job.getLockedAt()); + } + jdbcTemplate.update( + "UPDATE async_job SET status=?, next_run_at=?, retry_count=?, max_retry=?, locked_by=?, locked_at=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + job.getStatus().name(), + job.getNextRunAt(), + job.getRetryCount(), + job.getMaxRetry(), + job.getLockedBy(), + job.getLockedAt(), + job.getId() + ); + return job; + } + + @Override + public Optional findById(Long id) { + List list = jdbcTemplate.query("SELECT * FROM async_job WHERE id=?", ROW_MAPPER, id); + return list.stream().findFirst(); + } + + @Override + public Optional findByIdempotencyKey(String idempotencyKey) { + if (idempotencyKey == null || idempotencyKey.isEmpty()) { + return Optional.empty(); + } + List list = jdbcTemplate.query("SELECT * FROM async_job WHERE idempotency_key=?", ROW_MAPPER, idempotencyKey); + return list.stream().findFirst(); + } + + @Override + public List findReady(LocalDateTime now, int limit) { + return jdbcTemplate.query( + "SELECT * FROM async_job WHERE status='READY' AND next_run_at<=? ORDER BY next_run_at ASC LIMIT ?", + ROW_MAPPER, + now, + limit + ); + } + + private Long tenantId() { + Long tenantId = AuthContext.tenantId(); + return tenantId == null ? 0L : tenantId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/scheduler/service/AsyncJobService.java b/backend/src/main/java/com/writeoff/module/scheduler/service/AsyncJobService.java new file mode 100644 index 0000000..900e8b7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/scheduler/service/AsyncJobService.java @@ -0,0 +1,92 @@ +package com.writeoff.module.scheduler.service; + +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.observability.service.ObservabilityService; +import com.writeoff.module.scheduler.model.AsyncJob; +import com.writeoff.module.scheduler.model.AsyncJobStatus; +import com.writeoff.module.scheduler.repository.AsyncJobRepository; +import com.writeoff.security.AuthContext; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +public class AsyncJobService { + private static final Logger log = LoggerFactory.getLogger(AsyncJobService.class); + private final AsyncJobRepository asyncJobRepository; + private final ObservabilityService observabilityService; + + @Autowired + public AsyncJobService(AsyncJobRepository asyncJobRepository, ObservabilityService observabilityService) { + this.asyncJobRepository = asyncJobRepository; + this.observabilityService = observabilityService; + } + + public AsyncJobService(AsyncJobRepository asyncJobRepository) { + this(asyncJobRepository, null); + } + + public AsyncJob enqueue(String jobType, String payload, String idempotencyKey) { + if (idempotencyKey != null && asyncJobRepository.findByIdempotencyKey(idempotencyKey).isPresent()) { + throw new BusinessException(10002, "请求幂等冲突"); + } + AsyncJob job = new AsyncJob( + null, + AuthContext.tenantId(), + jobType, + payload, + AsyncJobStatus.READY, + LocalDateTime.now(), + 0, + 3, + idempotencyKey, + null, + null + ); + AsyncJob saved = asyncJobRepository.save(job); + if (observabilityService != null) { + observabilityService.recordAsyncMetric(jobType, "READY"); + } + return saved; + } + + public List fetchReadyJobs(int limit) { + return asyncJobRepository.findReady(LocalDateTime.now(), limit); + } + + public void markRunning(AsyncJob job, String worker) { + job.setStatus(AsyncJobStatus.RUNNING); + job.setLockedBy(worker); + job.setLockedAt(LocalDateTime.now()); + asyncJobRepository.save(job); + } + + public void markSuccess(AsyncJob job) { + job.setStatus(AsyncJobStatus.SUCCESS); + asyncJobRepository.save(job); + if (observabilityService != null) { + observabilityService.recordAsyncMetric(job.getJobType(), "SUCCESS"); + } + } + + public void markFailed(AsyncJob job, Exception ex) { + int retry = job.getRetryCount() + 1; + job.setRetryCount(retry); + if (retry >= job.getMaxRetry()) { + job.setStatus(AsyncJobStatus.FAILED); + log.error("Async job failed permanently, jobId={}, type={}, err={}", job.getId(), job.getJobType(), ex.getMessage()); + } else { + job.setStatus(AsyncJobStatus.READY); + job.setNextRunAt(LocalDateTime.now().plusSeconds(retry == 1 ? 30 : (retry == 2 ? 120 : 300))); + log.warn("Async job retry scheduled, jobId={}, retry={}", job.getId(), retry); + } + asyncJobRepository.save(job); + if (observabilityService != null) { + observabilityService.recordAsyncMetric(job.getJobType(), "FAILED"); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/controller/InvoiceProfileController.java b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/controller/InvoiceProfileController.java new file mode 100644 index 0000000..1580e18 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/controller/InvoiceProfileController.java @@ -0,0 +1,54 @@ +package com.writeoff.module.setting.invoiceprofile.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.setting.invoiceprofile.dto.CreateInvoiceProfileRequest; +import com.writeoff.module.setting.invoiceprofile.dto.UpdateInvoiceProfileRequest; +import com.writeoff.module.setting.invoiceprofile.model.InvoiceProfileInfo; +import com.writeoff.module.setting.invoiceprofile.service.InvoiceProfileService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; + +@RestController +@RequestMapping({"/api/invoice-profiles", "/api/invoice-heads"}) +public class InvoiceProfileController { + private final InvoiceProfileService invoiceProfileService; + + public InvoiceProfileController(InvoiceProfileService invoiceProfileService) { + this.invoiceProfileService = invoiceProfileService; + } + + @GetMapping + @RequirePermission(value = "invoice.profile.read", dataScope = DataScopeType.TENANT, auditAction = "INVOICE_PROFILE_LIST") + public ApiResponse> list() { + return ApiResponse.success(invoiceProfileService.list()); + } + + @PostMapping + @RequirePermission(value = "invoice.profile.manage", dataScope = DataScopeType.TENANT, auditAction = "INVOICE_PROFILE_CREATE") + public ApiResponse create(@RequestBody @Valid CreateInvoiceProfileRequest request) { + return ApiResponse.success(invoiceProfileService.create(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "invoice.profile.manage", dataScope = DataScopeType.TENANT, auditAction = "INVOICE_PROFILE_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, + @RequestBody @Valid UpdateInvoiceProfileRequest request) { + return ApiResponse.success(invoiceProfileService.update(id, request)); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "invoice.profile.manage", dataScope = DataScopeType.TENANT, auditAction = "INVOICE_PROFILE_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + return ApiResponse.success(invoiceProfileService.enable(id)); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "invoice.profile.manage", dataScope = DataScopeType.TENANT, auditAction = "INVOICE_PROFILE_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + return ApiResponse.success(invoiceProfileService.disable(id)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/dto/CreateInvoiceProfileRequest.java b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/dto/CreateInvoiceProfileRequest.java new file mode 100644 index 0000000..e6297e9 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/dto/CreateInvoiceProfileRequest.java @@ -0,0 +1,82 @@ +package com.writeoff.module.setting.invoiceprofile.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateInvoiceProfileRequest { + @NotBlank(message = "企业名称不能为空") + private String companyName; + @NotBlank(message = "税号不能为空") + private String taxNo; + @NotBlank(message = "开户行不能为空") + private String bankName; + @NotBlank(message = "账号不能为空") + private String accountNo; + private String address; + private String phone; + private Long defaultProjectId; + private String status; + + public String getCompanyName() { + return companyName; + } + + public void setCompanyName(String companyName) { + this.companyName = companyName; + } + + public String getTaxNo() { + return taxNo; + } + + public void setTaxNo(String taxNo) { + this.taxNo = taxNo; + } + + public String getBankName() { + return bankName; + } + + public void setBankName(String bankName) { + this.bankName = bankName; + } + + public String getAccountNo() { + return accountNo; + } + + public void setAccountNo(String accountNo) { + this.accountNo = accountNo; + } + + public String getAddress() { + return address; + } + + public void setAddress(String address) { + this.address = address; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public Long getDefaultProjectId() { + return defaultProjectId; + } + + public void setDefaultProjectId(Long defaultProjectId) { + this.defaultProjectId = defaultProjectId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/dto/UpdateInvoiceProfileRequest.java b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/dto/UpdateInvoiceProfileRequest.java new file mode 100644 index 0000000..ef8e8de --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/dto/UpdateInvoiceProfileRequest.java @@ -0,0 +1,4 @@ +package com.writeoff.module.setting.invoiceprofile.dto; + +public class UpdateInvoiceProfileRequest extends CreateInvoiceProfileRequest { +} diff --git a/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/model/InvoiceProfileInfo.java b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/model/InvoiceProfileInfo.java new file mode 100644 index 0000000..57eded3 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/model/InvoiceProfileInfo.java @@ -0,0 +1,61 @@ +package com.writeoff.module.setting.invoiceprofile.model; + +public class InvoiceProfileInfo { + private Long id; + private String companyName; + private String taxNo; + private String bankName; + private String accountNo; + private String address; + private String phone; + private Long defaultProjectId; + private String status; + + public InvoiceProfileInfo(Long id, String companyName, String taxNo, String bankName, String accountNo, String address, String phone, Long defaultProjectId, String status) { + this.id = id; + this.companyName = companyName; + this.taxNo = taxNo; + this.bankName = bankName; + this.accountNo = accountNo; + this.address = address; + this.phone = phone; + this.defaultProjectId = defaultProjectId; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getCompanyName() { + return companyName; + } + + public String getTaxNo() { + return taxNo; + } + + public String getBankName() { + return bankName; + } + + public String getAccountNo() { + return accountNo; + } + + public String getAddress() { + return address; + } + + public String getPhone() { + return phone; + } + + public Long getDefaultProjectId() { + return defaultProjectId; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/service/InvoiceProfileService.java b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/service/InvoiceProfileService.java new file mode 100644 index 0000000..78078d9 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/setting/invoiceprofile/service/InvoiceProfileService.java @@ -0,0 +1,170 @@ +package com.writeoff.module.setting.invoiceprofile.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.setting.invoiceprofile.dto.CreateInvoiceProfileRequest; +import com.writeoff.module.setting.invoiceprofile.dto.UpdateInvoiceProfileRequest; +import com.writeoff.module.setting.invoiceprofile.model.InvoiceProfileInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class InvoiceProfileService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper ROW_MAPPER = (rs, n) -> new InvoiceProfileInfo( + rs.getLong("id"), + rs.getString("company_name"), + rs.getString("tax_no"), + rs.getString("bank_name"), + rs.getString("account_no"), + rs.getString("address"), + rs.getString("phone"), + rs.getObject("default_project_id") == null ? null : rs.getLong("default_project_id"), + rs.getString("status") + ); + + public InvoiceProfileService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult list() { + List list = jdbcTemplate.query( + "SELECT * FROM invoice_profile WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC", + ROW_MAPPER, + tenantId() + ); + return new PageResult(list, list.size(), 1, 200); + } + + @Transactional(rollbackFor = Exception.class) + public InvoiceProfileInfo create(CreateInvoiceProfileRequest request) { + assertUnique(request.getTaxNo(), request.getAccountNo(), null); + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "INSERT INTO invoice_profile (tenant_id, company_name, tax_no, bank_name, account_no, address, phone, default_project_id, status, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tenantId(), + request.getCompanyName(), + request.getTaxNo(), + request.getBankName(), + request.getAccountNo(), + request.getAddress(), + request.getPhone(), + request.getDefaultProjectId(), + status, + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id),0) FROM invoice_profile WHERE tenant_id=?", Long.class, tenantId()); + return findById(id == null ? 0L : id); + } + + @Transactional(rollbackFor = Exception.class) + public InvoiceProfileInfo update(Long id, UpdateInvoiceProfileRequest request) { + assertExists(id); + assertUnique(request.getTaxNo(), request.getAccountNo(), id); + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "UPDATE invoice_profile SET company_name=?, tax_no=?, bank_name=?, account_no=?, address=?, phone=?, default_project_id=?, status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? " + + "WHERE tenant_id=? AND id=?", + request.getCompanyName(), + request.getTaxNo(), + request.getBankName(), + request.getAccountNo(), + request.getAddress(), + request.getPhone(), + request.getDefaultProjectId(), + status, + safeUserId(), + tenantId(), + id + ); + return findById(id); + } + + public InvoiceProfileInfo enable(Long id) { + return updateStatus(id, "ENABLED"); + } + + public InvoiceProfileInfo disable(Long id) { + return updateStatus(id, "DISABLED"); + } + + private InvoiceProfileInfo updateStatus(Long id, String status) { + assertExists(id); + jdbcTemplate.update( + "UPDATE invoice_profile SET status=?, updated_at=CURRENT_TIMESTAMP, updated_by=? WHERE tenant_id=? AND id=?", + status, + safeUserId(), + tenantId(), + id + ); + return findById(id); + } + + private void assertUnique(String taxNo, String accountNo, Long selfId) { + if (taxNo == null || taxNo.trim().isEmpty() || accountNo == null || accountNo.trim().isEmpty()) { + throw new BusinessException(10001, "税号和账号不能为空"); + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM invoice_profile WHERE tenant_id=? AND tax_no=? AND account_no=? AND is_deleted=0 AND (? IS NULL OR id<>?)", + Integer.class, + tenantId(), + taxNo.trim(), + accountNo.trim(), + selfId, + selfId + ); + if (count != null && count > 0) { + throw new BusinessException(10001, "同税号与账号的发票抬头已存在"); + } + } + + private InvoiceProfileInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT * FROM invoice_profile WHERE tenant_id=? AND id=? AND is_deleted=0", + ROW_MAPPER, + tenantId(), + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "发票抬头不存在"); + } + return list.get(0); + } + + private void assertExists(Long id) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM invoice_profile WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + id + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "发票抬头不存在"); + } + } + + private String normalizeStatus(String status) { + String val = status == null ? "ENABLED" : status.trim().toUpperCase(); + if (!"ENABLED".equals(val) && !"DISABLED".equals(val)) { + throw new BusinessException(10001, "状态仅支持 ENABLED/DISABLED"); + } + return val; + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/AuditLogController.java b/backend/src/main/java/com/writeoff/module/system/controller/AuditLogController.java new file mode 100644 index 0000000..f0edd78 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/AuditLogController.java @@ -0,0 +1,78 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.export.model.ExportTaskInfo; +import com.writeoff.module.system.model.OperationAuditLogInfo; +import com.writeoff.module.system.service.OperationAuditLogService; +import javax.validation.constraints.NotBlank; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/audit-logs") +public class AuditLogController { + private final OperationAuditLogService operationAuditLogService; + + public AuditLogController(OperationAuditLogService operationAuditLogService) { + this.operationAuditLogService = operationAuditLogService; + } + + @GetMapping + @RequirePermission(value = "audit.log.read", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "AUDIT_LOG_LIST") + public ApiResponse> list( + @RequestParam(value = "userId", required = false) Long userId, + @RequestParam(value = "actionCode", required = false) String actionCode, + @RequestParam(value = "pageNo", required = false) Integer pageNo, + @RequestParam(value = "pageSize", required = false) Integer pageSize) { + return ApiResponse.success(operationAuditLogService.list(userId, actionCode, pageNo, pageSize)); + } + + @GetMapping("/export") + @RequirePermission(value = "audit.log.read", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "AUDIT_LOG_EXPORT") + public ApiResponse> export( + @RequestParam(value = "userId", required = false) Long userId, + @RequestParam(value = "actionCode", required = false) String actionCode) { + return ApiResponse.success(operationAuditLogService.list(userId, actionCode, 1, 500)); + } + + @GetMapping("/export-tasks") + @RequirePermission(value = "audit.log.read", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "AUDIT_LOG_EXPORT_TASKS") + public ApiResponse> exportTasks() { + return ApiResponse.success(operationAuditLogService.listExportTasks()); + } + + @PostMapping("/export-tasks") + @RequirePermission(value = "audit.log.read", dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "AUDIT_LOG_EXPORT_TASK_CREATE") + public ApiResponse> createExportTask(@RequestBody AuditLogExportTaskRequest request) { + return ApiResponse.success(operationAuditLogService.createExportTask( + request.getUserId(), + request.getActionCode(), + request.getIdempotencyKey(), + request.getFileName() + )); + } + + public static class AuditLogExportTaskRequest { + @NotBlank(message = "幂等键不能为空") + private String idempotencyKey; + private Long userId; + private String actionCode; + private String fileName; + + public String getIdempotencyKey() { return idempotencyKey; } + public void setIdempotencyKey(String idempotencyKey) { this.idempotencyKey = idempotencyKey; } + public Long getUserId() { return userId; } + public void setUserId(Long userId) { this.userId = userId; } + public String getActionCode() { return actionCode; } + public void setActionCode(String actionCode) { this.actionCode = actionCode; } + public String getFileName() { return fileName; } + public void setFileName(String fileName) { this.fileName = fileName; } + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/DataPermissionController.java b/backend/src/main/java/com/writeoff/module/system/controller/DataPermissionController.java new file mode 100644 index 0000000..6af1bc7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/DataPermissionController.java @@ -0,0 +1,82 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.dto.AssignRoleDataPermissionRequest; +import com.writeoff.module.system.dto.CreateDataPermissionPolicyRequest; +import com.writeoff.module.system.dto.UpdateDataPermissionPolicyRequest; +import com.writeoff.module.system.model.DataPermissionPolicy; +import com.writeoff.module.system.service.DataPermissionService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.Map; + +@RestController +@RequestMapping({"/api/data-permissions", "/api/data-scope-policies"}) +public class DataPermissionController { + private final DataPermissionService dataPermissionService; + + public DataPermissionController(DataPermissionService dataPermissionService) { + this.dataPermissionService = dataPermissionService; + } + + @GetMapping + @RequirePermission(value = "data.permission.read", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_LIST") + public ApiResponse> list() { + return ApiResponse.success(dataPermissionService.listPolicies()); + } + + @PostMapping + @RequirePermission(value = "data.permission.manage", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_CREATE") + public ApiResponse create(@RequestBody @Valid CreateDataPermissionPolicyRequest request) { + return ApiResponse.success(dataPermissionService.createPolicy(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "data.permission.manage", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid UpdateDataPermissionPolicyRequest request) { + return ApiResponse.success(dataPermissionService.updatePolicy(id, request)); + } + + @PostMapping("/{id}/assign-roles") + @RequirePermission(value = "data.permission.manage", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_ASSIGN_ROLES") + public ApiResponse assignRoles(@PathVariable("id") Long id, @RequestBody @Valid AssignRoleDataPermissionRequest request) { + dataPermissionService.assignRoles(id, request); + return ApiResponse.success("OK"); + } + + @GetMapping("/{id}/roles") + @RequirePermission(value = "data.permission.read", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_ROLES") + public ApiResponse> policyRoles(@PathVariable("id") Long id) { + return ApiResponse.success(dataPermissionService.listPolicyRoleIds(id)); + } + + @PostMapping("/{id}/copy") + @RequirePermission(value = "data.permission.manage", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_COPY") + public ApiResponse copy(@PathVariable("id") Long id) { + return ApiResponse.success(dataPermissionService.copyPolicy(id)); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "data.permission.manage", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + dataPermissionService.enablePolicy(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "data.permission.manage", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + dataPermissionService.disablePolicy(id); + return ApiResponse.success("OK"); + } + + @GetMapping("/current-scope") + @RequirePermission(value = "data.permission.read", dataScope = DataScopeType.TENANT, auditAction = "DATA_PERMISSION_CURRENT_SCOPE") + public ApiResponse> currentScope() { + return ApiResponse.success(dataPermissionService.currentScopeSummary()); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/DictionaryController.java b/backend/src/main/java/com/writeoff/module/system/controller/DictionaryController.java new file mode 100644 index 0000000..03e817f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/DictionaryController.java @@ -0,0 +1,86 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.system.dto.CreatePlatformDictionaryItemRequest; +import com.writeoff.module.system.dto.UpdatePlatformDictionaryItemRequest; +import com.writeoff.module.system.model.PlatformDictionaryItem; +import com.writeoff.module.system.service.PlatformDictionaryService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/dictionaries") +public class DictionaryController { + private final PlatformDictionaryService platformDictionaryService; + + public DictionaryController(PlatformDictionaryService platformDictionaryService) { + this.platformDictionaryService = platformDictionaryService; + } + + /** + * 租户端通用字典查询(只读)。 + */ + @GetMapping + public ApiResponse> list( + @RequestParam(value = "dictType", required = false) String dictType, + @RequestParam(value = "enabledOnly", required = false) Boolean enabledOnly + ) { + return ApiResponse.success(platformDictionaryService.list(dictType, enabledOnly)); + } + + /** + * 租户端与平台端共享读取专家字典选项。 + */ + @GetMapping("/expert-options") + public ApiResponse>> expertOptions() { + Map> data = new LinkedHashMap>(); + data.put("titles", platformDictionaryService.list("EXPERT_TITLE", true)); + data.put("hospitals", platformDictionaryService.list("EXPERT_HOSPITAL", true)); + return ApiResponse.success(data); + } + + /** + * 租户端新增字典项。 + */ + @PostMapping + @RequirePermission(value = "dictionary.manage", dataScope = DataScopeType.TENANT, auditAction = "DICTIONARY_CREATE") + public ApiResponse create(@RequestBody @Valid CreatePlatformDictionaryItemRequest request) { + return ApiResponse.success(platformDictionaryService.create(request)); + } + + /** + * 租户端更新字典项。 + */ + @PutMapping("/{id}") + @RequirePermission(value = "dictionary.manage", dataScope = DataScopeType.TENANT, auditAction = "DICTIONARY_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, + @RequestBody @Valid UpdatePlatformDictionaryItemRequest request) { + return ApiResponse.success(platformDictionaryService.update(id, request)); + } + + /** + * 租户端启用字典项。 + */ + @PostMapping("/{id}/enable") + @RequirePermission(value = "dictionary.manage", dataScope = DataScopeType.TENANT, auditAction = "DICTIONARY_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + platformDictionaryService.enable(id); + return ApiResponse.success("OK"); + } + + /** + * 租户端停用字典项。 + */ + @PostMapping("/{id}/disable") + @RequirePermission(value = "dictionary.manage", dataScope = DataScopeType.TENANT, auditAction = "DICTIONARY_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + platformDictionaryService.disable(id); + return ApiResponse.success("OK"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/EnterpriseController.java b/backend/src/main/java/com/writeoff/module/system/controller/EnterpriseController.java new file mode 100644 index 0000000..32906ab --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/EnterpriseController.java @@ -0,0 +1,78 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.dto.CreateEnterpriseRequest; +import com.writeoff.module.system.dto.EnterpriseLogoUploadSignRequest; +import com.writeoff.module.system.dto.UpdateEnterpriseRequest; +import com.writeoff.module.system.model.EnterpriseInfo; +import com.writeoff.module.system.service.EnterpriseService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/enterprises") +public class EnterpriseController { + private final EnterpriseService enterpriseService; + + public EnterpriseController(EnterpriseService enterpriseService) { + this.enterpriseService = enterpriseService; + } + + @GetMapping + @RequirePermission(value = "enterprise.read", dataScope = DataScopeType.TENANT, auditAction = "ENTERPRISE_LIST") + public ApiResponse> list( + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return ApiResponse.success(enterpriseService.list(pageNo, pageSize)); + } + + @PostMapping + @RequirePermission(value = "enterprise.manage", dataScope = DataScopeType.TENANT, auditAction = "ENTERPRISE_CREATE") + public ApiResponse create(@RequestBody @Valid CreateEnterpriseRequest request) { + return ApiResponse.success(enterpriseService.create(request)); + } + + @PostMapping("/logo-upload-sign") + @RequirePermission(value = "enterprise.manage", dataScope = DataScopeType.TENANT, auditAction = "ENTERPRISE_LOGO_UPLOAD_SIGN") + public ApiResponse> logoUploadSign(@RequestBody @Valid EnterpriseLogoUploadSignRequest request) { + return ApiResponse.success(enterpriseService.presignLogoUpload(request.getFileName(), request.getContentType())); + } + + @PutMapping("/{id}") + @RequirePermission(value = "enterprise.manage", dataScope = DataScopeType.TENANT, auditAction = "ENTERPRISE_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid UpdateEnterpriseRequest request) { + return ApiResponse.success(enterpriseService.update(id, request)); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "enterprise.manage", dataScope = DataScopeType.TENANT, auditAction = "ENTERPRISE_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + enterpriseService.enable(id); + return ApiResponse.success("ok"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "enterprise.manage", dataScope = DataScopeType.TENANT, auditAction = "ENTERPRISE_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + enterpriseService.disable(id); + return ApiResponse.success("ok"); + } + + @PostMapping("/{id}/delete") + @RequirePermission(value = "enterprise.delete", dataScope = DataScopeType.TENANT, auditAction = "ENTERPRISE_DELETE") + public ApiResponse softDelete(@PathVariable("id") Long id) { + enterpriseService.softDelete(id); + return ApiResponse.success("ok"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/GlobalSearchController.java b/backend/src/main/java/com/writeoff/module/system/controller/GlobalSearchController.java new file mode 100644 index 0000000..9d5f3a1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/GlobalSearchController.java @@ -0,0 +1,25 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.system.model.GlobalSearchResult; +import com.writeoff.module.system.service.GlobalSearchService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/search") +public class GlobalSearchController { + private final GlobalSearchService globalSearchService; + + public GlobalSearchController(GlobalSearchService globalSearchService) { + this.globalSearchService = globalSearchService; + } + + @GetMapping("/global") + public ApiResponse global(@RequestParam(value = "q", required = false) String keyword, + @RequestParam(value = "limitPerType", required = false) Integer limitPerType) { + return ApiResponse.success(globalSearchService.search(keyword, limitPerType)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/MenuController.java b/backend/src/main/java/com/writeoff/module/system/controller/MenuController.java new file mode 100644 index 0000000..930d9a2 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/MenuController.java @@ -0,0 +1,72 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.dto.CreateMenuRequest; +import com.writeoff.module.system.dto.ReorderMenusRequest; +import com.writeoff.module.system.dto.UpdateMenuRequest; +import com.writeoff.module.system.model.MenuInfo; +import com.writeoff.module.system.service.MenuService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.List; + +@RestController +@RequestMapping("/api/menus") +public class MenuController { + private final MenuService menuService; + + public MenuController(MenuService menuService) { + this.menuService = menuService; + } + + @GetMapping + @RequirePermission(value = "role.read", dataScope = DataScopeType.TENANT, auditAction = "MENU_LIST") + public ApiResponse> list() { + return ApiResponse.success(menuService.list()); + } + + @GetMapping("/current") + public ApiResponse> current() { + Long userId = AuthContext.userId(); + if (userId == null) { + return ApiResponse.success(java.util.Collections.emptyList()); + } + return ApiResponse.success(menuService.currentUserMenus(userId)); + } + + @PostMapping + @RequirePermission(value = "role.permission.bind", dataScope = DataScopeType.TENANT, auditAction = "MENU_CREATE") + public ApiResponse create(@RequestBody @Valid CreateMenuRequest request) { + return ApiResponse.success(menuService.create(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "role.permission.bind", dataScope = DataScopeType.TENANT, auditAction = "MENU_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid UpdateMenuRequest request) { + return ApiResponse.success(menuService.update(id, request)); + } + + @PostMapping("/reorder") + @RequirePermission(value = "role.permission.bind", dataScope = DataScopeType.TENANT, auditAction = "MENU_REORDER") + public ApiResponse reorder(@RequestBody @Valid ReorderMenusRequest request) { + menuService.reorderMenus(request); + return ApiResponse.success("ok"); + } + + @GetMapping("/{id}/roles") + @RequirePermission(value = "role.read", dataScope = DataScopeType.TENANT, auditAction = "MENU_ROLES") + public ApiResponse> menuRoles(@PathVariable("id") Long id) { + return ApiResponse.success(menuService.getMenuRoleNames(id)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/PermissionController.java b/backend/src/main/java/com/writeoff/module/system/controller/PermissionController.java new file mode 100644 index 0000000..7d52b90 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/PermissionController.java @@ -0,0 +1,27 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.model.PermissionInfo; +import com.writeoff.module.system.service.SystemUserService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/permissions") +public class PermissionController { + private final SystemUserService systemUserService; + + public PermissionController(SystemUserService systemUserService) { + this.systemUserService = systemUserService; + } + + @GetMapping + @RequirePermission(value = "permission.read", dataScope = DataScopeType.TENANT, auditAction = "PERMISSION_LIST") + public ApiResponse> list() { + return ApiResponse.success(systemUserService.listPermissions()); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/PlatformAuditLogController.java b/backend/src/main/java/com/writeoff/module/system/controller/PlatformAuditLogController.java new file mode 100644 index 0000000..9a2cbdc --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/PlatformAuditLogController.java @@ -0,0 +1,35 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.model.OperationAuditLogInfo; +import com.writeoff.module.system.service.OperationAuditLogService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/platform/audit-logs") +public class PlatformAuditLogController { + private final OperationAuditLogService operationAuditLogService; + + public PlatformAuditLogController(OperationAuditLogService operationAuditLogService) { + this.operationAuditLogService = operationAuditLogService; + } + + @GetMapping + @RequirePermission(value = "platform.audit.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_AUDIT_LOG_LIST") + public ApiResponse> list( + @RequestParam(value = "userId", required = false) Long userId, + @RequestParam(value = "actionCode", required = false) String actionCode, + @RequestParam(value = "tenantId", required = false) Long tenantId, + @RequestParam(value = "scope", required = false) String scope, + @RequestParam(value = "pageNo", required = false) Integer pageNo, + @RequestParam(value = "pageSize", required = false) Integer pageSize) { + return ApiResponse.success(operationAuditLogService.listPlatform(userId, actionCode, tenantId, scope, pageNo, pageSize)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/PlatformDictionaryController.java b/backend/src/main/java/com/writeoff/module/system/controller/PlatformDictionaryController.java new file mode 100644 index 0000000..26ca8bb --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/PlatformDictionaryController.java @@ -0,0 +1,95 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.system.dto.CreatePlatformDictionaryItemRequest; +import com.writeoff.module.system.dto.CreatePlatformDictionaryTypeRequest; +import com.writeoff.module.system.dto.UpdatePlatformDictionaryItemRequest; +import com.writeoff.module.system.model.PlatformDictionaryItem; +import com.writeoff.module.system.model.PlatformDictionaryType; +import com.writeoff.module.system.service.PlatformDictionaryService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/platform/dictionaries") +public class PlatformDictionaryController { + private final PlatformDictionaryService platformDictionaryService; + + public PlatformDictionaryController(PlatformDictionaryService platformDictionaryService) { + this.platformDictionaryService = platformDictionaryService; + } + + @GetMapping + @RequirePermission(value = "platform.dictionary.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_DICTIONARY_LIST") + public ApiResponse> list( + @RequestParam(value = "dictType", required = false) String dictType, + @RequestParam(value = "enabledOnly", required = false) Boolean enabledOnly + ) { + return ApiResponse.success(platformDictionaryService.list(dictType, enabledOnly)); + } + + @GetMapping("/types") + @RequirePermission(value = "platform.dictionary.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_DICTIONARY_TYPE_LIST") + public ApiResponse> listTypes( + @RequestParam(value = "enabledOnly", required = false) Boolean enabledOnly + ) { + return ApiResponse.success(platformDictionaryService.listTypes(enabledOnly)); + } + + @PostMapping("/types") + @RequirePermission(value = "platform.dictionary.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_DICTIONARY_TYPE_CREATE") + public ApiResponse createType(@RequestBody @Valid CreatePlatformDictionaryTypeRequest request) { + return ApiResponse.success(platformDictionaryService.createType(request)); + } + + @PostMapping + @RequirePermission(value = "platform.dictionary.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_DICTIONARY_CREATE") + public ApiResponse create(@RequestBody @Valid CreatePlatformDictionaryItemRequest request) { + return ApiResponse.success(platformDictionaryService.create(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "platform.dictionary.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_DICTIONARY_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid UpdatePlatformDictionaryItemRequest request) { + return ApiResponse.success(platformDictionaryService.update(id, request)); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "platform.dictionary.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_DICTIONARY_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + platformDictionaryService.enable(id); + return ApiResponse.success("ok"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "platform.dictionary.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_DICTIONARY_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + platformDictionaryService.disable(id); + return ApiResponse.success("ok"); + } + + /** + * 供专家模块读取的共享字典选项。 + */ + @GetMapping("/expert-options") + public ApiResponse>> expertOptions() { + Map> data = new LinkedHashMap>(); + data.put("titles", platformDictionaryService.list("EXPERT_TITLE", true)); + data.put("hospitals", platformDictionaryService.list("EXPERT_HOSPITAL", true)); + return ApiResponse.success(data); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/PlatformMenuController.java b/backend/src/main/java/com/writeoff/module/system/controller/PlatformMenuController.java new file mode 100644 index 0000000..2c37074 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/PlatformMenuController.java @@ -0,0 +1,83 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.dto.BindPlatformMenuRolesRequest; +import com.writeoff.module.system.dto.CreateMenuRequest; +import com.writeoff.module.system.dto.ReorderMenusRequest; +import com.writeoff.module.system.dto.UpdateMenuRequest; +import com.writeoff.module.system.model.MenuInfo; +import com.writeoff.module.system.service.PlatformMenuService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Collections; +import java.util.List; +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/platform/menus") +public class PlatformMenuController { + private final PlatformMenuService platformMenuService; + + public PlatformMenuController(PlatformMenuService platformMenuService) { + this.platformMenuService = platformMenuService; + } + + @GetMapping + @RequirePermission(value = "platform.menu.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_MENU_LIST") + public ApiResponse> list() { + return ApiResponse.success(platformMenuService.list()); + } + + @GetMapping("/current") + public ApiResponse> current() { + Long userId = AuthContext.userId(); + if (userId == null || AuthContext.scope() != AuthScope.PLATFORM) { + return ApiResponse.success(Collections.emptyList()); + } + return ApiResponse.success(platformMenuService.currentUserMenus(userId)); + } + + @PostMapping + @RequirePermission(value = "platform.menu.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_MENU_CREATE") + public ApiResponse create(@RequestBody @Valid CreateMenuRequest request) { + return ApiResponse.success(platformMenuService.create(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "platform.menu.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_MENU_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid UpdateMenuRequest request) { + return ApiResponse.success(platformMenuService.update(id, request)); + } + + @PostMapping("/reorder") + @RequirePermission(value = "platform.menu.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_MENU_REORDER") + public ApiResponse reorder(@RequestBody @Valid ReorderMenusRequest request) { + platformMenuService.reorderMenus(request); + return ApiResponse.success("ok"); + } + + @GetMapping("/{id}/roles") + @RequirePermission(value = "platform.role.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_MENU_ROLES") + public ApiResponse> menuRoles(@PathVariable("id") Long id) { + return ApiResponse.success(platformMenuService.getMenuRoleIds(id)); + } + + @PostMapping("/{id}/roles") + @RequirePermission(value = "platform.menu.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_MENU_BIND_ROLES") + public ApiResponse bindRoles(@PathVariable("id") Long id, @RequestBody @Valid BindPlatformMenuRolesRequest request) { + platformMenuService.bindMenuRoles(id, request); + return ApiResponse.success("ok"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/PlatformPermissionController.java b/backend/src/main/java/com/writeoff/module/system/controller/PlatformPermissionController.java new file mode 100644 index 0000000..97136c6 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/PlatformPermissionController.java @@ -0,0 +1,28 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.model.PermissionInfo; +import com.writeoff.module.system.service.PlatformIamService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/platform/permissions") +public class PlatformPermissionController { + private final PlatformIamService platformIamService; + + public PlatformPermissionController(PlatformIamService platformIamService) { + this.platformIamService = platformIamService; + } + + @GetMapping + @RequirePermission(value = "platform.permission.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_PERMISSION_LIST") + public ApiResponse> list() { + return ApiResponse.success(platformIamService.listPermissions()); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/PlatformRoleController.java b/backend/src/main/java/com/writeoff/module/system/controller/PlatformRoleController.java new file mode 100644 index 0000000..620e2e5 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/PlatformRoleController.java @@ -0,0 +1,93 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.dto.BindRoleMenusRequest; +import com.writeoff.module.system.dto.BindRolePermissionsRequest; +import com.writeoff.module.system.dto.CreateRoleRequest; +import com.writeoff.module.system.dto.UpdateRoleRequest; +import com.writeoff.module.system.model.RoleInfo; +import com.writeoff.module.system.service.PlatformIamService; +import com.writeoff.module.system.service.PlatformMenuService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/platform/roles") +public class PlatformRoleController { + private final PlatformIamService platformIamService; + private final PlatformMenuService platformMenuService; + + public PlatformRoleController(PlatformIamService platformIamService, PlatformMenuService platformMenuService) { + this.platformIamService = platformIamService; + this.platformMenuService = platformMenuService; + } + + @GetMapping + @RequirePermission(value = "platform.role.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_LIST") + public ApiResponse> list() { + return ApiResponse.success(platformIamService.listRoles()); + } + + @PostMapping + @RequirePermission(value = "platform.role.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_CREATE") + public ApiResponse create(@RequestBody @Valid CreateRoleRequest request) { + return ApiResponse.success(platformIamService.createRole(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "platform.role.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid UpdateRoleRequest request) { + return ApiResponse.success(platformIamService.updateRole(id, request)); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "platform.role.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + platformIamService.enableRole(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "platform.role.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + platformIamService.disableRole(id); + return ApiResponse.success("OK"); + } + + @GetMapping("/{id}/permissions") + @RequirePermission(value = "platform.role.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_PERMISSIONS") + public ApiResponse> getRolePermissions(@PathVariable("id") Long id) { + return ApiResponse.success(platformIamService.getRolePermissionIds(id)); + } + + @PostMapping("/{id}/permissions") + @RequirePermission(value = "platform.role.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_BIND_PERMISSIONS") + public ApiResponse bindPermissions(@PathVariable("id") Long id, @RequestBody @Valid BindRolePermissionsRequest request) { + platformIamService.bindRolePermissions(id, request); + return ApiResponse.success("OK"); + } + + @GetMapping("/{id}/menus") + @RequirePermission(value = "platform.role.read", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_MENUS") + public ApiResponse> getRoleMenus(@PathVariable("id") Long id) { + return ApiResponse.success(platformMenuService.getRoleMenuIds(id)); + } + + @PostMapping("/{id}/menus") + @RequirePermission(value = "platform.role.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_ROLE_BIND_MENUS") + public ApiResponse bindMenus(@PathVariable("id") Long id, @RequestBody @Valid BindRoleMenusRequest request) { + platformMenuService.bindRoleMenus(id, request); + return ApiResponse.success("OK"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/PlatformTenantController.java b/backend/src/main/java/com/writeoff/module/system/controller/PlatformTenantController.java new file mode 100644 index 0000000..1badfb8 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/PlatformTenantController.java @@ -0,0 +1,97 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.dto.CreateTenantAdminRequest; +import com.writeoff.module.system.dto.CreateTenantRequest; +import com.writeoff.module.system.dto.EnterpriseLogoUploadSignRequest; +import com.writeoff.module.system.dto.UpdateTenantRequest; +import com.writeoff.module.system.model.TenantInfo; +import com.writeoff.module.system.service.TenantService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/platform/tenants") +public class PlatformTenantController { + private final TenantService tenantService; + + public PlatformTenantController(TenantService tenantService) { + this.tenantService = tenantService; + } + + @GetMapping + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_LIST") + public ApiResponse> list() { + return ApiResponse.success(tenantService.list()); + } + + @GetMapping("/{id}/admin") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_GET_ADMIN") + public ApiResponse> getAdmin(@PathVariable("id") Long id) { + return ApiResponse.success(tenantService.getTenantAdmin(id, "TENANT_ADMIN")); + } + + @PostMapping + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_CREATE") + public ApiResponse create(@RequestBody @Valid CreateTenantRequest request) { + return ApiResponse.success(tenantService.create(request)); + } + + @PostMapping("/logo-upload-sign") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_LOGO_UPLOAD_SIGN") + public ApiResponse> logoUploadSign(@RequestBody @Valid EnterpriseLogoUploadSignRequest request) { + return ApiResponse.success(tenantService.presignLogoUpload(request.getFileName(), request.getContentType())); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + tenantService.enable(id); + return ApiResponse.success("ok"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + tenantService.disable(id); + return ApiResponse.success("ok"); + } + + @PutMapping("/{id}") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, + @RequestBody @Valid UpdateTenantRequest request) { + return ApiResponse.success(tenantService.updateTenant(id, request.getTenantName(), request.getLogoUrl())); + } + + @PostMapping("/{id}/delete") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_DELETE") + public ApiResponse softDelete(@PathVariable("id") Long id) { + tenantService.softDelete(id); + return ApiResponse.success("ok"); + } + + @PostMapping("/{id}/admin") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_SET_ADMIN") + public ApiResponse> setAdmin(@PathVariable("id") Long id, + @RequestBody @Valid CreateTenantAdminRequest request) { + return ApiResponse.success(tenantService.createTenantAdmin(id, request)); + } + + @PostMapping("/{id}/init-baseline") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "PLATFORM_TENANT_INIT_BASELINE") + public ApiResponse> initBaseline(@PathVariable("id") Long id) { + return ApiResponse.success(tenantService.initTenantBaseline(id)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/PlatformUserController.java b/backend/src/main/java/com/writeoff/module/system/controller/PlatformUserController.java new file mode 100644 index 0000000..7efaeb8 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/PlatformUserController.java @@ -0,0 +1,86 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.model.ImportResult; +import com.writeoff.module.system.dto.AssignUserRoleRequest; +import com.writeoff.module.system.dto.CreateUserRequest; +import com.writeoff.module.system.dto.ImportUsersRequest; +import com.writeoff.module.system.dto.ResetPasswordRequest; +import com.writeoff.module.system.model.SystemUser; +import com.writeoff.module.system.service.PlatformIamService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/platform/users") +public class PlatformUserController { + private final PlatformIamService platformIamService; + + public PlatformUserController(PlatformIamService platformIamService) { + this.platformIamService = platformIamService; + } + + @GetMapping + @RequirePermission(value = "platform.user.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_USER_LIST") + public ApiResponse> list(@RequestParam(value = "keyword", required = false) String keyword) { + return ApiResponse.success(platformIamService.listUsers(keyword)); + } + + @PostMapping + @RequirePermission(value = "platform.user.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_USER_CREATE") + public ApiResponse create(@RequestBody @Valid CreateUserRequest request) { + return ApiResponse.success(platformIamService.createUser(request)); + } + + @PostMapping("/import") + @RequirePermission(value = "platform.user.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_USER_IMPORT") + public ApiResponse importUsers(@RequestBody @Valid ImportUsersRequest request) { + return ApiResponse.success(platformIamService.importUsers(request.getUsers())); + } + + @PutMapping("/{id}") + @RequirePermission(value = "platform.user.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_USER_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid CreateUserRequest request) { + return ApiResponse.success(platformIamService.updateUser(id, request)); + } + + @PostMapping("/assign-role") + @RequirePermission(value = "platform.user.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_USER_ASSIGN_ROLE") + public ApiResponse assignRole(@RequestBody @Valid AssignUserRoleRequest request) { + platformIamService.assignRole(request); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "platform.user.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_USER_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + platformIamService.enableUser(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "platform.user.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_USER_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + platformIamService.disableUser(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/reset-password") + @RequirePermission(value = "platform.user.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.GLOBAL_READONLY, auditAction = "PLATFORM_USER_RESET_PASSWORD") + public ApiResponse resetPassword(@PathVariable("id") Long id, @RequestBody @Valid ResetPasswordRequest request) { + platformIamService.resetPassword(id, request); + return ApiResponse.success("OK"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/ProfileController.java b/backend/src/main/java/com/writeoff/module/system/controller/ProfileController.java new file mode 100644 index 0000000..dee5ba1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/ProfileController.java @@ -0,0 +1,71 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.system.dto.ChangePasswordRequest; +import com.writeoff.module.system.dto.UpdateProfilePreferencesRequest; +import com.writeoff.module.system.model.ProfilePreferencesInfo; +import com.writeoff.module.system.service.PlatformIamService; +import com.writeoff.module.system.service.SystemUserService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/profile") +public class ProfileController { + + private final SystemUserService systemUserService; + private final PlatformIamService platformIamService; + + public ProfileController(SystemUserService systemUserService, PlatformIamService platformIamService) { + this.systemUserService = systemUserService; + this.platformIamService = platformIamService; + } + + @GetMapping("/preferences") + public ApiResponse preferences() { + Long userId = AuthContext.userId(); + if (userId == null) { + throw new com.writeoff.common.exception.BusinessException(11003, "会话失效"); + } + if (AuthContext.scope() == AuthScope.PLATFORM) { + return ApiResponse.success(platformIamService.getMyPreferences(userId)); + } + return ApiResponse.success(systemUserService.getMyPreferences(userId)); + } + + @PostMapping("/change-password") + // 这里因为是用户级别操作自身数据,只要拦截器确保已登录,不需要 RequirePermission 进行复杂的权限校验 + // 审计可以由切面或服务层内部记录,这里重点是提供 C 端基础功能 + public ApiResponse changePassword(@RequestBody @Valid ChangePasswordRequest request) { + Long userId = AuthContext.userId(); + if (userId == null) { + throw new com.writeoff.common.exception.BusinessException(11003, "会话失效"); + } + if (AuthContext.scope() == AuthScope.PLATFORM) { + platformIamService.changeMyPassword(userId, request.getOldPassword(), request.getNewPassword()); + return ApiResponse.success("OK"); + } + systemUserService.changeMyPassword(userId, request.getOldPassword(), request.getNewPassword()); + return ApiResponse.success("OK"); + } + + @PutMapping("/preferences") + public ApiResponse updatePreferences(@RequestBody UpdateProfilePreferencesRequest request) { + Long userId = AuthContext.userId(); + if (userId == null) { + throw new com.writeoff.common.exception.BusinessException(11003, "会话失效"); + } + if (AuthContext.scope() == AuthScope.PLATFORM) { + return ApiResponse.success(platformIamService.updateMyPreferences(userId, request)); + } + return ApiResponse.success(systemUserService.updateMyPreferences(userId, request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/RoleController.java b/backend/src/main/java/com/writeoff/module/system/controller/RoleController.java new file mode 100644 index 0000000..7b26fae --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/RoleController.java @@ -0,0 +1,102 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.dto.BindRolePermissionsRequest; +import com.writeoff.module.system.dto.BindRoleMenusRequest; +import com.writeoff.module.system.dto.CreateRoleRequest; +import com.writeoff.module.system.dto.UpdateRoleRequest; +import com.writeoff.module.system.model.RoleInfo; +import com.writeoff.module.system.service.MenuService; +import com.writeoff.module.system.service.SystemUserService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/roles") +public class RoleController { + private final SystemUserService systemUserService; + private final MenuService menuService; + + public RoleController(SystemUserService systemUserService, MenuService menuService) { + this.systemUserService = systemUserService; + this.menuService = menuService; + } + + @GetMapping + @RequirePermission(value = "role.read", dataScope = DataScopeType.TENANT, auditAction = "ROLE_LIST") + public ApiResponse> list( + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return ApiResponse.success(systemUserService.listRoles(pageNo, pageSize)); + } + + @PostMapping + @RequirePermission(value = "role.create", dataScope = DataScopeType.TENANT, auditAction = "ROLE_CREATE") + public ApiResponse create(@RequestBody @Valid CreateRoleRequest request) { + return ApiResponse.success(systemUserService.createRole(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "role.update", dataScope = DataScopeType.TENANT, auditAction = "ROLE_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid UpdateRoleRequest request) { + return ApiResponse.success(systemUserService.updateRole(id, request)); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "role.enable", dataScope = DataScopeType.TENANT, auditAction = "ROLE_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + systemUserService.enableRole(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "role.disable", dataScope = DataScopeType.TENANT, auditAction = "ROLE_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + systemUserService.disableRole(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/delete") + @RequirePermission(value = "role.delete", dataScope = DataScopeType.TENANT, auditAction = "ROLE_DELETE") + public ApiResponse softDelete(@PathVariable("id") Long id) { + systemUserService.softDeleteRole(id); + return ApiResponse.success("OK"); + } + + @GetMapping("/{id}/permissions") + @RequirePermission(value = "role.read", dataScope = DataScopeType.TENANT, auditAction = "ROLE_PERMISSIONS") + public ApiResponse> getRolePermissions(@PathVariable("id") Long id) { + return ApiResponse.success(systemUserService.getRolePermissionIds(id)); + } + + @PostMapping("/{id}/permissions") + @RequirePermission(value = "role.permission.bind", dataScope = DataScopeType.TENANT, auditAction = "ROLE_BIND_PERMISSIONS") + public ApiResponse bindPermissions(@PathVariable("id") Long id, @RequestBody @Valid BindRolePermissionsRequest request) { + systemUserService.bindRolePermissions(id, request); + return ApiResponse.success("OK"); + } + + @GetMapping("/{id}/menus") + @RequirePermission(value = "role.read", dataScope = DataScopeType.TENANT, auditAction = "ROLE_MENUS") + public ApiResponse> getRoleMenus(@PathVariable("id") Long id) { + return ApiResponse.success(menuService.getRoleMenuIds(id)); + } + + @PostMapping("/{id}/menus") + @RequirePermission(value = "role.permission.bind", dataScope = DataScopeType.TENANT, auditAction = "ROLE_BIND_MENUS") + public ApiResponse bindMenus(@PathVariable("id") Long id, @RequestBody @Valid BindRoleMenusRequest request) { + menuService.bindRoleMenus(id, request); + return ApiResponse.success("OK"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/SystemController.java b/backend/src/main/java/com/writeoff/module/system/controller/SystemController.java new file mode 100644 index 0000000..9402ee7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/SystemController.java @@ -0,0 +1,56 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.LinkedHashMap; +import java.util.Map; + +@RestController +@RequestMapping({"/api/system", "/api"}) +public class SystemController { + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Value("${spring.application.name:writeoff-backend}") + private String applicationName; + + @Value("${app.runtime.version:${APP_VERSION:${project.version:0.0.1-SNAPSHOT}}}") + private String applicationVersion; + + @Value("${app.runtime.build-time:${BUILD_TIME:unknown}}") + private String buildTime; + + @GetMapping("/health") + public ApiResponse> health() { + return ApiResponse.success(buildHealthPayload()); + } + + private Map buildHealthPayload() { + Map result = new LinkedHashMap<>(); + result.put("status", "UP"); + result.put("app", applicationName); + result.put("timestamp", LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)); + result.put("version", applicationVersion); + result.put("buildTime", buildTime); + result.put("stateStore", "MYSQL"); + + try { + jdbcTemplate.queryForObject("SELECT 1", Integer.class); + result.put("database", "UP"); + } catch (Exception e) { + result.put("database", "DOWN"); + result.put("databaseError", e.getMessage()); + result.put("status", "DEGRADED"); + } + return result; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/TenantController.java b/backend/src/main/java/com/writeoff/module/system/controller/TenantController.java new file mode 100644 index 0000000..bf637a1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/TenantController.java @@ -0,0 +1,70 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.system.dto.CreateTenantRequest; +import com.writeoff.module.system.dto.EnterpriseLogoUploadSignRequest; +import com.writeoff.module.system.dto.UpdateTenantRequest; +import com.writeoff.module.system.model.TenantInfo; +import com.writeoff.module.system.service.TenantService; +import com.writeoff.security.AuthContext; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.PermissionDomain; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/tenants") +public class TenantController { + private final TenantService tenantService; + + public TenantController(TenantService tenantService) { + this.tenantService = tenantService; + } + + @GetMapping + @RequirePermission(value = "tenant.manage", dataScope = DataScopeType.TENANT, auditAction = "TENANT_LIST") + public ApiResponse> list() { + return ApiResponse.success(tenantService.listCurrentTenant()); + } + + @PostMapping + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "TENANT_CREATE") + public ApiResponse create(@RequestBody @Valid CreateTenantRequest request) { + return ApiResponse.success(tenantService.create(request)); + } + + @PostMapping("/logo-upload-sign") + @RequirePermission(value = "tenant.manage", dataScope = DataScopeType.TENANT, auditAction = "TENANT_LOGO_UPLOAD_SIGN") + public ApiResponse> logoUploadSign(@RequestBody @Valid EnterpriseLogoUploadSignRequest request) { + return ApiResponse.success(tenantService.presignLogoUpload(request.getFileName(), request.getContentType())); + } + + @PutMapping("/{id}") + @RequirePermission(value = "tenant.manage", dataScope = DataScopeType.TENANT, auditAction = "TENANT_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, + @RequestBody @Valid UpdateTenantRequest request) { + Long currentTenantId = AuthContext.requireTenantId(); + if (!currentTenantId.equals(id)) { + throw new BusinessException(10003, "租户不存在"); + } + return ApiResponse.success(tenantService.updateTenant(id, request.getTenantName(), request.getLogoUrl())); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "TENANT_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + tenantService.enable(id); + return ApiResponse.success("ok"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "platform.tenant.manage", domain = PermissionDomain.PLATFORM, dataScope = DataScopeType.TENANT, auditAction = "TENANT_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + tenantService.disable(id); + return ApiResponse.success("ok"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/UserController.java b/backend/src/main/java/com/writeoff/module/system/controller/UserController.java new file mode 100644 index 0000000..cfcbcbf --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/UserController.java @@ -0,0 +1,133 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.model.ImportResult; +import com.writeoff.module.system.dto.AssignUserRoleRequest; +import com.writeoff.module.system.dto.CreateUserRequest; +import com.writeoff.module.system.dto.CreateUserDelegationRequest; +import com.writeoff.module.system.dto.ImportUsersRequest; +import com.writeoff.module.system.dto.ResetPasswordRequest; +import com.writeoff.module.system.model.SystemUser; +import com.writeoff.module.system.model.UserDelegationInfo; +import com.writeoff.module.system.model.UserRoleHistory; +import com.writeoff.module.system.service.SystemUserService; +import com.writeoff.module.system.service.UserDelegationService; +import com.writeoff.module.export.dto.CreateExportTaskRequest; +import com.writeoff.module.export.service.ExportTaskService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PutMapping; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/users") +public class UserController { + private final SystemUserService systemUserService; + private final UserDelegationService userDelegationService; + private final ExportTaskService exportTaskService; + + public UserController(SystemUserService systemUserService, UserDelegationService userDelegationService, ExportTaskService exportTaskService) { + this.systemUserService = systemUserService; + this.userDelegationService = userDelegationService; + this.exportTaskService = exportTaskService; + } + + @GetMapping + @RequirePermission(value = "user.read", dataScope = DataScopeType.TENANT, auditAction = "USER_LIST") + public ApiResponse> list( + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize, + @RequestParam(value = "includeDeleted", required = false) Boolean includeDeleted, + @RequestParam(value = "keyword", required = false) String keyword) { + return ApiResponse.success(systemUserService.listUsers(pageNo, pageSize, includeDeleted, keyword)); + } + + @PostMapping + @RequirePermission(value = "user.create", dataScope = DataScopeType.TENANT, auditAction = "USER_CREATE") + public ApiResponse create(@RequestBody @Valid CreateUserRequest request) { + return ApiResponse.success(systemUserService.createUser(request)); + } + + @PutMapping("/{id}") + @RequirePermission(value = "user.update", dataScope = DataScopeType.TENANT, auditAction = "USER_UPDATE") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody @Valid CreateUserRequest request) { + return ApiResponse.success(systemUserService.updateUser(id, request)); + } + + @PostMapping("/assign-role") + @RequirePermission(value = "user.role.assign", dataScope = DataScopeType.TENANT, auditAction = "USER_ASSIGN_ROLE") + public ApiResponse assignRole(@RequestBody @Valid AssignUserRoleRequest request) { + systemUserService.assignRole(request.getUserId(), request.getRoleId()); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/enable") + @RequirePermission(value = "user.enable", dataScope = DataScopeType.TENANT, auditAction = "USER_ENABLE") + public ApiResponse enable(@PathVariable("id") Long id) { + systemUserService.enableUser(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "user.disable", dataScope = DataScopeType.TENANT, auditAction = "USER_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + systemUserService.disableUser(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/delete") + @RequirePermission(value = "user.delete", dataScope = DataScopeType.TENANT, auditAction = "USER_DELETE") + public ApiResponse softDelete(@PathVariable("id") Long id) { + systemUserService.softDeleteUser(id); + return ApiResponse.success("OK"); + } + + @PostMapping("/{id}/reset-password") + @RequirePermission(value = "user.password.reset", dataScope = DataScopeType.TENANT, auditAction = "USER_RESET_PASSWORD") + public ApiResponse resetPassword(@PathVariable("id") Long id, @RequestBody @Valid ResetPasswordRequest request) { + systemUserService.resetPassword(id, request); + return ApiResponse.success("OK"); + } + + @GetMapping("/{id}/role-history") + @RequirePermission(value = "user.role.history.read", dataScope = DataScopeType.TENANT, auditAction = "USER_ROLE_HISTORY") + public ApiResponse> roleHistory(@PathVariable("id") Long id) { + return ApiResponse.success(systemUserService.listUserRoleHistory(id)); + } + + @GetMapping("/{id}/delegations") + @RequirePermission(value = "user.delegation.manage", dataScope = DataScopeType.TENANT, auditAction = "USER_DELEGATION_LIST") + public ApiResponse> delegations(@PathVariable("id") Long id) { + return ApiResponse.success(userDelegationService.listByUserId(id)); + } + + @PostMapping("/{id}/delegations") + @RequirePermission(value = "user.delegation.manage", dataScope = DataScopeType.TENANT, auditAction = "USER_DELEGATION_CREATE") + public ApiResponse createDelegation(@PathVariable("id") Long id, + @RequestBody @Valid CreateUserDelegationRequest request) { + return ApiResponse.success(userDelegationService.create(id, request)); + } + + @PostMapping("/import") + @RequirePermission(value = "user.import", dataScope = DataScopeType.TENANT, auditAction = "USER_IMPORT") + public ApiResponse importUsers(@RequestBody @Valid ImportUsersRequest request) { + return ApiResponse.success(systemUserService.importUsers(request.getUsers())); + } + + @PostMapping("/export") + @RequirePermission(value = "user.read", dataScope = DataScopeType.TENANT, auditAction = "USER_EXPORT") + public ApiResponse> exportUsers(@RequestBody @Valid CreateExportTaskRequest request) { + request.setTaskCode("USER_EXPORT"); + request.setBizType("USER"); + return ApiResponse.success(exportTaskService.create(request)); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/controller/UserDelegationController.java b/backend/src/main/java/com/writeoff/module/system/controller/UserDelegationController.java new file mode 100644 index 0000000..66780ae --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/controller/UserDelegationController.java @@ -0,0 +1,31 @@ +package com.writeoff.module.system.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.module.system.dto.DisableDelegationRequest; +import com.writeoff.module.system.service.UserDelegationService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/api/delegations") +public class UserDelegationController { + private final UserDelegationService userDelegationService; + + public UserDelegationController(UserDelegationService userDelegationService) { + this.userDelegationService = userDelegationService; + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "user.delegation.manage", dataScope = DataScopeType.TENANT, auditAction = "USER_DELEGATION_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id, @RequestBody @Valid DisableDelegationRequest request) { + userDelegationService.disable(id, request); + return ApiResponse.success("OK"); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/AssignRoleDataPermissionRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/AssignRoleDataPermissionRequest.java new file mode 100644 index 0000000..f2fb200 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/AssignRoleDataPermissionRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotNull; +import java.util.List; + +public class AssignRoleDataPermissionRequest { + @NotNull(message = "角色ID不能为空") + private List roleIds; + private String assignMode; + + public List getRoleIds() { + return roleIds; + } + + public void setRoleIds(List roleIds) { + this.roleIds = roleIds; + } + + public String getAssignMode() { + return assignMode; + } + + public void setAssignMode(String assignMode) { + this.assignMode = assignMode; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/AssignUserRoleRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/AssignUserRoleRequest.java new file mode 100644 index 0000000..fce0ed9 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/AssignUserRoleRequest.java @@ -0,0 +1,18 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotNull; + +public class AssignUserRoleRequest { + @NotNull(message = "用户ID不能为空") + private Long userId; + @NotNull(message = "角色ID不能为空") + private Long roleId; + + public Long getUserId() { + return userId; + } + + public Long getRoleId() { + return roleId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/BindPlatformMenuRolesRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/BindPlatformMenuRolesRequest.java new file mode 100644 index 0000000..afc96ec --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/BindPlatformMenuRolesRequest.java @@ -0,0 +1,13 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotNull; +import java.util.List; + +public class BindPlatformMenuRolesRequest { + @NotNull(message = "角色ID列表不能为空") + private List roleIds; + + public List getRoleIds() { + return roleIds; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/BindRoleMenusRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/BindRoleMenusRequest.java new file mode 100644 index 0000000..4293ded --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/BindRoleMenusRequest.java @@ -0,0 +1,13 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotNull; +import java.util.List; + +public class BindRoleMenusRequest { + @NotNull(message = "菜单ID列表不能为空") + private List menuIds; + + public List getMenuIds() { + return menuIds; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/BindRolePermissionsRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/BindRolePermissionsRequest.java new file mode 100644 index 0000000..ebc85c3 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/BindRolePermissionsRequest.java @@ -0,0 +1,17 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotNull; +import java.util.List; + +public class BindRolePermissionsRequest { + @NotNull(message = "权限ID列表不能为空") + private List permissionIds; + + public List getPermissionIds() { + return permissionIds; + } + + public void setPermissionIds(List permissionIds) { + this.permissionIds = permissionIds; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/ChangePasswordRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/ChangePasswordRequest.java new file mode 100644 index 0000000..824b081 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/ChangePasswordRequest.java @@ -0,0 +1,29 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +public class ChangePasswordRequest { + @NotBlank(message = "原密码不能为空") + private String oldPassword; + + @NotBlank(message = "新密码不能为空") + @Size(min = 6, message = "新密码长度至少6位") + private String newPassword; + + public String getOldPassword() { + return oldPassword; + } + + public void setOldPassword(String oldPassword) { + this.oldPassword = oldPassword; + } + + public String getNewPassword() { + return newPassword; + } + + public void setNewPassword(String newPassword) { + this.newPassword = newPassword; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreateDataPermissionPolicyRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreateDataPermissionPolicyRequest.java new file mode 100644 index 0000000..0ba31c1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreateDataPermissionPolicyRequest.java @@ -0,0 +1,110 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateDataPermissionPolicyRequest { + @NotBlank(message = "策略名称不能为空") + private String policyName; + @NotBlank(message = "项目范围不能为空") + private String projectScope; + private String projectIdsCsv; + @NotBlank(message = "会议范围不能为空") + private String meetingScope; + private String meetingIdsCsv; + @NotBlank(message = "用户范围不能为空") + private String userScope; + private String userIdsCsv; + @NotBlank(message = "专家范围不能为空") + private String expertScope; + private String expertIdsCsv; + private String moduleScope; + private Boolean exportAllowed; + + public String getPolicyName() { + return policyName; + } + + public void setPolicyName(String policyName) { + this.policyName = policyName; + } + + public String getProjectScope() { + return projectScope; + } + + public void setProjectScope(String projectScope) { + this.projectScope = projectScope; + } + + public String getProjectIdsCsv() { + return projectIdsCsv; + } + + public void setProjectIdsCsv(String projectIdsCsv) { + this.projectIdsCsv = projectIdsCsv; + } + + public String getMeetingScope() { + return meetingScope; + } + + public void setMeetingScope(String meetingScope) { + this.meetingScope = meetingScope; + } + + public String getMeetingIdsCsv() { + return meetingIdsCsv; + } + + public void setMeetingIdsCsv(String meetingIdsCsv) { + this.meetingIdsCsv = meetingIdsCsv; + } + + public String getModuleScope() { + return moduleScope; + } + + public String getUserScope() { + return userScope; + } + + public void setUserScope(String userScope) { + this.userScope = userScope; + } + + public String getUserIdsCsv() { + return userIdsCsv; + } + + public void setUserIdsCsv(String userIdsCsv) { + this.userIdsCsv = userIdsCsv; + } + + public String getExpertScope() { + return expertScope; + } + + public void setExpertScope(String expertScope) { + this.expertScope = expertScope; + } + + public String getExpertIdsCsv() { + return expertIdsCsv; + } + + public void setExpertIdsCsv(String expertIdsCsv) { + this.expertIdsCsv = expertIdsCsv; + } + + public void setModuleScope(String moduleScope) { + this.moduleScope = moduleScope; + } + + public Boolean getExportAllowed() { + return exportAllowed; + } + + public void setExportAllowed(Boolean exportAllowed) { + this.exportAllowed = exportAllowed; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreateEnterpriseRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreateEnterpriseRequest.java new file mode 100644 index 0000000..13807e3 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreateEnterpriseRequest.java @@ -0,0 +1,22 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateEnterpriseRequest { + @NotBlank(message = "企业名称不能为空") + private String enterpriseName; + private String enterpriseUrl; + private String logoUrl; + + public String getEnterpriseName() { + return enterpriseName; + } + + public String getEnterpriseUrl() { + return enterpriseUrl; + } + + public String getLogoUrl() { + return logoUrl; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreateMenuRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreateMenuRequest.java new file mode 100644 index 0000000..1bca260 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreateMenuRequest.java @@ -0,0 +1,37 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class CreateMenuRequest { + @NotBlank(message = "菜单编码不能为空") + private String menuCode; + @NotBlank(message = "菜单名称不能为空") + private String menuName; + @NotBlank(message = "路由地址不能为空") + private String routePath; + @NotBlank(message = "权限码不能为空") + private String permissionCode; + @NotNull(message = "排序不能为空") + private Integer sortNo; + + public String getMenuCode() { + return menuCode; + } + + public String getMenuName() { + return menuName; + } + + public String getRoutePath() { + return routePath; + } + + public String getPermissionCode() { + return permissionCode; + } + + public Integer getSortNo() { + return sortNo; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreatePlatformDictionaryItemRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreatePlatformDictionaryItemRequest.java new file mode 100644 index 0000000..8f63429 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreatePlatformDictionaryItemRequest.java @@ -0,0 +1,56 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class CreatePlatformDictionaryItemRequest { + @NotBlank(message = "字典类型不能为空") + private String dictType; + @NotBlank(message = "字典编码不能为空") + private String dictCode; + @NotBlank(message = "字典名称不能为空") + private String dictName; + @NotNull(message = "排序不能为空") + private Integer sortNo; + private String remark; + + public String getDictType() { + return dictType; + } + + public void setDictType(String dictType) { + this.dictType = dictType; + } + + public String getDictCode() { + return dictCode; + } + + public void setDictCode(String dictCode) { + this.dictCode = dictCode; + } + + public String getDictName() { + return dictName; + } + + public void setDictName(String dictName) { + this.dictName = dictName; + } + + public Integer getSortNo() { + return sortNo; + } + + public void setSortNo(Integer sortNo) { + this.sortNo = sortNo; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreatePlatformDictionaryTypeRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreatePlatformDictionaryTypeRequest.java new file mode 100644 index 0000000..6a4b79b --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreatePlatformDictionaryTypeRequest.java @@ -0,0 +1,46 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class CreatePlatformDictionaryTypeRequest { + @NotBlank(message = "字典类型编码不能为空") + private String dictType; + @NotBlank(message = "字典类型名称不能为空") + private String dictName; + @NotNull(message = "排序不能为空") + private Integer sortNo; + private String remark; + + public String getDictType() { + return dictType; + } + + public void setDictType(String dictType) { + this.dictType = dictType; + } + + public String getDictName() { + return dictName; + } + + public void setDictName(String dictName) { + this.dictName = dictName; + } + + public Integer getSortNo() { + return sortNo; + } + + public void setSortNo(Integer sortNo) { + this.sortNo = sortNo; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreateRoleRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreateRoleRequest.java new file mode 100644 index 0000000..ca2953d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreateRoleRequest.java @@ -0,0 +1,26 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateRoleRequest { + @NotBlank(message = "角色编码不能为空") + private String roleCode; + @NotBlank(message = "角色名称不能为空") + private String roleName; + + public String getRoleCode() { + return roleCode; + } + + public void setRoleCode(String roleCode) { + this.roleCode = roleCode; + } + + public String getRoleName() { + return roleName; + } + + public void setRoleName(String roleName) { + this.roleName = roleName; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreateTenantAdminRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreateTenantAdminRequest.java new file mode 100644 index 0000000..9c766db --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreateTenantAdminRequest.java @@ -0,0 +1,32 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateTenantAdminRequest { + @NotBlank(message = "管理员姓名不能为空") + private String userName; + + @NotBlank(message = "管理员手机号不能为空") + private String phone; + + @NotBlank(message = "管理员邮箱不能为空") + private String email; + + private String roleCode; + + public String getUserName() { + return userName; + } + + public String getPhone() { + return phone; + } + + public String getEmail() { + return email; + } + + public String getRoleCode() { + return roleCode; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreateTenantRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreateTenantRequest.java new file mode 100644 index 0000000..570f164 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreateTenantRequest.java @@ -0,0 +1,35 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateTenantRequest { + @NotBlank(message = "租户编码不能为空") + private String tenantCode; + @NotBlank(message = "租户名称不能为空") + private String tenantName; + private String logoUrl; + + public String getTenantCode() { + return tenantCode; + } + + public void setTenantCode(String tenantCode) { + this.tenantCode = tenantCode; + } + + public String getTenantName() { + return tenantName; + } + + public void setTenantName(String tenantName) { + this.tenantName = tenantName; + } + + public String getLogoUrl() { + return logoUrl; + } + + public void setLogoUrl(String logoUrl) { + this.logoUrl = logoUrl; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreateUserDelegationRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreateUserDelegationRequest.java new file mode 100644 index 0000000..e5a661a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreateUserDelegationRequest.java @@ -0,0 +1,30 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class CreateUserDelegationRequest { + @NotNull(message = "代理人不能为空") + private Long delegateUserId; + @NotBlank(message = "生效时间不能为空") + private String effectiveFrom; + @NotBlank(message = "失效时间不能为空") + private String effectiveTo; + private String reason; + + public Long getDelegateUserId() { + return delegateUserId; + } + + public String getEffectiveFrom() { + return effectiveFrom; + } + + public String getEffectiveTo() { + return effectiveTo; + } + + public String getReason() { + return reason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/CreateUserRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/CreateUserRequest.java new file mode 100644 index 0000000..1c15362 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/CreateUserRequest.java @@ -0,0 +1,67 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.Email; +import javax.validation.constraints.NotBlank; + +public class CreateUserRequest { + @NotBlank(message = "\u7528\u6237\u540d\u4e0d\u80fd\u4e3a\u7a7a") + private String userName; + + @NotBlank(message = "\u624b\u673a\u53f7\u4e0d\u80fd\u4e3a\u7a7a") + private String phone; + + private String password; + @NotBlank(message = "\u90ae\u7bb1\u4e0d\u80fd\u4e3a\u7a7a") + @Email(message = "\u90ae\u7bb1\u683c\u5f0f\u4e0d\u6b63\u786e") + private String email; + private String validFrom; + private String validTo; + + public String getUserName() { + return userName; + } + + public String getPhone() { + return phone; + } + + public String getPassword() { + return password; + } + + public String getEmail() { + return email; + } + + public String getValidFrom() { + return validFrom; + } + + public String getValidTo() { + return validTo; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public void setPassword(String password) { + this.password = password; + } + + public void setEmail(String email) { + this.email = email; + } + + public void setValidFrom(String validFrom) { + this.validFrom = validFrom; + } + + public void setValidTo(String validTo) { + this.validTo = validTo; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/DisableDelegationRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/DisableDelegationRequest.java new file mode 100644 index 0000000..7ee67df --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/DisableDelegationRequest.java @@ -0,0 +1,12 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class DisableDelegationRequest { + @NotBlank(message = "停用原因不能为空") + private String reason; + + public String getReason() { + return reason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/EnterpriseLogoUploadSignRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/EnterpriseLogoUploadSignRequest.java new file mode 100644 index 0000000..b1384d4 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/EnterpriseLogoUploadSignRequest.java @@ -0,0 +1,17 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class EnterpriseLogoUploadSignRequest { + @NotBlank(message = "文件名不能为空") + private String fileName; + private String contentType; + + public String getFileName() { + return fileName; + } + + public String getContentType() { + return contentType; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/ImportUserItemRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/ImportUserItemRequest.java new file mode 100644 index 0000000..21d0e36 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/ImportUserItemRequest.java @@ -0,0 +1,67 @@ +package com.writeoff.module.system.dto; + +public class ImportUserItemRequest { + private String userName; + private String phone; + private String password; + private String email; + private String validFrom; + private String validTo; + private String roleCode; + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } + + public String getPhone() { + return phone; + } + + public void setPhone(String phone) { + this.phone = phone; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getValidFrom() { + return validFrom; + } + + public void setValidFrom(String validFrom) { + this.validFrom = validFrom; + } + + public String getValidTo() { + return validTo; + } + + public void setValidTo(String validTo) { + this.validTo = validTo; + } + + public String getRoleCode() { + return roleCode; + } + + public void setRoleCode(String roleCode) { + this.roleCode = roleCode; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/ImportUsersRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/ImportUsersRequest.java new file mode 100644 index 0000000..4238ace --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/ImportUsersRequest.java @@ -0,0 +1,19 @@ +package com.writeoff.module.system.dto; + +import javax.validation.Valid; +import javax.validation.constraints.NotEmpty; +import java.util.List; + +public class ImportUsersRequest { + @Valid + @NotEmpty(message = "导入列表不能为空") + private List users; + + public List getUsers() { + return users; + } + + public void setUsers(List users) { + this.users = users; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/ReorderMenusRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/ReorderMenusRequest.java new file mode 100644 index 0000000..3ab8ed7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/ReorderMenusRequest.java @@ -0,0 +1,28 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotNull; +import java.util.List; + +public class ReorderMenusRequest { + @NotNull(message = "菜单排序列表不能为空") + private List menus; + + public List getMenus() { + return menus; + } + + public static class MenuSortItem { + @NotNull(message = "菜单ID不能为空") + private Long id; + @NotNull(message = "排序值不能为空") + private Integer sortNo; + + public Long getId() { + return id; + } + + public Integer getSortNo() { + return sortNo; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/ResetPasswordRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/ResetPasswordRequest.java new file mode 100644 index 0000000..86e928e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/ResetPasswordRequest.java @@ -0,0 +1,16 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class ResetPasswordRequest { + @NotBlank(message = "新密码不能为空") + private String newPassword; + + public String getNewPassword() { + return newPassword; + } + + public void setNewPassword(String newPassword) { + this.newPassword = newPassword; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/UpdateDataPermissionPolicyRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/UpdateDataPermissionPolicyRequest.java new file mode 100644 index 0000000..0fa25f1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/UpdateDataPermissionPolicyRequest.java @@ -0,0 +1,4 @@ +package com.writeoff.module.system.dto; + +public class UpdateDataPermissionPolicyRequest extends CreateDataPermissionPolicyRequest { +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/UpdateEnterpriseRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/UpdateEnterpriseRequest.java new file mode 100644 index 0000000..b022c58 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/UpdateEnterpriseRequest.java @@ -0,0 +1,22 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class UpdateEnterpriseRequest { + @NotBlank(message = "企业名称不能为空") + private String enterpriseName; + private String enterpriseUrl; + private String logoUrl; + + public String getEnterpriseName() { + return enterpriseName; + } + + public String getEnterpriseUrl() { + return enterpriseUrl; + } + + public String getLogoUrl() { + return logoUrl; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/UpdateMenuRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/UpdateMenuRequest.java new file mode 100644 index 0000000..45143b2 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/UpdateMenuRequest.java @@ -0,0 +1,37 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class UpdateMenuRequest { + @NotBlank(message = "菜单名称不能为空") + private String menuName; + @NotBlank(message = "路由地址不能为空") + private String routePath; + @NotBlank(message = "权限码不能为空") + private String permissionCode; + @NotNull(message = "排序不能为空") + private Integer sortNo; + @NotBlank(message = "状态不能为空") + private String status; + + public String getMenuName() { + return menuName; + } + + public String getRoutePath() { + return routePath; + } + + public String getPermissionCode() { + return permissionCode; + } + + public Integer getSortNo() { + return sortNo; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/UpdatePlatformDictionaryItemRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/UpdatePlatformDictionaryItemRequest.java new file mode 100644 index 0000000..51ca323 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/UpdatePlatformDictionaryItemRequest.java @@ -0,0 +1,46 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class UpdatePlatformDictionaryItemRequest { + @NotBlank(message = "字典名称不能为空") + private String dictName; + @NotNull(message = "排序不能为空") + private Integer sortNo; + private String remark; + @NotBlank(message = "状态不能为空") + private String status; + + public String getDictName() { + return dictName; + } + + public void setDictName(String dictName) { + this.dictName = dictName; + } + + public Integer getSortNo() { + return sortNo; + } + + public void setSortNo(Integer sortNo) { + this.sortNo = sortNo; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/UpdateProfilePreferencesRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/UpdateProfilePreferencesRequest.java new file mode 100644 index 0000000..ecda537 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/UpdateProfilePreferencesRequest.java @@ -0,0 +1,31 @@ +package com.writeoff.module.system.dto; + +public class UpdateProfilePreferencesRequest { + private String themeMode; + private String density; + private String themeScheme; + + public String getThemeMode() { + return themeMode; + } + + public void setThemeMode(String themeMode) { + this.themeMode = themeMode; + } + + public String getDensity() { + return density; + } + + public void setDensity(String density) { + this.density = density; + } + + public String getThemeScheme() { + return themeScheme; + } + + public void setThemeScheme(String themeScheme) { + this.themeScheme = themeScheme; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/UpdateRoleRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/UpdateRoleRequest.java new file mode 100644 index 0000000..407c4e1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/UpdateRoleRequest.java @@ -0,0 +1,16 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class UpdateRoleRequest { + @NotBlank(message = "角色名称不能为空") + private String roleName; + + public String getRoleName() { + return roleName; + } + + public void setRoleName(String roleName) { + this.roleName = roleName; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/dto/UpdateTenantRequest.java b/backend/src/main/java/com/writeoff/module/system/dto/UpdateTenantRequest.java new file mode 100644 index 0000000..e1a23af --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/dto/UpdateTenantRequest.java @@ -0,0 +1,17 @@ +package com.writeoff.module.system.dto; + +import javax.validation.constraints.NotBlank; + +public class UpdateTenantRequest { + @NotBlank(message = "租户名称不能为空") + private String tenantName; + private String logoUrl; + + public String getTenantName() { + return tenantName; + } + + public String getLogoUrl() { + return logoUrl; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/job/UserDelegationExpireScheduler.java b/backend/src/main/java/com/writeoff/module/system/job/UserDelegationExpireScheduler.java new file mode 100644 index 0000000..1dbc2f0 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/job/UserDelegationExpireScheduler.java @@ -0,0 +1,19 @@ +package com.writeoff.module.system.job; + +import com.writeoff.module.system.service.UserDelegationService; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +public class UserDelegationExpireScheduler { + private final UserDelegationService userDelegationService; + + public UserDelegationExpireScheduler(UserDelegationService userDelegationService) { + this.userDelegationService = userDelegationService; + } + + @Scheduled(fixedDelayString = "${app.user-delegation.expire-check-ms:60000}") + public void expireDelegations() { + userDelegationService.markAutoExpiredAllTenants(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/BizChangeLogInfo.java b/backend/src/main/java/com/writeoff/module/system/model/BizChangeLogInfo.java new file mode 100644 index 0000000..40c2ed5 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/BizChangeLogInfo.java @@ -0,0 +1,111 @@ +package com.writeoff.module.system.model; + +public class BizChangeLogInfo { + private final Long id; + private final String bizType; + private final Long bizId; + private final String changeType; + private final String fieldCode; + private final String fieldName; + private final String beforeValue; + private final String afterValue; + private final Long relatedUserId; + private final String relatedUserName; + private final Long operatorUserId; + private final String operatorUserName; + private final String batchId; + private final String remark; + private final String createdAt; + + public BizChangeLogInfo(Long id, + String bizType, + Long bizId, + String changeType, + String fieldCode, + String fieldName, + String beforeValue, + String afterValue, + Long relatedUserId, + String relatedUserName, + Long operatorUserId, + String operatorUserName, + String batchId, + String remark, + String createdAt) { + this.id = id; + this.bizType = bizType; + this.bizId = bizId; + this.changeType = changeType; + this.fieldCode = fieldCode; + this.fieldName = fieldName; + this.beforeValue = beforeValue; + this.afterValue = afterValue; + this.relatedUserId = relatedUserId; + this.relatedUserName = relatedUserName; + this.operatorUserId = operatorUserId; + this.operatorUserName = operatorUserName; + this.batchId = batchId; + this.remark = remark; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public String getBizType() { + return bizType; + } + + public Long getBizId() { + return bizId; + } + + public String getChangeType() { + return changeType; + } + + public String getFieldCode() { + return fieldCode; + } + + public String getFieldName() { + return fieldName; + } + + public String getBeforeValue() { + return beforeValue; + } + + public String getAfterValue() { + return afterValue; + } + + public Long getRelatedUserId() { + return relatedUserId; + } + + public String getRelatedUserName() { + return relatedUserName; + } + + public Long getOperatorUserId() { + return operatorUserId; + } + + public String getOperatorUserName() { + return operatorUserName; + } + + public String getBatchId() { + return batchId; + } + + public String getRemark() { + return remark; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/DataPermissionPolicy.java b/backend/src/main/java/com/writeoff/module/system/model/DataPermissionPolicy.java new file mode 100644 index 0000000..8bc35d1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/DataPermissionPolicy.java @@ -0,0 +1,97 @@ +package com.writeoff.module.system.model; + +public class DataPermissionPolicy { + private Long id; + private String policyName; + private String projectScope; + private String projectIdsCsv; + private String meetingScope; + private String meetingIdsCsv; + private String userScope; + private String userIdsCsv; + private String expertScope; + private String expertIdsCsv; + private String moduleScope; + private Boolean exportAllowed; + private String status; + + public DataPermissionPolicy(Long id, + String policyName, + String projectScope, + String projectIdsCsv, + String meetingScope, + String meetingIdsCsv, + String userScope, + String userIdsCsv, + String expertScope, + String expertIdsCsv, + String moduleScope, + Boolean exportAllowed, + String status) { + this.id = id; + this.policyName = policyName; + this.projectScope = projectScope; + this.projectIdsCsv = projectIdsCsv; + this.meetingScope = meetingScope; + this.meetingIdsCsv = meetingIdsCsv; + this.userScope = userScope; + this.userIdsCsv = userIdsCsv; + this.expertScope = expertScope; + this.expertIdsCsv = expertIdsCsv; + this.moduleScope = moduleScope; + this.exportAllowed = exportAllowed; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getPolicyName() { + return policyName; + } + + public String getProjectScope() { + return projectScope; + } + + public String getProjectIdsCsv() { + return projectIdsCsv; + } + + public String getMeetingScope() { + return meetingScope; + } + + public String getMeetingIdsCsv() { + return meetingIdsCsv; + } + + public String getUserScope() { + return userScope; + } + + public String getUserIdsCsv() { + return userIdsCsv; + } + + public String getExpertScope() { + return expertScope; + } + + public String getExpertIdsCsv() { + return expertIdsCsv; + } + + public String getModuleScope() { + return moduleScope; + } + + public Boolean getExportAllowed() { + return exportAllowed; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/EnterpriseInfo.java b/backend/src/main/java/com/writeoff/module/system/model/EnterpriseInfo.java new file mode 100644 index 0000000..90adf19 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/EnterpriseInfo.java @@ -0,0 +1,43 @@ +package com.writeoff.module.system.model; + +public class EnterpriseInfo { + private Long id; + private String enterpriseName; + private String enterpriseUrl; + private String logoUrl; + private String status; + private String createdAt; + + public EnterpriseInfo(Long id, String enterpriseName, String enterpriseUrl, String logoUrl, String status, String createdAt) { + this.id = id; + this.enterpriseName = enterpriseName; + this.enterpriseUrl = enterpriseUrl; + this.logoUrl = logoUrl; + this.status = status; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public String getEnterpriseName() { + return enterpriseName; + } + + public String getEnterpriseUrl() { + return enterpriseUrl; + } + + public String getLogoUrl() { + return logoUrl; + } + + public String getStatus() { + return status; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchGroup.java b/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchGroup.java new file mode 100644 index 0000000..8798081 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchGroup.java @@ -0,0 +1,27 @@ +package com.writeoff.module.system.model; + +import java.util.List; + +public class GlobalSearchGroup { + private final String type; + private final String label; + private final List items; + + public GlobalSearchGroup(String type, String label, List items) { + this.type = type; + this.label = label; + this.items = items; + } + + public String getType() { + return type; + } + + public String getLabel() { + return label; + } + + public List getItems() { + return items; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchItem.java b/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchItem.java new file mode 100644 index 0000000..7a08a77 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchItem.java @@ -0,0 +1,37 @@ +package com.writeoff.module.system.model; + +public class GlobalSearchItem { + private final String type; + private final String title; + private final String subtitle; + private final String routePath; + private final String badge; + + public GlobalSearchItem(String type, String title, String subtitle, String routePath, String badge) { + this.type = type; + this.title = title; + this.subtitle = subtitle; + this.routePath = routePath; + this.badge = badge; + } + + public String getType() { + return type; + } + + public String getTitle() { + return title; + } + + public String getSubtitle() { + return subtitle; + } + + public String getRoutePath() { + return routePath; + } + + public String getBadge() { + return badge; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchResult.java b/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchResult.java new file mode 100644 index 0000000..16a5314 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/GlobalSearchResult.java @@ -0,0 +1,27 @@ +package com.writeoff.module.system.model; + +import java.util.List; + +public class GlobalSearchResult { + private final String keyword; + private final int total; + private final List groups; + + public GlobalSearchResult(String keyword, int total, List groups) { + this.keyword = keyword; + this.total = total; + this.groups = groups; + } + + public String getKeyword() { + return keyword; + } + + public int getTotal() { + return total; + } + + public List getGroups() { + return groups; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/MenuInfo.java b/backend/src/main/java/com/writeoff/module/system/model/MenuInfo.java new file mode 100644 index 0000000..7c00eba --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/MenuInfo.java @@ -0,0 +1,49 @@ +package com.writeoff.module.system.model; + +public class MenuInfo { + private Long id; + private String menuCode; + private String menuName; + private String routePath; + private String permissionCode; + private Integer sortNo; + private String status; + + public MenuInfo(Long id, String menuCode, String menuName, String routePath, String permissionCode, Integer sortNo, String status) { + this.id = id; + this.menuCode = menuCode; + this.menuName = menuName; + this.routePath = routePath; + this.permissionCode = permissionCode; + this.sortNo = sortNo; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getMenuCode() { + return menuCode; + } + + public String getMenuName() { + return menuName; + } + + public String getRoutePath() { + return routePath; + } + + public String getPermissionCode() { + return permissionCode; + } + + public Integer getSortNo() { + return sortNo; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/OperationAuditLogInfo.java b/backend/src/main/java/com/writeoff/module/system/model/OperationAuditLogInfo.java new file mode 100644 index 0000000..6365a6d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/OperationAuditLogInfo.java @@ -0,0 +1,99 @@ +package com.writeoff.module.system.model; + +public class OperationAuditLogInfo { + private Long id; + private Long tenantId; + private Long userId; + private String scope; + private String actionCode; + private String bizType; + private String bizId; + private String httpMethod; + private String requestUri; + private String requestId; + private Integer statusCode; + private Boolean success; + private String errorMessage; + private String ip; + private String createdAt; + + public OperationAuditLogInfo(Long id, Long tenantId, Long userId, String scope, String actionCode, String bizType, String bizId, + String httpMethod, String requestUri, String requestId, Integer statusCode, Boolean success, + String errorMessage, String ip, String createdAt) { + this.id = id; + this.tenantId = tenantId; + this.userId = userId; + this.scope = scope; + this.actionCode = actionCode; + this.bizType = bizType; + this.bizId = bizId; + this.httpMethod = httpMethod; + this.requestUri = requestUri; + this.requestId = requestId; + this.statusCode = statusCode; + this.success = success; + this.errorMessage = errorMessage; + this.ip = ip; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public Long getTenantId() { + return tenantId; + } + + public Long getUserId() { + return userId; + } + + public String getScope() { + return scope; + } + + public String getActionCode() { + return actionCode; + } + + public String getBizType() { + return bizType; + } + + public String getBizId() { + return bizId; + } + + public String getHttpMethod() { + return httpMethod; + } + + public String getRequestUri() { + return requestUri; + } + + public String getRequestId() { + return requestId; + } + + public Integer getStatusCode() { + return statusCode; + } + + public Boolean getSuccess() { + return success; + } + + public String getErrorMessage() { + return errorMessage; + } + + public String getIp() { + return ip; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/PermissionInfo.java b/backend/src/main/java/com/writeoff/module/system/model/PermissionInfo.java new file mode 100644 index 0000000..540c03b --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/PermissionInfo.java @@ -0,0 +1,31 @@ +package com.writeoff.module.system.model; + +public class PermissionInfo { + private Long id; + private String permissionCode; + private String permissionName; + private String module; + + public PermissionInfo(Long id, String permissionCode, String permissionName, String module) { + this.id = id; + this.permissionCode = permissionCode; + this.permissionName = permissionName; + this.module = module; + } + + public Long getId() { + return id; + } + + public String getPermissionCode() { + return permissionCode; + } + + public String getPermissionName() { + return permissionName; + } + + public String getModule() { + return module; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/PlatformDictionaryItem.java b/backend/src/main/java/com/writeoff/module/system/model/PlatformDictionaryItem.java new file mode 100644 index 0000000..d4935cc --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/PlatformDictionaryItem.java @@ -0,0 +1,52 @@ +package com.writeoff.module.system.model; + +/** + * 平台级数据字典项,供全平台租户共享读取。 + */ +public class PlatformDictionaryItem { + private Long id; + private String dictType; + private String dictCode; + private String dictName; + private Integer sortNo; + private String status; + private String remark; + + public PlatformDictionaryItem(Long id, String dictType, String dictCode, String dictName, Integer sortNo, String status, String remark) { + this.id = id; + this.dictType = dictType; + this.dictCode = dictCode; + this.dictName = dictName; + this.sortNo = sortNo; + this.status = status; + this.remark = remark; + } + + public Long getId() { + return id; + } + + public String getDictType() { + return dictType; + } + + public String getDictCode() { + return dictCode; + } + + public String getDictName() { + return dictName; + } + + public Integer getSortNo() { + return sortNo; + } + + public String getStatus() { + return status; + } + + public String getRemark() { + return remark; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/PlatformDictionaryType.java b/backend/src/main/java/com/writeoff/module/system/model/PlatformDictionaryType.java new file mode 100644 index 0000000..c50b96d --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/PlatformDictionaryType.java @@ -0,0 +1,46 @@ +package com.writeoff.module.system.model; + +/** + * 平台级字典类型定义。 + */ +public class PlatformDictionaryType { + private Long id; + private String dictType; + private String dictName; + private Integer sortNo; + private String status; + private String remark; + + public PlatformDictionaryType(Long id, String dictType, String dictName, Integer sortNo, String status, String remark) { + this.id = id; + this.dictType = dictType; + this.dictName = dictName; + this.sortNo = sortNo; + this.status = status; + this.remark = remark; + } + + public Long getId() { + return id; + } + + public String getDictType() { + return dictType; + } + + public String getDictName() { + return dictName; + } + + public Integer getSortNo() { + return sortNo; + } + + public String getStatus() { + return status; + } + + public String getRemark() { + return remark; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/PlatformRoleInfo.java b/backend/src/main/java/com/writeoff/module/system/model/PlatformRoleInfo.java new file mode 100644 index 0000000..be22438 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/PlatformRoleInfo.java @@ -0,0 +1,31 @@ +package com.writeoff.module.system.model; + +public class PlatformRoleInfo { + private Long id; + private String roleCode; + private String roleName; + private String status; + + public PlatformRoleInfo(Long id, String roleCode, String roleName, String status) { + this.id = id; + this.roleCode = roleCode; + this.roleName = roleName; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getRoleCode() { + return roleCode; + } + + public String getRoleName() { + return roleName; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/ProfilePreferencesInfo.java b/backend/src/main/java/com/writeoff/module/system/model/ProfilePreferencesInfo.java new file mode 100644 index 0000000..507c76a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/ProfilePreferencesInfo.java @@ -0,0 +1,25 @@ +package com.writeoff.module.system.model; + +public class ProfilePreferencesInfo { + private final String themeMode; + private final String density; + private final String themeScheme; + + public ProfilePreferencesInfo(String themeMode, String density, String themeScheme) { + this.themeMode = themeMode; + this.density = density; + this.themeScheme = themeScheme; + } + + public String getThemeMode() { + return themeMode; + } + + public String getDensity() { + return density; + } + + public String getThemeScheme() { + return themeScheme; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/RoleInfo.java b/backend/src/main/java/com/writeoff/module/system/model/RoleInfo.java new file mode 100644 index 0000000..68a4fda --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/RoleInfo.java @@ -0,0 +1,31 @@ +package com.writeoff.module.system.model; + +public class RoleInfo { + private Long id; + private String roleCode; + private String roleName; + private String status; + + public RoleInfo(Long id, String roleCode, String roleName, String status) { + this.id = id; + this.roleCode = roleCode; + this.roleName = roleName; + this.status = status; + } + + public Long getId() { + return id; + } + + public String getRoleCode() { + return roleCode; + } + + public String getRoleName() { + return roleName; + } + + public String getStatus() { + return status; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/SystemUser.java b/backend/src/main/java/com/writeoff/module/system/model/SystemUser.java new file mode 100644 index 0000000..f681c3b --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/SystemUser.java @@ -0,0 +1,75 @@ +package com.writeoff.module.system.model; + +public class SystemUser { + private Long id; + private String userName; + private String phone; + private String email; + private String status; + private String validFrom; + private String validTo; + private String roleCodes; + private String roleNames; + private boolean deleted; + + public SystemUser(Long id, String userName, String phone, String email, String status, String validFrom, String validTo) { + this(id, userName, phone, email, status, validFrom, validTo, "", ""); + } + + public SystemUser(Long id, String userName, String phone, String email, String status, String validFrom, String validTo, String roleCodes, String roleNames) { + this(id, userName, phone, email, status, validFrom, validTo, roleCodes, roleNames, false); + } + + public SystemUser(Long id, String userName, String phone, String email, String status, String validFrom, String validTo, String roleCodes, String roleNames, boolean deleted) { + this.id = id; + this.userName = userName; + this.phone = phone; + this.email = email; + this.status = status; + this.validFrom = validFrom; + this.validTo = validTo; + this.roleCodes = roleCodes; + this.roleNames = roleNames; + this.deleted = deleted; + } + + public Long getId() { + return id; + } + + public String getUserName() { + return userName; + } + + public String getPhone() { + return phone; + } + + public String getEmail() { + return email; + } + + public String getStatus() { + return status; + } + + public String getValidFrom() { + return validFrom; + } + + public String getValidTo() { + return validTo; + } + + public String getRoleCodes() { + return roleCodes; + } + + public String getRoleNames() { + return roleNames; + } + + public boolean isDeleted() { + return deleted; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/TenantInfo.java b/backend/src/main/java/com/writeoff/module/system/model/TenantInfo.java new file mode 100644 index 0000000..e0c0868 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/TenantInfo.java @@ -0,0 +1,43 @@ +package com.writeoff.module.system.model; + +public class TenantInfo { + private Long id; + private String tenantCode; + private String tenantName; + private String logoUrl; + private String status; + private String createdAt; + + public TenantInfo(Long id, String tenantCode, String tenantName, String logoUrl, String status, String createdAt) { + this.id = id; + this.tenantCode = tenantCode; + this.tenantName = tenantName; + this.logoUrl = logoUrl; + this.status = status; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public String getTenantCode() { + return tenantCode; + } + + public String getTenantName() { + return tenantName; + } + + public String getLogoUrl() { + return logoUrl; + } + + public String getStatus() { + return status; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/UserDelegationInfo.java b/backend/src/main/java/com/writeoff/module/system/model/UserDelegationInfo.java new file mode 100644 index 0000000..b7e8a1c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/UserDelegationInfo.java @@ -0,0 +1,61 @@ +package com.writeoff.module.system.model; + +public class UserDelegationInfo { + private Long id; + private Long userId; + private Long delegateUserId; + private String effectiveFrom; + private String effectiveTo; + private String status; + private String reason; + private String disabledReason; + private String createdAt; + + public UserDelegationInfo(Long id, Long userId, Long delegateUserId, String effectiveFrom, String effectiveTo, String status, String reason, String disabledReason, String createdAt) { + this.id = id; + this.userId = userId; + this.delegateUserId = delegateUserId; + this.effectiveFrom = effectiveFrom; + this.effectiveTo = effectiveTo; + this.status = status; + this.reason = reason; + this.disabledReason = disabledReason; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public Long getDelegateUserId() { + return delegateUserId; + } + + public String getEffectiveFrom() { + return effectiveFrom; + } + + public String getEffectiveTo() { + return effectiveTo; + } + + public String getStatus() { + return status; + } + + public String getReason() { + return reason; + } + + public String getDisabledReason() { + return disabledReason; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/model/UserRoleHistory.java b/backend/src/main/java/com/writeoff/module/system/model/UserRoleHistory.java new file mode 100644 index 0000000..2e263ab --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/model/UserRoleHistory.java @@ -0,0 +1,43 @@ +package com.writeoff.module.system.model; + +public class UserRoleHistory { + private Long id; + private Long userId; + private Long oldRoleId; + private Long newRoleId; + private String actionType; + private String createdAt; + + public UserRoleHistory(Long id, Long userId, Long oldRoleId, Long newRoleId, String actionType, String createdAt) { + this.id = id; + this.userId = userId; + this.oldRoleId = oldRoleId; + this.newRoleId = newRoleId; + this.actionType = actionType; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public Long getUserId() { + return userId; + } + + public Long getOldRoleId() { + return oldRoleId; + } + + public Long getNewRoleId() { + return newRoleId; + } + + public String getActionType() { + return actionType; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/BizChangeLogService.java b/backend/src/main/java/com/writeoff/module/system/service/BizChangeLogService.java new file mode 100644 index 0000000..8633e3a --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/BizChangeLogService.java @@ -0,0 +1,268 @@ +package com.writeoff.module.system.service; + +import com.writeoff.module.system.model.BizChangeLogInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +@Service +public class BizChangeLogService { + private static final RowMapper ROW_MAPPER = (rs, n) -> new BizChangeLogInfo( + rs.getLong("id"), + rs.getString("biz_type"), + rs.getLong("biz_id"), + rs.getString("change_type"), + rs.getString("field_code"), + rs.getString("field_name"), + rs.getString("before_value"), + rs.getString("after_value"), + rs.getObject("related_user_id") == null ? null : rs.getLong("related_user_id"), + rs.getString("related_user_name"), + rs.getLong("operator_user_id"), + rs.getString("operator_user_name"), + rs.getString("batch_id"), + rs.getString("remark"), + rs.getString("created_at") + ); + + private final JdbcTemplate jdbcTemplate; + + public BizChangeLogService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public List listByBiz(String bizType, Long bizId) { + return jdbcTemplate.query( + "SELECT id, biz_type, biz_id, change_type, field_code, field_name, before_value, after_value, " + + "related_user_id, related_user_name, operator_user_id, operator_user_name, batch_id, remark, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM biz_change_log " + + "WHERE tenant_id=? AND biz_type=? AND biz_id=? AND is_deleted=0 " + + "ORDER BY id DESC", + ROW_MAPPER, + tenantId(), + safeTrim(bizType), + bizId + ); + } + + public void logFieldChange(String bizType, + Long bizId, + String changeType, + String fieldCode, + String fieldName, + Object beforeValue, + Object afterValue, + String batchId, + String remark) { + if (Objects.equals(normalizeValue(beforeValue), normalizeValue(afterValue))) { + return; + } + insert( + bizType, + bizId, + changeType, + fieldCode, + fieldName, + stringify(beforeValue), + stringify(afterValue), + null, + null, + batchId, + remark + ); + } + + public void logAction(String bizType, Long bizId, String changeType, String remark) { + insert(bizType, bizId, changeType, null, null, null, null, null, null, null, remark); + } + + public void logRelationAdd(String bizType, + Long bizId, + String changeType, + String fieldCode, + String fieldName, + Long relatedUserId, + String relatedUserName, + String batchId, + String remark) { + insert( + bizType, + bizId, + changeType, + fieldCode, + fieldName, + null, + relatedUserName, + relatedUserId, + relatedUserName, + batchId, + remark + ); + } + + public void logRelationRemove(String bizType, + Long bizId, + String changeType, + String fieldCode, + String fieldName, + Long relatedUserId, + String relatedUserName, + String batchId, + String remark) { + insert( + bizType, + bizId, + changeType, + fieldCode, + fieldName, + relatedUserName, + null, + relatedUserId, + relatedUserName, + batchId, + remark + ); + } + + public String newBatchId() { + return UUID.randomUUID().toString().replace("-", ""); + } + + public String loadUserName(Long userId) { + long id = userId == null ? 0L : userId; + if (id <= 0L) { + return ""; + } + List list = jdbcTemplate.query( + "SELECT user_name FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + (rs, n) -> rs.getString("user_name"), + tenantId(), + id + ); + return list.isEmpty() ? "" : safeTrim(list.get(0)); + } + + public List loadUserRefs(List userIds) { + List validIds = new ArrayList(); + for (Long userId : userIds) { + long id = userId == null ? 0L : userId; + if (id > 0L && !validIds.contains(id)) { + validIds.add(id); + } + } + if (validIds.isEmpty()) { + return new ArrayList(); + } + StringBuilder sql = new StringBuilder("SELECT id, user_name FROM sys_user WHERE tenant_id=? AND is_deleted=0 AND id IN ("); + List args = new ArrayList(); + args.add(tenantId()); + for (int i = 0; i < validIds.size(); i++) { + if (i > 0) { + sql.append(","); + } + sql.append("?"); + args.add(validIds.get(i)); + } + sql.append(")"); + return jdbcTemplate.query( + sql.toString(), + (rs, n) -> new UserRef(rs.getLong("id"), safeTrim(rs.getString("user_name"))), + args.toArray() + ); + } + + private void insert(String bizType, + Long bizId, + String changeType, + String fieldCode, + String fieldName, + String beforeValue, + String afterValue, + Long relatedUserId, + String relatedUserName, + String batchId, + String remark) { + jdbcTemplate.update( + "INSERT INTO biz_change_log (tenant_id, biz_type, biz_id, change_type, field_code, field_name, before_value, after_value, " + + "related_user_id, related_user_name, operator_user_id, operator_user_name, batch_id, remark, is_deleted) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)", + tenantId(), + safeTrim(bizType), + bizId == null ? 0L : bizId, + safeTrim(changeType), + emptyToNull(fieldCode), + emptyToNull(fieldName), + truncate(beforeValue, 2000), + truncate(afterValue, 2000), + relatedUserId, + emptyToNull(relatedUserName), + safeUserId(), + emptyToNull(loadUserName(safeUserId())), + emptyToNull(batchId), + truncate(remark, 500) + ); + } + + private String normalizeValue(Object value) { + return stringify(value); + } + + private String stringify(Object value) { + if (value == null) { + return null; + } + String text = String.valueOf(value).trim(); + return text.isEmpty() ? null : text; + } + + private String truncate(String value, int maxLength) { + String normalized = stringify(value); + if (normalized == null || normalized.length() <= maxLength) { + return normalized; + } + return normalized.substring(0, maxLength); + } + + private String emptyToNull(String value) { + String normalized = safeTrim(value); + return normalized.isEmpty() ? null : normalized; + } + + private String safeTrim(String value) { + return value == null ? "" : value.trim(); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + public static class UserRef { + private final Long userId; + private final String userName; + + public UserRef(Long userId, String userName) { + this.userId = userId; + this.userName = userName; + } + + public Long getUserId() { + return userId; + } + + public String getUserName() { + return userName; + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/DataPermissionService.java b/backend/src/main/java/com/writeoff/module/system/service/DataPermissionService.java new file mode 100644 index 0000000..ac2205f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/DataPermissionService.java @@ -0,0 +1,681 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.system.dto.AssignRoleDataPermissionRequest; +import com.writeoff.module.system.dto.CreateDataPermissionPolicyRequest; +import com.writeoff.module.system.dto.UpdateDataPermissionPolicyRequest; +import com.writeoff.module.system.model.DataPermissionPolicy; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +public class DataPermissionService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper POLICY_ROW_MAPPER = (rs, n) -> new DataPermissionPolicy( + rs.getLong("id"), + rs.getString("policy_name"), + rs.getString("project_scope"), + rs.getString("project_ids_csv"), + rs.getString("meeting_scope"), + rs.getString("meeting_ids_csv"), + rs.getString("user_scope"), + rs.getString("user_ids_csv"), + rs.getString("expert_scope"), + rs.getString("expert_ids_csv"), + rs.getString("module_scope"), + rs.getInt("export_allowed") == 1, + rs.getString("status") + ); + + public DataPermissionService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult listPolicies() { + List list = jdbcTemplate.query( + "SELECT * FROM data_permission_policy WHERE tenant_id=? ORDER BY id DESC", + POLICY_ROW_MAPPER + , + tenantId() + ); + return new PageResult<>(list, list.size(), 1, 50); + } + + public DataPermissionPolicy createPolicy(CreateDataPermissionPolicyRequest request) { + jdbcTemplate.update( + "INSERT INTO data_permission_policy (tenant_id, policy_name, project_scope, project_ids_csv, meeting_scope, meeting_ids_csv, user_scope, user_ids_csv, expert_scope, expert_ids_csv, module_scope, export_allowed, status) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ENABLED')", + tenantId(), + request.getPolicyName(), + request.getProjectScope(), + request.getProjectIdsCsv(), + request.getMeetingScope(), + request.getMeetingIdsCsv(), + request.getUserScope(), + request.getUserIdsCsv(), + request.getExpertScope(), + request.getExpertIdsCsv(), + request.getModuleScope(), + Boolean.TRUE.equals(request.getExportAllowed()) ? 1 : 0 + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM data_permission_policy WHERE tenant_id=?", Long.class, tenantId()); + return findById(id == null ? 0L : id); + } + + public DataPermissionPolicy updatePolicy(Long id, UpdateDataPermissionPolicyRequest request) { + assertPolicyExists(id); + jdbcTemplate.update( + "UPDATE data_permission_policy SET policy_name=?, project_scope=?, project_ids_csv=?, meeting_scope=?, meeting_ids_csv=?, user_scope=?, user_ids_csv=?, expert_scope=?, expert_ids_csv=?, module_scope=?, export_allowed=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + request.getPolicyName(), + request.getProjectScope(), + request.getProjectIdsCsv(), + request.getMeetingScope(), + request.getMeetingIdsCsv(), + request.getUserScope(), + request.getUserIdsCsv(), + request.getExpertScope(), + request.getExpertIdsCsv(), + request.getModuleScope(), + Boolean.TRUE.equals(request.getExportAllowed()) ? 1 : 0, + tenantId(), + id + ); + return findById(id); + } + + public DataPermissionPolicy copyPolicy(Long id) { + DataPermissionPolicy source = findById(id); + String copiedName = source.getPolicyName() + "_COPY_" + System.currentTimeMillis(); + jdbcTemplate.update( + "INSERT INTO data_permission_policy (tenant_id, policy_name, project_scope, project_ids_csv, meeting_scope, meeting_ids_csv, user_scope, user_ids_csv, expert_scope, expert_ids_csv, module_scope, export_allowed, status) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'ENABLED')", + tenantId(), + copiedName, + source.getProjectScope(), + source.getProjectIdsCsv(), + source.getMeetingScope(), + source.getMeetingIdsCsv(), + source.getUserScope(), + source.getUserIdsCsv(), + source.getExpertScope(), + source.getExpertIdsCsv(), + source.getModuleScope(), + Boolean.TRUE.equals(source.getExportAllowed()) ? 1 : 0 + ); + Long newId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM data_permission_policy WHERE tenant_id=?", Long.class, tenantId()); + return findById(newId == null ? 0L : newId); + } + + public void enablePolicy(Long id) { + assertPolicyExists(id); + jdbcTemplate.update( + "UPDATE data_permission_policy SET status='ENABLED', updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + tenantId(), + id + ); + } + + public void disablePolicy(Long id) { + assertPolicyExists(id); + jdbcTemplate.update( + "UPDATE data_permission_policy SET status='DISABLED', updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + tenantId(), + id + ); + } + + public void assignRoles(Long policyId, AssignRoleDataPermissionRequest request) { + assertPolicyExists(policyId); + String assignMode = request.getAssignMode() == null ? "APPEND" : request.getAssignMode().trim().toUpperCase(); + if ("REPLACE".equals(assignMode)) { + jdbcTemplate.update("DELETE FROM role_data_permission WHERE tenant_id=? AND policy_id=?", tenantId(), policyId); + } + List roleIds = request.getRoleIds() == null ? new ArrayList() : request.getRoleIds(); + for (Long roleId : roleIds) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM role_data_permission WHERE tenant_id=? AND role_id=? AND policy_id=?", + Integer.class, + tenantId(), + roleId, + policyId + ); + if (count != null && count > 0) { + continue; + } + jdbcTemplate.update( + "INSERT INTO role_data_permission (tenant_id, role_id, policy_id) VALUES (?, ?, ?)", + tenantId(), + roleId, + policyId + ); + } + } + + public Map currentScopeSummary() { + Long userId = AuthContext.userId(); + DataScope scope = resolveCurrentUserScope(); + Map data = new LinkedHashMap<>(); + data.put("userId", userId); + data.put("projectAll", scope.isProjectAll()); + data.put("projectIds", new ArrayList<>(scope.getProjectIds())); + data.put("projectOwnerOnly", scope.isProjectOwnerOnly()); + data.put("meetingAll", scope.isMeetingAll()); + data.put("meetingIds", new ArrayList<>(scope.getMeetingIds())); + data.put("meetingOwnerOnly", scope.isMeetingOwnerOnly()); + data.put("userAll", scope.isUserAll()); + data.put("userIds", new ArrayList<>(scope.getUserIds())); + data.put("userOwnerOnly", scope.isUserOwnerOnly()); + data.put("expertAll", scope.isExpertAll()); + data.put("expertIds", new ArrayList<>(scope.getExpertIds())); + data.put("expertOwnerOnly", scope.isExpertOwnerOnly()); + data.put("matchedPolicyIds", userId == null ? new ArrayList() : listMatchedPolicyIds(userId)); + data.put("exportAllowed", canExportCurrentUser()); + return data; + } + + public List listPolicyRoleIds(Long policyId) { + assertPolicyExists(policyId); + return jdbcTemplate.queryForList( + "SELECT role_id FROM role_data_permission WHERE tenant_id=? AND policy_id=? ORDER BY role_id ASC", + Long.class, + tenantId(), + policyId + ); + } + + public DataScope resolveCurrentUserScope() { + Long userId = AuthContext.userId(); + if (userId == null) { + return DataScope.allowAll(); + } + return resolveUserScope(userId); + } + + public DataScope resolveUserScope(Long userId) { + List policies = jdbcTemplate.query( + "SELECT DISTINCT p.* FROM user_role ur " + + "JOIN role_data_permission rdp ON ur.role_id=rdp.role_id AND ur.tenant_id=rdp.tenant_id " + + "JOIN data_permission_policy p ON rdp.policy_id=p.id AND p.tenant_id=rdp.tenant_id " + + "WHERE ur.user_id=? AND ur.tenant_id=? AND p.status='ENABLED'", + POLICY_ROW_MAPPER, + userId, + tenantId() + ); + if (policies.isEmpty()) { + return DataScope.denyAll(); + } + boolean projectAll = false; + boolean meetingAll = false; + boolean userAll = false; + boolean expertAll = false; + boolean projectOwnerOnly = false; + boolean meetingOwnerOnly = false; + boolean userOwnerOnly = false; + boolean expertOwnerOnly = false; + Set projectIds = new HashSet<>(); + Set meetingIds = new HashSet<>(); + Set userIds = new HashSet<>(); + Set expertIds = new HashSet<>(); + for (DataPermissionPolicy p : policies) { + if ("ALL".equalsIgnoreCase(p.getProjectScope())) { + projectAll = true; + } else if ("IDS".equalsIgnoreCase(p.getProjectScope())) { + projectIds.addAll(parseIds(p.getProjectIdsCsv())); + } else if ("OWNER".equalsIgnoreCase(p.getProjectScope())) { + projectOwnerOnly = true; + } + if ("ALL".equalsIgnoreCase(p.getMeetingScope())) { + meetingAll = true; + } else if ("IDS".equalsIgnoreCase(p.getMeetingScope())) { + meetingIds.addAll(parseIds(p.getMeetingIdsCsv())); + } else if ("OWNER".equalsIgnoreCase(p.getMeetingScope())) { + meetingOwnerOnly = true; + } + if ("ALL".equalsIgnoreCase(p.getUserScope())) { + userAll = true; + } else if ("IDS".equalsIgnoreCase(p.getUserScope())) { + userIds.addAll(parseIds(p.getUserIdsCsv())); + } else if ("OWNER".equalsIgnoreCase(p.getUserScope())) { + userOwnerOnly = true; + } + if ("ALL".equalsIgnoreCase(p.getExpertScope())) { + expertAll = true; + } else if ("IDS".equalsIgnoreCase(p.getExpertScope())) { + expertIds.addAll(parseIds(p.getExpertIdsCsv())); + } else if ("OWNER".equalsIgnoreCase(p.getExpertScope())) { + expertOwnerOnly = true; + } + } + DataScope baseScope = new DataScope( + projectAll, projectIds, projectOwnerOnly, + meetingAll, meetingIds, meetingOwnerOnly, + userAll, userIds, userOwnerOnly, + expertAll, expertIds, expertOwnerOnly + ); + return applyProjectBindingScopeIfNeeded(userId, baseScope); + } + + public boolean canAccessProject(Long projectId, DataScope scope) { + return canAccessProject(projectId, null, scope); + } + + public boolean canAccessProject(Long projectId, Long createdBy, DataScope scope) { + if (scope.isProjectAll() || scope.getProjectIds().contains(projectId)) { + return true; + } + return scope.isProjectOwnerOnly() && AuthContext.userId() != null && AuthContext.userId().equals(createdBy); + } + + public boolean canAccessMeeting(Long meetingId, Long projectId, DataScope scope) { + return canAccessMeeting(meetingId, projectId, null, null, scope); + } + + public boolean canAccessMeeting(Long meetingId, Long projectId, Long meetingCreatedBy, Long projectCreatedBy, DataScope scope) { + if (scope.isMeetingAll()) { + return true; + } + if (scope.getMeetingIds().contains(meetingId)) { + return true; + } + if (scope.isMeetingOwnerOnly() && AuthContext.userId() != null && AuthContext.userId().equals(meetingCreatedBy)) { + return true; + } + if (scope.isProjectOwnerOnly()) { + return canAccessProject(projectId, projectCreatedBy, scope); + } + return canAccessProject(projectId, scope); + } + + public boolean canAccessUser(Long targetUserId, Long targetCreatedBy, DataScope scope) { + if (scope.isUserAll() || scope.getUserIds().contains(targetUserId)) { + return true; + } + return scope.isUserOwnerOnly() && AuthContext.userId() != null && AuthContext.userId().equals(targetCreatedBy); + } + + public boolean canAccessExpert(Long expertId, Long expertCreatedBy, DataScope scope) { + if (scope.isExpertAll() || scope.getExpertIds().contains(expertId)) { + return true; + } + return scope.isExpertOwnerOnly() && AuthContext.userId() != null && AuthContext.userId().equals(expertCreatedBy); + } + + public boolean canExportCurrentUser() { + Long userId = AuthContext.userId(); + if (userId == null) { + return false; + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM user_role ur " + + "JOIN role_data_permission rdp ON ur.role_id=rdp.role_id AND ur.tenant_id=rdp.tenant_id " + + "JOIN data_permission_policy p ON rdp.policy_id=p.id AND p.tenant_id=rdp.tenant_id " + + "WHERE ur.user_id=? AND ur.tenant_id=? AND p.status='ENABLED' AND p.export_allowed=1", + Integer.class, + userId, + tenantId() + ); + return count != null && count > 0; + } + + private List listMatchedPolicyIds(Long userId) { + return jdbcTemplate.queryForList( + "SELECT DISTINCT p.id FROM user_role ur " + + "JOIN role_data_permission rdp ON ur.role_id=rdp.role_id AND ur.tenant_id=rdp.tenant_id " + + "JOIN data_permission_policy p ON rdp.policy_id=p.id AND p.tenant_id=rdp.tenant_id " + + "WHERE ur.user_id=? AND ur.tenant_id=? AND p.status='ENABLED' ORDER BY p.id ASC", + Long.class, + userId, + tenantId() + ); + } + + private DataScope applyProjectBindingScopeIfNeeded(Long userId, DataScope baseScope) { + Set roleCodes = listUserRoleCodes(userId); + if (roleCodes.contains("TENANT_ADMIN")) { + return baseScope; + } + boolean ownerScoped = roleCodes.contains("PROJECT_OWNER"); + boolean executorScoped = roleCodes.contains("PROJECT_EXECUTOR"); + boolean legacyExecutorScoped = roleCodes.contains("EXECUTOR"); + if (!ownerScoped && !executorScoped && !legacyExecutorScoped) { + return baseScope; + } + Set boundProjectIds = listBoundProjectIds(userId, ownerScoped, executorScoped, legacyExecutorScoped); + if (boundProjectIds.isEmpty()) { + return DataScope.denyAll(); + } + Set effectiveProjectIds = new HashSet<>(boundProjectIds); + if (!baseScope.isProjectAll() && !baseScope.isProjectOwnerOnly()) { + effectiveProjectIds.retainAll(baseScope.getProjectIds()); + } + if (effectiveProjectIds.isEmpty()) { + return DataScope.denyAll(); + } + return new DataScope( + false, effectiveProjectIds, baseScope.isProjectOwnerOnly(), + false, new HashSet(), false, + baseScope.isUserAll(), new HashSet(baseScope.getUserIds()), baseScope.isUserOwnerOnly(), + baseScope.isExpertAll(), new HashSet(baseScope.getExpertIds()), baseScope.isExpertOwnerOnly() + ); + } + + public Map listProjectCreators(Collection projectIds) { + Map result = new LinkedHashMap<>(); + if (projectIds == null || projectIds.isEmpty()) { + return result; + } + String inSql = buildInClause(projectIds.size()); + List args = new ArrayList<>(); + args.add(tenantId()); + args.addAll(projectIds); + List> rows = jdbcTemplate.queryForList( + "SELECT id, created_by FROM project WHERE tenant_id=? AND is_deleted=0 AND id IN (" + inSql + ")", + args.toArray() + ); + for (Map row : rows) { + result.put(((Number) row.get("id")).longValue(), ((Number) row.get("created_by")).longValue()); + } + return result; + } + + public Map listMeetingCreators(Collection meetingIds) { + Map result = new LinkedHashMap<>(); + if (meetingIds == null || meetingIds.isEmpty()) { + return result; + } + String inSql = buildInClause(meetingIds.size()); + List args = new ArrayList<>(); + args.add(tenantId()); + args.addAll(meetingIds); + List> rows = jdbcTemplate.queryForList( + "SELECT id, created_by FROM meeting WHERE tenant_id=? AND is_deleted=0 AND id IN (" + inSql + ")", + args.toArray() + ); + for (Map row : rows) { + result.put(((Number) row.get("id")).longValue(), ((Number) row.get("created_by")).longValue()); + } + return result; + } + + public Map listMeetingProjectIds(Collection meetingIds) { + Map result = new LinkedHashMap<>(); + if (meetingIds == null || meetingIds.isEmpty()) { + return result; + } + String inSql = buildInClause(meetingIds.size()); + List args = new ArrayList<>(); + args.add(tenantId()); + args.addAll(meetingIds); + List> rows = jdbcTemplate.queryForList( + "SELECT id, project_id FROM meeting WHERE tenant_id=? AND is_deleted=0 AND id IN (" + inSql + ")", + args.toArray() + ); + for (Map row : rows) { + result.put(((Number) row.get("id")).longValue(), ((Number) row.get("project_id")).longValue()); + } + return result; + } + + public Map listExpertCreators(Collection expertIds) { + Map result = new LinkedHashMap<>(); + if (expertIds == null || expertIds.isEmpty()) { + return result; + } + String inSql = buildInClause(expertIds.size()); + List args = new ArrayList<>(); + args.add(0L); + args.addAll(expertIds); + List> rows = jdbcTemplate.queryForList( + "SELECT id, created_by FROM expert WHERE tenant_id=? AND is_deleted=0 AND id IN (" + inSql + ")", + args.toArray() + ); + for (Map row : rows) { + result.put(((Number) row.get("id")).longValue(), ((Number) row.get("created_by")).longValue()); + } + return result; + } + + private Set listUserRoleCodes(Long userId) { + List roleCodes = jdbcTemplate.queryForList( + "SELECT DISTINCT r.role_code FROM user_role ur " + + "JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id " + + "WHERE ur.tenant_id=? AND ur.user_id=? AND r.is_deleted=0", + String.class, + tenantId(), + userId + ); + return new HashSet<>(roleCodes); + } + + private Set listBoundProjectIds(Long userId, boolean ownerScoped, boolean executorScoped, boolean legacyExecutorScoped) { + List ids; + if (ownerScoped && executorScoped && legacyExecutorScoped) { + ids = jdbcTemplate.queryForList( + "SELECT DISTINCT project_id FROM project_user_binding " + + "WHERE tenant_id=? AND user_id=? AND is_deleted=0 AND bind_role_code IN ('PROJECT_OWNER', 'PROJECT_EXECUTOR', 'EXECUTOR')", + Long.class, + tenantId(), + userId + ); + } else if (ownerScoped && executorScoped) { + ids = jdbcTemplate.queryForList( + "SELECT DISTINCT project_id FROM project_user_binding " + + "WHERE tenant_id=? AND user_id=? AND is_deleted=0 AND bind_role_code IN ('PROJECT_OWNER', 'PROJECT_EXECUTOR')", + Long.class, + tenantId(), + userId + ); + } else if (ownerScoped && legacyExecutorScoped) { + ids = jdbcTemplate.queryForList( + "SELECT DISTINCT project_id FROM project_user_binding " + + "WHERE tenant_id=? AND user_id=? AND is_deleted=0 AND bind_role_code IN ('PROJECT_OWNER', 'EXECUTOR')", + Long.class, + tenantId(), + userId + ); + } else if (executorScoped && legacyExecutorScoped) { + ids = jdbcTemplate.queryForList( + "SELECT DISTINCT project_id FROM project_user_binding " + + "WHERE tenant_id=? AND user_id=? AND is_deleted=0 AND bind_role_code IN ('PROJECT_EXECUTOR', 'EXECUTOR')", + Long.class, + tenantId(), + userId + ); + } else if (ownerScoped) { + ids = jdbcTemplate.queryForList( + "SELECT DISTINCT project_id FROM project_user_binding " + + "WHERE tenant_id=? AND user_id=? AND is_deleted=0 AND bind_role_code='PROJECT_OWNER'", + Long.class, + tenantId(), + userId + ); + } else { + if (executorScoped) { + ids = jdbcTemplate.queryForList( + "SELECT DISTINCT project_id FROM project_user_binding " + + "WHERE tenant_id=? AND user_id=? AND is_deleted=0 AND bind_role_code='PROJECT_EXECUTOR'", + Long.class, + tenantId(), + userId + ); + } else { + ids = jdbcTemplate.queryForList( + "SELECT DISTINCT project_id FROM project_user_binding " + + "WHERE tenant_id=? AND user_id=? AND is_deleted=0 AND bind_role_code='EXECUTOR'", + Long.class, + tenantId(), + userId + ); + } + } + return new HashSet<>(ids); + } + + private Set parseIds(String idsCsv) { + Set ids = new HashSet<>(); + if (idsCsv == null || idsCsv.trim().isEmpty()) { + return ids; + } + String[] arr = idsCsv.split(","); + for (String item : arr) { + String val = item.trim(); + if (val.isEmpty()) { + continue; + } + try { + ids.add(Long.parseLong(val)); + } catch (NumberFormatException ignored) { + } + } + return ids; + } + + private void assertPolicyExists(Long id) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM data_permission_policy WHERE tenant_id=? AND id=?", + Integer.class, + tenantId(), + id + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "数据权限策略不存在"); + } + } + + private DataPermissionPolicy findById(Long id) { + List list = jdbcTemplate.query( + "SELECT * FROM data_permission_policy WHERE tenant_id=? AND id=?", + POLICY_ROW_MAPPER, + tenantId(), + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "数据权限策略不存在"); + } + return list.get(0); + } + + public static class DataScope { + private final boolean projectAll; + private final Set projectIds; + private final boolean projectOwnerOnly; + private final boolean meetingAll; + private final Set meetingIds; + private final boolean meetingOwnerOnly; + private final boolean userAll; + private final Set userIds; + private final boolean userOwnerOnly; + private final boolean expertAll; + private final Set expertIds; + private final boolean expertOwnerOnly; + + public DataScope(boolean projectAll, + Set projectIds, + boolean projectOwnerOnly, + boolean meetingAll, + Set meetingIds, + boolean meetingOwnerOnly, + boolean userAll, + Set userIds, + boolean userOwnerOnly, + boolean expertAll, + Set expertIds, + boolean expertOwnerOnly) { + this.projectAll = projectAll; + this.projectIds = projectIds; + this.projectOwnerOnly = projectOwnerOnly; + this.meetingAll = meetingAll; + this.meetingIds = meetingIds; + this.meetingOwnerOnly = meetingOwnerOnly; + this.userAll = userAll; + this.userIds = userIds; + this.userOwnerOnly = userOwnerOnly; + this.expertAll = expertAll; + this.expertIds = expertIds; + this.expertOwnerOnly = expertOwnerOnly; + } + + public static DataScope allowAll() { + return new DataScope(true, new HashSet(), false, true, new HashSet(), false, true, new HashSet(), false, true, new HashSet(), false); + } + + public static DataScope denyAll() { + return new DataScope(false, new HashSet(), false, false, new HashSet(), false, false, new HashSet(), false, false, new HashSet(), false); + } + + public boolean isProjectAll() { + return projectAll; + } + + public Set getProjectIds() { + return projectIds; + } + + public boolean isProjectOwnerOnly() { + return projectOwnerOnly; + } + + public boolean isMeetingAll() { + return meetingAll; + } + + public Set getMeetingIds() { + return meetingIds; + } + + public boolean isMeetingOwnerOnly() { + return meetingOwnerOnly; + } + + public boolean isUserAll() { + return userAll; + } + + public Set getUserIds() { + return userIds; + } + + public boolean isUserOwnerOnly() { + return userOwnerOnly; + } + + public boolean isExpertAll() { + return expertAll; + } + + public Set getExpertIds() { + return expertIds; + } + + public boolean isExpertOwnerOnly() { + return expertOwnerOnly; + } + } + + private String buildInClause(int size) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < size; i++) { + if (i > 0) { + sb.append(","); + } + sb.append("?"); + } + return sb.toString(); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/EnterpriseService.java b/backend/src/main/java/com/writeoff/module/system/service/EnterpriseService.java new file mode 100644 index 0000000..8048b66 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/EnterpriseService.java @@ -0,0 +1,239 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.system.dto.CreateEnterpriseRequest; +import com.writeoff.module.system.dto.UpdateEnterpriseRequest; +import com.writeoff.module.system.model.EnterpriseInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +@Service +public class EnterpriseService { + private final JdbcTemplate jdbcTemplate; + private final OssService ossService; + + private static final RowMapper ENTERPRISE_ROW_MAPPER = (rs, n) -> new EnterpriseInfo( + rs.getLong("id"), + rs.getString("enterprise_name"), + rs.getString("enterprise_url"), + rs.getString("logo_url"), + rs.getString("status"), + rs.getString("created_at") + ); + + public EnterpriseService(JdbcTemplate jdbcTemplate, OssService ossService) { + this.jdbcTemplate = jdbcTemplate; + this.ossService = ossService; + } + + public PageResult list(int pageNo, int pageSize) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + int offset = (safePage - 1) * safeSize; + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM enterprise WHERE tenant_id=? AND is_deleted=0", + Integer.class, + tenantId() + ); + long totalCount = total == null ? 0 : total; + + List list = jdbcTemplate.query( + "SELECT id, enterprise_name, enterprise_url, logo_url, status, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM enterprise WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC LIMIT ? OFFSET ?", + ENTERPRISE_ROW_MAPPER, + tenantId(), + safeSize, + offset + ); + return new PageResult(list, totalCount, safePage, safeSize); + } + + public EnterpriseInfo create(CreateEnterpriseRequest request) { + String name = request.getEnterpriseName().trim(); + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM enterprise WHERE tenant_id=? AND enterprise_name=? AND is_deleted=0", + Integer.class, + tenantId(), + name + ); + if (exists != null && exists > 0) { + throw new BusinessException(10001, "企业名称已存在"); + } + jdbcTemplate.update( + "INSERT INTO enterprise (tenant_id, enterprise_name, enterprise_url, logo_url, status, is_deleted, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, 'ENABLED', 0, ?, ?)", + tenantId(), + name, + normalizeNullable(request.getEnterpriseUrl()), + normalizeNullable(request.getLogoUrl()), + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM enterprise WHERE tenant_id=?", Long.class, tenantId()); + return findById(id == null ? 0L : id); + } + + public EnterpriseInfo update(Long id, UpdateEnterpriseRequest request) { + assertExists(id); + String name = request.getEnterpriseName().trim(); + Integer dup = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM enterprise WHERE tenant_id=? AND enterprise_name=? AND id<>? AND is_deleted=0", + Integer.class, + tenantId(), + name, + id + ); + if (dup != null && dup > 0) { + throw new BusinessException(10001, "企业名称已存在"); + } + jdbcTemplate.update( + "UPDATE enterprise SET enterprise_name=?, enterprise_url=?, logo_url=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND id=?", + name, + normalizeNullable(request.getEnterpriseUrl()), + normalizeNullable(request.getLogoUrl()), + safeUserId(), + tenantId(), + id + ); + return findById(id); + } + + public void enable(Long id) { + assertExists(id); + jdbcTemplate.update( + "UPDATE enterprise SET status='ENABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + id + ); + } + + public void disable(Long id) { + assertExists(id); + jdbcTemplate.update( + "UPDATE enterprise SET status='DISABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + id + ); + } + + public void softDelete(Long id) { + assertExists(id); + // 检查是否有活跃项目关联 + Integer activeProjects = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM project WHERE tenant_id=? AND partner_enterprise_id=? AND is_deleted=0 AND status NOT IN ('ARCHIVED', 'TERMINATED')", + Integer.class, + tenantId(), + id + ); + if (activeProjects != null && activeProjects > 0) { + throw new BusinessException(10001, "该企业下存在活跃项目,请先归档相关项目后再删除"); + } + jdbcTemplate.update( + "UPDATE enterprise SET is_deleted=1, status='DISABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + id + ); + } + + public void assertEnabled(Long enterpriseId) { + if (enterpriseId == null) { + return; + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM enterprise WHERE tenant_id=? AND id=? AND is_deleted=0 AND status='ENABLED'", + Integer.class, + tenantId(), + enterpriseId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "企业不存在或已停用"); + } + } + + public Map presignLogoUpload(String fileName, String contentType) { + String name = fileName == null ? "" : fileName.trim(); + if (name.isEmpty()) { + throw new BusinessException(10001, "文件名不能为空"); + } + String ext = ""; + int idx = name.lastIndexOf('.'); + if (idx > 0 && idx < name.length() - 1) { + ext = "." + name.substring(idx + 1).toLowerCase(); + } + String normalizedContentType = normalizeContentType(contentType); + String objectKey = "enterprise/logo/" + tenantId() + "/" + safeUserId() + "/" + UUID.randomUUID().toString().replace("-", "") + ext; + String uploadUrl = ossService.generateUploadUrl(objectKey, normalizedContentType); + Map data = new LinkedHashMap(); + data.put("objectKey", objectKey); + data.put("uploadUrl", uploadUrl); + data.put("contentType", normalizedContentType); + data.put("method", "PUT"); + return data; + } + + private EnterpriseInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, enterprise_name, enterprise_url, logo_url, status, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM enterprise WHERE tenant_id=? AND id=? AND is_deleted=0", + ENTERPRISE_ROW_MAPPER, + tenantId(), + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "企业不存在"); + } + return list.get(0); + } + + private void assertExists(Long id) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM enterprise WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + id + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "企业不存在"); + } + } + + private String normalizeNullable(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private String normalizeContentType(String contentType) { + if (contentType == null || contentType.trim().isEmpty()) { + return "application/octet-stream"; + } + return contentType.trim(); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/GlobalSearchService.java b/backend/src/main/java/com/writeoff/module/system/service/GlobalSearchService.java new file mode 100644 index 0000000..ef9c262 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/GlobalSearchService.java @@ -0,0 +1,365 @@ +package com.writeoff.module.system.service; + +import com.writeoff.module.system.model.GlobalSearchGroup; +import com.writeoff.module.system.model.GlobalSearchItem; +import com.writeoff.module.system.model.GlobalSearchResult; +import com.writeoff.module.system.model.MenuInfo; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import com.writeoff.security.PermissionService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +public class GlobalSearchService { + private final JdbcTemplate jdbcTemplate; + private final MenuService menuService; + private final PlatformMenuService platformMenuService; + private final PermissionService permissionService; + private final DataPermissionService dataPermissionService; + + public GlobalSearchService(JdbcTemplate jdbcTemplate, + MenuService menuService, + PlatformMenuService platformMenuService, + PermissionService permissionService, + DataPermissionService dataPermissionService) { + this.jdbcTemplate = jdbcTemplate; + this.menuService = menuService; + this.platformMenuService = platformMenuService; + this.permissionService = permissionService; + this.dataPermissionService = dataPermissionService; + } + + public GlobalSearchResult search(String keyword, Integer limitPerType) { + String normalizedKeyword = normalizeKeyword(keyword); + int safeLimit = normalizeLimit(limitPerType); + Long userId = AuthContext.userId(); + if (userId == null || normalizedKeyword.isEmpty()) { + return new GlobalSearchResult(normalizedKeyword, 0, Collections.emptyList()); + } + + List groups = AuthContext.scope() == AuthScope.PLATFORM + ? searchPlatform(userId, normalizedKeyword, safeLimit) + : searchTenant(userId, normalizedKeyword, safeLimit); + int total = 0; + for (GlobalSearchGroup group : groups) { + total += group.getItems() == null ? 0 : group.getItems().size(); + } + return new GlobalSearchResult(normalizedKeyword, total, groups); + } + + private List searchTenant(Long userId, String keyword, int limit) { + List groups = new ArrayList(); + + List menuItems = searchTenantMenus(userId, keyword, limit); + if (!menuItems.isEmpty()) { + groups.add(new GlobalSearchGroup("MENU", "菜单", menuItems)); + } + + if (permissionService.hasPermission(userId, "meeting.read")) { + List meetingItems = searchTenantMeetings(keyword, limit); + if (!meetingItems.isEmpty()) { + groups.add(new GlobalSearchGroup("MEETING", "会议", meetingItems)); + } + } + + if (permissionService.hasPermission(userId, "user.read")) { + List userItems = searchTenantUsers(keyword, limit); + if (!userItems.isEmpty()) { + groups.add(new GlobalSearchGroup("USER", "用户", userItems)); + } + } + + return groups; + } + + private List searchPlatform(Long userId, String keyword, int limit) { + List groups = new ArrayList(); + + List menuItems = searchPlatformMenus(userId, keyword, limit); + if (!menuItems.isEmpty()) { + groups.add(new GlobalSearchGroup("MENU", "平台菜单", menuItems)); + } + + if (permissionService.hasPlatformPermission(userId, "platform.user.manage")) { + List userItems = searchPlatformUsers(keyword, limit); + if (!userItems.isEmpty()) { + groups.add(new GlobalSearchGroup("USER", "平台用户", userItems)); + } + } + + return groups; + } + + private List searchTenantMenus(Long userId, String keyword, int limit) { + List menus = menuService.currentUserMenus(userId); + return buildMenuItems(menus, keyword, limit); + } + + private List searchPlatformMenus(Long userId, String keyword, int limit) { + List menus = platformMenuService.currentUserMenus(userId); + return buildMenuItems(menus, keyword, limit); + } + + private List buildMenuItems(List menus, String keyword, int limit) { + List items = new ArrayList(); + if (menus == null || menus.isEmpty()) { + return items; + } + for (MenuInfo menu : menus) { + if (!matchesKeyword(keyword, menu.getMenuName(), menu.getRoutePath(), menu.getMenuCode())) { + continue; + } + items.add(new GlobalSearchItem( + "MENU", + safeText(menu.getMenuName()), + safeText(normalizeMenuRoutePath(menu.getRoutePath())), + normalizeMenuRoutePath(menu.getRoutePath()), + null + )); + if (items.size() >= limit) { + break; + } + } + return items; + } + + private List searchTenantMeetings(String keyword, int limit) { + String like = "%" + keyword + "%"; + List> rows = jdbcTemplate.queryForList( + "SELECT m.id, m.topic, m.start_time, m.status, m.audit_status, m.project_id, " + + "m.created_by AS meeting_created_by, p.created_by AS project_created_by, p.project_name " + + "FROM meeting m " + + "LEFT JOIN project p ON m.tenant_id=p.tenant_id AND m.project_id=p.id AND p.is_deleted=0 " + + "WHERE m.tenant_id=? AND m.is_deleted=0 " + + "AND (m.topic LIKE ? OR p.project_name LIKE ? OR CAST(m.id AS CHAR) LIKE ?) " + + "ORDER BY m.id DESC LIMIT ?", + tenantId(), + like, + like, + like, + limit * 4 + ); + DataPermissionService.DataScope scope = dataPermissionService == null + ? DataPermissionService.DataScope.allowAll() + : dataPermissionService.resolveCurrentUserScope(); + List items = new ArrayList(); + for (Map row : rows) { + Long meetingId = toLong(row.get("id")); + Long projectId = toLong(row.get("project_id")); + Long meetingCreatedBy = toLong(row.get("meeting_created_by")); + Long projectCreatedBy = toLong(row.get("project_created_by")); + if (meetingId == null || meetingId <= 0) { + continue; + } + if (dataPermissionService != null && !dataPermissionService.canAccessMeeting(meetingId, projectId, meetingCreatedBy, projectCreatedBy, scope)) { + continue; + } + String topic = safeText(row.get("topic")); + String projectName = safeText(row.get("project_name")); + String startTime = safeText(row.get("start_time")); + String subtitle = joinParts( + projectName.isEmpty() ? null : "项目:" + projectName, + startTime.isEmpty() ? null : "时间:" + startTime + ); + items.add(new GlobalSearchItem( + "MEETING", + topic.isEmpty() ? "会议#" + meetingId : topic, + subtitle, + "/meetings?meetingId=" + meetingId + "&topic=" + topic, + safeText(row.get("status")) + )); + if (items.size() >= limit) { + break; + } + } + return items; + } + + private List searchTenantUsers(String keyword, int limit) { + String like = "%" + keyword + "%"; + List> rows = jdbcTemplate.queryForList( + "SELECT u.id, u.user_name, u.phone, u.email, u.status, u.created_by, " + + "COALESCE(GROUP_CONCAT(DISTINCT r.role_name ORDER BY r.id SEPARATOR '、'), '') AS role_names " + + "FROM sys_user u " + + "LEFT JOIN user_role ur ON u.tenant_id=ur.tenant_id AND u.id=ur.user_id " + + "LEFT JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id AND r.is_deleted=0 " + + "WHERE u.tenant_id=? AND u.is_deleted=0 AND (u.user_name LIKE ? OR u.phone LIKE ? OR u.email LIKE ?) " + + "GROUP BY u.id, u.user_name, u.phone, u.email, u.status, u.created_by " + + "ORDER BY u.id DESC LIMIT ?", + tenantId(), + like, + like, + like, + limit * 4 + ); + DataPermissionService.DataScope scope = dataPermissionService == null + ? DataPermissionService.DataScope.allowAll() + : dataPermissionService.resolveCurrentUserScope(); + List items = new ArrayList(); + for (Map row : rows) { + Long userId = toLong(row.get("id")); + Long createdBy = toLong(row.get("created_by")); + if (userId == null || userId <= 0) { + continue; + } + if (dataPermissionService != null && !dataPermissionService.canAccessUser(userId, createdBy, scope)) { + continue; + } + String userName = safeText(row.get("user_name")); + String phone = safeText(row.get("phone")); + String email = safeText(row.get("email")); + String roleNames = safeText(row.get("role_names")); + String subtitle = joinParts( + phone.isEmpty() ? null : "手机号:" + phone, + email.isEmpty() ? null : email, + roleNames.isEmpty() ? null : "角色:" + roleNames + ); + items.add(new GlobalSearchItem( + "USER", + userName.isEmpty() ? ("用户#" + userId) : userName, + subtitle, + "/users?keyword=" + resolveUserKeyword(phone, email, userName, userId), + safeText(row.get("status")) + )); + if (items.size() >= limit) { + break; + } + } + return items; + } + + private List searchPlatformUsers(String keyword, int limit) { + String like = "%" + keyword + "%"; + List> rows = jdbcTemplate.queryForList( + "SELECT id, user_name, phone, email, status " + + "FROM platform_user " + + "WHERE is_deleted=0 AND (user_name LIKE ? OR phone LIKE ? OR email LIKE ?) " + + "ORDER BY id DESC LIMIT ?", + like, + like, + like, + limit + ); + List items = new ArrayList(); + for (Map row : rows) { + Long userId = toLong(row.get("id")); + String userName = safeText(row.get("user_name")); + String phone = safeText(row.get("phone")); + String email = safeText(row.get("email")); + String subtitle = joinParts( + phone.isEmpty() ? null : "手机号:" + phone, + email.isEmpty() ? null : email + ); + items.add(new GlobalSearchItem( + "USER", + userName.isEmpty() ? ("平台用户#" + (userId == null ? 0L : userId)) : userName, + subtitle, + "/platform/users?keyword=" + resolveUserKeyword(phone, email, userName, userId), + safeText(row.get("status")) + )); + } + return items; + } + + private boolean matchesKeyword(String keyword, String... values) { + if (keyword == null || keyword.isEmpty()) { + return false; + } + String normalizedKeyword = keyword.toLowerCase(Locale.ROOT); + if (values == null) { + return false; + } + for (String value : values) { + if (value == null) { + continue; + } + if (value.toLowerCase(Locale.ROOT).contains(normalizedKeyword)) { + return true; + } + } + return false; + } + + private String normalizeKeyword(String keyword) { + return keyword == null ? "" : keyword.trim(); + } + + private int normalizeLimit(Integer limitPerType) { + if (limitPerType == null) { + return 5; + } + return Math.max(1, Math.min(limitPerType, 10)); + } + + private String normalizeMenuRoutePath(String routePath) { + String raw = safeText(routePath); + if ("/permissions".equals(raw)) { + return "/menus?tab=permissions"; + } + if ("/platform/permissions".equals(raw)) { + return "/platform/menus?tab=permissions"; + } + return raw; + } + + private String resolveUserKeyword(String phone, String email, String userName, Long userId) { + if (phone != null && !phone.trim().isEmpty()) { + return phone.trim(); + } + if (email != null && !email.trim().isEmpty()) { + return email.trim(); + } + if (userName != null && !userName.trim().isEmpty()) { + return userName.trim(); + } + return String.valueOf(userId == null ? 0L : userId); + } + + private String joinParts(String... parts) { + StringBuilder builder = new StringBuilder(); + if (parts == null) { + return ""; + } + for (String part : parts) { + if (part == null || part.trim().isEmpty()) { + continue; + } + if (builder.length() > 0) { + builder.append(" | "); + } + builder.append(part.trim()); + } + return builder.toString(); + } + + private String safeText(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private Long toLong(Object value) { + if (value == null) { + return null; + } + if (value instanceof Number) { + return ((Number) value).longValue(); + } + try { + return Long.valueOf(String.valueOf(value).trim()); + } catch (Exception ex) { + return null; + } + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/MenuService.java b/backend/src/main/java/com/writeoff/module/system/service/MenuService.java new file mode 100644 index 0000000..34878a9 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/MenuService.java @@ -0,0 +1,238 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.system.dto.BindRoleMenusRequest; +import com.writeoff.module.system.dto.CreateMenuRequest; +import com.writeoff.module.system.dto.ReorderMenusRequest; +import com.writeoff.module.system.dto.UpdateMenuRequest; +import com.writeoff.module.system.model.MenuInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashSet; +import java.util.List; + +@Service +public class MenuService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper MENU_ROW_MAPPER = (rs, n) -> new MenuInfo( + rs.getLong("id"), + rs.getString("menu_code"), + rs.getString("menu_name"), + rs.getString("route_path"), + rs.getString("permission_code"), + rs.getInt("sort_no"), + rs.getString("status") + ); + + public MenuService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult list() { + List list = jdbcTemplate.query( + "SELECT id, menu_code, menu_name, route_path, permission_code, sort_no, status " + + "FROM menu WHERE tenant_id=? AND is_deleted=0 ORDER BY sort_no ASC, id ASC", + MENU_ROW_MAPPER, + tenantId() + ); + return new PageResult<>(list, list.size(), 1, 200); + } + + public List currentUserMenus(Long userId) { + return jdbcTemplate.query( + "SELECT DISTINCT m.id, m.menu_code, m.menu_name, m.route_path, m.permission_code, m.sort_no, m.status " + + "FROM user_role ur " + + "JOIN role_menu rm ON ur.tenant_id=rm.tenant_id AND ur.role_id=rm.role_id " + + "JOIN menu m ON rm.tenant_id=m.tenant_id AND rm.menu_id=m.id " + + "WHERE ur.tenant_id=? AND ur.user_id=? AND m.is_deleted=0 AND m.status='ENABLED' " + + "AND (m.permission_code IS NULL OR EXISTS (" + + " SELECT 1 FROM user_role ur2 " + + " JOIN role_permission rp2 ON ur2.tenant_id=rp2.tenant_id AND ur2.role_id=rp2.role_id " + + " JOIN permission p2 ON rp2.permission_id=p2.id " + + " WHERE ur2.tenant_id=ur.tenant_id AND ur2.user_id=ur.user_id AND p2.permission_code=m.permission_code" + + ")) " + + "ORDER BY m.sort_no ASC, m.id ASC", + MENU_ROW_MAPPER, + tenantId(), + userId + ); + } + + public MenuInfo create(CreateMenuRequest request) { + assertPermissionExists(request.getPermissionCode().trim()); + Integer dup = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM menu WHERE tenant_id=? AND menu_code=?", + Integer.class, + tenantId(), + request.getMenuCode().trim() + ); + if (dup != null && dup > 0) { + throw new BusinessException(10001, "菜单编码已存在"); + } + jdbcTemplate.update( + "INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, 'ENABLED', 0, ?, ?)", + tenantId(), + request.getMenuCode().trim(), + request.getMenuName().trim(), + request.getRoutePath().trim(), + request.getPermissionCode().trim(), + request.getSortNo(), + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM menu WHERE tenant_id=?", Long.class, tenantId()); + return findById(id == null ? 0L : id); + } + + public MenuInfo update(Long id, UpdateMenuRequest request) { + assertMenuExists(id); + assertPermissionExists(request.getPermissionCode().trim()); + String status = request.getStatus().trim().toUpperCase(); + if (!"ENABLED".equals(status) && !"DISABLED".equals(status)) { + throw new BusinessException(10001, "菜单状态非法"); + } + jdbcTemplate.update( + "UPDATE menu SET menu_name=?, route_path=?, permission_code=?, sort_no=?, status=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND id=?", + request.getMenuName().trim(), + request.getRoutePath().trim(), + request.getPermissionCode().trim(), + request.getSortNo(), + status, + safeUserId(), + tenantId(), + id + ); + return findById(id); + } + + public List getRoleMenuIds(Long roleId) { + assertRoleExists(roleId); + return jdbcTemplate.queryForList( + "SELECT rm.menu_id FROM role_menu rm " + + "JOIN menu m ON rm.tenant_id=m.tenant_id AND rm.menu_id=m.id " + + "WHERE rm.tenant_id=? AND rm.role_id=? AND m.is_deleted=0 ORDER BY rm.menu_id ASC", + Long.class, + tenantId(), + roleId + ); + } + + @Transactional + public void bindRoleMenus(Long roleId, BindRoleMenusRequest request) { + assertRoleExists(roleId); + if (request.getMenuIds() == null || request.getMenuIds().isEmpty()) { + jdbcTemplate.update("DELETE FROM role_menu WHERE tenant_id=? AND role_id=?", tenantId(), roleId); + return; + } + LinkedHashSet menuIds = new LinkedHashSet(request.getMenuIds()); + for (Long menuId : menuIds) { + assertMenuExists(menuId); + } + jdbcTemplate.update("DELETE FROM role_menu WHERE tenant_id=? AND role_id=?", tenantId(), roleId); + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM role_menu", Long.class); + long currentId = nextId == null ? 1L : nextId; + for (Long menuId : menuIds) { + jdbcTemplate.update( + "INSERT INTO role_menu (id, tenant_id, role_id, menu_id) VALUES (?, ?, ?, ?)", + currentId++, + tenantId(), + roleId, + menuId + ); + } + } + + public List getMenuRoleNames(Long menuId) { + assertMenuExists(menuId); + return jdbcTemplate.queryForList( + "SELECT r.role_name FROM role_menu rm " + + "JOIN role r ON rm.tenant_id=r.tenant_id AND rm.role_id=r.id " + + "WHERE rm.tenant_id=? AND rm.menu_id=? AND r.is_deleted=0 ORDER BY r.id ASC", + String.class, + tenantId(), + menuId + ); + } + + public void reorderMenus(ReorderMenusRequest request) { + if (request.getMenus() == null || request.getMenus().isEmpty()) { + return; + } + for (ReorderMenusRequest.MenuSortItem item : request.getMenus()) { + assertMenuExists(item.getId()); + jdbcTemplate.update( + "UPDATE menu SET sort_no=?, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + item.getSortNo(), + safeUserId(), + tenantId(), + item.getId() + ); + } + } + + private MenuInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, menu_code, menu_name, route_path, permission_code, sort_no, status " + + "FROM menu WHERE tenant_id=? AND id=? AND is_deleted=0", + MENU_ROW_MAPPER, + tenantId(), + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "菜单不存在"); + } + return list.get(0); + } + + private void assertMenuExists(Long menuId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM menu WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + menuId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "菜单不存在"); + } + } + + private void assertRoleExists(Long roleId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM role WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + roleId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "角色不存在"); + } + } + + private void assertPermissionExists(String permissionCode) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM permission WHERE permission_code=?", + Integer.class, + permissionCode + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "权限码不存在"); + } + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/OperationAuditLogService.java b/backend/src/main/java/com/writeoff/module/system/service/OperationAuditLogService.java new file mode 100644 index 0000000..dbf3038 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/OperationAuditLogService.java @@ -0,0 +1,214 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.module.export.dto.CreateExportTaskRequest; +import com.writeoff.module.export.model.ExportTaskInfo; +import com.writeoff.module.export.service.ExportTaskService; +import com.writeoff.module.system.model.OperationAuditLogInfo; +import com.writeoff.security.AuthContext; +import com.writeoff.security.AuthScope; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; + +@Service +public class OperationAuditLogService { + private static final Logger log = LoggerFactory.getLogger(OperationAuditLogService.class); + private final JdbcTemplate jdbcTemplate; + private final ExportTaskService exportTaskService; + + private static final RowMapper ROW_MAPPER = (rs, n) -> new OperationAuditLogInfo( + rs.getLong("id"), + rs.getLong("tenant_id"), + rs.getLong("user_id"), + rs.getString("scope"), + rs.getString("action_code"), + rs.getString("biz_type"), + rs.getString("biz_id"), + rs.getString("http_method"), + rs.getString("request_uri"), + rs.getString("request_id"), + rs.getInt("status_code"), + rs.getInt("success") == 1, + rs.getString("error_message"), + rs.getString("ip"), + rs.getString("created_at") + ); + private static final RowMapper EXPORT_TASK_ROW_MAPPER = (rs, n) -> new ExportTaskInfo( + rs.getLong("id"), + rs.getString("task_code"), + rs.getString("biz_type"), + rs.getString("biz_id"), + rs.getString("file_name"), + rs.getString("file_oss_key"), + rs.getString("status"), + rs.getInt("retry_count"), + rs.getInt("download_count"), + rs.getString("token_expire_at"), + rs.getString("error_message"), + rs.getString("created_at"), + rs.getString("finished_at") + ); + + public OperationAuditLogService(JdbcTemplate jdbcTemplate, ExportTaskService exportTaskService) { + this.jdbcTemplate = jdbcTemplate; + this.exportTaskService = exportTaskService; + } + + public void log(Long tenantId, Long userId, AuthScope scope, String actionCode, String bizType, String bizId, + String httpMethod, String requestUri, String requestQuery, String requestId, Integer statusCode, + boolean success, String errorMessage, String ip, String userAgent) { + jdbcTemplate.update( + "INSERT INTO operation_audit_log (tenant_id, user_id, scope, action_code, biz_type, biz_id, http_method, request_uri, request_query, request_id, status_code, success, error_message, ip, user_agent) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tenantId == null ? 0L : tenantId, + userId == null ? 0L : userId, + scope == null ? AuthScope.TENANT.name() : scope.name(), + actionCode == null ? "UNKNOWN" : actionCode, + bizType, + bizId, + httpMethod, + requestUri, + requestQuery, + requestId, + statusCode == null ? 200 : statusCode, + success ? 1 : 0, + errorMessage, + ip, + userAgent + ); + } + + public PageResult list(Long userId, String actionCode, Integer pageNo, Integer pageSize) { + Long tenantId = tenantId(); + int resolvedPageNo = normalizePageNo(pageNo); + int resolvedPageSize = normalizePageSize(pageSize); + int offset = (resolvedPageNo - 1) * resolvedPageSize; + StringBuilder whereSql = new StringBuilder( + " FROM operation_audit_log WHERE UPPER(TRIM(IFNULL(scope, 'TENANT')))='TENANT' AND tenant_id=?" + ); + List args = new ArrayList(); + args.add(tenantId); + if (userId != null) { + whereSql.append(" AND user_id=?"); + args.add(userId); + } + if (actionCode != null && !actionCode.trim().isEmpty()) { + whereSql.append(" AND action_code=?"); + args.add(actionCode.trim()); + } + String countSql = "SELECT COUNT(1)" + whereSql; + Long total = jdbcTemplate.queryForObject(countSql, Long.class, args.toArray()); + String dataSql = "SELECT id, tenant_id, user_id, scope, action_code, biz_type, biz_id, http_method, request_uri, request_id, status_code, success, error_message, ip, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + whereSql + + " ORDER BY id DESC LIMIT ? OFFSET ?"; + List dataArgs = new ArrayList(args); + dataArgs.add(resolvedPageSize); + dataArgs.add(offset); + log.info("[AuditLog][TENANT] SQL: {}", dataSql); + log.info("[AuditLog][TENANT] ARGS: {}", args); + List list = jdbcTemplate.query(dataSql, ROW_MAPPER, dataArgs.toArray()); + return new PageResult(list, total == null ? 0L : total, resolvedPageNo, resolvedPageSize); + } + + public PageResult listPlatform(Long userId, String actionCode, Long tenantId, String scope, Integer pageNo, Integer pageSize) { + int resolvedPageNo = normalizePageNo(pageNo); + int resolvedPageSize = normalizePageSize(pageSize); + int offset = (resolvedPageNo - 1) * resolvedPageSize; + StringBuilder whereSql = new StringBuilder(" FROM operation_audit_log WHERE 1=1"); + List args = new ArrayList(); + if (tenantId != null) { + whereSql.append(" AND tenant_id=?"); + args.add(tenantId); + } + if (scope != null && !scope.trim().isEmpty()) { + whereSql.append(" AND UPPER(TRIM(IFNULL(scope, ''))) = ?"); + args.add(scope.trim().toUpperCase()); + } + if (userId != null) { + whereSql.append(" AND user_id=?"); + args.add(userId); + } + if (actionCode != null && !actionCode.trim().isEmpty()) { + whereSql.append(" AND action_code=?"); + args.add(actionCode.trim()); + } + String countSql = "SELECT COUNT(1)" + whereSql; + Long total = jdbcTemplate.queryForObject(countSql, Long.class, args.toArray()); + String dataSql = "SELECT id, tenant_id, user_id, scope, action_code, biz_type, biz_id, http_method, request_uri, request_id, status_code, success, error_message, ip, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + whereSql + + " ORDER BY id DESC LIMIT ? OFFSET ?"; + List dataArgs = new ArrayList(args); + dataArgs.add(resolvedPageSize); + dataArgs.add(offset); + log.info("[AuditLog][PLATFORM] SQL: {}", dataSql); + log.info("[AuditLog][PLATFORM] ARGS: {}", args); + List list = jdbcTemplate.query(dataSql, ROW_MAPPER, dataArgs.toArray()); + return new PageResult(list, total == null ? 0L : total, resolvedPageNo, resolvedPageSize); + } + + public PageResult listExportTasks() { + List list = jdbcTemplate.query( + "SELECT id, task_code, biz_type, biz_id, file_name, file_oss_key, status, retry_count, IFNULL(download_count,0) AS download_count, " + + "DATE_FORMAT(download_token_expire_at, '%Y-%m-%d %H:%i:%s') AS token_expire_at, error_message, " + + "DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at, DATE_FORMAT(finished_at, '%Y-%m-%d %H:%i:%s') AS finished_at " + + "FROM export_task WHERE tenant_id=? AND biz_type='AUDIT_LOG' AND is_deleted=0 ORDER BY id DESC LIMIT 300", + EXPORT_TASK_ROW_MAPPER, + tenantId() + ); + return new PageResult(list, list.size(), 1, 300); + } + + public java.util.Map createExportTask(Long userId, String actionCode, String idempotencyKey, String fileName) { + CreateExportTaskRequest request = new CreateExportTaskRequest(); + request.setIdempotencyKey(idempotencyKey); + request.setTaskCode("AUDIT_LOG_EXPORT"); + request.setBizType("AUDIT_LOG"); + request.setBizId(null); + request.setFiltersJson(buildFilterJson(userId, actionCode)); + request.setFileName(fileName); + return exportTaskService.create(request); + } + + private String buildFilterJson(Long userId, String actionCode) { + StringBuilder sb = new StringBuilder("{"); + boolean hasPrev = false; + if (userId != null) { + sb.append("\"userId\":").append(userId); + hasPrev = true; + } + if (actionCode != null && !actionCode.trim().isEmpty()) { + if (hasPrev) { + sb.append(","); + } + sb.append("\"actionCode\":\"").append(actionCode.trim().replace("\"", "")).append("\""); + } + sb.append("}"); + return sb.toString(); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private int normalizePageNo(Integer pageNo) { + if (pageNo == null || pageNo < 1) { + return 1; + } + return pageNo; + } + + private int normalizePageSize(Integer pageSize) { + if (pageSize == null || pageSize < 1) { + return 20; + } + return Math.min(pageSize, 200); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/PlatformDictionaryService.java b/backend/src/main/java/com/writeoff/module/system/service/PlatformDictionaryService.java new file mode 100644 index 0000000..80b27b7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/PlatformDictionaryService.java @@ -0,0 +1,267 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.system.dto.CreatePlatformDictionaryItemRequest; +import com.writeoff.module.system.dto.CreatePlatformDictionaryTypeRequest; +import com.writeoff.module.system.dto.UpdatePlatformDictionaryItemRequest; +import com.writeoff.module.system.model.PlatformDictionaryItem; +import com.writeoff.module.system.model.PlatformDictionaryType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +@Service +public class PlatformDictionaryService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper ITEM_ROW_MAPPER = (rs, n) -> new PlatformDictionaryItem( + rs.getLong("id"), + rs.getString("dict_type"), + rs.getString("dict_code"), + rs.getString("dict_name"), + rs.getInt("sort_no"), + rs.getString("status"), + rs.getString("remark") + ); + private static final RowMapper TYPE_ROW_MAPPER = (rs, n) -> new PlatformDictionaryType( + rs.getLong("id"), + rs.getString("dict_type"), + rs.getString("dict_name"), + rs.getInt("sort_no"), + rs.getString("status"), + rs.getString("remark") + ); + private static final Pattern DICT_TYPE_PATTERN = Pattern.compile("^[A-Z][A-Z0-9_]{1,63}$"); + + public PlatformDictionaryService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public List list(String dictType, Boolean enabledOnly) { + StringBuilder sql = new StringBuilder( + "SELECT id, dict_type, dict_code, dict_name, sort_no, status, remark " + + "FROM platform_dictionary_item WHERE is_deleted=0" + ); + if (dictType != null && !dictType.trim().isEmpty()) { + sql.append(" AND dict_type='").append(dictType.trim().replace("'", "''")).append("'"); + } + if (Boolean.TRUE.equals(enabledOnly)) { + sql.append(" AND status='ENABLED'"); + } + sql.append(" ORDER BY dict_type ASC, sort_no ASC, id ASC"); + return jdbcTemplate.query(sql.toString(), ITEM_ROW_MAPPER); + } + + public List listTypes(Boolean enabledOnly) { + StringBuilder sql = new StringBuilder( + "SELECT id, dict_type, dict_name, sort_no, status, remark " + + "FROM platform_dictionary_type WHERE is_deleted=0" + ); + if (Boolean.TRUE.equals(enabledOnly)) { + sql.append(" AND status='ENABLED'"); + } + sql.append(" ORDER BY sort_no ASC, id ASC"); + return jdbcTemplate.query(sql.toString(), TYPE_ROW_MAPPER); + } + + public PlatformDictionaryType createType(CreatePlatformDictionaryTypeRequest request) { + String dictType = normalizeDictType(request.getDictType()); + Integer dup = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_dictionary_type WHERE dict_type=? AND is_deleted=0", + Integer.class, + dictType + ); + if (dup != null && dup > 0) { + throw new BusinessException(10001, "字典类型已存在"); + } + jdbcTemplate.update( + "INSERT INTO platform_dictionary_type (dict_type, dict_name, sort_no, status, remark, created_by, updated_by) " + + "VALUES (?, ?, ?, 'ENABLED', ?, 0, 0)", + dictType, + request.getDictName().trim(), + request.getSortNo(), + request.getRemark() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM platform_dictionary_type", Long.class); + return findTypeById(id == null ? 0L : id); + } + + public PlatformDictionaryItem create(CreatePlatformDictionaryItemRequest request) { + String dictType = normalizeDictType(request.getDictType()); + assertTypeExists(dictType); + String dictCode = normalizeDictCode(request.getDictCode()); + Integer dup = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_dictionary_item WHERE dict_type=? AND dict_code=? AND is_deleted=0", + Integer.class, + dictType, + dictCode + ); + if (dup != null && dup > 0) { + throw new BusinessException(10001, "字典编码已存在"); + } + jdbcTemplate.update( + "INSERT INTO platform_dictionary_item (dict_type, dict_code, dict_name, sort_no, status, remark, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, 'ENABLED', ?, 0, 0)", + dictType, + dictCode, + request.getDictName().trim(), + request.getSortNo(), + request.getRemark() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM platform_dictionary_item", Long.class); + return findById(id == null ? 0L : id); + } + + public PlatformDictionaryItem update(Long id, UpdatePlatformDictionaryItemRequest request) { + assertExists(id); + String status = normalizeStatus(request.getStatus()); + jdbcTemplate.update( + "UPDATE platform_dictionary_item SET dict_name=?, sort_no=?, remark=?, status=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + request.getDictName().trim(), + request.getSortNo(), + request.getRemark(), + status, + id + ); + return findById(id); + } + + public void enable(Long id) { + assertExists(id); + jdbcTemplate.update("UPDATE platform_dictionary_item SET status='ENABLED', updated_at=CURRENT_TIMESTAMP WHERE id=?", id); + } + + public void disable(Long id) { + assertExists(id); + jdbcTemplate.update("UPDATE platform_dictionary_item SET status='DISABLED', updated_at=CURRENT_TIMESTAMP WHERE id=?", id); + } + + private PlatformDictionaryItem findById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, dict_type, dict_code, dict_name, sort_no, status, remark FROM platform_dictionary_item WHERE id=? AND is_deleted=0", + ITEM_ROW_MAPPER, + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "字典项不存在"); + } + return list.get(0); + } + + private void assertExists(Long id) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_dictionary_item WHERE id=? AND is_deleted=0", + Integer.class, + id + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "字典项不存在"); + } + } + + private String normalizeDictType(String value) { + String val = value == null ? "" : value.trim().toUpperCase(); + if (!DICT_TYPE_PATTERN.matcher(val).matches()) { + throw new BusinessException(10001, "字典类型编码格式非法,仅支持大写字母/数字/下划线,且需以字母开头"); + } + return val; + } + + private String normalizeDictCode(String value) { + String val = value == null ? "" : value.trim().toUpperCase(); + if (val.isEmpty()) { + throw new BusinessException(10001, "字典编码不能为空"); + } + return val; + } + + private String normalizeStatus(String value) { + String val = value == null ? "" : value.trim().toUpperCase(); + if (!"ENABLED".equals(val) && !"DISABLED".equals(val)) { + throw new BusinessException(10001, "状态必须为 ENABLED 或 DISABLED"); + } + return val; + } + + private PlatformDictionaryType findTypeById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, dict_type, dict_name, sort_no, status, remark FROM platform_dictionary_type WHERE id=? AND is_deleted=0", + TYPE_ROW_MAPPER, + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "字典类型不存在"); + } + return list.get(0); + } + + public PlatformDictionaryItem findEnabledItemByName(String dictType, String dictName) { + String type = dictType == null ? "" : dictType.trim().toUpperCase(Locale.ROOT); + String name = dictName == null ? "" : dictName.trim(); + if (type.isEmpty() || name.isEmpty()) { + return null; + } + List list = jdbcTemplate.query( + "SELECT id, dict_type, dict_code, dict_name, sort_no, status, remark " + + "FROM platform_dictionary_item WHERE dict_type=? AND dict_name=? AND status='ENABLED' AND is_deleted=0 LIMIT 1", + ITEM_ROW_MAPPER, + type, + name + ); + return list.isEmpty() ? null : list.get(0); + } + + public PlatformDictionaryItem createEnabledItem(String dictType, String dictName, String dictCodePrefix, String remark) { + String type = normalizeDictType(dictType); + String name = dictName == null ? "" : dictName.trim(); + if (name.isEmpty()) { + throw new BusinessException(10001, "????????"); + } + PlatformDictionaryItem existing = findEnabledItemByName(type, name); + if (existing != null) { + return existing; + } + assertTypeExists(type); + String basePrefix = dictCodePrefix == null || dictCodePrefix.trim().isEmpty() + ? type + : dictCodePrefix.trim().toUpperCase(Locale.ROOT); + String code = basePrefix; + int suffix = 1; + while (existsDictCode(type, code)) { + code = basePrefix + "_" + suffix; + suffix += 1; + } + CreatePlatformDictionaryItemRequest request = new CreatePlatformDictionaryItemRequest(); + request.setDictType(type); + request.setDictCode(code); + request.setDictName(name); + request.setSortNo(9999); + request.setRemark(remark); + return create(request); + } + + private boolean existsDictCode(String dictType, String dictCode) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_dictionary_item WHERE dict_type=? AND dict_code=? AND is_deleted=0", + Integer.class, + dictType, + dictCode + ); + return count != null && count > 0; + } + + private void assertTypeExists(String dictType) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_dictionary_type WHERE dict_type=? AND is_deleted=0", + Integer.class, + dictType + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "字典类型不存在,请先新增字典类型"); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/PlatformIamService.java b/backend/src/main/java/com/writeoff/module/system/service/PlatformIamService.java new file mode 100644 index 0000000..a70eb57 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/PlatformIamService.java @@ -0,0 +1,573 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.model.ImportResult; +import com.writeoff.common.util.ImportValidationUtils; +import com.writeoff.module.system.dto.AssignUserRoleRequest; +import com.writeoff.module.system.dto.BindRolePermissionsRequest; +import com.writeoff.module.system.dto.CreateRoleRequest; +import com.writeoff.module.system.dto.CreateUserRequest; +import com.writeoff.module.system.dto.ImportUserItemRequest; +import com.writeoff.module.system.dto.ResetPasswordRequest; +import com.writeoff.module.system.dto.UpdateProfilePreferencesRequest; +import com.writeoff.module.system.dto.UpdateRoleRequest; +import com.writeoff.module.system.model.PermissionInfo; +import com.writeoff.module.system.model.ProfilePreferencesInfo; +import com.writeoff.module.system.model.RoleInfo; +import com.writeoff.module.system.model.SystemUser; +import com.writeoff.security.PasswordCodecService; +import com.writeoff.security.PasswordPolicyService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +public class PlatformIamService { + private final JdbcTemplate jdbcTemplate; + private final PasswordPolicyService passwordPolicyService; + private final PasswordCodecService passwordCodecService; + + private static final RowMapper USER_ROW_MAPPER = (rs, n) -> new SystemUser( + rs.getLong("id"), + rs.getString("user_name"), + rs.getString("phone"), + rs.getString("email"), + rs.getString("status"), + rs.getString("valid_from"), + rs.getString("valid_to") + ); + + private static final RowMapper ROLE_ROW_MAPPER = (rs, n) -> new RoleInfo( + rs.getLong("id"), + rs.getString("role_code"), + rs.getString("role_name"), + rs.getString("status") + ); + + private static final RowMapper PERMISSION_ROW_MAPPER = (rs, n) -> new PermissionInfo( + rs.getLong("id"), + rs.getString("permission_code"), + rs.getString("permission_name"), + rs.getString("module") + ); + + public PlatformIamService(JdbcTemplate jdbcTemplate, PasswordPolicyService passwordPolicyService, PasswordCodecService passwordCodecService) { + this.jdbcTemplate = jdbcTemplate; + this.passwordPolicyService = passwordPolicyService; + this.passwordCodecService = passwordCodecService; + } + + public PageResult listUsers() { + return listUsers(null); + } + + public PageResult listUsers(String keyword) { + String normalizedKeyword = keyword == null ? "" : keyword.trim(); + StringBuilder sql = new StringBuilder( + "SELECT id, user_name, phone, email, status, " + + "DATE_FORMAT(valid_from, '%Y-%m-%d %H:%i:%s') AS valid_from, " + + "DATE_FORMAT(valid_to, '%Y-%m-%d %H:%i:%s') AS valid_to " + + "FROM platform_user WHERE is_deleted=0" + ); + List args = new java.util.ArrayList(); + if (!normalizedKeyword.isEmpty()) { + sql.append(" AND (user_name LIKE ? OR phone LIKE ? OR email LIKE ?)"); + String like = "%" + normalizedKeyword + "%"; + args.add(like); + args.add(like); + args.add(like); + } + sql.append(" ORDER BY id DESC"); + List list = jdbcTemplate.query( + sql.toString(), + USER_ROW_MAPPER, + args.toArray() + ); + return new PageResult(list, list.size(), 1, 100); + } + + public SystemUser createUser(CreateUserRequest request) { + assertPhoneAvailable(request.getPhone(), null); + ImportValidationUtils.validateOptionalEmail(request.getEmail()); + ImportValidationUtils.validateDateRange(request.getValidFrom(), request.getValidTo()); + if (request.getPassword() == null || request.getPassword().trim().isEmpty()) { + throw new BusinessException(10001, "\u5bc6\u7801\u4e0d\u80fd\u4e3a\u7a7a"); + } + passwordPolicyService.validate(request.getPassword()); + String passwordHash = passwordCodecService.encode(request.getPassword()); + jdbcTemplate.update( + "INSERT INTO platform_user (user_name, phone, email, password_hash, status, valid_from, valid_to, is_deleted, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, 'ENABLED', ?, ?, 0, 0, 0)", + request.getUserName().trim(), + request.getPhone().trim(), + normalizeOptionalText(request.getEmail()), + passwordHash, + parseTimestampOrDefault(request.getValidFrom(), LocalDateTime.now()), + parseTimestampOrDefault(request.getValidTo(), LocalDateTime.of(2099, 12, 31, 23, 59, 59)) + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM platform_user", Long.class); + return getUserById(id == null ? 0L : id); + } + + public SystemUser updateUser(Long id, CreateUserRequest request) { + assertUserExists(id); + assertPhoneAvailable(request.getPhone(), id); + ImportValidationUtils.validateOptionalEmail(request.getEmail()); + ImportValidationUtils.validateDateRange(request.getValidFrom(), request.getValidTo()); + String normalizedEmail = normalizeOptionalText(request.getEmail()); + if (request.getPassword() != null && !request.getPassword().trim().isEmpty()) { + passwordPolicyService.validate(request.getPassword()); + jdbcTemplate.update( + "UPDATE platform_user SET user_name=?, phone=?, email=?, password_hash=?, valid_from=?, valid_to=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + request.getUserName().trim(), + request.getPhone().trim(), + normalizedEmail, + passwordCodecService.encode(request.getPassword()), + parseTimestampOrDefault(request.getValidFrom(), LocalDateTime.now()), + parseTimestampOrDefault(request.getValidTo(), LocalDateTime.of(2099, 12, 31, 23, 59, 59)), + id + ); + } else { + jdbcTemplate.update( + "UPDATE platform_user SET user_name=?, phone=?, email=?, valid_from=?, valid_to=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + request.getUserName().trim(), + request.getPhone().trim(), + normalizedEmail, + parseTimestampOrDefault(request.getValidFrom(), LocalDateTime.now()), + parseTimestampOrDefault(request.getValidTo(), LocalDateTime.of(2099, 12, 31, 23, 59, 59)), + id + ); + } + return getUserById(id); + } + + public ImportResult importUsers(List users) { + ImportResult result = new ImportResult(); + result.setTotal(users == null ? 0 : users.size()); + if (users == null) { + return result; + } + Set batchPhones = new HashSet(); + for (int i = 0; i < users.size(); i++) { + ImportUserItemRequest item = users.get(i); + int rowNo = i + 2; + try { + Long roleId = validateImportUser(item, batchPhones); + CreateUserRequest request = new CreateUserRequest(); + request.setUserName(item.getUserName() == null ? null : item.getUserName().trim()); + request.setPhone(item.getPhone() == null ? null : item.getPhone().trim()); + request.setPassword(item.getPassword() == null ? null : item.getPassword().trim()); + request.setEmail(item.getEmail() == null ? null : item.getEmail().trim()); + request.setValidFrom(item.getValidFrom()); + request.setValidTo(item.getValidTo()); + SystemUser created = createUser(request); + if (roleId != null) { + assignRole(created.getId(), roleId); + } + result.markSuccess(); + } catch (Exception ex) { + result.addError(rowNo, buildUserIdentifier(item), resolveImportMessage(ex)); + } + } + return result; + } + + public void assignRole(AssignUserRoleRequest request) { + assignRole(request.getUserId(), request.getRoleId()); + } + + public void assignRole(Long userId, Long roleId) { + assertUserExists(userId); + assertRoleExists(roleId); + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_user_role WHERE user_id=? AND role_id=?", + Integer.class, + userId, + roleId + ); + if (exists != null && exists > 0) { + return; + } + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM platform_user_role", Long.class); + jdbcTemplate.update( + "INSERT INTO platform_user_role (id, user_id, role_id) VALUES (?, ?, ?)", + nextId == null ? 1L : nextId, + userId, + roleId + ); + } + + public void enableUser(Long userId) { + assertUserExists(userId); + jdbcTemplate.update("UPDATE platform_user SET status='ENABLED', updated_at=CURRENT_TIMESTAMP WHERE id=?", userId); + } + + public void disableUser(Long userId) { + assertUserExists(userId); + jdbcTemplate.update("UPDATE platform_user SET status='DISABLED', updated_at=CURRENT_TIMESTAMP WHERE id=?", userId); + } + + public void resetPassword(Long userId, ResetPasswordRequest request) { + assertUserExists(userId); + passwordPolicyService.validate(request.getNewPassword()); + jdbcTemplate.update( + "UPDATE platform_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + passwordCodecService.encode(request.getNewPassword()), + userId + ); + } + + public void changeMyPassword(Long userId, String oldPassword, String newPassword) { + assertUserExists(userId); + passwordPolicyService.validate(newPassword); + String currentPasswordHash = jdbcTemplate.queryForObject( + "SELECT password_hash FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1", + String.class, + userId + ); + if (!passwordCodecService.matches(oldPassword, currentPasswordHash)) { + throw new BusinessException(11001, "鍘熷瘑鐮佷笉姝g‘"); + } + jdbcTemplate.update( + "UPDATE platform_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + passwordCodecService.encode(newPassword), + userId + ); + } + + public void onSuccessfulLogin(Long userId, String rawPassword) { + List hashes = jdbcTemplate.queryForList( + "SELECT password_hash FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1", + String.class, + userId + ); + if (hashes.isEmpty()) { + return; + } + String currentHash = hashes.get(0); + if (passwordCodecService.isEncoded(currentHash) || !passwordCodecService.matches(rawPassword, currentHash)) { + return; + } + jdbcTemplate.update( + "UPDATE platform_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + passwordCodecService.encode(rawPassword), + userId + ); + } + + public ProfilePreferencesInfo getMyPreferences(Long userId) { + assertUserExists(userId); + List> rows = jdbcTemplate.queryForList( + "SELECT ui_theme_mode, ui_density, ui_theme_scheme FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1", + userId + ); + if (rows.isEmpty()) { + throw new BusinessException(10003, "用户不存在"); + } + return new ProfilePreferencesInfo( + normalizeThemeMode(rows.get(0).get("ui_theme_mode")), + normalizeDensity(rows.get(0).get("ui_density")), + normalizeThemeScheme(rows.get(0).get("ui_theme_scheme")) + ); + } + + public ProfilePreferencesInfo updateMyPreferences(Long userId, UpdateProfilePreferencesRequest request) { + assertUserExists(userId); + String themeMode = normalizeThemeMode(request == null ? null : request.getThemeMode()); + String density = normalizeDensity(request == null ? null : request.getDensity()); + String themeScheme = normalizeThemeScheme(request == null ? null : request.getThemeScheme()); + jdbcTemplate.update( + "UPDATE platform_user SET ui_theme_mode=?, ui_density=?, ui_theme_scheme=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + themeMode, + density, + themeScheme, + userId + ); + return new ProfilePreferencesInfo(themeMode, density, themeScheme); + } + + public PageResult listRoles() { + List list = jdbcTemplate.query( + "SELECT id, role_code, role_name, status FROM platform_role WHERE is_deleted=0 ORDER BY id DESC", + ROLE_ROW_MAPPER + ); + return new PageResult(list, list.size(), 1, 100); + } + + public RoleInfo createRole(CreateRoleRequest request) { + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_role WHERE role_code=?", + Integer.class, + request.getRoleCode().trim() + ); + if (exists != null && exists > 0) { + throw new BusinessException(10001, "\u89d2\u8272\u7f16\u7801\u5df2\u5b58\u5728"); + } + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM platform_role", Long.class); + long roleId = nextId == null ? 1L : nextId; + jdbcTemplate.update( + "INSERT INTO platform_role (id, role_code, role_name, status, is_deleted, created_by, updated_by) VALUES (?, ?, ?, 'ENABLED', 0, 0, 0)", + roleId, + request.getRoleCode().trim(), + request.getRoleName().trim() + ); + return new RoleInfo(roleId, request.getRoleCode().trim(), request.getRoleName().trim(), "ENABLED"); + } + + public RoleInfo updateRole(Long roleId, UpdateRoleRequest request) { + assertRoleExists(roleId); + jdbcTemplate.update( + "UPDATE platform_role SET role_name=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + request.getRoleName().trim(), + roleId + ); + return jdbcTemplate.queryForObject( + "SELECT id, role_code, role_name, status FROM platform_role WHERE id=?", + ROLE_ROW_MAPPER, + roleId + ); + } + + public void enableRole(Long roleId) { + updateRoleStatus(roleId, "ENABLED"); + } + + public void disableRole(Long roleId) { + updateRoleStatus(roleId, "DISABLED"); + } + + public PageResult listPermissions() { + List list = jdbcTemplate.query( + "SELECT id, permission_code, permission_name, module FROM platform_permission ORDER BY module ASC, id ASC", + PERMISSION_ROW_MAPPER + ); + return new PageResult(list, list.size(), 1, 200); + } + + public List getRolePermissionIds(Long roleId) { + assertRoleExists(roleId); + return jdbcTemplate.queryForList( + "SELECT permission_id FROM platform_role_permission WHERE role_id=? ORDER BY permission_id ASC", + Long.class, + roleId + ); + } + + public void bindRolePermissions(Long roleId, BindRolePermissionsRequest request) { + assertRoleExists(roleId); + jdbcTemplate.update("DELETE FROM platform_role_permission WHERE role_id=?", roleId); + if (request.getPermissionIds() == null || request.getPermissionIds().isEmpty()) { + return; + } + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM platform_role_permission", Long.class); + long currentId = nextId == null ? 1L : nextId; + for (Long permissionId : request.getPermissionIds()) { + Integer permExists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_permission WHERE id=?", + Integer.class, + permissionId + ); + if (permExists == null || permExists == 0) { + throw new BusinessException(10003, "\u6743\u9650\u4e0d\u5b58\u5728"); + } + jdbcTemplate.update( + "INSERT INTO platform_role_permission (id, role_id, permission_id) VALUES (?, ?, ?)", + currentId++, + roleId, + permissionId + ); + } + } + + private SystemUser getUserById(Long userId) { + List list = jdbcTemplate.query( + "SELECT id, user_name, phone, email, status, " + + "DATE_FORMAT(valid_from, '%Y-%m-%d %H:%i:%s') AS valid_from, " + + "DATE_FORMAT(valid_to, '%Y-%m-%d %H:%i:%s') AS valid_to " + + "FROM platform_user WHERE id=? AND is_deleted=0", + USER_ROW_MAPPER, + userId + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "\u7528\u6237\u4e0d\u5b58\u5728"); + } + return list.get(0); + } + + private void assertUserExists(Long userId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_user WHERE id=? AND is_deleted=0", + Integer.class, + userId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "\u7528\u6237\u4e0d\u5b58\u5728"); + } + } + + private void assertRoleExists(Long roleId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_role WHERE id=? AND is_deleted=0", + Integer.class, + roleId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "\u89d2\u8272\u4e0d\u5b58\u5728"); + } + } + + private void updateRoleStatus(Long roleId, String status) { + assertRoleExists(roleId); + jdbcTemplate.update( + "UPDATE platform_role SET status=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + status, + roleId + ); + } + + private void assertPhoneAvailable(String phone, Long excludeUserId) { + ImportValidationUtils.validatePhone(phone); + String normalizedPhone = phone.trim(); + Integer count; + if (excludeUserId == null) { + count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_user WHERE phone=? AND is_deleted=0", + Integer.class, + normalizedPhone + ); + } else { + count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_user WHERE phone=? AND is_deleted=0 AND id<>?", + Integer.class, + normalizedPhone, + excludeUserId + ); + } + if (count != null && count > 0) { + throw new BusinessException(10001, "\u624b\u673a\u53f7\u5df2\u5b58\u5728"); + } + } + + private Long validateImportUser(ImportUserItemRequest item, Set batchPhones) { + if (item == null) { + throw new BusinessException(10001, "\u5bfc\u5165\u884c\u4e0d\u80fd\u4e3a\u7a7a"); + } + if (item.getUserName() == null || item.getUserName().trim().isEmpty()) { + throw new BusinessException(10001, "\u7528\u6237\u540d\u4e0d\u80fd\u4e3a\u7a7a"); + } + if (item.getPassword() == null || item.getPassword().trim().isEmpty()) { + throw new BusinessException(10001, "\u5bc6\u7801\u4e0d\u80fd\u4e3a\u7a7a"); + } + ImportValidationUtils.validatePhone(item.getPhone()); + ImportValidationUtils.validateOptionalEmail(item.getEmail()); + ImportValidationUtils.validateDateRange(item.getValidFrom(), item.getValidTo()); + passwordPolicyService.validate(item.getPassword().trim()); + String phone = ImportValidationUtils.trim(item.getPhone()); + if (!batchPhones.add(phone)) { + throw new BusinessException(10001, "\u6279\u6b21\u5185\u624b\u673a\u53f7\u91cd\u590d"); + } + String roleCode = ImportValidationUtils.trim(item.getRoleCode()); + if (roleCode.isEmpty()) { + return null; + } + Long roleId = findRoleIdByCode(roleCode); + if (roleId == null) { + throw new BusinessException(10001, "\u89d2\u8272\u7f16\u7801\u4e0d\u5b58\u5728: " + roleCode); + } + return roleId; + } + + private Long findRoleIdByCode(String roleCode) { + List ids = jdbcTemplate.queryForList( + "SELECT id FROM platform_role WHERE role_code=? AND is_deleted=0 LIMIT 1", + Long.class, + roleCode + ); + return ids.isEmpty() ? null : ids.get(0); + } + + private String buildUserIdentifier(ImportUserItemRequest item) { + if (item == null) { + return ""; + } + String userName = item.getUserName() == null ? "" : item.getUserName().trim(); + String phone = item.getPhone() == null ? "" : item.getPhone().trim(); + if (!userName.isEmpty() && !phone.isEmpty()) { + return userName + "/" + phone; + } + return !userName.isEmpty() ? userName : phone; + } + + private String resolveImportMessage(Exception ex) { + if (ex instanceof BusinessException) { + return ex.getMessage(); + } + return ex.getMessage() == null || ex.getMessage().trim().isEmpty() + ? "\u5bfc\u5165\u5931\u8d25" + : ex.getMessage(); + } + + private String normalizeOptionalText(String raw) { + String value = ImportValidationUtils.trim(raw); + return value.isEmpty() ? null : value; + } + + private Timestamp parseTimestampOrDefault(String raw, LocalDateTime defaultValue) { + if (raw == null || raw.trim().isEmpty()) { + return Timestamp.valueOf(defaultValue); + } + String normalized = normalizeDateTimeString(raw); + return Timestamp.valueOf(LocalDateTime.parse(normalized, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + } + + private String normalizeDateTimeString(String raw) { + String normalized = raw.trim().replace("T", " "); + if (normalized.length() == 16) { + return normalized + ":00"; + } + return normalized; + } + + public String normalizeThemeMode(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ("LIGHT".equals(value) || "DARK".equals(value) || "SYSTEM".equals(value)) { + return value; + } + return "SYSTEM"; + } + + public String normalizeDensity(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ("COMPACT".equals(value) || "COMFORTABLE".equals(value)) { + return value; + } + return "COMFORTABLE"; + } + + public String normalizeThemeScheme(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ( + "SLATE".equals(value) || + "OCEAN".equals(value) || + "FOREST".equals(value) || + "GRAPHITE".equals(value) || + "AMBER".equals(value) || + "RUBY".equals(value) || + "MIST".equals(value) || + "SAGE".equals(value) || + "DAWN".equals(value) + ) { + return value; + } + return "SLATE"; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/PlatformMenuService.java b/backend/src/main/java/com/writeoff/module/system/service/PlatformMenuService.java new file mode 100644 index 0000000..a264d76 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/PlatformMenuService.java @@ -0,0 +1,239 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.system.dto.BindPlatformMenuRolesRequest; +import com.writeoff.module.system.dto.BindRoleMenusRequest; +import com.writeoff.module.system.dto.CreateMenuRequest; +import com.writeoff.module.system.dto.ReorderMenusRequest; +import com.writeoff.module.system.dto.UpdateMenuRequest; +import com.writeoff.module.system.model.MenuInfo; +import com.writeoff.security.PermissionService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class PlatformMenuService { + private final JdbcTemplate jdbcTemplate; + private final PermissionService permissionService; + + private static final RowMapper MENU_ROW_MAPPER = (rs, n) -> new MenuInfo( + rs.getLong("id"), + rs.getString("menu_code"), + rs.getString("menu_name"), + rs.getString("route_path"), + rs.getString("permission_code"), + rs.getInt("sort_no"), + rs.getString("status") + ); + + public PlatformMenuService(JdbcTemplate jdbcTemplate, PermissionService permissionService) { + this.jdbcTemplate = jdbcTemplate; + this.permissionService = permissionService; + } + + public PageResult list() { + List list = jdbcTemplate.query( + "SELECT id, menu_code, menu_name, route_path, permission_code, sort_no, status " + + "FROM platform_menu WHERE is_deleted=0 ORDER BY sort_no ASC, id ASC", + MENU_ROW_MAPPER + ); + return new PageResult(list, list.size(), 1, 200); + } + + public List currentUserMenus(Long userId) { + List menus = jdbcTemplate.query( + "SELECT DISTINCT m.id, m.menu_code, m.menu_name, m.route_path, m.permission_code, m.sort_no, m.status " + + "FROM platform_user_role ur " + + "JOIN platform_role_menu rm ON ur.role_id=rm.role_id " + + "JOIN platform_menu m ON rm.menu_id=m.id " + + "WHERE ur.user_id=? AND m.is_deleted=0 AND m.status='ENABLED' " + + "ORDER BY m.sort_no ASC, m.id ASC", + MENU_ROW_MAPPER, + userId + ); + java.util.List perms = permissionService.getPlatformPermissions(userId); + java.util.Set permSet = new java.util.HashSet(perms); + java.util.List filtered = new java.util.ArrayList(); + for (MenuInfo menu : menus) { + String code = menu.getPermissionCode(); + if (code == null || code.trim().isEmpty() || permSet.contains(code)) { + filtered.add(menu); + } + } + return filtered; + } + + public MenuInfo create(CreateMenuRequest request) { + assertPermissionExists(request.getPermissionCode().trim()); + Integer dup = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_menu WHERE menu_code=?", + Integer.class, + request.getMenuCode().trim() + ); + if (dup != null && dup > 0) { + throw new BusinessException(10001, "菜单编码已存在"); + } + jdbcTemplate.update( + "INSERT INTO platform_menu (menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, 'ENABLED', 0, 0, 0)", + request.getMenuCode().trim(), + request.getMenuName().trim(), + request.getRoutePath().trim(), + request.getPermissionCode().trim(), + request.getSortNo() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM platform_menu", Long.class); + return findById(id == null ? 0L : id); + } + + public MenuInfo update(Long id, UpdateMenuRequest request) { + assertMenuExists(id); + assertPermissionExists(request.getPermissionCode().trim()); + String status = request.getStatus().trim().toUpperCase(); + if (!"ENABLED".equals(status) && !"DISABLED".equals(status)) { + throw new BusinessException(10001, "菜单状态非法"); + } + jdbcTemplate.update( + "UPDATE platform_menu SET menu_name=?, route_path=?, permission_code=?, sort_no=?, status=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE id=?", + request.getMenuName().trim(), + request.getRoutePath().trim(), + request.getPermissionCode().trim(), + request.getSortNo(), + status, + id + ); + return findById(id); + } + + public void reorderMenus(ReorderMenusRequest request) { + if (request.getMenus() == null || request.getMenus().isEmpty()) { + return; + } + for (ReorderMenusRequest.MenuSortItem item : request.getMenus()) { + assertMenuExists(item.getId()); + jdbcTemplate.update( + "UPDATE platform_menu SET sort_no=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + item.getSortNo(), + item.getId() + ); + } + } + + public List getMenuRoleIds(Long menuId) { + assertMenuExists(menuId); + return jdbcTemplate.queryForList( + "SELECT role_id FROM platform_role_menu WHERE menu_id=? ORDER BY role_id ASC", + Long.class, + menuId + ); + } + + public List getMenuRoleNames(Long menuId) { + assertMenuExists(menuId); + return jdbcTemplate.queryForList( + "SELECT r.role_name FROM platform_role_menu rm " + + "JOIN platform_role r ON rm.role_id=r.id " + + "WHERE rm.menu_id=? AND r.is_deleted=0 ORDER BY r.id ASC", + String.class, + menuId + ); + } + + public void bindMenuRoles(Long menuId, BindPlatformMenuRolesRequest request) { + assertMenuExists(menuId); + jdbcTemplate.update("DELETE FROM platform_role_menu WHERE menu_id=?", menuId); + if (request.getRoleIds() == null || request.getRoleIds().isEmpty()) { + return; + } + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM platform_role_menu", Long.class); + long currentId = nextId == null ? 1L : nextId; + for (Long roleId : request.getRoleIds()) { + assertRoleExists(roleId); + jdbcTemplate.update( + "INSERT INTO platform_role_menu (id, role_id, menu_id) VALUES (?, ?, ?)", + currentId++, + roleId, + menuId + ); + } + } + + public List getRoleMenuIds(Long roleId) { + assertRoleExists(roleId); + return jdbcTemplate.queryForList( + "SELECT menu_id FROM platform_role_menu WHERE role_id=? ORDER BY menu_id ASC", + Long.class, + roleId + ); + } + + public void bindRoleMenus(Long roleId, BindRoleMenusRequest request) { + assertRoleExists(roleId); + jdbcTemplate.update("DELETE FROM platform_role_menu WHERE role_id=?", roleId); + if (request.getMenuIds() == null || request.getMenuIds().isEmpty()) { + return; + } + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM platform_role_menu", Long.class); + long currentId = nextId == null ? 1L : nextId; + for (Long menuId : request.getMenuIds()) { + assertMenuExists(menuId); + jdbcTemplate.update( + "INSERT INTO platform_role_menu (id, role_id, menu_id) VALUES (?, ?, ?)", + currentId++, + roleId, + menuId + ); + } + } + + private MenuInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, menu_code, menu_name, route_path, permission_code, sort_no, status " + + "FROM platform_menu WHERE id=? AND is_deleted=0", + MENU_ROW_MAPPER, + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "菜单不存在"); + } + return list.get(0); + } + + private void assertMenuExists(Long menuId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_menu WHERE id=? AND is_deleted=0", + Integer.class, + menuId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "菜单不存在"); + } + } + + private void assertRoleExists(Long roleId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_role WHERE id=? AND is_deleted=0", + Integer.class, + roleId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "角色不存在"); + } + } + + private void assertPermissionExists(String permissionCode) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_permission WHERE permission_code=?", + Integer.class, + permissionCode + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "权限码不存在"); + } + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/PlatformRoleService.java b/backend/src/main/java/com/writeoff/module/system/service/PlatformRoleService.java new file mode 100644 index 0000000..ddc28d1 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/PlatformRoleService.java @@ -0,0 +1,33 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.module.system.model.PlatformRoleInfo; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class PlatformRoleService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper ROLE_ROW_MAPPER = (rs, n) -> new PlatformRoleInfo( + rs.getLong("id"), + rs.getString("role_code"), + rs.getString("role_name"), + rs.getString("status") + ); + + public PlatformRoleService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult list() { + List list = jdbcTemplate.query( + "SELECT id, role_code, role_name, status FROM platform_role WHERE is_deleted=0 ORDER BY id DESC", + ROLE_ROW_MAPPER + ); + return new PageResult(list, list.size(), 1, 100); + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/SystemUserService.java b/backend/src/main/java/com/writeoff/module/system/service/SystemUserService.java new file mode 100644 index 0000000..4d70a84 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/SystemUserService.java @@ -0,0 +1,962 @@ +package com.writeoff.module.system.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.model.ImportResult; +import com.writeoff.common.util.ImportValidationUtils; +import com.writeoff.module.notification.dto.DispatchNotificationRequest; +import com.writeoff.module.notification.service.NotificationDispatchService; +import com.writeoff.module.system.dto.CreateRoleRequest; +import com.writeoff.module.system.dto.CreateUserRequest; +import com.writeoff.module.system.dto.BindRolePermissionsRequest; +import com.writeoff.module.system.dto.ImportUserItemRequest; +import com.writeoff.module.system.dto.ResetPasswordRequest; +import com.writeoff.module.system.dto.UpdateProfilePreferencesRequest; +import com.writeoff.module.system.dto.UpdateRoleRequest; +import com.writeoff.module.system.model.PermissionInfo; +import com.writeoff.module.system.model.ProfilePreferencesInfo; +import com.writeoff.module.system.model.RoleInfo; +import com.writeoff.module.system.model.SystemUser; +import com.writeoff.module.system.model.UserRoleHistory; +import com.writeoff.security.AuthContext; +import com.writeoff.security.PasswordCodecService; +import com.writeoff.security.PasswordPolicyService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +public class SystemUserService { + private static final Logger log = LoggerFactory.getLogger(SystemUserService.class); + private final JdbcTemplate jdbcTemplate; + private final DataPermissionService dataPermissionService; + private final NotificationDispatchService notificationDispatchService; + private final PasswordPolicyService passwordPolicyService; + private final PasswordCodecService passwordCodecService; + private final TransactionTemplate transactionTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final RowMapper USER_ROW_MAPPER = (rs, n) -> new SystemUser( + rs.getLong("id"), + rs.getString("user_name"), + rs.getString("phone"), + rs.getString("email"), + rs.getString("status"), + rs.getString("valid_from"), + rs.getString("valid_to"), + rs.getString("role_codes"), + rs.getString("role_names"), + rs.getInt("is_deleted") == 1 + ); + + private static final RowMapper ROLE_ROW_MAPPER = (rs, n) -> new RoleInfo( + rs.getLong("id"), + rs.getString("role_code"), + rs.getString("role_name"), + rs.getString("status") + ); + private static final RowMapper PERMISSION_ROW_MAPPER = (rs, n) -> new PermissionInfo( + rs.getLong("id"), + rs.getString("permission_code"), + rs.getString("permission_name"), + rs.getString("module") + ); + + private static final RowMapper USER_ROLE_HISTORY_ROW_MAPPER = (rs, n) -> new UserRoleHistory( + rs.getLong("id"), + rs.getLong("user_id"), + rs.getObject("old_role_id") == null ? null : rs.getLong("old_role_id"), + rs.getLong("new_role_id"), + rs.getString("action_type"), + rs.getString("created_at") + ); + + public SystemUserService(JdbcTemplate jdbcTemplate, + DataPermissionService dataPermissionService, + NotificationDispatchService notificationDispatchService, + PasswordPolicyService passwordPolicyService, + PasswordCodecService passwordCodecService, + PlatformTransactionManager transactionManager) { + this.jdbcTemplate = jdbcTemplate; + this.dataPermissionService = dataPermissionService; + this.notificationDispatchService = notificationDispatchService; + this.passwordPolicyService = passwordPolicyService; + this.passwordCodecService = passwordCodecService; + this.transactionTemplate = new TransactionTemplate(transactionManager); + } + + public PageResult listUsers(int pageNo, int pageSize, Boolean includeDeleted) { + return listUsers(pageNo, pageSize, includeDeleted, null); + } + + public PageResult listUsers(int pageNo, int pageSize, Boolean includeDeleted, String keyword) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + int offset = (safePage - 1) * safeSize; + boolean withDeleted = Boolean.TRUE.equals(includeDeleted); + String normalizedKeyword = keyword == null ? "" : keyword.trim(); + + StringBuilder totalWhereSql = new StringBuilder("WHERE u.tenant_id=?"); + StringBuilder dataWhereSql = new StringBuilder("WHERE u.tenant_id=?"); + List whereArgs = new ArrayList(); + whereArgs.add(tenantId()); + if (!withDeleted) { + totalWhereSql.append(" AND u.is_deleted=0"); + dataWhereSql.append(" AND u.is_deleted=0"); + } + if (!normalizedKeyword.isEmpty()) { + String like = "%" + normalizedKeyword + "%"; + totalWhereSql.append(" AND (u.user_name LIKE ? OR u.phone LIKE ? OR u.email LIKE ?)"); + dataWhereSql.append(" AND (u.user_name LIKE ? OR u.phone LIKE ? OR u.email LIKE ?)"); + whereArgs.add(like); + whereArgs.add(like); + whereArgs.add(like); + } + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM sys_user u " + totalWhereSql, + Integer.class, + whereArgs.toArray() + ); + long totalCount = total == null ? 0 : total; + + List dataArgs = new ArrayList(whereArgs); + dataArgs.add(safeSize); + dataArgs.add(offset); + List list = jdbcTemplate.query( + "SELECT u.id, u.user_name, u.phone, u.email, u.status, " + + "DATE_FORMAT(u.valid_from, '%Y-%m-%d %H:%i:%s') AS valid_from, " + + "DATE_FORMAT(u.valid_to, '%Y-%m-%d %H:%i:%s') AS valid_to, " + + "COALESCE(GROUP_CONCAT(DISTINCT r.role_code ORDER BY r.id SEPARATOR ','), '') AS role_codes, " + + "COALESCE(GROUP_CONCAT(DISTINCT r.role_name ORDER BY r.id SEPARATOR ','), '') AS role_names, " + + "u.is_deleted AS is_deleted " + + "FROM sys_user u " + + "LEFT JOIN user_role ur ON u.tenant_id=ur.tenant_id AND u.id=ur.user_id " + + "LEFT JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id AND r.is_deleted=0 " + + dataWhereSql + " " + + "GROUP BY u.id, u.user_name, u.phone, u.email, u.status, u.valid_from, u.valid_to, u.is_deleted " + + "ORDER BY u.id DESC LIMIT ? OFFSET ?", + USER_ROW_MAPPER, + dataArgs.toArray() + ); + if (dataPermissionService != null) { + DataPermissionService.DataScope scope = dataPermissionService.resolveCurrentUserScope(); + Map creatorMap = listUserCreators(list.stream().map(SystemUser::getId).collect(Collectors.toList())); + list = list.stream() + .filter(user -> dataPermissionService.canAccessUser(user.getId(), creatorMap.get(user.getId()), scope)) + .collect(Collectors.toList()); + } + return new PageResult<>(list, totalCount, safePage, safeSize); + } + + public PageResult listUsers(int pageNo, int pageSize) { + return listUsers(pageNo, pageSize, false, null); + } + + public SystemUser createUser(CreateUserRequest request) { + final String userName = request.getUserName() == null ? "" : request.getUserName().trim(); + final String phone = request.getPhone() == null ? "" : request.getPhone().trim(); + final String email = request.getEmail() == null ? "" : request.getEmail().trim(); + final String rawPassword = request.getPassword() == null ? "" : request.getPassword().trim(); + if (rawPassword.isEmpty()) { + throw new BusinessException(10001, "\u5bc6\u7801\u4e0d\u80fd\u4e3a\u7a7a"); + } + passwordPolicyService.validate(rawPassword); + final String validFrom = request.getValidFrom() == null || request.getValidFrom().trim().isEmpty() + ? LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + : normalizeDateTimeString(request.getValidFrom()); + final String validTo = request.getValidTo() == null || request.getValidTo().trim().isEmpty() + ? "2099-12-31 23:59:59" + : normalizeDateTimeString(request.getValidTo()); + return transactionTemplate.execute(status -> { + assertPhoneAvailable(phone, null); + String passwordHash = passwordCodecService.encode(rawPassword); + String tenantSwitchAccountKey = resolveTenantSwitchAccountKeyByPhone(phone, null); + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO sys_user (tenant_id, user_name, phone, email, password_hash, tenant_switch_account_key, status, valid_from, valid_to, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, 'ENABLED', ?, ?, ?, ?)", + Statement.RETURN_GENERATED_KEYS + ); + Timestamp validFromTimestamp = parseTimestamp(validFrom); + Timestamp validToTimestamp = parseTimestamp(validTo); + Long operator = safeUserId(); + ps.setLong(1, tenantId()); + ps.setString(2, userName); + ps.setString(3, phone); + ps.setString(4, email); + ps.setString(5, passwordHash); + ps.setString(6, tenantSwitchAccountKey); + ps.setTimestamp(7, validFromTimestamp); + ps.setTimestamp(8, validToTimestamp); + ps.setLong(9, operator); + ps.setLong(10, operator); + return ps; + }, keyHolder); + Long id = keyHolder.getKey() == null ? null : keyHolder.getKey().longValue(); + autoAssignExecutorRoleWhenCreatorIsProjectExecutor(id); + sendUserCreatedMail(id, userName, phone, email, validFrom, validTo); + return new SystemUser(id, userName, phone, email, "ENABLED", validFrom, validTo, "", ""); + }); + } + + public SystemUser updateUser(Long userId, CreateUserRequest request) { + assertUserExists(userId); + assertPhoneAvailable(request.getPhone(), userId); + String loadedTenantSwitchAccountKey = loadTenantSwitchAccountKey(userId); + final String tenantSwitchAccountKey = + loadedTenantSwitchAccountKey == null || loadedTenantSwitchAccountKey.trim().isEmpty() + ? resolveTenantSwitchAccountKeyByPhone(request.getPhone(), userId) + : loadedTenantSwitchAccountKey; + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "UPDATE sys_user SET user_name=?, phone=?, email=?, tenant_switch_account_key=?, valid_from=?, valid_to=?, updated_by=?, updated_at=CURRENT_TIMESTAMP" + + (request.getPassword() == null || request.getPassword().trim().isEmpty() ? "" : ", password_hash=?") + + " WHERE tenant_id=? AND id=?" + ); + Timestamp validFrom = parseTimestamp(request.getValidFrom()); + Timestamp validTo = parseTimestamp(request.getValidTo()); + Long operator = safeUserId(); + int idx = 1; + ps.setString(idx++, request.getUserName()); + ps.setString(idx++, request.getPhone()); + ps.setString(idx++, request.getEmail()); + ps.setString(idx++, tenantSwitchAccountKey); + ps.setTimestamp(idx++, validFrom == null ? Timestamp.valueOf(LocalDateTime.now()) : validFrom); + ps.setTimestamp(idx++, validTo == null ? Timestamp.valueOf(LocalDateTime.of(2099, 12, 31, 23, 59, 59)) : validTo); + ps.setLong(idx++, operator); + if (request.getPassword() != null && !request.getPassword().trim().isEmpty()) { + passwordPolicyService.validate(request.getPassword()); + ps.setString(idx++, passwordCodecService.encode(request.getPassword())); + } + ps.setLong(idx++, tenantId()); + ps.setLong(idx, userId); + return ps; + }); + String validFrom = request.getValidFrom() == null || request.getValidFrom().trim().isEmpty() + ? LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")) + : normalizeDateTimeString(request.getValidFrom()); + String validTo = request.getValidTo() == null || request.getValidTo().trim().isEmpty() + ? "2099-12-31 23:59:59" + : normalizeDateTimeString(request.getValidTo()); + return new SystemUser(userId, request.getUserName(), request.getPhone(), request.getEmail(), null, validFrom, validTo, "", ""); + } + + public ImportResult importUsers(List users) { + ImportResult result = new ImportResult(); + result.setTotal(users == null ? 0 : users.size()); + if (users == null) { + return result; + } + Set batchPhones = new HashSet(); + for (int i = 0; i < users.size(); i++) { + ImportUserItemRequest item = users.get(i); + int rowNo = i + 2; + try { + Long roleId = validateImportUser(item, batchPhones); + CreateUserRequest request = new CreateUserRequest(); + request.setUserName(item.getUserName() == null ? null : item.getUserName().trim()); + request.setPhone(item.getPhone() == null ? null : item.getPhone().trim()); + request.setPassword(item.getPassword() == null ? null : item.getPassword().trim()); + request.setEmail(item.getEmail() == null ? null : item.getEmail().trim()); + request.setValidFrom(item.getValidFrom()); + request.setValidTo(item.getValidTo()); + SystemUser created = createUser(request); + if (roleId != null) { + assignRole(created.getId(), roleId); + } + result.markSuccess(); + } catch (Exception ex) { + result.addError(rowNo, buildUserIdentifier(item), resolveImportMessage(ex)); + } + } + return result; + } + + public PageResult listRoles(int pageNo, int pageSize) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + int offset = (safePage - 1) * safeSize; + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM role WHERE tenant_id=? AND is_deleted=0", + Integer.class, + tenantId() + ); + long totalCount = total == null ? 0 : total; + + List list = jdbcTemplate.query( + "SELECT * FROM role WHERE tenant_id=? AND is_deleted=0 ORDER BY id DESC LIMIT ? OFFSET ?", + ROLE_ROW_MAPPER, + tenantId(), + safeSize, + offset + ); + return new PageResult<>(list, totalCount, safePage, safeSize); + } + + public void assignRole(Long userId, Long roleId) { + Integer userExists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM sys_user WHERE id=? AND is_deleted=0", + Integer.class, + userId + ); + if (userExists == null || userExists == 0) { + throw new BusinessException(10003, "用户不存在"); + } + Integer roleExists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM role WHERE id=? AND is_deleted=0", + Integer.class, + roleId + ); + if (roleExists == null || roleExists == 0) { + throw new BusinessException(10003, "角色不存在"); + } + + Integer relationExists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM user_role WHERE tenant_id=? AND user_id=? AND role_id=?", + Integer.class, + tenantId(), + userId, + roleId + ); + if (relationExists != null && relationExists > 0) { + return; + } + Long oldRoleId = currentRoleId(userId); + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM user_role", Long.class); + jdbcTemplate.update( + "INSERT INTO user_role (id, tenant_id, user_id, role_id) VALUES (?, ?, ?, ?)", + nextId, + tenantId(), + userId, + roleId + ); + jdbcTemplate.update( + "INSERT INTO user_role_history (tenant_id, user_id, old_role_id, new_role_id, action_type, action_reason, created_by) VALUES (?, ?, ?, ?, 'ASSIGN', ?, 0)", + tenantId(), + userId, + oldRoleId, + roleId, + "用户分配角色" + ); + } + + public List getUserRoles(Long userId) { + return getUserRoles(userId, tenantId()); + } + + public List getUserRoles(Long userId, Long tenantId) { + if (tenantId == null) { + throw new BusinessException(10001, "租户信息不能为空"); + } + List roleCodes = jdbcTemplate.queryForList( + "SELECT r.role_code FROM user_role ur " + + "JOIN role r ON ur.role_id=r.id " + + "WHERE ur.user_id=? AND ur.tenant_id=? AND r.is_deleted=0", + String.class, + userId, + tenantId + ); + return roleCodes == null ? new ArrayList() : roleCodes; + } + + public void enableUser(Long userId) { + assertUserExists(userId); + jdbcTemplate.update("UPDATE sys_user SET status='ENABLED', updated_at=CURRENT_TIMESTAMP WHERE id=?", userId); + } + + public void disableUser(Long userId) { + assertUserExists(userId); + jdbcTemplate.update("UPDATE sys_user SET status='DISABLED', updated_at=CURRENT_TIMESTAMP WHERE id=?", userId); + } + + public void softDeleteUser(Long userId) { + assertUserExists(userId); + // 检查是否有待处理审核任务 + Integer pendingTasks = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM audit_task WHERE tenant_id=? AND assignee_user_id=? AND is_deleted=0 AND status='PENDING'", + Integer.class, + tenantId(), + userId + ); + if (pendingTasks != null && pendingTasks > 0) { + throw new BusinessException(10001, "该用户还有待处理的审核任务,请先转交后再删除"); + } + jdbcTemplate.update( + "UPDATE sys_user SET is_deleted=1, status='DISABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + userId + ); + } + + public void resetPassword(Long userId, ResetPasswordRequest request) { + assertUserExists(userId); + passwordPolicyService.validate(request.getNewPassword()); + jdbcTemplate.update( + "UPDATE sys_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + passwordCodecService.encode(request.getNewPassword()), + userId + ); + } + + public void changeMyPassword(Long userId, String oldPassword, String newPassword) { + assertUserExists(userId); + passwordPolicyService.validate(newPassword); + String currentPasswordHash = jdbcTemplate.queryForObject( + "SELECT password_hash FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + String.class, + tenantId(), + userId + ); + if (!passwordCodecService.matches(oldPassword, currentPasswordHash)) { + throw new BusinessException(11001, "原密码不正确"); + } + jdbcTemplate.update( + "UPDATE sys_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + passwordCodecService.encode(newPassword), + tenantId(), + userId + ); + } + + public void onSuccessfulLogin(Long userId, Long tenantId, String phone, String rawPassword) { + upgradeStoredPasswordHash(userId, tenantId, rawPassword); + ensureTenantSwitchAccountKeyForPassword(phone, rawPassword); + } + + public ProfilePreferencesInfo getMyPreferences(Long userId) { + assertUserExists(userId); + List> rows = jdbcTemplate.queryForList( + "SELECT ui_theme_mode, ui_density, ui_theme_scheme FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + tenantId(), + userId + ); + if (rows.isEmpty()) { + throw new BusinessException(10003, "用户不存在"); + } + return new ProfilePreferencesInfo( + normalizeThemeMode(rows.get(0).get("ui_theme_mode")), + normalizeDensity(rows.get(0).get("ui_density")), + normalizeThemeScheme(rows.get(0).get("ui_theme_scheme")) + ); + } + + public ProfilePreferencesInfo updateMyPreferences(Long userId, UpdateProfilePreferencesRequest request) { + assertUserExists(userId); + String themeMode = normalizeThemeMode(request == null ? null : request.getThemeMode()); + String density = normalizeDensity(request == null ? null : request.getDensity()); + String themeScheme = normalizeThemeScheme(request == null ? null : request.getThemeScheme()); + jdbcTemplate.update( + "UPDATE sys_user SET ui_theme_mode=?, ui_density=?, ui_theme_scheme=?, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + themeMode, + density, + themeScheme, + safeUserId(), + tenantId(), + userId + ); + return new ProfilePreferencesInfo(themeMode, density, themeScheme); + } + + public PageResult listUserRoleHistory(Long userId) { + assertUserExists(userId); + List list = jdbcTemplate.query( + "SELECT id, user_id, old_role_id, new_role_id, action_type, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM user_role_history WHERE tenant_id=? AND user_id=? ORDER BY id DESC", + USER_ROLE_HISTORY_ROW_MAPPER, + tenantId(), + userId + ); + return new PageResult<>(list, list.size(), 1, 20); + } + + public RoleInfo createRole(CreateRoleRequest request) { + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM role WHERE tenant_id=? AND role_code=?", + Integer.class, + tenantId(), + request.getRoleCode() + ); + if (exists != null && exists > 0) { + throw new BusinessException(10001, "角色编码已存在"); + } + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM role", Long.class); + jdbcTemplate.update( + "INSERT INTO role (id, tenant_id, role_code, role_name, status, is_deleted, created_by, updated_by) VALUES (?, ?, ?, ?, 'ENABLED', 0, 0, 0)", + nextId, + tenantId(), + request.getRoleCode(), + request.getRoleName() + ); + return new RoleInfo(nextId, request.getRoleCode(), request.getRoleName(), "ENABLED"); + } + + public RoleInfo updateRole(Long roleId, UpdateRoleRequest request) { + assertRoleExists(roleId); + jdbcTemplate.update( + "UPDATE role SET role_name=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + request.getRoleName(), + tenantId(), + roleId + ); + return jdbcTemplate.queryForObject( + "SELECT * FROM role WHERE tenant_id=? AND id=?", + ROLE_ROW_MAPPER, + tenantId(), + roleId + ); + } + + public void enableRole(Long roleId) { + updateRoleStatus(roleId, "ENABLED"); + } + + public void disableRole(Long roleId) { + updateRoleStatus(roleId, "DISABLED"); + } + + public void softDeleteRole(Long roleId) { + assertRoleExists(roleId); + // 检查是否有活跃用户绑定 + Integer activeBindings = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM user_role ur " + + "JOIN sys_user u ON ur.tenant_id=u.tenant_id AND ur.user_id=u.id " + + "WHERE ur.tenant_id=? AND ur.role_id=? AND u.is_deleted=0 AND u.status='ENABLED'", + Integer.class, + tenantId(), + roleId + ); + if (activeBindings != null && activeBindings > 0) { + throw new BusinessException(10001, "该角色下仍有活跃用户,请先解绑后再删除"); + } + jdbcTemplate.update( + "UPDATE role SET is_deleted=1, status='DISABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + roleId + ); + } + + public PageResult listPermissions() { + List list = jdbcTemplate.query("SELECT * FROM permission ORDER BY module ASC, id ASC", PERMISSION_ROW_MAPPER); + return new PageResult<>(list, list.size(), 1, 200); + } + + public List getRolePermissionIds(Long roleId) { + assertRoleExists(roleId); + return jdbcTemplate.queryForList( + "SELECT permission_id FROM role_permission WHERE tenant_id=? AND role_id=? ORDER BY permission_id ASC", + Long.class, + tenantId(), + roleId + ); + } + + public void bindRolePermissions(Long roleId, BindRolePermissionsRequest request) { + assertRoleExists(roleId); + jdbcTemplate.update("DELETE FROM role_permission WHERE tenant_id=? AND role_id=?", tenantId(), roleId); + List ids = request.getPermissionIds(); + if (ids == null || ids.isEmpty()) { + return; + } + Long nextId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM role_permission", Long.class); + long currentId = nextId == null ? 1L : nextId; + for (Long permissionId : ids) { + jdbcTemplate.update( + "INSERT INTO role_permission (id, tenant_id, role_id, permission_id) VALUES (?, ?, ?, ?)", + currentId++, + tenantId(), + roleId, + permissionId + ); + } + } + + private void updateRoleStatus(Long roleId, String status) { + assertRoleExists(roleId); + jdbcTemplate.update("UPDATE role SET status=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", status, tenantId(), roleId); + } + + private void assertUserExists(Long userId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + userId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "用户不存在"); + } + } + + private void assertRoleExists(Long roleId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM role WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + roleId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "角色不存在"); + } + } + + private Long currentRoleId(Long userId) { + List roleIds = jdbcTemplate.queryForList( + "SELECT role_id FROM user_role WHERE tenant_id=? AND user_id=? ORDER BY id DESC LIMIT 1", + Long.class, + tenantId(), + userId + ); + return roleIds.isEmpty() ? null : roleIds.get(0); + } + + private void autoAssignExecutorRoleWhenCreatorIsProjectExecutor(Long createdUserId) { + if (createdUserId == null || !currentUserHasRole("PROJECT_EXECUTOR")) { + return; + } + Long executorRoleId = findRoleIdByCode("EXECUTOR"); + if (executorRoleId == null) { + return; + } + assignRole(createdUserId, executorRoleId); + } + + private boolean currentUserHasRole(String roleCode) { + Long userId = AuthContext.userId(); + if (userId == null || roleCode == null || roleCode.trim().isEmpty()) { + return false; + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM user_role ur " + + "JOIN role r ON ur.tenant_id=r.tenant_id AND ur.role_id=r.id " + + "WHERE ur.tenant_id=? AND ur.user_id=? AND r.role_code=? AND r.is_deleted=0", + Integer.class, + tenantId(), + userId, + roleCode + ); + return count != null && count > 0; + } + + private Long findRoleIdByCode(String roleCode) { + List ids = jdbcTemplate.queryForList( + "SELECT id FROM role WHERE tenant_id=? AND role_code=? AND is_deleted=0 LIMIT 1", + Long.class, + tenantId(), + roleCode + ); + return ids.isEmpty() ? null : ids.get(0); + } + + private void assertPhoneAvailable(String phone, Long excludeUserId) { + if (phone == null || phone.trim().isEmpty()) { + throw new BusinessException(10001, "手机号不能为空"); + } + String normalizedPhone = phone.trim(); + Integer count; + if (excludeUserId == null) { + count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND phone=? AND is_deleted=0", + Integer.class, + tenantId(), + normalizedPhone + ); + } else { + count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND phone=? AND is_deleted=0 AND id<>?", + Integer.class, + tenantId(), + normalizedPhone, + excludeUserId + ); + } + if (count != null && count > 0) { + throw new BusinessException(10001, "手机号已存在"); + } + } + + private Long validateImportUser(ImportUserItemRequest item, Set batchPhones) { + if (item == null) { + throw new BusinessException(10001, "导入行不能为空"); + } + if (item.getUserName() == null || item.getUserName().trim().isEmpty()) { + throw new BusinessException(10001, "用户名不能为空"); + } + if (item.getPassword() == null || item.getPassword().trim().isEmpty()) { + throw new BusinessException(10001, "密码不能为空"); + } + ImportValidationUtils.validatePhone(item.getPhone()); + ImportValidationUtils.validateRequiredEmail(item.getEmail()); + ImportValidationUtils.validateDateRange(item.getValidFrom(), item.getValidTo()); + String phone = ImportValidationUtils.trim(item.getPhone()); + if (!batchPhones.add(phone)) { + throw new BusinessException(10001, "批次内手机号重复"); + } + passwordPolicyService.validate(item.getPassword().trim()); + String roleCode = ImportValidationUtils.trim(item.getRoleCode()); + if (roleCode.isEmpty()) { + return null; + } + Long roleId = findRoleIdByCode(roleCode); + if (roleId == null) { + throw new BusinessException(10001, "\u89d2\u8272\u7f16\u7801\u4e0d\u5b58\u5728: " + roleCode); + } + return roleId; + } + + private String buildUserIdentifier(ImportUserItemRequest item) { + if (item == null) { + return ""; + } + String userName = item.getUserName() == null ? "" : item.getUserName().trim(); + String phone = item.getPhone() == null ? "" : item.getPhone().trim(); + if (!userName.isEmpty() && !phone.isEmpty()) { + return userName + "/" + phone; + } + return !userName.isEmpty() ? userName : phone; + } + + private String resolveImportMessage(Exception ex) { + if (ex instanceof BusinessException) { + return ex.getMessage(); + } + return ex.getMessage() == null || ex.getMessage().trim().isEmpty() + ? "导入失败" + : ex.getMessage(); + } + + private void sendUserCreatedMail(Long userId, String userName, String phone, String email, String validFrom, String validTo) { + if (userId == null || userId <= 0) { + return; + } + try { + List> tenants = jdbcTemplate.queryForList( + "SELECT tenant_code, tenant_name FROM tenant WHERE id=? LIMIT 1", + tenantId() + ); + String tenantCode = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_code")); + String tenantName = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_name")); + String loginPath = "/" + tenantCode + "/login"; + Map variables = new LinkedHashMap(); + variables.put("userId", userId); + variables.put("targetUserId", userId); + variables.put("userName", userName); + variables.put("phone", phone); + variables.put("email", email); + variables.put("validFrom", validFrom); + variables.put("validTo", validTo); + variables.put("tenantCode", tenantCode); + variables.put("tenantName", tenantName); + variables.put("loginPath", loginPath); + DispatchNotificationRequest request = new DispatchNotificationRequest(); + request.setIdempotencyKey("user-created-" + tenantId() + "-" + userId); + request.setEventCode("USER_CREATED"); + request.setBizType("USER"); + request.setBizId("user-" + userId); + request.setVariablesJson(objectMapper.writeValueAsString(variables)); + notificationDispatchService.dispatch(request); + } catch (Exception ex) { + log.warn("auto trigger user created notification failed, tenantId={}, userId={}, err={}", + tenantId(), userId, ex.getMessage(), ex); + } + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private void upgradeStoredPasswordHash(Long userId, Long tenantId, String rawPassword) { + List hashes = jdbcTemplate.queryForList( + "SELECT password_hash FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + String.class, + tenantId, + userId + ); + if (hashes.isEmpty()) { + return; + } + String currentHash = hashes.get(0); + if (passwordCodecService.isEncoded(currentHash) || !passwordCodecService.matches(rawPassword, currentHash)) { + return; + } + jdbcTemplate.update( + "UPDATE sys_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + passwordCodecService.encode(rawPassword), + tenantId, + userId + ); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Map listUserCreators(List userIds) { + Map result = new LinkedHashMap<>(); + if (userIds == null || userIds.isEmpty()) { + return result; + } + String placeholders = userIds.stream().map(i -> "?").collect(Collectors.joining(",")); + List args = new ArrayList<>(); + args.add(tenantId()); + args.addAll(userIds); + List> rows = jdbcTemplate.queryForList( + "SELECT id, created_by FROM sys_user WHERE tenant_id=? AND is_deleted=0 AND id IN (" + placeholders + ")", + args.toArray() + ); + for (Map row : rows) { + result.put(((Number) row.get("id")).longValue(), ((Number) row.get("created_by")).longValue()); + } + return result; + } + + private Timestamp parseTimestamp(String raw) { + if (raw == null || raw.trim().isEmpty()) { + return null; + } + String normalized = normalizeDateTimeString(raw); + return Timestamp.valueOf(LocalDateTime.parse(normalized, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + } + + private String normalizeDateTimeString(String raw) { + String value = raw.trim().replace("T", " "); + if (value.length() == 16) { + return value + ":00"; + } + return value; + } + + private String loadTenantSwitchAccountKey(Long userId) { + List keys = jdbcTemplate.queryForList( + "SELECT tenant_switch_account_key FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + String.class, + tenantId(), + userId + ); + return keys.isEmpty() ? "" : String.valueOf(keys.get(0) == null ? "" : keys.get(0)).trim(); + } + + private String resolveTenantSwitchAccountKeyByPhone(String phone, Long excludeUserId) { + String normalizedPhone = phone == null ? "" : phone.trim(); + if (normalizedPhone.isEmpty()) { + return "acct_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(); + } + String sql = + "SELECT tenant_switch_account_key FROM sys_user " + + "WHERE phone=? AND is_deleted=0 AND tenant_switch_account_key IS NOT NULL AND tenant_switch_account_key<>'' " + + (excludeUserId == null ? "" : "AND id<>? ") + + "ORDER BY id ASC LIMIT 1"; + List keys = excludeUserId == null + ? jdbcTemplate.queryForList(sql, String.class, normalizedPhone) + : jdbcTemplate.queryForList(sql, String.class, normalizedPhone, excludeUserId); + if (!keys.isEmpty()) { + String existing = String.valueOf(keys.get(0) == null ? "" : keys.get(0)).trim(); + if (!existing.isEmpty()) { + return existing; + } + } + return "acct_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(); + } + + private void ensureTenantSwitchAccountKeyForPassword(String phone, String rawPassword) { + String normalizedPhone = phone == null ? "" : phone.trim(); + if (normalizedPhone.isEmpty() || rawPassword == null || rawPassword.isEmpty()) { + return; + } + List> rows = jdbcTemplate.queryForList( + "SELECT id, password_hash, tenant_switch_account_key FROM sys_user WHERE phone=? AND is_deleted=0", + normalizedPhone + ); + if (rows.isEmpty()) { + return; + } + List matchedUserIds = new ArrayList(); + String sharedKey = ""; + for (Map row : rows) { + String storedPassword = row.get("password_hash") == null ? null : String.valueOf(row.get("password_hash")); + if (!passwordCodecService.matches(rawPassword, storedPassword)) { + continue; + } + matchedUserIds.add(((Number) row.get("id")).longValue()); + String existingKey = row.get("tenant_switch_account_key") == null ? "" : String.valueOf(row.get("tenant_switch_account_key")).trim(); + if (sharedKey.isEmpty() && !existingKey.isEmpty()) { + sharedKey = existingKey; + } + } + if (matchedUserIds.isEmpty()) { + return; + } + if (sharedKey.isEmpty()) { + sharedKey = "acct_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(); + } + for (Long matchedUserId : matchedUserIds) { + jdbcTemplate.update( + "UPDATE sys_user SET tenant_switch_account_key=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND (tenant_switch_account_key IS NULL OR tenant_switch_account_key<>?)", + sharedKey, + matchedUserId, + sharedKey + ); + } + } + + public String normalizeThemeMode(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ("LIGHT".equals(value) || "DARK".equals(value) || "SYSTEM".equals(value)) { + return value; + } + return "SYSTEM"; + } + + public String normalizeDensity(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ("COMPACT".equals(value) || "COMFORTABLE".equals(value)) { + return value; + } + return "COMFORTABLE"; + } + + public String normalizeThemeScheme(Object raw) { + String value = raw == null ? "" : String.valueOf(raw).trim().toUpperCase(); + if ( + "SLATE".equals(value) || + "OCEAN".equals(value) || + "FOREST".equals(value) || + "GRAPHITE".equals(value) || + "AMBER".equals(value) || + "RUBY".equals(value) || + "MIST".equals(value) || + "SAGE".equals(value) || + "DAWN".equals(value) + ) { + return value; + } + return "SLATE"; + } +} + diff --git a/backend/src/main/java/com/writeoff/module/system/service/TenantService.java b/backend/src/main/java/com/writeoff/module/system/service/TenantService.java new file mode 100644 index 0000000..025f622 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/TenantService.java @@ -0,0 +1,655 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.system.dto.CreateTenantAdminRequest; +import com.writeoff.module.system.dto.CreateTenantRequest; +import com.writeoff.module.system.model.TenantInfo; +import com.writeoff.module.notification.provider.NotificationChannelProvider; +import com.writeoff.module.notification.provider.NotificationSendResult; +import com.writeoff.security.AuthContext; +import com.writeoff.security.PasswordCodecService; +import com.writeoff.security.PasswordPolicyService; +import com.writeoff.security.PasswordSetupService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Service +public class TenantService { + private final JdbcTemplate jdbcTemplate; + private final OssService ossService; + private final Map providerMap; + private final PasswordPolicyService passwordPolicyService; + private final PasswordCodecService passwordCodecService; + @Autowired + private PasswordSetupService passwordSetupService; + private final String tenantAdminMailSubjectTemplate; + private final String tenantAdminMailBodyTemplate; + + private static final RowMapper TENANT_ROW_MAPPER = (rs, n) -> new TenantInfo( + rs.getLong("id"), + rs.getString("tenant_code"), + rs.getString("tenant_name"), + rs.getString("logo_url"), + rs.getString("status"), + rs.getString("created_at") + ); + + public TenantService(JdbcTemplate jdbcTemplate, + OssService ossService, + List providers, + PasswordPolicyService passwordPolicyService, + PasswordCodecService passwordCodecService, + @Value("${app.notification.tenant-admin-mail-subject-template:租户管理员账号通知}") String tenantAdminMailSubjectTemplate, + @Value("${app.notification.tenant-admin-mail-body-template:操作类型:{actionCn}\\n租户名称:{tenantName}\\n租户编码:{tenantCode}\\n登录地址:{loginPath}\\n管理员账号:{phone}\\n管理员密码:{password}\\n请首次登录后立即修改密码。}") String tenantAdminMailBodyTemplate) { + this.jdbcTemplate = jdbcTemplate; + this.ossService = ossService; + this.providerMap = new HashMap(); + this.passwordPolicyService = passwordPolicyService; + this.passwordCodecService = passwordCodecService; + this.tenantAdminMailSubjectTemplate = tenantAdminMailSubjectTemplate; + this.tenantAdminMailBodyTemplate = tenantAdminMailBodyTemplate; + if (providers != null) { + for (NotificationChannelProvider provider : providers) { + providerMap.put(provider.channel().toUpperCase(), provider); + } + } + } + + public PageResult list() { + List list = jdbcTemplate.query( + "SELECT id, tenant_code, tenant_name, logo_url, status, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM tenant WHERE is_deleted=0 ORDER BY id DESC", + TENANT_ROW_MAPPER + ); + return new PageResult(list, list.size(), 1, 50); + } + + public PageResult listCurrentTenant() { + TenantInfo currentTenant = findById(AuthContext.requireTenantId()); + return new PageResult(java.util.Collections.singletonList(currentTenant), 1, 1, 1); + } + + public TenantInfo create(CreateTenantRequest request) { + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM tenant WHERE tenant_code=?", + Integer.class, + request.getTenantCode().trim() + ); + if (exists != null && exists > 0) { + throw new BusinessException(10001, "租户编码已存在"); + } + jdbcTemplate.update( + "INSERT INTO tenant (tenant_code, tenant_name, logo_url, status, is_deleted, created_by, updated_by) VALUES (?, ?, ?, 'ENABLED', 0, ?, ?)", + request.getTenantCode().trim(), + request.getTenantName().trim(), + normalizeNullable(request.getLogoUrl()), + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM tenant", Long.class); + long tenantId = id == null ? 0L : id; + initTenantBaseline(tenantId); + return findById(tenantId); + } + + public java.util.Map initTenantBaseline(Long tenantId) { + assertExists(tenantId); + int menuCount = ensureTenantMenusFromTemplate(tenantId); + int roleCount = ensureTenantRolesFromTemplate(tenantId); + int rolePermCount = 0; + int roleMenuCount = 0; + List> templateRoles = jdbcTemplate.queryForList( + "SELECT role_code FROM role WHERE tenant_id=1 AND is_deleted=0" + ); + for (Map row : templateRoles) { + String roleCode = String.valueOf(row.get("role_code")); + Long targetRoleId = ensureTenantRole(tenantId, roleCode); + rolePermCount += ensureRolePermissionsFromTemplate(tenantId, targetRoleId, roleCode); + roleMenuCount += ensureRoleMenusFromTemplate(tenantId, targetRoleId, roleCode); + } + java.util.Map data = new java.util.LinkedHashMap(); + data.put("tenantId", tenantId); + data.put("menuInitialized", menuCount); + data.put("roleInitialized", roleCount); + data.put("rolePermissionInitialized", rolePermCount); + data.put("roleMenuInitialized", roleMenuCount); + return data; + } + + public void enable(Long tenantId) { + assertExists(tenantId); + jdbcTemplate.update("UPDATE tenant SET status='ENABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", safeUserId(), tenantId); + } + + public void disable(Long tenantId) { + assertExists(tenantId); + jdbcTemplate.update("UPDATE tenant SET status='DISABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", safeUserId(), tenantId); + } + + public TenantInfo updateTenant(Long tenantId, String tenantName, String logoUrl) { + assertExists(tenantId); + if (tenantName == null || tenantName.trim().isEmpty()) { + throw new BusinessException(10001, "租户名称不能为空"); + } + jdbcTemplate.update( + "UPDATE tenant SET tenant_name=?, logo_url=?, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE id=? AND is_deleted=0", + tenantName.trim(), + normalizeNullable(logoUrl), + safeUserId(), + tenantId + ); + return findById(tenantId); + } + + public void softDelete(Long tenantId) { + assertExists(tenantId); + // 检查是否有活跃业务数据 + Integer activeProjects = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM project WHERE tenant_id=? AND is_deleted=0 AND status NOT IN ('ARCHIVED', 'TERMINATED')", + Integer.class, + tenantId + ); + if (activeProjects != null && activeProjects > 0) { + throw new BusinessException(10001, "该租户下存在活跃项目,请先归档或终止所有项目后再删除"); + } + jdbcTemplate.update( + "UPDATE tenant SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + safeUserId(), + tenantId + ); + } + + public java.util.Map createTenantAdmin(Long tenantId, CreateTenantAdminRequest request) { + assertExists(tenantId); + String roleCode = request.getRoleCode() == null || request.getRoleCode().trim().isEmpty() + ? "TENANT_ADMIN" + : request.getRoleCode().trim(); + Long roleId = ensureTenantRole(tenantId, roleCode); + ensureRolePermissionsFromTemplate(tenantId, roleId, roleCode); + ensureRoleMenusFromTemplate(tenantId, roleId, roleCode); + Long existingAdminUserId = findTenantAdminUserId(tenantId, roleCode); + String passwordHash = passwordCodecService.encode(generateTemporaryPassword()); + + List> samePhoneUsers = jdbcTemplate.queryForList( + "SELECT id FROM sys_user WHERE tenant_id=? AND phone=? AND is_deleted=0 LIMIT 1", + tenantId, + request.getPhone().trim() + ); + if (!samePhoneUsers.isEmpty()) { + Long phoneUserId = ((Number) samePhoneUsers.get(0).get("id")).longValue(); + if (existingAdminUserId == null || !phoneUserId.equals(existingAdminUserId)) { + throw new BusinessException(10001, "该租户下手机号已存在"); + } + } + + long uid; + String action; + if (existingAdminUserId == null) { + String tenantSwitchAccountKey = resolveTenantSwitchAccountKeyByPhone(request.getPhone().trim(), null); + jdbcTemplate.update( + "INSERT INTO sys_user (tenant_id, user_name, phone, email, password_hash, tenant_switch_account_key, status, valid_from, valid_to, is_deleted, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, 'ENABLED', NOW(), '2099-12-31 23:59:59', 0, ?, ?)", + tenantId, + request.getUserName().trim(), + request.getPhone().trim(), + request.getEmail().trim(), + passwordHash, + tenantSwitchAccountKey, + safeUserId(), + safeUserId() + ); + Long userId = jdbcTemplate.queryForObject( + "SELECT IFNULL(MAX(id), 0) FROM sys_user WHERE tenant_id=?", + Long.class, + tenantId + ); + uid = userId == null ? 0L : userId; + action = "CREATED"; + } else { + uid = existingAdminUserId; + String tenantSwitchAccountKey = loadTenantSwitchAccountKey(tenantId, uid); + if (tenantSwitchAccountKey == null || tenantSwitchAccountKey.trim().isEmpty()) { + tenantSwitchAccountKey = resolveTenantSwitchAccountKeyByPhone(request.getPhone().trim(), uid); + } + jdbcTemplate.update( + "UPDATE sys_user SET user_name=?, phone=?, email=?, password_hash=?, tenant_switch_account_key=?, status='ENABLED', valid_to='2099-12-31 23:59:59', updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND id=?", + request.getUserName().trim(), + request.getPhone().trim(), + request.getEmail().trim(), + passwordHash, + tenantSwitchAccountKey, + safeUserId(), + tenantId, + uid + ); + action = "UPDATED"; + } + + Integer relationExists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM user_role WHERE tenant_id=? AND user_id=? AND role_id=?", + Integer.class, + tenantId, + uid, + roleId + ); + if (relationExists == null || relationExists == 0) { + Long nextUserRoleId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM user_role", Long.class); + jdbcTemplate.update( + "INSERT INTO user_role (id, tenant_id, user_id, role_id) VALUES (?, ?, ?, ?)", + nextUserRoleId == null ? 1L : nextUserRoleId, + tenantId, + uid, + roleId + ); + } + jdbcTemplate.update( + "INSERT INTO user_role_history (tenant_id, user_id, old_role_id, new_role_id, action_type, action_reason, created_by) VALUES (?, ?, NULL, ?, 'ASSIGN', ?, ?)", + tenantId, + uid, + roleId, + "平台设置租户管理员", + safeUserId() + ); + + String setupLink = passwordSetupService.issueTenantAdminSetupLink(tenantId, uid, safeUserId()); + sendTenantAdminMail(tenantId, request, action, setupLink); + + java.util.Map data = new java.util.LinkedHashMap(); + data.put("tenantId", tenantId); + data.put("userId", uid); + data.put("roleId", roleId); + data.put("roleCode", roleCode); + data.put("action", action); + return data; + } + + public java.util.Map getTenantAdmin(Long tenantId, String roleCode) { + assertExists(tenantId); + String normalizedRoleCode = roleCode == null || roleCode.trim().isEmpty() ? "TENANT_ADMIN" : roleCode.trim(); + List> rows = jdbcTemplate.queryForList( + "SELECT u.id, u.user_name, u.phone, u.email, r.role_code " + + "FROM user_role ur " + + "JOIN role r ON ur.role_id=r.id " + + "JOIN sys_user u ON ur.user_id=u.id " + + "WHERE ur.tenant_id=? AND r.tenant_id=? AND r.role_code=? AND r.is_deleted=0 AND u.is_deleted=0 " + + "ORDER BY ur.id DESC LIMIT 1", + tenantId, + tenantId, + normalizedRoleCode + ); + if (rows.isEmpty()) { + return null; + } + Map row = rows.get(0); + java.util.Map data = new java.util.LinkedHashMap(); + data.put("userId", ((Number) row.get("id")).longValue()); + data.put("userName", row.get("user_name")); + data.put("phone", row.get("phone")); + data.put("email", row.get("email")); + data.put("roleCode", row.get("role_code")); + return data; + } + + public Map presignLogoUpload(String fileName, String contentType) { + String name = fileName == null ? "" : fileName.trim(); + if (name.isEmpty()) { + throw new BusinessException(10001, "文件名不能为空"); + } + String ext = ""; + int idx = name.lastIndexOf('.'); + if (idx > 0 && idx < name.length() - 1) { + ext = "." + name.substring(idx + 1).toLowerCase(); + } + String normalizedContentType = normalizeContentType(contentType); + String objectKey = "tenant/logo/" + safeUserId() + "/" + UUID.randomUUID().toString().replace("-", "") + ext; + String uploadUrl = ossService.generateUploadUrl(objectKey, normalizedContentType); + Map data = new LinkedHashMap(); + data.put("objectKey", objectKey); + data.put("uploadUrl", uploadUrl); + data.put("contentType", normalizedContentType); + data.put("method", "PUT"); + return data; + } + + private Long ensureTenantRole(Long tenantId, String roleCode) { + java.util.List roleIds = jdbcTemplate.queryForList( + "SELECT id FROM role WHERE tenant_id=? AND role_code=? AND is_deleted=0 LIMIT 1", + Long.class, + tenantId, + roleCode + ); + if (!roleIds.isEmpty()) { + return roleIds.get(0); + } + java.util.List> templateRows = jdbcTemplate.queryForList( + "SELECT id, role_name FROM role WHERE tenant_id=1 AND role_code=? AND is_deleted=0 LIMIT 1", + roleCode + ); + String roleName = "单位管理员"; + if (!templateRows.isEmpty()) { + java.util.Map row = templateRows.get(0); + roleName = String.valueOf(row.get("role_name")); + } + Long nextRoleId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM role", Long.class); + long roleId = nextRoleId == null ? 1L : nextRoleId; + jdbcTemplate.update( + "INSERT INTO role (id, tenant_id, role_code, role_name, status, is_deleted, created_by, updated_by) VALUES (?, ?, ?, ?, 'ENABLED', 0, ?, ?)", + roleId, + tenantId, + roleCode, + roleName, + safeUserId(), + safeUserId() + ); + return roleId; + } + + private int ensureRolePermissionsFromTemplate(Long tenantId, Long roleId, String roleCode) { + List> templates = jdbcTemplate.queryForList( + "SELECT id FROM role WHERE tenant_id=1 AND role_code=? AND is_deleted=0 LIMIT 1", + roleCode + ); + if (templates.isEmpty()) { + return 0; + } + Long templateRoleId = ((Number) templates.get(0).get("id")).longValue(); + List permIds = jdbcTemplate.queryForList( + "SELECT permission_id FROM role_permission WHERE tenant_id=1 AND role_id=?", + Long.class, + templateRoleId + ); + int inserted = 0; + if (!permIds.isEmpty()) { + Long nextRolePermId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM role_permission", Long.class); + long currentPermId = nextRolePermId == null ? 1L : nextRolePermId; + for (Long permissionId : permIds) { + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM role_permission WHERE tenant_id=? AND role_id=? AND permission_id=?", + Integer.class, + tenantId, + roleId, + permissionId + ); + if (exists != null && exists > 0) { + continue; + } + jdbcTemplate.update( + "INSERT INTO role_permission (id, tenant_id, role_id, permission_id) VALUES (?, ?, ?, ?)", + currentPermId++, + tenantId, + roleId, + permissionId + ); + inserted++; + } + } + return inserted; + } + + private int ensureRoleMenusFromTemplate(Long tenantId, Long roleId, String roleCode) { + List> templates = jdbcTemplate.queryForList( + "SELECT id FROM role WHERE tenant_id=1 AND role_code=? AND is_deleted=0 LIMIT 1", + roleCode + ); + if (templates.isEmpty()) { + return 0; + } + Long templateRoleId = ((Number) templates.get(0).get("id")).longValue(); + List> templateMenus = jdbcTemplate.queryForList( + "SELECT m.menu_code FROM role_menu rm " + + "JOIN menu m ON rm.tenant_id=m.tenant_id AND rm.menu_id=m.id " + + "WHERE rm.tenant_id=1 AND rm.role_id=? AND m.is_deleted=0", + templateRoleId + ); + int inserted = 0; + if (!templateMenus.isEmpty()) { + Long nextRoleMenuId = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) + 1 FROM role_menu", Long.class); + long currentMenuId = nextRoleMenuId == null ? 1L : nextRoleMenuId; + for (Map row : templateMenus) { + String menuCode = String.valueOf(row.get("menu_code")); + List targetMenuIds = jdbcTemplate.queryForList( + "SELECT id FROM menu WHERE tenant_id=? AND menu_code=? AND is_deleted=0 LIMIT 1", + Long.class, + tenantId, + menuCode + ); + if (targetMenuIds.isEmpty()) { + continue; + } + Long targetMenuId = targetMenuIds.get(0); + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM role_menu WHERE tenant_id=? AND role_id=? AND menu_id=?", + Integer.class, + tenantId, + roleId, + targetMenuId + ); + if (exists != null && exists > 0) { + continue; + } + jdbcTemplate.update( + "INSERT INTO role_menu (id, tenant_id, role_id, menu_id) VALUES (?, ?, ?, ?)", + currentMenuId++, + tenantId, + roleId, + targetMenuId + ); + inserted++; + } + } + return inserted; + } + + private int ensureTenantMenusFromTemplate(Long tenantId) { + List> templateMenus = jdbcTemplate.queryForList( + "SELECT menu_code, menu_name, route_path, permission_code, sort_no, status " + + "FROM menu WHERE tenant_id=1 AND is_deleted=0" + ); + if (templateMenus.isEmpty()) { + return 0; + } + int inserted = 0; + for (Map row : templateMenus) { + String menuCode = String.valueOf(row.get("menu_code")); + Integer exists = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM menu WHERE tenant_id=? AND menu_code=? AND is_deleted=0", + Integer.class, + tenantId, + menuCode + ); + if (exists != null && exists > 0) { + continue; + } + jdbcTemplate.update( + "INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)", + tenantId, + menuCode, + String.valueOf(row.get("menu_name")), + String.valueOf(row.get("route_path")), + row.get("permission_code"), + ((Number) row.get("sort_no")).intValue(), + String.valueOf(row.get("status")), + safeUserId(), + safeUserId() + ); + inserted++; + } + return inserted; + } + + private int ensureTenantRolesFromTemplate(Long tenantId) { + List> templateRoles = jdbcTemplate.queryForList( + "SELECT role_code FROM role WHERE tenant_id=1 AND is_deleted=0" + ); + if (templateRoles.isEmpty()) { + return 0; + } + int inserted = 0; + for (Map row : templateRoles) { + String roleCode = String.valueOf(row.get("role_code")); + List exists = jdbcTemplate.queryForList( + "SELECT id FROM role WHERE tenant_id=? AND role_code=? AND is_deleted=0 LIMIT 1", + Long.class, + tenantId, + roleCode + ); + if (exists.isEmpty()) { + ensureTenantRole(tenantId, roleCode); + inserted++; + } + } + return inserted; + } + + private Long findTenantAdminUserId(Long tenantId, String roleCode) { + List ids = jdbcTemplate.queryForList( + "SELECT u.id FROM user_role ur " + + "JOIN role r ON ur.role_id=r.id " + + "JOIN sys_user u ON ur.user_id=u.id " + + "WHERE ur.tenant_id=? AND r.tenant_id=? AND r.role_code=? AND r.is_deleted=0 AND u.is_deleted=0 " + + "ORDER BY ur.id DESC LIMIT 1", + Long.class, + tenantId, + tenantId, + roleCode + ); + return ids.isEmpty() ? null : ids.get(0); + } + + private void sendTenantAdminMail(Long tenantId, CreateTenantAdminRequest request, String action, String setupLink) { + NotificationChannelProvider provider = providerMap.get("EMAIL"); + if (provider == null) { + throw new BusinessException(10003, "邮件通道不可用"); + } + List> tenants = jdbcTemplate.queryForList( + "SELECT tenant_code, tenant_name FROM tenant WHERE id=? LIMIT 1", + tenantId + ); + String tenantCode = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_code")); + String tenantName = tenants.isEmpty() ? "" : String.valueOf(tenants.get(0).get("tenant_name")); + String loginPath = "/" + tenantCode + "/login"; + String actionCn = "UPDATED".equals(action) ? "修改管理员" : "新增管理员"; + String subject = renderTemplate(tenantAdminMailSubjectTemplate, tenantCode, tenantName, loginPath, request.getPhone().trim(), setupLink, action, actionCn); + String content = renderTemplate(tenantAdminMailBodyTemplate, tenantCode, tenantName, loginPath, request.getPhone().trim(), setupLink, action, actionCn); + String payload = "{\"subject\":\"" + jsonEscape(subject) + "\",\"content\":\"" + jsonEscape(content) + "\",\"action\":\"" + jsonEscape(action) + + "\",\"tenantCode\":\"" + jsonEscape(tenantCode) + "\",\"tenantName\":\"" + jsonEscape(tenantName) + "\",\"loginPath\":\"" + + jsonEscape(loginPath) + "\",\"phone\":\"" + jsonEscape(request.getPhone().trim()) + "\",\"setupLink\":\"" + jsonEscape(setupLink) + "\"}"; + NotificationSendResult result = provider.send(request.getEmail().trim(), payload, null); + if (result == null || !result.isAccepted()) { + throw new BusinessException(10001, "管理员账号邮件发送失败"); + } + } + + private String renderTemplate(String template, String tenantCode, String tenantName, String loginPath, + String phone, String setupLink, String action, String actionCn) { + String value = template == null ? "" : template; + value = value.replace("{tenantCode}", tenantCode == null ? "" : tenantCode); + value = value.replace("{tenantName}", tenantName == null ? "" : tenantName); + value = value.replace("{loginPath}", loginPath == null ? "" : loginPath); + value = value.replace("{phone}", phone == null ? "" : phone); + value = value.replace("{setupLink}", setupLink == null ? "" : setupLink); + value = value.replace("{action}", action == null ? "" : action); + value = value.replace("{actionCn}", actionCn == null ? "" : actionCn); + return value; + } + + private String jsonEscape(String raw) { + if (raw == null) { + return ""; + } + return raw + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\r", "") + .replace("\n", "\\n"); + } + + private void assertExists(Long tenantId) { + Integer count = jdbcTemplate.queryForObject("SELECT COUNT(1) FROM tenant WHERE id=? AND is_deleted=0", Integer.class, tenantId); + if (count == null || count == 0) { + throw new BusinessException(10003, "租户不存在"); + } + } + + private TenantInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, tenant_code, tenant_name, logo_url, status, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM tenant WHERE id=? AND is_deleted=0", + TENANT_ROW_MAPPER, + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "租户不存在"); + } + return list.get(0); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private String normalizeNullable(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private String normalizeContentType(String contentType) { + if (contentType == null || contentType.trim().isEmpty()) { + return "application/octet-stream"; + } + return contentType.trim(); + } + + private String loadTenantSwitchAccountKey(Long tenantId, Long userId) { + List keys = jdbcTemplate.queryForList( + "SELECT tenant_switch_account_key FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1", + String.class, + tenantId, + userId + ); + return keys.isEmpty() ? "" : String.valueOf(keys.get(0) == null ? "" : keys.get(0)).trim(); + } + + private String resolveTenantSwitchAccountKeyByPhone(String phone, Long excludeUserId) { + String normalizedPhone = phone == null ? "" : phone.trim(); + if (normalizedPhone.isEmpty()) { + return "acct_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(); + } + String sql = + "SELECT tenant_switch_account_key FROM sys_user " + + "WHERE phone=? AND is_deleted=0 AND tenant_switch_account_key IS NOT NULL AND tenant_switch_account_key<>'' " + + (excludeUserId == null ? "" : "AND id<>? ") + + "ORDER BY id ASC LIMIT 1"; + List keys = excludeUserId == null + ? jdbcTemplate.queryForList(sql, String.class, normalizedPhone) + : jdbcTemplate.queryForList(sql, String.class, normalizedPhone, excludeUserId); + if (!keys.isEmpty()) { + String existing = String.valueOf(keys.get(0) == null ? "" : keys.get(0)).trim(); + if (!existing.isEmpty()) { + return existing; + } + } + return "acct_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(); + } + + private String generateTemporaryPassword() { + return "Tmp@" + UUID.randomUUID().toString().replace("-", "").substring(0, 8) + "aA1"; + } +} diff --git a/backend/src/main/java/com/writeoff/module/system/service/UserDelegationService.java b/backend/src/main/java/com/writeoff/module/system/service/UserDelegationService.java new file mode 100644 index 0000000..8a25079 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/system/service/UserDelegationService.java @@ -0,0 +1,183 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.system.dto.CreateUserDelegationRequest; +import com.writeoff.module.system.dto.DisableDelegationRequest; +import com.writeoff.module.system.model.UserDelegationInfo; +import com.writeoff.security.AuthContext; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +public class UserDelegationService { + private final JdbcTemplate jdbcTemplate; + + private static final RowMapper ROW_MAPPER = (rs, n) -> new UserDelegationInfo( + rs.getLong("id"), + rs.getLong("user_id"), + rs.getLong("delegate_user_id"), + rs.getString("effective_from"), + rs.getString("effective_to"), + rs.getString("status"), + rs.getString("reason"), + rs.getString("disabled_reason"), + rs.getString("created_at") + ); + + public UserDelegationService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public PageResult listByUserId(Long userId) { + assertUserExists(userId); + markAutoExpiredByTenant(); + List list = jdbcTemplate.query( + "SELECT id, user_id, delegate_user_id, " + + "DATE_FORMAT(effective_from, '%Y-%m-%d %H:%i:%s') AS effective_from, " + + "DATE_FORMAT(effective_to, '%Y-%m-%d %H:%i:%s') AS effective_to, " + + "status, reason, disabled_reason, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM user_delegation WHERE tenant_id=? AND user_id=? AND is_deleted=0 ORDER BY id DESC", + ROW_MAPPER, + tenantId(), + userId + ); + return new PageResult<>(list, list.size(), 1, 50); + } + + public UserDelegationInfo create(Long userId, CreateUserDelegationRequest request) { + assertUserExists(userId); + assertUserExists(request.getDelegateUserId()); + if (userId.equals(request.getDelegateUserId())) { + throw new BusinessException(10001, "代理人不能与被代理人相同"); + } + Timestamp from = parseTimestamp(request.getEffectiveFrom()); + Timestamp to = parseTimestamp(request.getEffectiveTo()); + if (!to.after(from)) { + throw new BusinessException(10001, "失效时间必须晚于生效时间"); + } + String status = from.toLocalDateTime().isAfter(LocalDateTime.now()) ? "PENDING" : "ENABLED"; + jdbcTemplate.update( + "INSERT INTO user_delegation (tenant_id, user_id, delegate_user_id, effective_from, effective_to, status, reason, is_deleted, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)", + tenantId(), + userId, + request.getDelegateUserId(), + from, + to, + status, + normalizeNullable(request.getReason()), + safeUserId(), + safeUserId() + ); + Long id = jdbcTemplate.queryForObject("SELECT IFNULL(MAX(id), 0) FROM user_delegation WHERE tenant_id=?", Long.class, tenantId()); + return findById(id == null ? 0L : id); + } + + public void disable(Long delegationId, DisableDelegationRequest request) { + markAutoExpiredByTenant(); + UserDelegationInfo item = findById(delegationId); + if ("DISABLED".equals(item.getStatus()) || "EXPIRED".equals(item.getStatus())) { + return; + } + jdbcTemplate.update( + "UPDATE user_delegation SET status='DISABLED', disabled_reason=?, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND id=?", + request.getReason().trim(), + safeUserId(), + tenantId(), + delegationId + ); + } + + public void markAutoExpiredByTenant() { + jdbcTemplate.update( + "UPDATE user_delegation SET status='EXPIRED', updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND is_deleted=0 AND status IN ('PENDING','ENABLED') AND effective_to < CURRENT_TIMESTAMP", + safeUserId(), + tenantId() + ); + jdbcTemplate.update( + "UPDATE user_delegation SET status='ENABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND is_deleted=0 AND status='PENDING' AND effective_from <= CURRENT_TIMESTAMP AND effective_to >= CURRENT_TIMESTAMP", + safeUserId(), + tenantId() + ); + } + + public void markAutoExpiredAllTenants() { + jdbcTemplate.update( + "UPDATE user_delegation SET status='EXPIRED', updated_by=0, updated_at=CURRENT_TIMESTAMP " + + "WHERE is_deleted=0 AND status IN ('PENDING','ENABLED') AND effective_to < CURRENT_TIMESTAMP" + ); + jdbcTemplate.update( + "UPDATE user_delegation SET status='ENABLED', updated_by=0, updated_at=CURRENT_TIMESTAMP " + + "WHERE is_deleted=0 AND status='PENDING' AND effective_from <= CURRENT_TIMESTAMP AND effective_to >= CURRENT_TIMESTAMP" + ); + } + + private UserDelegationInfo findById(Long id) { + List list = jdbcTemplate.query( + "SELECT id, user_id, delegate_user_id, " + + "DATE_FORMAT(effective_from, '%Y-%m-%d %H:%i:%s') AS effective_from, " + + "DATE_FORMAT(effective_to, '%Y-%m-%d %H:%i:%s') AS effective_to, " + + "status, reason, disabled_reason, DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at " + + "FROM user_delegation WHERE tenant_id=? AND id=? AND is_deleted=0", + ROW_MAPPER, + tenantId(), + id + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "代理授权不存在"); + } + return list.get(0); + } + + private void assertUserExists(Long userId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + userId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "用户不存在"); + } + } + + private Timestamp parseTimestamp(String raw) { + String value = normalizeDateTimeString(raw); + return Timestamp.valueOf(LocalDateTime.parse(value, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + } + + private String normalizeDateTimeString(String raw) { + String value = raw == null ? "" : raw.trim().replace("T", " "); + if (value.length() == 16) { + return value + ":00"; + } + return value; + } + + private String normalizeNullable(String value) { + if (value == null) { + return null; + } + String t = value.trim(); + return t.isEmpty() ? null : t; + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/controller/TemplateController.java b/backend/src/main/java/com/writeoff/module/template/controller/TemplateController.java new file mode 100644 index 0000000..b7ce9c7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/controller/TemplateController.java @@ -0,0 +1,195 @@ +package com.writeoff.module.template.controller; + +import com.writeoff.common.api.ApiResponse; +import com.writeoff.common.api.PageResult; +import com.writeoff.module.template.dto.CreateTemplateRequest; +import com.writeoff.module.template.dto.CreateTemplateVersionRequest; +import com.writeoff.module.template.dto.BindFlowTemplateRequest; +import com.writeoff.module.template.dto.RollbackTemplateRequest; +import com.writeoff.module.template.dto.TemplateUploadSignRequest; +import com.writeoff.module.template.model.TemplateDownloadLogInfo; +import com.writeoff.module.template.model.TemplateFlowLinkInfo; +import com.writeoff.module.template.model.TemplateInfo; +import com.writeoff.module.template.model.TemplateTypeOption; +import com.writeoff.module.template.model.TemplateVersionInfo; +import com.writeoff.module.template.service.TemplateService; +import com.writeoff.security.DataScopeType; +import com.writeoff.security.RequirePermission; +import org.springframework.web.bind.annotation.*; + +import javax.servlet.http.HttpServletRequest; +import javax.validation.Valid; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/templates") +public class TemplateController { + private final TemplateService templateService; + + public TemplateController(TemplateService templateService) { + this.templateService = templateService; + } + + @GetMapping + @RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_LIST") + public ApiResponse> list( + @RequestParam(value = "templateName", required = false) String templateName, + @RequestParam(value = "templateType", required = false) String templateType, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "scopeType", required = false) String scopeType, + @RequestParam(value = "bizScene", required = false) String bizScene, + @RequestParam(value = "watermarkEnabled", required = false) Boolean watermarkEnabled, + @RequestParam(value = "effectiveStatus", required = false) String effectiveStatus, + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return ApiResponse.success(templateService.list(templateName, templateType, status, scopeType, bizScene, watermarkEnabled, effectiveStatus, pageNo, pageSize)); + } + + @GetMapping("/published-options") + @RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_PUBLISHED_OPTIONS") + public ApiResponse> publishedOptions( + @RequestParam(value = "bizScene", required = false) String bizScene) { + return ApiResponse.success(templateService.listPublishedOptions(bizScene)); + } + + @GetMapping("/type-options") + @RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_TYPE_OPTIONS") + public ApiResponse> typeOptions() { + return ApiResponse.success(templateService.listTypeOptions()); + } + + @GetMapping("/flow-scene-options") + @RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_FLOW_SCENE_OPTIONS") + public ApiResponse>> flowSceneOptions() { + return ApiResponse.success(templateService.flowSceneOptions()); + } + + @GetMapping("/flow-links") + @RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_FLOW_LINKS") + public ApiResponse> flowLinks() { + return ApiResponse.success(templateService.listFlowLinks()); + } + + @PostMapping("/flow-links/{sceneCode}/bind") + @RequirePermission(value = "template.flow.link", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_FLOW_LINK_BIND") + public ApiResponse bindFlowLink(@PathVariable("sceneCode") String sceneCode, + @RequestBody @Valid BindFlowTemplateRequest request) { + return ApiResponse.success(templateService.bindFlowLink(sceneCode, request.getTemplateId())); + } + + @PostMapping("/type-options/{typeCode}/enable") + @RequirePermission(value = "template.publish", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_TYPE_ENABLE") + public ApiResponse enableTypeOption(@PathVariable("typeCode") String typeCode) { + templateService.enableTypeOption(typeCode); + return ApiResponse.success("ok"); + } + + @PostMapping("/type-options/{typeCode}/disable") + @RequirePermission(value = "template.publish", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_TYPE_DISABLE") + public ApiResponse disableTypeOption(@PathVariable("typeCode") String typeCode) { + templateService.disableTypeOption(typeCode); + return ApiResponse.success("ok"); + } + + @PostMapping + @RequirePermission(value = "template.create", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_CREATE") + public ApiResponse create(@RequestBody @Valid CreateTemplateRequest request) { + return ApiResponse.success(templateService.create(request)); + } + + @PostMapping("/upload-sign") + @RequirePermission(value = "template.create", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_UPLOAD_SIGN") + public ApiResponse> uploadSign(@RequestBody @Valid TemplateUploadSignRequest request) { + return ApiResponse.success(templateService.presignUpload(request)); + } + + @GetMapping("/{id}/versions") + @RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_VERSIONS") + public ApiResponse> versions(@PathVariable("id") Long id) { + return ApiResponse.success(templateService.versions(id)); + } + + @PostMapping("/{id}/versions") + @RequirePermission(value = "template.create", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_VERSION_CREATE") + public ApiResponse addVersion(@PathVariable("id") Long id, + @RequestBody @Valid CreateTemplateVersionRequest request) { + return ApiResponse.success(templateService.addVersion(id, request)); + } + + @PostMapping("/{id}/publish") + @RequirePermission(value = "template.publish", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_PUBLISH") + public ApiResponse publish(@PathVariable("id") Long id) { + return ApiResponse.success(templateService.publish(id)); + } + + @PostMapping("/{id}/disable") + @RequirePermission(value = "template.disable", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_DISABLE") + public ApiResponse disable(@PathVariable("id") Long id) { + return ApiResponse.success(templateService.disable(id)); + } + + @PostMapping("/{id}/archive") + @RequirePermission(value = "template.archive", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_ARCHIVE") + public ApiResponse archive(@PathVariable("id") Long id) { + return ApiResponse.success(templateService.archive(id)); + } + + @PostMapping("/{id}/rollback") + @RequirePermission(value = "template.rollback", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_ROLLBACK") + public ApiResponse rollback(@PathVariable("id") Long id, + @RequestBody @Valid RollbackTemplateRequest request) { + return ApiResponse.success(templateService.rollback(id, request)); + } + + @GetMapping("/{id}/download") + @RequirePermission(value = "template.download", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_DOWNLOAD") + public ApiResponse> download(@PathVariable("id") Long id, HttpServletRequest request) { + return ApiResponse.success(templateService.download(id, request.getRemoteAddr(), request.getHeader("User-Agent"))); + } + + @GetMapping("/{id}/download-watermark") + @RequirePermission(value = "template.download", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_DOWNLOAD_WATERMARK") + public ApiResponse> downloadWatermark(@PathVariable("id") Long id, + @RequestParam(value = "watermarkText", required = false) String watermarkText, + HttpServletRequest request) { + return ApiResponse.success(templateService.downloadWatermark(id, watermarkText, request.getRemoteAddr(), request.getHeader("User-Agent"))); + } + + @GetMapping("/{id}/versions/diff") + @RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_VERSION_DIFF") + public ApiResponse> versionDiff(@PathVariable("id") Long id, + @RequestParam(value = "leftVersionNo", required = false) Integer leftVersionNo, + @RequestParam(value = "rightVersionNo", required = false) Integer rightVersionNo) { + return ApiResponse.success(templateService.versionDiff(id, leftVersionNo, rightVersionNo)); + } + + @GetMapping("/download-logs") + @RequirePermission(value = "template.read", dataScope = DataScopeType.TENANT, auditAction = "TEMPLATE_DOWNLOAD_LOGS") + public ApiResponse> downloadLogs( + @RequestParam(value = "templateId", required = false) Long templateId, + @RequestParam(value = "templateName", required = false) String templateName, + @RequestParam(value = "userId", required = false) Long userId, + @RequestParam(value = "userKeyword", required = false) String userKeyword, + @RequestParam(value = "versionNo", required = false) Integer versionNo, + @RequestParam(value = "downloadType", required = false) String downloadType, + @RequestParam(value = "ip", required = false) String ip, + @RequestParam(value = "downloadedFrom", required = false) String downloadedFrom, + @RequestParam(value = "downloadedTo", required = false) String downloadedTo, + @RequestParam(value = "pageNo", defaultValue = "1") int pageNo, + @RequestParam(value = "pageSize", defaultValue = "20") int pageSize) { + return ApiResponse.success(templateService.listDownloadLogs( + templateId, + templateName, + userId, + userKeyword, + versionNo, + downloadType, + ip, + downloadedFrom, + downloadedTo, + pageNo, + pageSize + )); + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/dto/BindFlowTemplateRequest.java b/backend/src/main/java/com/writeoff/module/template/dto/BindFlowTemplateRequest.java new file mode 100644 index 0000000..5d4e43f --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/dto/BindFlowTemplateRequest.java @@ -0,0 +1,16 @@ +package com.writeoff.module.template.dto; + +import javax.validation.constraints.NotNull; + +public class BindFlowTemplateRequest { + @NotNull(message = "模板ID不能为空") + private Long templateId; + + public Long getTemplateId() { + return templateId; + } + + public void setTemplateId(Long templateId) { + this.templateId = templateId; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/dto/CreateTemplateRequest.java b/backend/src/main/java/com/writeoff/module/template/dto/CreateTemplateRequest.java new file mode 100644 index 0000000..c4730ff --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/dto/CreateTemplateRequest.java @@ -0,0 +1,127 @@ +package com.writeoff.module.template.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateTemplateRequest { + @NotBlank(message = "模板名称不能为空") + private String templateName; + @NotBlank(message = "模板类型不能为空") + private String templateType; + @NotBlank(message = "适用范围不能为空") + private String scopeType; + private Long projectId; + private Long meetingId; + private Long scopeId; + private String bizScene; + @NotBlank(message = "模板文件Key不能为空") + private String objectKey; + private String changeLog; + private String effectiveFrom; + private String effectiveTo; + private Boolean watermarkEnabled; + private Integer downloadRateLimitPerHour; + + public String getTemplateName() { + return templateName; + } + + public void setTemplateName(String templateName) { + this.templateName = templateName; + } + + public String getTemplateType() { + return templateType; + } + + public void setTemplateType(String templateType) { + this.templateType = templateType; + } + + public String getScopeType() { + return scopeType; + } + + public void setScopeType(String scopeType) { + this.scopeType = scopeType; + } + + public Long getProjectId() { + return projectId; + } + + public void setProjectId(Long projectId) { + this.projectId = projectId; + } + + public Long getMeetingId() { + return meetingId; + } + + public void setMeetingId(Long meetingId) { + this.meetingId = meetingId; + } + + public Long getScopeId() { + return scopeId; + } + + public void setScopeId(Long scopeId) { + this.scopeId = scopeId; + } + + public String getBizScene() { + return bizScene; + } + + public void setBizScene(String bizScene) { + this.bizScene = bizScene; + } + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } + + public String getChangeLog() { + return changeLog; + } + + public void setChangeLog(String changeLog) { + this.changeLog = changeLog; + } + + public String getEffectiveFrom() { + return effectiveFrom; + } + + public void setEffectiveFrom(String effectiveFrom) { + this.effectiveFrom = effectiveFrom; + } + + public String getEffectiveTo() { + return effectiveTo; + } + + public void setEffectiveTo(String effectiveTo) { + this.effectiveTo = effectiveTo; + } + + public Boolean getWatermarkEnabled() { + return watermarkEnabled; + } + + public void setWatermarkEnabled(Boolean watermarkEnabled) { + this.watermarkEnabled = watermarkEnabled; + } + + public Integer getDownloadRateLimitPerHour() { + return downloadRateLimitPerHour; + } + + public void setDownloadRateLimitPerHour(Integer downloadRateLimitPerHour) { + this.downloadRateLimitPerHour = downloadRateLimitPerHour; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/dto/CreateTemplateVersionRequest.java b/backend/src/main/java/com/writeoff/module/template/dto/CreateTemplateVersionRequest.java new file mode 100644 index 0000000..9691ac3 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/dto/CreateTemplateVersionRequest.java @@ -0,0 +1,25 @@ +package com.writeoff.module.template.dto; + +import javax.validation.constraints.NotBlank; + +public class CreateTemplateVersionRequest { + @NotBlank(message = "模板文件Key不能为空") + private String objectKey; + private String changeLog; + + public String getObjectKey() { + return objectKey; + } + + public void setObjectKey(String objectKey) { + this.objectKey = objectKey; + } + + public String getChangeLog() { + return changeLog; + } + + public void setChangeLog(String changeLog) { + this.changeLog = changeLog; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/dto/RollbackTemplateRequest.java b/backend/src/main/java/com/writeoff/module/template/dto/RollbackTemplateRequest.java new file mode 100644 index 0000000..d08bfea --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/dto/RollbackTemplateRequest.java @@ -0,0 +1,27 @@ +package com.writeoff.module.template.dto; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.NotNull; + +public class RollbackTemplateRequest { + @NotNull(message = "回滚版本号不能为空") + private Integer versionNo; + @NotBlank(message = "回滚原因不能为空") + private String rollbackReason; + + public Integer getVersionNo() { + return versionNo; + } + + public void setVersionNo(Integer versionNo) { + this.versionNo = versionNo; + } + + public String getRollbackReason() { + return rollbackReason; + } + + public void setRollbackReason(String rollbackReason) { + this.rollbackReason = rollbackReason; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/dto/TemplateUploadSignRequest.java b/backend/src/main/java/com/writeoff/module/template/dto/TemplateUploadSignRequest.java new file mode 100644 index 0000000..3deaa73 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/dto/TemplateUploadSignRequest.java @@ -0,0 +1,34 @@ +package com.writeoff.module.template.dto; + +import javax.validation.constraints.NotBlank; + +public class TemplateUploadSignRequest { + @NotBlank(message = "文件名不能为空") + private String fileName; + private String contentType; + private String templateType; + + public String getFileName() { + return fileName; + } + + public void setFileName(String fileName) { + this.fileName = fileName; + } + + public String getContentType() { + return contentType; + } + + public void setContentType(String contentType) { + this.contentType = contentType; + } + + public String getTemplateType() { + return templateType; + } + + public void setTemplateType(String templateType) { + this.templateType = templateType; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/model/TemplateDownloadLogInfo.java b/backend/src/main/java/com/writeoff/module/template/model/TemplateDownloadLogInfo.java new file mode 100644 index 0000000..3f663fe --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/model/TemplateDownloadLogInfo.java @@ -0,0 +1,99 @@ +package com.writeoff.module.template.model; + +public class TemplateDownloadLogInfo { + private Long id; + private Long templateId; + private String templateName; + private Integer versionNo; + private Long userId; + private String userName; + private String userPhone; + private String objectKey; + private String downloadType; + private String watermarkText; + private Long projectId; + private Long meetingId; + private String ip; + private String userAgent; + private String downloadedAt; + + public TemplateDownloadLogInfo(Long id, Long templateId, String templateName, Integer versionNo, Long userId, String userName, String userPhone, + String objectKey, String downloadType, String watermarkText, Long projectId, Long meetingId, + String ip, String userAgent, String downloadedAt) { + this.id = id; + this.templateId = templateId; + this.templateName = templateName; + this.versionNo = versionNo; + this.userId = userId; + this.userName = userName; + this.userPhone = userPhone; + this.objectKey = objectKey; + this.downloadType = downloadType; + this.watermarkText = watermarkText; + this.projectId = projectId; + this.meetingId = meetingId; + this.ip = ip; + this.userAgent = userAgent; + this.downloadedAt = downloadedAt; + } + + public Long getId() { + return id; + } + + public Long getTemplateId() { + return templateId; + } + + public String getTemplateName() { + return templateName; + } + + public Integer getVersionNo() { + return versionNo; + } + + public Long getUserId() { + return userId; + } + + public String getUserName() { + return userName; + } + + public String getUserPhone() { + return userPhone; + } + + public String getObjectKey() { + return objectKey; + } + + public String getDownloadType() { + return downloadType; + } + + public String getWatermarkText() { + return watermarkText; + } + + public Long getProjectId() { + return projectId; + } + + public Long getMeetingId() { + return meetingId; + } + + public String getIp() { + return ip; + } + + public String getUserAgent() { + return userAgent; + } + + public String getDownloadedAt() { + return downloadedAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/model/TemplateFlowLinkInfo.java b/backend/src/main/java/com/writeoff/module/template/model/TemplateFlowLinkInfo.java new file mode 100644 index 0000000..a5b4208 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/model/TemplateFlowLinkInfo.java @@ -0,0 +1,43 @@ +package com.writeoff.module.template.model; + +public class TemplateFlowLinkInfo { + private String sceneCode; + private String sceneName; + private Long templateId; + private String templateName; + private String templateStatus; + private Integer versionNo; + + public TemplateFlowLinkInfo(String sceneCode, String sceneName, Long templateId, String templateName, String templateStatus, Integer versionNo) { + this.sceneCode = sceneCode; + this.sceneName = sceneName; + this.templateId = templateId; + this.templateName = templateName; + this.templateStatus = templateStatus; + this.versionNo = versionNo; + } + + public String getSceneCode() { + return sceneCode; + } + + public String getSceneName() { + return sceneName; + } + + public Long getTemplateId() { + return templateId; + } + + public String getTemplateName() { + return templateName; + } + + public String getTemplateStatus() { + return templateStatus; + } + + public Integer getVersionNo() { + return versionNo; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/model/TemplateInfo.java b/backend/src/main/java/com/writeoff/module/template/model/TemplateInfo.java new file mode 100644 index 0000000..15d3c29 --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/model/TemplateInfo.java @@ -0,0 +1,111 @@ +package com.writeoff.module.template.model; + +public class TemplateInfo { + private Long id; + private String templateName; + private String templateType; + private String scopeType; + private Long projectId; + private Long meetingId; + private Long scopeId; + private String bizScene; + private String status; + private Integer currentVersionNo; + private String currentObjectKey; + private String effectiveFrom; + private String effectiveTo; + private Boolean watermarkEnabled; + private Integer downloadRateLimitPerHour; + private String createdAt; + private String updatedAt; + + public TemplateInfo(Long id, String templateName, String templateType, String scopeType, Long projectId, Long meetingId, Long scopeId, String bizScene, + String status, Integer currentVersionNo, String currentObjectKey, String effectiveFrom, String effectiveTo, Boolean watermarkEnabled, Integer downloadRateLimitPerHour, + String createdAt, String updatedAt) { + this.id = id; + this.templateName = templateName; + this.templateType = templateType; + this.scopeType = scopeType; + this.projectId = projectId; + this.meetingId = meetingId; + this.scopeId = scopeId; + this.bizScene = bizScene; + this.status = status; + this.currentVersionNo = currentVersionNo; + this.currentObjectKey = currentObjectKey; + this.effectiveFrom = effectiveFrom; + this.effectiveTo = effectiveTo; + this.watermarkEnabled = watermarkEnabled; + this.downloadRateLimitPerHour = downloadRateLimitPerHour; + this.createdAt = createdAt; + this.updatedAt = updatedAt; + } + + public Long getId() { + return id; + } + + public String getTemplateName() { + return templateName; + } + + public String getTemplateType() { + return templateType; + } + + public String getScopeType() { + return scopeType; + } + + public Long getProjectId() { + return projectId; + } + + public Long getMeetingId() { + return meetingId; + } + + public Long getScopeId() { + return scopeId; + } + + public String getStatus() { + return status; + } + + public String getBizScene() { + return bizScene; + } + + public Integer getCurrentVersionNo() { + return currentVersionNo; + } + + public String getCurrentObjectKey() { + return currentObjectKey; + } + + public String getEffectiveFrom() { + return effectiveFrom; + } + + public String getEffectiveTo() { + return effectiveTo; + } + + public Boolean getWatermarkEnabled() { + return watermarkEnabled; + } + + public Integer getDownloadRateLimitPerHour() { + return downloadRateLimitPerHour; + } + + public String getCreatedAt() { + return createdAt; + } + + public String getUpdatedAt() { + return updatedAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/model/TemplateTypeOption.java b/backend/src/main/java/com/writeoff/module/template/model/TemplateTypeOption.java new file mode 100644 index 0000000..5f17b8e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/model/TemplateTypeOption.java @@ -0,0 +1,31 @@ +package com.writeoff.module.template.model; + +public class TemplateTypeOption { + private String typeCode; + private String typeName; + private String status; + private Integer sortNo; + + public TemplateTypeOption(String typeCode, String typeName, String status, Integer sortNo) { + this.typeCode = typeCode; + this.typeName = typeName; + this.status = status; + this.sortNo = sortNo; + } + + public String getTypeCode() { + return typeCode; + } + + public String getTypeName() { + return typeName; + } + + public String getStatus() { + return status; + } + + public Integer getSortNo() { + return sortNo; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/model/TemplateVersionInfo.java b/backend/src/main/java/com/writeoff/module/template/model/TemplateVersionInfo.java new file mode 100644 index 0000000..df4f15c --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/model/TemplateVersionInfo.java @@ -0,0 +1,61 @@ +package com.writeoff.module.template.model; + +public class TemplateVersionInfo { + private Long id; + private Long templateId; + private Integer versionNo; + private String objectKey; + private String versionStatus; + private Boolean effective; + private String changeLog; + private String rollbackReason; + private String createdAt; + + public TemplateVersionInfo(Long id, Long templateId, Integer versionNo, String objectKey, String versionStatus, Boolean effective, String changeLog, String rollbackReason, String createdAt) { + this.id = id; + this.templateId = templateId; + this.versionNo = versionNo; + this.objectKey = objectKey; + this.versionStatus = versionStatus; + this.effective = effective; + this.changeLog = changeLog; + this.rollbackReason = rollbackReason; + this.createdAt = createdAt; + } + + public Long getId() { + return id; + } + + public Long getTemplateId() { + return templateId; + } + + public Integer getVersionNo() { + return versionNo; + } + + public String getObjectKey() { + return objectKey; + } + + public String getVersionStatus() { + return versionStatus; + } + + public Boolean getEffective() { + return effective; + } + + public String getChangeLog() { + return changeLog; + } + + public String getRollbackReason() { + return rollbackReason; + } + + public String getCreatedAt() { + return createdAt; + } +} diff --git a/backend/src/main/java/com/writeoff/module/template/service/TemplateService.java b/backend/src/main/java/com/writeoff/module/template/service/TemplateService.java new file mode 100644 index 0000000..305a92e --- /dev/null +++ b/backend/src/main/java/com/writeoff/module/template/service/TemplateService.java @@ -0,0 +1,1056 @@ +package com.writeoff.module.template.service; + +import com.writeoff.common.api.PageResult; +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.file.service.OssService; +import com.writeoff.module.template.dto.CreateTemplateRequest; +import com.writeoff.module.template.dto.CreateTemplateVersionRequest; +import com.writeoff.module.template.dto.RollbackTemplateRequest; +import com.writeoff.module.template.dto.TemplateUploadSignRequest; +import com.writeoff.module.template.model.TemplateDownloadLogInfo; +import com.writeoff.module.template.model.TemplateFlowLinkInfo; +import com.writeoff.module.template.model.TemplateInfo; +import com.writeoff.module.template.model.TemplateTypeOption; +import com.writeoff.module.template.model.TemplateVersionInfo; +import com.writeoff.security.AuthContext; +import com.writeoff.security.PermissionService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +@Service +public class TemplateService { + private final JdbcTemplate jdbcTemplate; + private final OssService ossService; + private final PermissionService permissionService; + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final Set SUPPORTED_TEMPLATE_TYPES = new HashSet(Arrays.asList( + "AGENDA", "SIGN_IN", "INVITATION", "OTHER" + )); + + private static final RowMapper TEMPLATE_ROW_MAPPER = (rs, n) -> new TemplateInfo( + rs.getLong("id"), + rs.getString("template_name"), + rs.getString("template_type"), + rs.getString("scope_type"), + rs.getObject("project_id") == null ? null : rs.getLong("project_id"), + rs.getObject("meeting_id") == null ? null : rs.getLong("meeting_id"), + rs.getObject("scope_id") == null ? null : rs.getLong("scope_id"), + rs.getString("biz_scene"), + rs.getString("status"), + rs.getInt("current_version_no"), + rs.getString("current_object_key"), + rs.getString("effective_from"), + rs.getString("effective_to"), + rs.getInt("watermark_enabled") == 1, + rs.getInt("download_rate_limit_per_hour"), + rs.getString("created_at"), + rs.getString("updated_at") + ); + + private static final RowMapper VERSION_ROW_MAPPER = (rs, n) -> new TemplateVersionInfo( + rs.getLong("id"), + rs.getLong("template_id"), + rs.getInt("version_no"), + rs.getString("object_key"), + rs.getString("version_status"), + rs.getInt("is_effective") == 1, + rs.getString("change_log"), + rs.getString("rollback_reason"), + rs.getString("created_at") + ); + private static final RowMapper DOWNLOAD_LOG_ROW_MAPPER = (rs, n) -> new TemplateDownloadLogInfo( + rs.getLong("id"), + rs.getLong("template_id"), + rs.getString("template_name"), + rs.getInt("version_no"), + rs.getLong("user_id"), + rs.getString("user_name"), + rs.getString("user_phone"), + rs.getString("object_key"), + rs.getString("download_type"), + rs.getString("watermark_text"), + rs.getObject("project_id") == null ? null : rs.getLong("project_id"), + rs.getObject("meeting_id") == null ? null : rs.getLong("meeting_id"), + rs.getString("ip"), + rs.getString("user_agent"), + rs.getString("downloaded_at") + ); + private static final RowMapper TYPE_OPTION_ROW_MAPPER = (rs, n) -> new TemplateTypeOption( + rs.getString("type_code"), + rs.getString("type_name"), + rs.getString("status"), + rs.getInt("sort_no") + ); + private static final Set SUPPORTED_SCENES = new HashSet(Arrays.asList( + "MEETING_RECOMMEND", "AUDIT_NOTIFY", "SETTLEMENT" + )); + + public TemplateService(JdbcTemplate jdbcTemplate, OssService ossService, PermissionService permissionService) { + this.jdbcTemplate = jdbcTemplate; + this.ossService = ossService; + this.permissionService = permissionService; + } + + public PageResult list(String templateName, + String templateType, + String status, + String scopeType, + String bizScene, + Boolean watermarkEnabled, + String effectiveStatus, + int pageNo, + int pageSize) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + int offset = (safePage - 1) * safeSize; + String normalizedTemplateName = trimToNull(templateName); + String normalizedTemplateType = normalizeOptionalTemplateType(templateType); + String normalizedStatus = normalizeOptionalTemplateStatus(status); + String normalizedScopeType = normalizeOptionalScope(scopeType); + String normalizedBizScene = normalizeOptionalBizScene(bizScene); + String normalizedEffectiveStatus = normalizeOptionalEffectiveStatus(effectiveStatus); + StringBuilder whereSql = new StringBuilder(" WHERE t.tenant_id=? AND t.is_deleted=0"); + List whereArgs = new ArrayList(); + whereArgs.add(tenantId()); + if (normalizedTemplateName != null) { + whereSql.append(" AND t.template_name LIKE ?"); + whereArgs.add("%" + normalizedTemplateName + "%"); + } + if (normalizedTemplateType != null) { + whereSql.append(" AND t.template_type=?"); + whereArgs.add(normalizedTemplateType); + } + if (normalizedStatus != null) { + whereSql.append(" AND t.status=?"); + whereArgs.add(normalizedStatus); + } + if (normalizedScopeType != null) { + whereSql.append(" AND t.scope_type=?"); + whereArgs.add(normalizedScopeType); + } + if (normalizedBizScene != null) { + whereSql.append(" AND t.biz_scene=?"); + whereArgs.add(normalizedBizScene); + } + if (watermarkEnabled != null) { + whereSql.append(" AND t.watermark_enabled=?"); + whereArgs.add(Boolean.TRUE.equals(watermarkEnabled) ? 1 : 0); + } + appendEffectiveStatusFilter(whereSql, normalizedEffectiveStatus); + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM template t" + whereSql, + Integer.class, + whereArgs.toArray() + ); + long totalCount = total == null ? 0 : total; + + List dataArgs = new ArrayList(whereArgs); + dataArgs.add(safeSize); + dataArgs.add(offset); + List list = jdbcTemplate.query( + templateSelectSql() + + whereSql + + " ORDER BY t.updated_at DESC, t.id DESC LIMIT ? OFFSET ?", + TEMPLATE_ROW_MAPPER, + dataArgs.toArray() + ); + return new PageResult<>(list, totalCount, safePage, safeSize); + } + + public List listPublishedOptions(String bizScene) { + String normalizedBizScene = normalizeOptionalBizScene(bizScene); + StringBuilder sql = new StringBuilder(templateSelectSql()) + .append(" WHERE t.tenant_id=? AND t.is_deleted=0 AND t.status='PUBLISHED'"); + List args = new ArrayList(); + args.add(tenantId()); + if (normalizedBizScene != null) { + sql.append(" AND t.biz_scene=?"); + args.add(normalizedBizScene); + } + sql.append(" AND ").append(effectiveNowSql("t")); + sql.append(" ORDER BY t.updated_at DESC, t.id DESC"); + return jdbcTemplate.query(sql.toString(), TEMPLATE_ROW_MAPPER, args.toArray()); + } + + public List listMatchedForMeeting(Long meetingId, Long projectId) { + return jdbcTemplate.query( + "SELECT t.*, tv.object_key AS current_object_key " + + "FROM template t " + + "LEFT JOIN template_version tv ON tv.tenant_id=t.tenant_id AND tv.template_id=t.id AND tv.version_no=t.current_version_no " + + "WHERE t.tenant_id=? AND t.is_deleted=0 AND t.status='PUBLISHED' AND t.biz_scene='MEETING_RECOMMEND' " + + "AND " + effectiveNowSql("t") + " AND (" + + "t.scope_type='ALL' OR (t.scope_type='PROJECT' AND t.project_id=?) OR (t.scope_type='MEETING' AND t.meeting_id=?)) " + + "ORDER BY CASE t.scope_type WHEN 'MEETING' THEN 1 WHEN 'PROJECT' THEN 2 ELSE 3 END, t.id DESC", + TEMPLATE_ROW_MAPPER, + tenantId(), + projectId, + meetingId + ); + } + + public List listTypeOptions() { + return jdbcTemplate.query( + "SELECT type_code, type_name, status, sort_no FROM template_type_option WHERE tenant_id=? ORDER BY sort_no ASC", + TYPE_OPTION_ROW_MAPPER, + tenantId() + ); + } + + public void enableTypeOption(String typeCode) { + String normalized = normalizeTemplateType(typeCode); + assertTypeOptionExists(normalized); + jdbcTemplate.update( + "UPDATE template_type_option SET status='ENABLED', updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND type_code=?", + tenantId(), + normalized + ); + } + + public void disableTypeOption(String typeCode) { + String normalized = normalizeTemplateType(typeCode); + assertTypeOptionExists(normalized); + jdbcTemplate.update( + "UPDATE template_type_option SET status='DISABLED', updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND type_code=?", + tenantId(), + normalized + ); + } + + @Transactional(rollbackFor = Exception.class) + public TemplateInfo create(CreateTemplateRequest request) { + String scopeType = normalizeScope(request.getScopeType()); + String templateType = normalizeTemplateType(request.getTemplateType()); + String effectiveFrom = normalizeOptionalDateTime(request.getEffectiveFrom(), "effectiveFrom"); + String effectiveTo = normalizeOptionalDateTime(request.getEffectiveTo(), "effectiveTo"); + Integer downloadRateLimitPerHour = normalizeDownloadRateLimit(request.getDownloadRateLimitPerHour()); + assertEffectiveRangeValid(effectiveFrom, effectiveTo); + assertTypeOptionEnabled(templateType); + Long userId = safeUserId(); + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO template (tenant_id, template_name, template_type, scope_type, project_id, meeting_id, scope_id, biz_scene, status, current_version_no, effective_from, effective_to, watermark_enabled, download_rate_limit_per_hour, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'DRAFT', 1, STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s'), ?, ?, ?, ?)", + Statement.RETURN_GENERATED_KEYS + ); + ps.setLong(1, tenantId()); + ps.setString(2, request.getTemplateName()); + ps.setString(3, templateType); + ps.setString(4, scopeType); + ps.setObject(5, request.getProjectId()); + ps.setObject(6, request.getMeetingId()); + ps.setObject(7, request.getScopeId()); + ps.setString(8, normalizeBizScene(request.getBizScene())); + ps.setString(9, effectiveFrom); + ps.setString(10, effectiveTo); + ps.setInt(11, Boolean.TRUE.equals(request.getWatermarkEnabled()) ? 1 : 0); + ps.setInt(12, downloadRateLimitPerHour); + ps.setLong(13, userId); + ps.setLong(14, userId); + return ps; + }, keyHolder); + Long templateId = keyHolder.getKey() == null ? null : keyHolder.getKey().longValue(); + Long validTemplateId = templateId == null ? 0L : templateId; + jdbcTemplate.update( + "INSERT INTO template_version (tenant_id, template_id, version_no, object_key, version_status, is_effective, change_log, rollback_reason, created_by) " + + "VALUES (?, ?, 1, ?, 'DRAFT', 0, ?, NULL, ?)", + tenantId(), + validTemplateId, + request.getObjectKey(), + request.getChangeLog(), + userId + ); + return findById(validTemplateId); + } + + public List versions(Long templateId) { + assertTemplateExists(templateId); + return jdbcTemplate.query( + "SELECT * FROM template_version WHERE tenant_id=? AND template_id=? ORDER BY version_no DESC", + VERSION_ROW_MAPPER, + tenantId(), + templateId + ); + } + + public Map presignUpload(TemplateUploadSignRequest request) { + String fileName = request.getFileName().trim(); + String ext = ""; + int idx = fileName.lastIndexOf('.'); + if (idx > 0 && idx < fileName.length() - 1) { + ext = "." + fileName.substring(idx + 1).toLowerCase(); + } + String templateType = normalizeTemplateType(request.getTemplateType()); + assertTypeOptionEnabled(templateType); + String typeFolder = templateType.toLowerCase(); + Long userId = safeUserId(); + String objectKey = "template/" + typeFolder + "/" + userId + "/" + UUID.randomUUID().toString().replace("-", "") + ext; + String contentType = normalizeContentType(request.getContentType()); + String putUrl = ossService.generateUploadUrl(objectKey, contentType); + Map result = new LinkedHashMap(); + result.put("objectKey", objectKey); + result.put("uploadUrl", putUrl); + result.put("contentType", contentType); + result.put("method", "PUT"); + result.put("templateType", templateType); + return result; + } + + @Transactional(rollbackFor = Exception.class) + public TemplateInfo addVersion(Long templateId, CreateTemplateVersionRequest request) { + TemplateInfo template = findById(templateId); + assertTemplateEditable(template); + Integer nextVersionNo = jdbcTemplate.queryForObject( + "SELECT IFNULL(MAX(version_no), 0) + 1 FROM template_version WHERE tenant_id=? AND template_id=? FOR UPDATE", + Integer.class, + tenantId(), + templateId + ); + int versionNo = nextVersionNo == null ? 1 : nextVersionNo; + jdbcTemplate.update( + "INSERT INTO template_version (tenant_id, template_id, version_no, object_key, version_status, is_effective, change_log, rollback_reason, created_by) " + + "VALUES (?, ?, ?, ?, 'DRAFT', 0, ?, NULL, ?)", + tenantId(), + templateId, + versionNo, + request.getObjectKey(), + request.getChangeLog(), + safeUserId() + ); + jdbcTemplate.update( + "UPDATE template SET current_version_no=?, status='DRAFT', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + versionNo, + safeUserId(), + tenantId(), + templateId + ); + return findById(templateId); + } + + @Transactional(rollbackFor = Exception.class) + public TemplateInfo publish(Long templateId) { + TemplateInfo template = findById(templateId); + assertEffectiveRangeValid(template.getEffectiveFrom(), template.getEffectiveTo()); + if ("ARCHIVED".equalsIgnoreCase(template.getStatus())) { + throw new BusinessException(10003, "模板已归档,不能再次发布"); + } + assertTypeOptionEnabled(template.getTemplateType()); + assertTemplateCurrentVersionReady(template); + jdbcTemplate.update( + "UPDATE template SET status='PUBLISHED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + templateId + ); + jdbcTemplate.update( + "UPDATE template_version SET version_status='HISTORY', is_effective=0 WHERE tenant_id=? AND template_id=?", + tenantId(), + templateId + ); + jdbcTemplate.update( + "UPDATE template_version SET version_status='PUBLISHED', is_effective=1 WHERE tenant_id=? AND template_id=? AND version_no=?", + tenantId(), + templateId, + template.getCurrentVersionNo() + ); + return findById(templateId); + } + + public TemplateInfo disable(Long templateId) { + assertTemplateExists(templateId); + jdbcTemplate.update( + "UPDATE template SET status='DISABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + templateId + ); + return findById(templateId); + } + + public TemplateInfo archive(Long templateId) { + assertTemplateExists(templateId); + jdbcTemplate.update( + "UPDATE template SET status='ARCHIVED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + safeUserId(), + tenantId(), + templateId + ); + return findById(templateId); + } + + @Transactional(rollbackFor = Exception.class) + public TemplateInfo rollback(Long templateId, RollbackTemplateRequest request) { + TemplateInfo template = findById(templateId); + if ("ARCHIVED".equalsIgnoreCase(template.getStatus())) { + throw new BusinessException(10003, "已归档模板不允许回滚"); + } + Integer versionCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM template_version WHERE tenant_id=? AND template_id=? AND version_no=?", + Integer.class, + tenantId(), + templateId, + request.getVersionNo() + ); + if (versionCount == null || versionCount == 0) { + throw new BusinessException(10003, "目标版本不存在"); + } + String rollbackReason = normalizeRequiredText(request.getRollbackReason(), "rollbackReason"); + jdbcTemplate.update( + "UPDATE template SET current_version_no=?, status='PUBLISHED', updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?", + request.getVersionNo(), + safeUserId(), + tenantId(), + templateId + ); + jdbcTemplate.update( + "UPDATE template_version SET version_status='HISTORY', is_effective=0 WHERE tenant_id=? AND template_id=?", + tenantId(), + templateId + ); + jdbcTemplate.update( + "UPDATE template_version SET version_status='PUBLISHED', is_effective=1, rollback_reason=? WHERE tenant_id=? AND template_id=? AND version_no=?", + rollbackReason, + tenantId(), + templateId, + request.getVersionNo() + ); + return findById(templateId); + } + + @Transactional(rollbackFor = Exception.class) + public Map download(Long templateId, String ip, String userAgent) { + TemplateInfo template = findById(templateId); + if ("DISABLED".equalsIgnoreCase(template.getStatus()) || "ARCHIVED".equalsIgnoreCase(template.getStatus())) { + throw new BusinessException(10003, "模板已停用,无法下载"); + } + if (template.getCurrentObjectKey() == null || template.getCurrentObjectKey().trim().isEmpty()) { + throw new BusinessException(10003, "模板当前版本文件不存在"); + } + String signedUrl = ossService.generateDownloadUrl(template.getCurrentObjectKey()); + Long userId = safeUserId(); + assertTemplateDownloadAllowed(template); + assertTemplateEffectiveNow(template, "下载"); + assertDownloadRateLimit(template, userId); + jdbcTemplate.update( + "INSERT INTO template_download_log (tenant_id, template_id, version_no, user_id, object_key, download_type, watermark_text, project_id, meeting_id, ip, user_agent) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tenantId(), + template.getId(), + template.getCurrentVersionNo(), + userId, + template.getCurrentObjectKey(), + "NORMAL", + null, + template.getProjectId(), + template.getMeetingId(), + ip, + userAgent + ); + Map result = new LinkedHashMap(); + result.put("templateId", template.getId()); + result.put("versionNo", template.getCurrentVersionNo()); + result.put("objectKey", template.getCurrentObjectKey()); + result.put("signedUrl", signedUrl); + return result; + } + + @Transactional(rollbackFor = Exception.class) + public Map downloadWatermark(Long templateId, String watermarkText, String ip, String userAgent) { + TemplateInfo template = findById(templateId); + if ("DISABLED".equalsIgnoreCase(template.getStatus()) || "ARCHIVED".equalsIgnoreCase(template.getStatus())) { + throw new BusinessException(10003, "模板已停用,无法下载"); + } + if (template.getCurrentObjectKey() == null || template.getCurrentObjectKey().trim().isEmpty()) { + throw new BusinessException(10003, "模板当前版本文件不存在"); + } + String signedUrl = ossService.generateDownloadUrl(template.getCurrentObjectKey()); + Long userId = safeUserId(); + assertTemplateDownloadAllowed(template); + if (!template.getWatermarkEnabled()) { + throw new BusinessException(10003, "模板未启用水印下载"); + } + assertTemplateEffectiveNow(template, "水印下载"); + assertDownloadRateLimit(template, userId); + jdbcTemplate.update( + "INSERT INTO template_download_log (tenant_id, template_id, version_no, user_id, object_key, download_type, watermark_text, project_id, meeting_id, ip, user_agent) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + tenantId(), + template.getId(), + template.getCurrentVersionNo(), + userId, + template.getCurrentObjectKey(), + "WATERMARK", + watermarkText == null ? null : watermarkText.trim(), + template.getProjectId(), + template.getMeetingId(), + ip, + userAgent + ); + Map result = new LinkedHashMap(); + result.put("templateId", template.getId()); + result.put("versionNo", template.getCurrentVersionNo()); + result.put("objectKey", template.getCurrentObjectKey()); + result.put("signedUrl", signedUrl); + result.put("watermarkText", watermarkText == null ? "" : watermarkText.trim()); + return result; + } + + public Map versionDiff(Long templateId, Integer leftVersionNo, Integer rightVersionNo) { + assertTemplateExists(templateId); + Integer latest = jdbcTemplate.queryForObject( + "SELECT IFNULL(MAX(version_no), 0) FROM template_version WHERE tenant_id=? AND template_id=?", + Integer.class, + tenantId(), + templateId + ); + int right = rightVersionNo == null ? (latest == null ? 1 : latest) : rightVersionNo; + int left = leftVersionNo == null ? Math.max(1, right - 1) : leftVersionNo; + TemplateVersionInfo leftV = findVersion(templateId, left); + TemplateVersionInfo rightV = findVersion(templateId, right); + + Map diff = new LinkedHashMap(); + diff.put("templateId", templateId); + diff.put("leftVersionNo", left); + diff.put("rightVersionNo", right); + diff.put("leftObjectKey", leftV.getObjectKey()); + diff.put("rightObjectKey", rightV.getObjectKey()); + diff.put("leftStatus", leftV.getVersionStatus()); + diff.put("rightStatus", rightV.getVersionStatus()); + diff.put("leftChangeLog", leftV.getChangeLog()); + diff.put("rightChangeLog", rightV.getChangeLog()); + diff.put("objectKeyChanged", !safeEquals(leftV.getObjectKey(), rightV.getObjectKey())); + diff.put("statusChanged", !safeEquals(leftV.getVersionStatus(), rightV.getVersionStatus())); + diff.put("changeLogChanged", !safeEquals(leftV.getChangeLog(), rightV.getChangeLog())); + return diff; + } + + public PageResult listDownloadLogs(Long templateId, + String templateName, + Long userId, + String userKeyword, + Integer versionNo, + String downloadType, + String ip, + String downloadedFrom, + String downloadedTo, + int pageNo, + int pageSize) { + int safePage = Math.max(pageNo, 1); + int safeSize = Math.min(Math.max(pageSize, 1), 100); + int offset = (safePage - 1) * safeSize; + String normalizedTemplateName = trimToNull(templateName); + String normalizedUserKeyword = trimToNull(userKeyword); + String normalizedDownloadType = normalizeOptionalDownloadType(downloadType); + String normalizedIp = trimToNull(ip); + String normalizedDownloadedFrom = trimToNull(downloadedFrom); + String normalizedDownloadedTo = trimToNull(downloadedTo); + Long currentUserId = userId(); + boolean canReadAllLogs = currentUserId != null + && permissionService.hasPermission(currentUserId, "template.download.log.read.all"); + Long scopedUserId = canReadAllLogs ? userId : currentUserId; + String scopedUserKeyword = canReadAllLogs ? normalizedUserKeyword : null; + StringBuilder whereSql = new StringBuilder( + " FROM template_download_log l " + + "LEFT JOIN template t ON t.tenant_id=l.tenant_id AND t.id=l.template_id " + + "LEFT JOIN sys_user u ON u.tenant_id=l.tenant_id AND u.id=l.user_id " + + "WHERE l.tenant_id=?" + ); + List whereArgs = new ArrayList(); + whereArgs.add(tenantId()); + if (templateId != null) { + whereSql.append(" AND l.template_id=?"); + whereArgs.add(templateId); + } + if (normalizedTemplateName != null) { + whereSql.append(" AND t.template_name LIKE ?"); + whereArgs.add("%" + normalizedTemplateName + "%"); + } + if (scopedUserId != null) { + whereSql.append(" AND l.user_id=?"); + whereArgs.add(scopedUserId); + } + if (scopedUserKeyword != null) { + whereSql.append(" AND (u.user_name LIKE ? OR u.phone LIKE ?)"); + String like = "%" + scopedUserKeyword + "%"; + whereArgs.add(like); + whereArgs.add(like); + } + if (versionNo != null) { + whereSql.append(" AND l.version_no=?"); + whereArgs.add(versionNo); + } + if (normalizedDownloadType != null) { + whereSql.append(" AND l.download_type=?"); + whereArgs.add(normalizedDownloadType); + } + if (normalizedIp != null) { + whereSql.append(" AND l.ip LIKE ?"); + whereArgs.add("%" + normalizedIp + "%"); + } + if (normalizedDownloadedFrom != null) { + whereSql.append(" AND l.downloaded_at >= STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s')"); + whereArgs.add(normalizedDownloadedFrom); + } + if (normalizedDownloadedTo != null) { + whereSql.append(" AND l.downloaded_at <= STR_TO_DATE(?, '%Y-%m-%d %H:%i:%s')"); + whereArgs.add(normalizedDownloadedTo); + } + + Integer total = jdbcTemplate.queryForObject( + "SELECT COUNT(1)" + whereSql, + Integer.class, + whereArgs.toArray() + ); + long totalCount = total == null ? 0 : total; + + List dataArgs = new ArrayList(whereArgs); + dataArgs.add(safeSize); + dataArgs.add(offset); + List list = jdbcTemplate.query( + "SELECT l.id, l.template_id, COALESCE(t.template_name, '') AS template_name, " + + "l.version_no, l.user_id, COALESCE(u.user_name, '') AS user_name, COALESCE(u.phone, '') AS user_phone, " + + "l.object_key, l.download_type, COALESCE(l.watermark_text, '') AS watermark_text, " + + "l.project_id, l.meeting_id, l.ip, l.user_agent, DATE_FORMAT(l.downloaded_at, '%Y-%m-%d %H:%i:%s') AS downloaded_at" + + whereSql + + " ORDER BY l.downloaded_at DESC, l.id DESC LIMIT ? OFFSET ?", + DOWNLOAD_LOG_ROW_MAPPER, + dataArgs.toArray() + ); + return new PageResult(list, totalCount, safePage, safeSize); + } + + public List> flowSceneOptions() { + return Arrays.asList( + scene("MEETING_RECOMMEND", "会议推荐模板"), + scene("AUDIT_NOTIFY", "审核通知模板"), + scene("SETTLEMENT", "结算模板") + ); + } + + public List listFlowLinks() { + return jdbcTemplate.query( + "SELECT fl.scene_code, fl.template_id, t.template_name, t.status, t.current_version_no " + + "FROM template_flow_link fl " + + "LEFT JOIN template t ON t.tenant_id=fl.tenant_id AND t.id=fl.template_id " + + "WHERE fl.tenant_id=? ORDER BY fl.scene_code ASC", + (rs, n) -> new TemplateFlowLinkInfo( + rs.getString("scene_code"), + sceneName(rs.getString("scene_code")), + rs.getLong("template_id"), + rs.getString("template_name"), + rs.getString("status"), + rs.getInt("current_version_no") + ), + tenantId() + ); + } + + public TemplateFlowLinkInfo bindFlowLink(String sceneCode, Long templateId) { + String scene = normalizeBizScene(sceneCode); + TemplateInfo template = findById(templateId); + assertPublishedTemplate(template, "流程绑定"); + assertTemplateSceneMatches(scene, template); + assertTemplateEffectiveNow(template, "流程绑定"); + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM template_flow_link WHERE tenant_id=? AND scene_code=?", + Integer.class, + tenantId(), + scene + ); + if (count == null || count == 0) { + jdbcTemplate.update( + "INSERT INTO template_flow_link (tenant_id, scene_code, template_id, created_by, updated_by) VALUES (?, ?, ?, ?, ?)", + tenantId(), + scene, + templateId, + safeUserId(), + safeUserId() + ); + } else { + jdbcTemplate.update( + "UPDATE template_flow_link SET template_id=?, updated_by=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND scene_code=?", + templateId, + safeUserId(), + tenantId(), + scene + ); + } + return new TemplateFlowLinkInfo( + scene, + sceneName(scene), + template.getId(), + template.getTemplateName(), + template.getStatus(), + template.getCurrentVersionNo() + ); + } + + private String templateSelectSql() { + return "SELECT t.id, t.template_name, t.template_type, t.scope_type, t.project_id, t.meeting_id, t.scope_id, t.biz_scene, t.status, " + + "t.current_version_no, tv.object_key AS current_object_key, " + + "DATE_FORMAT(t.effective_from, '%Y-%m-%d %H:%i:%s') AS effective_from, " + + "DATE_FORMAT(t.effective_to, '%Y-%m-%d %H:%i:%s') AS effective_to, " + + "t.watermark_enabled, t.download_rate_limit_per_hour, " + + "DATE_FORMAT(t.created_at, '%Y-%m-%d %H:%i:%s') AS created_at, " + + "DATE_FORMAT(t.updated_at, '%Y-%m-%d %H:%i:%s') AS updated_at " + + "FROM template t " + + "LEFT JOIN template_version tv ON tv.tenant_id=t.tenant_id AND tv.template_id=t.id AND tv.version_no=t.current_version_no "; + } + + private void appendEffectiveStatusFilter(StringBuilder sql, String effectiveStatus) { + if (effectiveStatus == null) { + return; + } + if ("ACTIVE".equals(effectiveStatus)) { + sql.append(" AND (t.effective_from IS NULL OR t.effective_from<=CURRENT_TIMESTAMP)"); + sql.append(" AND (t.effective_to IS NULL OR t.effective_to>=CURRENT_TIMESTAMP)"); + return; + } + if ("UPCOMING".equals(effectiveStatus)) { + sql.append(" AND t.effective_from IS NOT NULL AND t.effective_from>CURRENT_TIMESTAMP"); + return; + } + sql.append(" AND t.effective_to IS NOT NULL AND t.effective_to=CURRENT_TIMESTAMP)"; + } + + private String normalizeScope(String scopeType) { + String value = scopeType == null ? "ALL" : scopeType.trim().toUpperCase(); + if (!"ALL".equals(value) && !"PROJECT".equals(value) && !"MEETING".equals(value)) { + throw new BusinessException(10003, "scopeType仅支持ALL/PROJECT/MEETING"); + } + return value; + } + + private String normalizeTemplateType(String templateType) { + String value = templateType == null ? "OTHER" : templateType.trim().toUpperCase(); + if ("SIGN".equals(value)) { + value = "SIGN_IN"; + } else if ("INVIT".equals(value) || "INVITATION_LETTER".equals(value)) { + value = "INVITATION"; + } + if (!SUPPORTED_TEMPLATE_TYPES.contains(value)) { + throw new BusinessException(10003, "templateType仅支持AGENDA/SIGN_IN/INVITATION/OTHER"); + } + return value; + } + + private String normalizeBizScene(String sceneCode) { + String value = sceneCode == null ? "MEETING_RECOMMEND" : sceneCode.trim().toUpperCase(); + if (!SUPPORTED_SCENES.contains(value)) { + throw new BusinessException(10003, "bizScene仅支持MEETING_RECOMMEND/AUDIT_NOTIFY/SETTLEMENT"); + } + return value; + } + + private String normalizeOptionalScope(String scopeType) { + String value = trimToNull(scopeType); + return value == null ? null : normalizeScope(value); + } + + private String normalizeOptionalTemplateType(String templateType) { + String value = trimToNull(templateType); + return value == null ? null : normalizeTemplateType(value); + } + + private String normalizeOptionalBizScene(String bizScene) { + String value = trimToNull(bizScene); + return value == null ? null : normalizeBizScene(value); + } + + private String normalizeOptionalTemplateStatus(String status) { + String value = trimToNull(status); + if (value == null) { + return null; + } + String normalized = value.toUpperCase(); + if (!"DRAFT".equals(normalized) && !"PUBLISHED".equals(normalized) + && !"DISABLED".equals(normalized) && !"ARCHIVED".equals(normalized)) { + throw new BusinessException(10003, "status浠呮敮鎸丏RAFT/PUBLISHED/DISABLED/ARCHIVED"); + } + return normalized; + } + + private String normalizeOptionalEffectiveStatus(String effectiveStatus) { + String value = trimToNull(effectiveStatus); + if (value == null) { + return null; + } + String normalized = value.toUpperCase(); + if (!"ACTIVE".equals(normalized) && !"UPCOMING".equals(normalized) && !"EXPIRED".equals(normalized)) { + throw new BusinessException(10003, "effectiveStatus仅支持ACTIVE/UPCOMING/EXPIRED"); + } + return normalized; + } + + private String normalizeOptionalDownloadType(String downloadType) { + String value = trimToNull(downloadType); + if (value == null) { + return null; + } + String normalized = value.toUpperCase(); + if (!"NORMAL".equals(normalized) && !"WATERMARK".equals(normalized)) { + throw new BusinessException(10003, "downloadType仅支持NORMAL/WATERMARK"); + } + return normalized; + } + + private String normalizeContentType(String contentType) { + if (contentType == null || contentType.trim().isEmpty()) { + return "application/octet-stream"; + } + return contentType.trim(); + } + + private String normalizeRequiredText(String value, String fieldName) { + String normalized = trimToNull(value); + if (normalized == null) { + throw new BusinessException(10003, fieldName + "不能为空"); + } + return normalized; + } + + private String normalizeOptionalDateTime(String value, String fieldName) { + String normalized = trimToNull(value); + if (normalized == null) { + return null; + } + try { + return LocalDateTime.parse(normalized, DATE_TIME_FORMATTER).format(DATE_TIME_FORMATTER); + } catch (DateTimeParseException ex) { + throw new BusinessException(10003, fieldName + "格式应为yyyy-MM-dd HH:mm:ss"); + } + } + + private Integer normalizeDownloadRateLimit(Integer downloadRateLimitPerHour) { + if (downloadRateLimitPerHour == null) { + return 100; + } + if (downloadRateLimitPerHour <= 0) { + throw new BusinessException(10003, "downloadRateLimitPerHour必须大于0"); + } + return downloadRateLimitPerHour; + } + + private String trimToNull(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + return trimmed.isEmpty() ? null : trimmed; + } + + private void assertTypeOptionExists(String typeCode) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM template_type_option WHERE tenant_id=? AND type_code=?", + Integer.class, + tenantId(), + typeCode + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "模板类型不存在"); + } + } + + private void assertTypeOptionEnabled(String typeCode) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM template_type_option WHERE tenant_id=? AND type_code=? AND status='ENABLED'", + Integer.class, + tenantId(), + typeCode + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "模板类型已停用: " + typeCode); + } + } + + private void assertTemplateExists(Long templateId) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM template WHERE tenant_id=? AND id=? AND is_deleted=0", + Integer.class, + tenantId(), + templateId + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "模板不存在"); + } + } + + private TemplateVersionInfo findVersion(Long templateId, Integer versionNo) { + List list = jdbcTemplate.query( + "SELECT * FROM template_version WHERE tenant_id=? AND template_id=? AND version_no=? LIMIT 1", + VERSION_ROW_MAPPER, + tenantId(), + templateId, + versionNo + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "模板版本不存在"); + } + return list.get(0); + } + + private boolean safeEquals(String a, String b) { + return (a == null && b == null) || (a != null && a.equals(b)); + } + + private void assertEffectiveRangeValid(String effectiveFrom, String effectiveTo) { + if (effectiveFrom == null || effectiveTo == null) { + return; + } + LocalDateTime start = LocalDateTime.parse(effectiveFrom, DATE_TIME_FORMATTER); + LocalDateTime end = LocalDateTime.parse(effectiveTo, DATE_TIME_FORMATTER); + if (start.isAfter(end)) { + throw new BusinessException(10003, "生效开始时间不能晚于生效结束时间"); + } + } + + private void assertPublishedTemplate(TemplateInfo template, String actionName) { + if (!"PUBLISHED".equalsIgnoreCase(template.getStatus())) { + throw new BusinessException(10003, actionName + "仅支持已发布模板"); + } + } + + private void assertTemplateEditable(TemplateInfo template) { + if ("ARCHIVED".equalsIgnoreCase(template.getStatus())) { + throw new BusinessException(10003, "已归档模板不允许新增版本"); + } + } + + private void assertTemplateCurrentVersionReady(TemplateInfo template) { + if (template.getCurrentObjectKey() == null || template.getCurrentObjectKey().trim().isEmpty()) { + throw new BusinessException(10003, "当前版本文件不存在,不能发布"); + } + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM template_version WHERE tenant_id=? AND template_id=? AND version_no=? AND object_key IS NOT NULL AND TRIM(object_key)<>''", + Integer.class, + tenantId(), + template.getId(), + template.getCurrentVersionNo() + ); + if (count == null || count == 0) { + throw new BusinessException(10003, "当前版本未找到可发布文件"); + } + } + + private void assertTemplateSceneMatches(String expectedScene, TemplateInfo template) { + if (!expectedScene.equalsIgnoreCase(template.getBizScene())) { + throw new BusinessException(10003, "模板业务场景与绑定场景不一致"); + } + } + + private void assertTemplateEffectiveNow(TemplateInfo template, String actionName) { + LocalDateTime now = LocalDateTime.now(); + String effectiveFrom = trimToNull(template.getEffectiveFrom()); + if (effectiveFrom != null) { + LocalDateTime start = LocalDateTime.parse(effectiveFrom, DATE_TIME_FORMATTER); + if (start.isAfter(now)) { + throw new BusinessException(10003, actionName + "失败,模板尚未生效"); + } + } + String effectiveTo = trimToNull(template.getEffectiveTo()); + if (effectiveTo != null) { + LocalDateTime end = LocalDateTime.parse(effectiveTo, DATE_TIME_FORMATTER); + if (end.isBefore(now)) { + throw new BusinessException(10003, actionName + "失败,模板已过期"); + } + } + } + + private void assertTemplateDownloadAllowed(TemplateInfo template) { + if ("DISABLED".equalsIgnoreCase(template.getStatus()) || "ARCHIVED".equalsIgnoreCase(template.getStatus())) { + throw new BusinessException(10003, "模板已停用或归档,无法下载"); + } + if (template.getCurrentObjectKey() == null || template.getCurrentObjectKey().trim().isEmpty()) { + throw new BusinessException(10003, "模板当前版本文件不存在"); + } + } + + private void assertDownloadRateLimit(TemplateInfo template, Long userId) { + Integer limit = template.getDownloadRateLimitPerHour(); + if (limit == null || limit <= 0) { + return; + } + Integer downloadedCount = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM template_download_log " + + "WHERE tenant_id=? AND template_id=? AND user_id=? AND downloaded_at>=DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 HOUR)", + Integer.class, + tenantId(), + template.getId(), + userId + ); + int currentCount = downloadedCount == null ? 0 : downloadedCount; + if (currentCount >= limit) { + throw new BusinessException(10003, "当前小时下载次数已达上限"); + } + } + + private TemplateInfo findById(Long templateId) { + List list = jdbcTemplate.query( + "SELECT t.*, tv.object_key AS current_object_key " + + "FROM template t " + + "LEFT JOIN template_version tv ON tv.tenant_id=t.tenant_id AND tv.template_id=t.id AND tv.version_no=t.current_version_no " + + "WHERE t.tenant_id=? AND t.id=? AND t.is_deleted=0", + TEMPLATE_ROW_MAPPER, + tenantId(), + templateId + ); + if (list.isEmpty()) { + throw new BusinessException(10003, "模板不存在"); + } + return list.get(0); + } + + private Map scene(String code, String name) { + Map data = new LinkedHashMap(); + data.put("sceneCode", code); + data.put("sceneName", name); + return data; + } + + private String sceneName(String sceneCode) { + if ("AUDIT_NOTIFY".equalsIgnoreCase(sceneCode)) { + return "审核通知模板"; + } + if ("SETTLEMENT".equalsIgnoreCase(sceneCode)) { + return "结算模板"; + } + return "会议推荐模板"; + } + + private Long safeUserId() { + Long userId = AuthContext.userId(); + return userId == null ? 0L : userId; + } + + private Long userId() { + return AuthContext.userId(); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/security/AuthContext.java b/backend/src/main/java/com/writeoff/security/AuthContext.java new file mode 100644 index 0000000..50388dd --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/AuthContext.java @@ -0,0 +1,47 @@ +package com.writeoff.security; + +public class AuthContext { + private static final ThreadLocal USER_ID_HOLDER = new ThreadLocal<>(); + private static final ThreadLocal TENANT_ID_HOLDER = new ThreadLocal<>(); + private static final ThreadLocal SCOPE_HOLDER = new ThreadLocal<>(); + + private AuthContext() { + } + + public static void set(Long userId, Long tenantId, AuthScope scope) { + USER_ID_HOLDER.set(userId); + TENANT_ID_HOLDER.set(tenantId); + SCOPE_HOLDER.set(scope); + } + + public static void set(Long userId, Long tenantId) { + set(userId, tenantId, AuthScope.TENANT); + } + + public static Long userId() { + return USER_ID_HOLDER.get(); + } + + public static Long tenantId() { + return TENANT_ID_HOLDER.get(); + } + + public static Long requireTenantId() { + Long tenantId = TENANT_ID_HOLDER.get(); + if (tenantId == null) { + throw new IllegalStateException("tenant context required"); + } + return tenantId; + } + + public static AuthScope scope() { + AuthScope scope = SCOPE_HOLDER.get(); + return scope == null ? AuthScope.TENANT : scope; + } + + public static void clear() { + USER_ID_HOLDER.remove(); + TENANT_ID_HOLDER.remove(); + SCOPE_HOLDER.remove(); + } +} diff --git a/backend/src/main/java/com/writeoff/security/AuthInterceptor.java b/backend/src/main/java/com/writeoff/security/AuthInterceptor.java new file mode 100644 index 0000000..fc7bd19 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/AuthInterceptor.java @@ -0,0 +1,379 @@ +package com.writeoff.security; + +import com.writeoff.common.exception.BusinessException; +import com.writeoff.common.exception.ErrorCodes; +import com.writeoff.common.web.RequestIdContext; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import com.writeoff.module.observability.service.ObservabilityService; +import com.writeoff.module.system.service.OperationAuditLogService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.time.LocalDateTime; +import java.util.UUID; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Component +public class AuthInterceptor implements HandlerInterceptor { + private static final long SESSION_ACTIVITY_TOUCH_INTERVAL_SECONDS = 60; + private final JwtTokenService jwtTokenService; + private final PermissionService permissionService; + private final OperationAuditLogService operationAuditLogService; + private final ObservabilityService observabilityService; + private final JdbcTemplate jdbcTemplate; + private final long idleTimeoutMinutes; + private static final Pattern BIZ_ID_PATTERN = Pattern.compile("/(\\d+)(/|$)"); + + public AuthInterceptor(JwtTokenService jwtTokenService, + PermissionService permissionService, + OperationAuditLogService operationAuditLogService, + ObservabilityService observabilityService, + JdbcTemplate jdbcTemplate, + @Value("${app.security.idle-timeout-minutes:60}") long idleTimeoutMinutes) { + this.jwtTokenService = jwtTokenService; + this.permissionService = permissionService; + this.operationAuditLogService = operationAuditLogService; + this.observabilityService = observabilityService; + this.jdbcTemplate = jdbcTemplate; + this.idleTimeoutMinutes = idleTimeoutMinutes <= 0 ? 60 : idleTimeoutMinutes; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + request.setAttribute("_startAtMs", System.currentTimeMillis()); + String requestId = resolveRequestId(request.getHeader("X-Request-Id")); + RequestIdContext.set(requestId); + response.setHeader("X-Request-Id", requestId); + String path = request.getRequestURI(); + if (path.startsWith("/api/auth/login") + || path.startsWith("/api/auth/platform-login") + || path.startsWith("/api/auth/password-public-key") + || path.startsWith("/api/auth/password-setup") + || path.startsWith("/api/auth/refresh") + || path.startsWith("/api/auth/logout") + || path.startsWith("/api/system/health") + || path.startsWith("/api/health") + || path.startsWith("/api/captcha")) { + return true; + } + String auth = request.getHeader("Authorization"); + if (auth == null || !auth.startsWith("Bearer ")) { + throw new BusinessException(ErrorCodes.UNAUTHORIZED, "未登录或Token无效"); + } + String token = auth.substring("Bearer ".length()); + try { + Claims claims = jwtTokenService.parse(token); + Long userId = claims.get("uid", Number.class).longValue(); + AuthScope scope = AuthScope.fromClaim(claims.get("scope", String.class)); + Number sidNum = claims.get("sid", Number.class); + Long sessionId = sidNum == null ? null : sidNum.longValue(); + Long tenantId = null; + if (scope == AuthScope.TENANT) { + Number tidNum = claims.get("tid", Number.class); + if (tidNum == null) { + throw new BusinessException(ErrorCodes.UNAUTHORIZED, "租户会话缺少租户信息"); + } + tenantId = tidNum.longValue(); + ensureTenantSessionValid(userId, tenantId); + ensureAccessSessionValid(sessionId, userId, tenantId, scope); + } else { + ensurePlatformSessionValid(userId); + ensureAccessSessionValid(sessionId, userId, null, scope); + } + AuthContext.set(userId, tenantId, scope); + + if (handler instanceof HandlerMethod) { + HandlerMethod hm = (HandlerMethod) handler; + RequirePermission rp = hm.getMethodAnnotation(RequirePermission.class); + if (rp != null) { + if (rp.domain() == PermissionDomain.PLATFORM) { + if (scope != AuthScope.PLATFORM || !permissionService.hasPlatformPermission(userId, rp.value())) { + throw new BusinessException(ErrorCodes.NO_PERMISSION, "无操作权限"); + } + } else { + if (scope != AuthScope.TENANT || !permissionService.hasPermission(userId, rp.value())) { + throw new BusinessException(ErrorCodes.NO_PERMISSION, "无操作权限"); + } + } + } + } + return true; + } catch (ExpiredJwtException e) { + throw new BusinessException(ErrorCodes.TOKEN_EXPIRED, "Token已过期"); + } catch (JwtException e) { + throw new BusinessException(ErrorCodes.UNAUTHORIZED, "未登录或Token无效"); + } + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + try { + String path = request.getRequestURI(); + if (path != null && path.startsWith("/api/")) { + Object startAtObj = request.getAttribute("_startAtMs"); + long startAt = startAtObj instanceof Long ? (Long) startAtObj : System.currentTimeMillis(); + long durationMs = Math.max(0L, System.currentTimeMillis() - startAt); + observabilityService.recordApiMetric(path, response.getStatus(), durationMs); + String method = request.getMethod(); + boolean shouldLog = !"GET".equalsIgnoreCase(method) + || path.startsWith("/api/platform/") + || path.contains("/export") + || path.contains("/download") + || path.contains("/login"); + if (shouldLog) { + if (path.contains("/export") || path.contains("/download")) { + observabilityService.recordExportMetric(path, ex == null && response.getStatus() < 400 ? "SUCCESS" : "FAILED"); + } + String actionCode = "API_CALL"; + if (handler instanceof HandlerMethod) { + HandlerMethod hm = (HandlerMethod) handler; + RequirePermission rp = hm.getMethodAnnotation(RequirePermission.class); + if (rp != null) { + actionCode = rp.auditAction() == null || rp.auditAction().trim().isEmpty() + ? rp.value() + : rp.auditAction().trim(); + } else { + actionCode = hm.getMethod().getName(); + } + } + AuthScope scope = AuthContext.scope(); + if (scope == null) { + if (path.contains("/platform-login")) { + scope = AuthScope.PLATFORM; + } else if (path.contains("/auth/login")) { + scope = AuthScope.TENANT; + } + } + operationAuditLogService.log( + AuthContext.tenantId(), + AuthContext.userId(), + scope, + actionCode, + resolveBizType(path), + resolveBizId(path), + method, + path, + request.getQueryString(), + RequestIdContext.get(), + response.getStatus(), + ex == null && response.getStatus() < 400, + ex == null ? null : ex.getMessage(), + request.getRemoteAddr(), + request.getHeader("User-Agent") + ); + } + } + } catch (Exception ignored) { + } + AuthContext.clear(); + RequestIdContext.clear(); + } + + private String resolveBizType(String path) { + if (path == null) { + return "unknown"; + } + String p = path.replace("/api/", ""); + int idx = p.indexOf("/"); + if (idx <= 0) { + return p; + } + return p.substring(0, idx); + } + + private String resolveBizId(String path) { + if (path == null) { + return null; + } + Matcher matcher = BIZ_ID_PATTERN.matcher(path); + if (matcher.find()) { + return matcher.group(1); + } + return null; + } + + private void ensureTenantSessionValid(Long userId, Long tenantId) { + java.util.List> rows = jdbcTemplate.queryForList( + "SELECT u.status, u.valid_from, u.valid_to, t.status AS tenant_status " + + "FROM sys_user u JOIN tenant t ON t.id=u.tenant_id " + + "WHERE u.id=? AND u.tenant_id=? AND u.is_deleted=0 AND t.is_deleted=0 LIMIT 1", + userId, + tenantId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + java.util.Map row = rows.get(0); + String userStatus = String.valueOf(row.get("status")); + String tenantStatus = String.valueOf(row.get("tenant_status")); + if (!"ENABLED".equals(userStatus) || !"ENABLED".equals(tenantStatus)) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime validFrom = toLocalDateTime(row.get("valid_from")); + LocalDateTime validTo = toLocalDateTime(row.get("valid_to")); + if (validFrom != null && now.isBefore(validFrom)) { + throw new BusinessException(ErrorCodes.ACCOUNT_EXPIRED, "账号尚未生效"); + } + if (validTo != null && now.isAfter(validTo)) { + throw new BusinessException(ErrorCodes.ACCOUNT_EXPIRED, "账号已过有效期"); + } + } + + private void ensurePlatformSessionValid(Long userId) { + java.util.List> rows = jdbcTemplate.queryForList( + "SELECT status, valid_from, valid_to FROM platform_user WHERE id=? AND is_deleted=0 LIMIT 1", + userId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + java.util.Map row = rows.get(0); + String userStatus = String.valueOf(row.get("status")); + if (!"ENABLED".equals(userStatus)) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + LocalDateTime now = LocalDateTime.now(); + LocalDateTime validFrom = toLocalDateTime(row.get("valid_from")); + LocalDateTime validTo = toLocalDateTime(row.get("valid_to")); + if (validFrom != null && now.isBefore(validFrom)) { + throw new BusinessException(ErrorCodes.ACCOUNT_EXPIRED, "账号尚未生效"); + } + if (validTo != null && now.isAfter(validTo)) { + throw new BusinessException(ErrorCodes.ACCOUNT_EXPIRED, "账号已过有效期"); + } + } + + private LocalDateTime toLocalDateTime(Object value) { + if (value == null) { + return null; + } + if (value instanceof LocalDateTime) { + return (LocalDateTime) value; + } + if (value instanceof java.sql.Timestamp) { + return ((java.sql.Timestamp) value).toLocalDateTime(); + } + if (value instanceof java.util.Date) { + return new java.sql.Timestamp(((java.util.Date) value).getTime()).toLocalDateTime(); + } + if (value instanceof String) { + String text = String.valueOf(value).trim(); + if (text.isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(text.replace(' ', 'T')); + } catch (Exception ignored) { + } + } + return null; + } + + private void ensureAccessSessionValid(Long sessionId, Long userId, Long tenantId, AuthScope scope) { + // Require session binding so admin revoke can take effect immediately. + if (sessionId == null || sessionId <= 0) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + java.util.List> rows = jdbcTemplate.queryForList( + "SELECT user_id, tenant_id, scope, status, issued_at, expires_at, last_used_at, is_deleted FROM auth_refresh_token WHERE id=? LIMIT 1", + sessionId + ); + if (rows.isEmpty()) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + java.util.Map row = rows.get(0); + int isDeleted = toFlagInt(row.get("is_deleted")); + if (isDeleted == 1) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + Long rowUserId = ((Number) row.get("user_id")).longValue(); + Long rowTenantId = row.get("tenant_id") == null ? null : ((Number) row.get("tenant_id")).longValue(); + String rowScope = String.valueOf(row.get("scope")); + String rowStatus = String.valueOf(row.get("status")); + LocalDateTime issuedAt = toLocalDateTime(row.get("issued_at")); + LocalDateTime expiresAt = toLocalDateTime(row.get("expires_at")); + LocalDateTime lastUsedAt = toLocalDateTime(row.get("last_used_at")); + LocalDateTime now = LocalDateTime.now(); + if (!rowUserId.equals(userId) || !scope.name().equals(rowScope)) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + if (scope == AuthScope.TENANT && (rowTenantId == null || !rowTenantId.equals(tenantId))) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + if (scope == AuthScope.PLATFORM && rowTenantId != null) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + if (!"ACTIVE".equals(rowStatus)) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + LocalDateTime lastActivityAt = lastUsedAt == null ? issuedAt : lastUsedAt; + if (lastActivityAt == null || now.isAfter(lastActivityAt.plusMinutes(idleTimeoutMinutes))) { + revokeIdleSession(sessionId); + throw new BusinessException(ErrorCodes.SESSION_INVALID, "浼氳瘽澶辨晥"); + } + if (expiresAt == null || now.isAfter(expiresAt)) { + throw new BusinessException(ErrorCodes.SESSION_INVALID, "会话失效"); + } + touchSessionActivity(sessionId, lastUsedAt, now); + } + + private String resolveRequestId(String requestIdHeader) { + String requestId = requestIdHeader == null ? "" : requestIdHeader.trim(); + if (!requestId.isEmpty()) { + return requestId; + } + return UUID.randomUUID().toString().replace("-", "").substring(0, 16); + } + + private int toFlagInt(Object value) { + if (value == null) { + return 0; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + if (value instanceof Boolean) { + return ((Boolean) value) ? 1 : 0; + } + String text = String.valueOf(value).trim(); + if ("true".equalsIgnoreCase(text)) { + return 1; + } + if ("false".equalsIgnoreCase(text)) { + return 0; + } + try { + return Integer.parseInt(text); + } catch (NumberFormatException ex) { + return 0; + } + } + + private void revokeIdleSession(Long sessionId) { + jdbcTemplate.update( + "UPDATE auth_refresh_token SET status='REVOKED', revoked_at=CURRENT_TIMESTAMP, revoked_reason='IDLE_TIMEOUT', updated_at=CURRENT_TIMESTAMP " + + "WHERE id=? AND status='ACTIVE' AND is_deleted=0", + sessionId + ); + } + + private void touchSessionActivity(Long sessionId, LocalDateTime lastUsedAt, LocalDateTime now) { + if (lastUsedAt != null && java.time.Duration.between(lastUsedAt, now).getSeconds() < SESSION_ACTIVITY_TOUCH_INTERVAL_SECONDS) { + return; + } + jdbcTemplate.update( + "UPDATE auth_refresh_token SET last_used_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP " + + "WHERE id=? AND status='ACTIVE' AND is_deleted=0", + sessionId + ); + } +} diff --git a/backend/src/main/java/com/writeoff/security/AuthScope.java b/backend/src/main/java/com/writeoff/security/AuthScope.java new file mode 100644 index 0000000..116e409 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/AuthScope.java @@ -0,0 +1,19 @@ +package com.writeoff.security; + +public enum AuthScope { + TENANT, + PLATFORM; + + public static AuthScope fromClaim(String raw) { + if (raw == null || raw.trim().isEmpty()) { + return TENANT; + } + String normalized = raw.trim().toUpperCase(); + for (AuthScope value : values()) { + if (value.name().equals(normalized)) { + return value; + } + } + throw new IllegalArgumentException("invalid auth scope: " + raw); + } +} diff --git a/backend/src/main/java/com/writeoff/security/CaptchaService.java b/backend/src/main/java/com/writeoff/security/CaptchaService.java new file mode 100644 index 0000000..2424a79 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/CaptchaService.java @@ -0,0 +1,144 @@ +package com.writeoff.security; + +import org.springframework.stereotype.Service; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.time.Instant; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 图形验证码服务 —— 基于 Java 2D 生成 4 位字母数字混合验证码。 + * 存储于内存 ConcurrentHashMap,单实例场景使用。 + */ +@Service +public class CaptchaService { + + private static final int CODE_LENGTH = 4; + private static final int WIDTH = 130; + private static final int HEIGHT = 40; + private static final long EXPIRE_SECONDS = 5 * 60; // 5 分钟 + private static final String CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; + + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + + /** + * 生成验证码。 + * + * @return map 包含 captchaId 和 image (Base64 PNG) + */ + public Map generate() { + cleanExpired(); + String captchaId = UUID.randomUUID().toString().replace("-", "").substring(0, 16); + Random random = new Random(); + StringBuilder code = new StringBuilder(); + for (int i = 0; i < CODE_LENGTH; i++) { + code.append(CHARS.charAt(random.nextInt(CHARS.length()))); + } + String codeStr = code.toString(); + store.put(captchaId, new CaptchaRecord(codeStr, Instant.now())); + + String base64 = renderImage(codeStr, random); + + Map result = new LinkedHashMap<>(); + result.put("captchaId", captchaId); + result.put("image", "data:image/png;base64," + base64); + return result; + } + + /** + * 校验验证码(一次性,校验后即删除)。 + */ + public boolean verify(String captchaId, String code) { + if (captchaId == null || code == null) { + return false; + } + CaptchaRecord record = store.remove(captchaId); + if (record == null) { + return false; + } + if (record.isExpired()) { + return false; + } + return record.code.equalsIgnoreCase(code.trim()); + } + + private String renderImage(String code, Random random) { + BufferedImage image = new BufferedImage(WIDTH, HEIGHT, BufferedImage.TYPE_INT_RGB); + Graphics2D g = image.createGraphics(); + + // 启用抗锯齿 + g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + + // 背景 + g.setColor(new Color(240, 243, 248)); + g.fillRect(0, 0, WIDTH, HEIGHT); + + // 干扰线 + for (int i = 0; i < 5; i++) { + g.setColor(new Color(180 + random.nextInt(50), 180 + random.nextInt(50), 200 + random.nextInt(50))); + g.setStroke(new BasicStroke(1.2f)); + g.drawLine(random.nextInt(WIDTH), random.nextInt(HEIGHT), random.nextInt(WIDTH), random.nextInt(HEIGHT)); + } + + // 干扰点 + for (int i = 0; i < 30; i++) { + g.setColor(new Color(150 + random.nextInt(80), 150 + random.nextInt(80), 180 + random.nextInt(60))); + g.fillOval(random.nextInt(WIDTH), random.nextInt(HEIGHT), 2, 2); + } + + // 绘制字符 + Color[] charColors = { + new Color(59, 130, 246), + new Color(99, 102, 241), + new Color(139, 92, 246), + new Color(14, 165, 233), + }; + g.setFont(new Font("SansSerif", Font.BOLD, 26)); + int charSpacing = (WIDTH - 20) / CODE_LENGTH; + for (int i = 0; i < code.length(); i++) { + g.setColor(charColors[i % charColors.length]); + double angle = (random.nextDouble() - 0.5) * 0.4; + int x = 12 + i * charSpacing; + int y = 28 + random.nextInt(6); + g.rotate(angle, x, y); + g.drawString(String.valueOf(code.charAt(i)), x, y); + g.rotate(-angle, x, y); + } + + g.dispose(); + + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "png", baos); + return Base64.getEncoder().encodeToString(baos.toByteArray()); + } catch (Exception e) { + return ""; + } + } + + private void cleanExpired() { + store.entrySet().removeIf(entry -> entry.getValue().isExpired()); + } + + private static class CaptchaRecord { + final String code; + final Instant createdAt; + + CaptchaRecord(String code, Instant createdAt) { + this.code = code; + this.createdAt = createdAt; + } + + boolean isExpired() { + return Instant.now().getEpochSecond() - createdAt.getEpochSecond() > EXPIRE_SECONDS; + } + } +} diff --git a/backend/src/main/java/com/writeoff/security/DataScopeType.java b/backend/src/main/java/com/writeoff/security/DataScopeType.java new file mode 100644 index 0000000..38a45e0 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/DataScopeType.java @@ -0,0 +1,9 @@ +package com.writeoff.security; + +public enum DataScopeType { + TENANT, + PROJECT, + MEETING, + MEETING_MODULE, + GLOBAL_READONLY +} diff --git a/backend/src/main/java/com/writeoff/security/JwtTokenService.java b/backend/src/main/java/com/writeoff/security/JwtTokenService.java new file mode 100644 index 0000000..3f3a71a --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/JwtTokenService.java @@ -0,0 +1,87 @@ +package com.writeoff.security; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; + +@Service +public class JwtTokenService { + private final Key key; + private final long accessExpireMs; + + public JwtTokenService(@Value("${app.security.jwt-secret}") String secret, + @Value("${app.security.access-expire-minutes:${app.security.jwt-expire-minutes:120}}") long accessExpireMinutes) { + byte[] bytes = secret.getBytes(StandardCharsets.UTF_8); + if (bytes.length < 32) { + byte[] padded = new byte[32]; + System.arraycopy(bytes, 0, padded, 0, bytes.length); + bytes = padded; + } + this.key = Keys.hmacShaKeyFor(bytes); + this.accessExpireMs = accessExpireMinutes * 60 * 1000; + } + + public String createTenantToken(Long userId, Long tenantId, String phone) { + return createTenantToken(userId, tenantId, phone, null); + } + + public String createTenantToken(Long userId, Long tenantId, String phone, Long sessionId) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + accessExpireMs); + Map claims = new HashMap<>(); + claims.put("uid", userId); + claims.put("tid", tenantId); + claims.put("scope", AuthScope.TENANT.name()); + claims.put("phone", phone); + if (sessionId != null && sessionId > 0) { + claims.put("sid", sessionId); + } + return Jwts.builder() + .setClaims(claims) + .setSubject("writeoff-user") + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public String createPlatformToken(Long userId, String phone) { + return createPlatformToken(userId, phone, null); + } + + public String createPlatformToken(Long userId, String phone, Long sessionId) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + accessExpireMs); + Map claims = new HashMap<>(); + claims.put("uid", userId); + claims.put("scope", AuthScope.PLATFORM.name()); + claims.put("phone", phone); + if (sessionId != null && sessionId > 0) { + claims.put("sid", sessionId); + } + return Jwts.builder() + .setClaims(claims) + .setSubject("writeoff-user") + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + } + + public Claims parse(String token) { + return Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/backend/src/main/java/com/writeoff/security/LoginAttemptService.java b/backend/src/main/java/com/writeoff/security/LoginAttemptService.java new file mode 100644 index 0000000..01023fb --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/LoginAttemptService.java @@ -0,0 +1,195 @@ +package com.writeoff.security; + +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; + +@Service +public class LoginAttemptService { + + private static final int MAX_ATTEMPTS = 5; + private static final long FAILURE_WINDOW_MINUTES = 30; + private static final long LOCK_DURATION_MINUTES = 15; + + private final JdbcTemplate jdbcTemplate; + + public LoginAttemptService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional + public LoginAttemptStatus recordFailure(String key) { + for (int retry = 0; retry < 2; retry++) { + try { + return recordFailureInternal(key); + } catch (DuplicateKeyException ex) { + if (retry == 1) { + throw ex; + } + } + } + throw new IllegalStateException("Unable to record login failure"); + } + + public void clearFailures(String key) { + jdbcTemplate.update("DELETE FROM auth_login_attempt WHERE attempt_key = ?", key); + } + + public boolean isLocked(String key) { + return getStatus(key).isLocked(); + } + + public long getRemainingLockSeconds(String key) { + return getStatus(key).getRemainingLockSeconds(); + } + + public int getFailureCount(String key) { + return getStatus(key).getFailureCount(); + } + + public LoginAttemptStatus getStatus(String key) { + return toStatus(findRecord(key), LocalDateTime.now()); + } + + private LoginAttemptStatus recordFailureInternal(String key) { + LocalDateTime now = LocalDateTime.now(); + AttemptRecord existing = findRecordForUpdate(key); + if (existing == null) { + jdbcTemplate.update( + "INSERT INTO auth_login_attempt (attempt_key, failure_count, window_started_at, last_failed_at, locked_until) " + + "VALUES (?, ?, ?, ?, ?)", + key, + 1, + Timestamp.valueOf(now), + Timestamp.valueOf(now), + null + ); + return new LoginAttemptStatus(1, 0); + } + + LoginAttemptStatus current = toStatus(existing, now); + if (current.isLocked()) { + return current; + } + + boolean windowExpired = isWindowExpired(existing.getWindowStartedAt(), now); + int nextFailureCount = windowExpired ? 1 : existing.getFailureCount() + 1; + LocalDateTime nextWindowStartedAt = windowExpired ? now : existing.getWindowStartedAt(); + LocalDateTime nextLockedUntil = nextFailureCount >= MAX_ATTEMPTS ? now.plusMinutes(LOCK_DURATION_MINUTES) : null; + + jdbcTemplate.update( + "UPDATE auth_login_attempt " + + "SET failure_count = ?, window_started_at = ?, last_failed_at = ?, locked_until = ?, updated_at = CURRENT_TIMESTAMP " + + "WHERE attempt_key = ?", + nextFailureCount, + Timestamp.valueOf(nextWindowStartedAt), + Timestamp.valueOf(now), + nextLockedUntil == null ? null : Timestamp.valueOf(nextLockedUntil), + key + ); + + return toStatus(new AttemptRecord(nextFailureCount, nextWindowStartedAt, nextLockedUntil), now); + } + + private AttemptRecord findRecord(String key) { + List records = jdbcTemplate.query( + "SELECT failure_count, window_started_at, locked_until FROM auth_login_attempt WHERE attempt_key = ?", + (rs, rowNum) -> mapAttemptRecord(rs), + key + ); + return records.isEmpty() ? null : records.get(0); + } + + private AttemptRecord findRecordForUpdate(String key) { + List records = jdbcTemplate.query( + "SELECT failure_count, window_started_at, locked_until FROM auth_login_attempt WHERE attempt_key = ? FOR UPDATE", + (rs, rowNum) -> mapAttemptRecord(rs), + key + ); + return records.isEmpty() ? null : records.get(0); + } + + private AttemptRecord mapAttemptRecord(ResultSet rs) throws SQLException { + Timestamp windowStartedAt = rs.getTimestamp("window_started_at"); + Timestamp lockedUntil = rs.getTimestamp("locked_until"); + return new AttemptRecord( + rs.getInt("failure_count"), + windowStartedAt == null ? null : windowStartedAt.toLocalDateTime(), + lockedUntil == null ? null : lockedUntil.toLocalDateTime() + ); + } + + private LoginAttemptStatus toStatus(AttemptRecord record, LocalDateTime now) { + if (record == null) { + return new LoginAttemptStatus(0, 0); + } + int failureCount = isWindowExpired(record.getWindowStartedAt(), now) ? 0 : record.getFailureCount(); + long remainingLockSeconds = 0; + if (record.getLockedUntil() != null && record.getLockedUntil().isAfter(now)) { + remainingLockSeconds = Math.max(1, Duration.between(now, record.getLockedUntil()).getSeconds()); + } + return new LoginAttemptStatus(failureCount, remainingLockSeconds); + } + + private boolean isWindowExpired(LocalDateTime windowStartedAt, LocalDateTime now) { + return windowStartedAt == null || !now.isBefore(windowStartedAt.plusMinutes(FAILURE_WINDOW_MINUTES)); + } + + public static class LoginAttemptStatus { + private final int failureCount; + private final long remainingLockSeconds; + + LoginAttemptStatus(int failureCount, long remainingLockSeconds) { + this.failureCount = failureCount; + this.remainingLockSeconds = remainingLockSeconds; + } + + public int getFailureCount() { + return failureCount; + } + + public long getRemainingLockSeconds() { + return remainingLockSeconds; + } + + public boolean isLocked() { + return remainingLockSeconds > 0; + } + + public int getRemainingAttempts() { + return Math.max(0, MAX_ATTEMPTS - failureCount); + } + } + + private static class AttemptRecord { + private final int failureCount; + private final LocalDateTime windowStartedAt; + private final LocalDateTime lockedUntil; + + AttemptRecord(int failureCount, LocalDateTime windowStartedAt, LocalDateTime lockedUntil) { + this.failureCount = failureCount; + this.windowStartedAt = windowStartedAt; + this.lockedUntil = lockedUntil; + } + + int getFailureCount() { + return failureCount; + } + + LocalDateTime getWindowStartedAt() { + return windowStartedAt; + } + + LocalDateTime getLockedUntil() { + return lockedUntil; + } + } +} diff --git a/backend/src/main/java/com/writeoff/security/LoginPasswordCryptoService.java b/backend/src/main/java/com/writeoff/security/LoginPasswordCryptoService.java new file mode 100644 index 0000000..4fd77ea --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/LoginPasswordCryptoService.java @@ -0,0 +1,71 @@ +package com.writeoff.security; + +import org.springframework.stereotype.Service; + +import javax.annotation.PostConstruct; +import javax.crypto.Cipher; +import javax.crypto.spec.OAEPParameterSpec; +import javax.crypto.spec.PSource; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.interfaces.RSAPrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.MGF1ParameterSpec; +import java.util.Base64; + +@Service +public class LoginPasswordCryptoService { + public static final String PASSWORD_PREFIX = "rsa:"; + private static final OAEPParameterSpec OAEP_SHA256_MGF1_SHA256 = new OAEPParameterSpec( + "SHA-256", + "MGF1", + MGF1ParameterSpec.SHA256, + PSource.PSpecified.DEFAULT + ); + + private volatile RSAPublicKey publicKey; + private volatile RSAPrivateKey privateKey; + + @PostConstruct + public void init() { + try { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA"); + generator.initialize(2048); + KeyPair keyPair = generator.generateKeyPair(); + this.publicKey = (RSAPublicKey) keyPair.getPublic(); + this.privateKey = (RSAPrivateKey) keyPair.getPrivate(); + } catch (Exception ex) { + throw new IllegalStateException("Unable to initialize login password crypto service", ex); + } + } + + public String getEncodedPublicKey() { + RSAPublicKey key = publicKey; + if (key == null) { + throw new IllegalStateException("Login password public key is not ready"); + } + return Base64.getEncoder().encodeToString(key.getEncoded()); + } + + public String unwrapPassword(String password) { + if (password == null || password.trim().isEmpty()) { + return password; + } + if (!password.startsWith(PASSWORD_PREFIX)) { + return password; + } + String cipherText = password.substring(PASSWORD_PREFIX.length()).trim(); + if (cipherText.isEmpty()) { + throw new IllegalArgumentException("Encrypted password cannot be empty"); + } + try { + Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); + cipher.init(Cipher.DECRYPT_MODE, privateKey, OAEP_SHA256_MGF1_SHA256); + byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(cipherText)); + return new String(decrypted, StandardCharsets.UTF_8); + } catch (Exception ex) { + throw new IllegalArgumentException("Unable to decrypt login password", ex); + } + } +} diff --git a/backend/src/main/java/com/writeoff/security/PasswordCodecService.java b/backend/src/main/java/com/writeoff/security/PasswordCodecService.java new file mode 100644 index 0000000..256d42c --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/PasswordCodecService.java @@ -0,0 +1,90 @@ +package com.writeoff.security; + +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; + +@Service +public class PasswordCodecService { + private static final String PREFIX = "pbkdf2"; + private static final String ALGORITHM = "sha1"; + private static final int ITERATIONS = 150000; + private static final int SALT_BYTES = 16; + private static final int KEY_BYTES = 32; + + private final SecureRandom secureRandom = new SecureRandom(); + + public String encode(String rawPassword) { + if (rawPassword == null) { + throw new IllegalArgumentException("Password cannot be null"); + } + byte[] salt = new byte[SALT_BYTES]; + secureRandom.nextBytes(salt); + byte[] hash = derive(rawPassword, salt, ITERATIONS, KEY_BYTES); + return PREFIX + "$" + ALGORITHM + "$" + ITERATIONS + "$" + toHex(salt) + "$" + toHex(hash); + } + + public boolean matches(String rawPassword, String storedPassword) { + if (rawPassword == null || storedPassword == null || storedPassword.trim().isEmpty()) { + return false; + } + if (!isEncoded(storedPassword)) { + return MessageDigest.isEqual( + rawPassword.getBytes(StandardCharsets.UTF_8), + storedPassword.getBytes(StandardCharsets.UTF_8) + ); + } + String[] parts = storedPassword.split("\\$"); + if (parts.length != 5) { + return false; + } + if (!PREFIX.equals(parts[0]) || !ALGORITHM.equals(parts[1])) { + return false; + } + try { + int iterations = Integer.parseInt(parts[2]); + byte[] salt = fromHex(parts[3]); + byte[] expected = fromHex(parts[4]); + byte[] actual = derive(rawPassword, salt, iterations, expected.length); + return MessageDigest.isEqual(actual, expected); + } catch (RuntimeException ex) { + return false; + } + } + + public boolean isEncoded(String storedPassword) { + return storedPassword != null && storedPassword.startsWith(PREFIX + "$"); + } + + private byte[] derive(String rawPassword, byte[] salt, int iterations, int keyBytes) { + try { + PBEKeySpec spec = new PBEKeySpec(rawPassword.toCharArray(), salt, iterations, keyBytes * 8); + return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1").generateSecret(spec).getEncoded(); + } catch (Exception ex) { + throw new IllegalStateException("Unable to encode password", ex); + } + } + + private String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + sb.append(String.format("%02x", value & 0xff)); + } + return sb.toString(); + } + + private byte[] fromHex(String hex) { + if (hex == null || (hex.length() % 2) != 0) { + throw new IllegalArgumentException("Invalid hex value"); + } + byte[] bytes = new byte[hex.length() / 2]; + for (int i = 0; i < hex.length(); i += 2) { + bytes[i / 2] = (byte) Integer.parseInt(hex.substring(i, i + 2), 16); + } + return bytes; + } +} diff --git a/backend/src/main/java/com/writeoff/security/PasswordPolicyService.java b/backend/src/main/java/com/writeoff/security/PasswordPolicyService.java new file mode 100644 index 0000000..2539369 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/PasswordPolicyService.java @@ -0,0 +1,60 @@ +package com.writeoff.security; + +import com.writeoff.common.exception.BusinessException; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * 密码策略校验服务。 + * 规则: + * - 最少8位 + * - 必须包含大写字母 + * - 必须包含小写字母 + * - 必须包含数字 + * - 必须包含特殊字符 + */ +@Service +public class PasswordPolicyService { + + private static final int MIN_LENGTH = 8; + private static final Pattern UPPER = Pattern.compile("[A-Z]"); + private static final Pattern LOWER = Pattern.compile("[a-z]"); + private static final Pattern DIGIT = Pattern.compile("[0-9]"); + private static final Pattern SPECIAL = Pattern.compile("[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?`~]"); + + /** + * 校验密码强度,不满足则抛 BusinessException。 + */ + public void validate(String password) { + List violations = check(password); + if (!violations.isEmpty()) { + throw new BusinessException(10001, "密码强度不足:" + String.join(";", violations)); + } + } + + /** + * 检查密码强度,返回不满足的规则列表(空列表表示通过)。 + */ + public List check(String password) { + List violations = new ArrayList<>(); + if (password == null || password.length() < MIN_LENGTH) { + violations.add("密码长度至少" + MIN_LENGTH + "位"); + } + if (password == null || !UPPER.matcher(password).find()) { + violations.add("需包含大写字母"); + } + if (password == null || !LOWER.matcher(password).find()) { + violations.add("需包含小写字母"); + } + if (password == null || !DIGIT.matcher(password).find()) { + violations.add("需包含数字"); + } + if (password == null || !SPECIAL.matcher(password).find()) { + violations.add("需包含特殊字符"); + } + return violations; + } +} diff --git a/backend/src/main/java/com/writeoff/security/PasswordSetupService.java b/backend/src/main/java/com/writeoff/security/PasswordSetupService.java new file mode 100644 index 0000000..fcbc751 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/PasswordSetupService.java @@ -0,0 +1,277 @@ +package com.writeoff.security; + +import com.writeoff.common.exception.BusinessException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.net.URLEncoder; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class PasswordSetupService { + private static final String SCENARIO_TENANT_ADMIN_SETUP = "TENANT_ADMIN_SETUP"; + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final JdbcTemplate jdbcTemplate; + private final PasswordPolicyService passwordPolicyService; + private final PasswordCodecService passwordCodecService; + private final String frontendBaseUrl; + private final long passwordSetupExpireMinutes; + private final SecureRandom secureRandom = new SecureRandom(); + + public PasswordSetupService(JdbcTemplate jdbcTemplate, + PasswordPolicyService passwordPolicyService, + PasswordCodecService passwordCodecService, + @Value("${app.frontend-base-url:http://localhost:5173}") String frontendBaseUrl, + @Value("${app.security.password-setup-expire-minutes:1440}") long passwordSetupExpireMinutes) { + this.jdbcTemplate = jdbcTemplate; + this.passwordPolicyService = passwordPolicyService; + this.passwordCodecService = passwordCodecService; + this.frontendBaseUrl = frontendBaseUrl; + this.passwordSetupExpireMinutes = passwordSetupExpireMinutes; + } + + @Transactional + public String issueTenantAdminSetupLink(Long tenantId, Long userId, Long operatorUserId) { + Map user = loadTenantAdminUser(tenantId, userId); + String tenantCode = String.valueOf(user.get("tenant_code")); + jdbcTemplate.update( + "UPDATE auth_password_setup_token " + + "SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND user_id=? AND scenario=? AND is_deleted=0 AND used_at IS NULL", + safeOperator(operatorUserId), + tenantId, + userId, + SCENARIO_TENANT_ADMIN_SETUP + ); + + String rawToken = generateToken(); + LocalDateTime expiresAt = LocalDateTime.now().plusMinutes(Math.max(passwordSetupExpireMinutes, 10L)); + jdbcTemplate.update( + "INSERT INTO auth_password_setup_token (tenant_id, user_id, scenario, token_hash, expires_at, created_by, updated_by) " + + "VALUES (?, ?, ?, ?, ?, ?, ?)", + tenantId, + userId, + SCENARIO_TENANT_ADMIN_SETUP, + hashToken(rawToken), + Timestamp.valueOf(expiresAt), + safeOperator(operatorUserId), + safeOperator(operatorUserId) + ); + return buildSetupLink(tenantCode, rawToken); + } + + public Map verifyTenantPasswordSetupToken(String tenantCode, String rawToken) { + Map tokenRecord = loadAvailableTokenRecord(tenantCode, rawToken); + Map data = new LinkedHashMap(); + data.put("tenantCode", tokenRecord.get("tenant_code")); + data.put("tenantName", tokenRecord.get("tenant_name")); + data.put("userName", tokenRecord.get("user_name")); + data.put("phone", maskPhone(tokenRecord.get("phone"))); + data.put("expiresAt", formatDateTime(tokenRecord.get("expires_at"))); + return data; + } + + @Transactional + public Map completeTenantPasswordSetup(String tenantCode, String rawToken, String newPassword) { + passwordPolicyService.validate(newPassword); + Map tokenRecord = loadAvailableTokenRecord(tenantCode, rawToken); + Long tokenId = ((Number) tokenRecord.get("id")).longValue(); + Long tenantId = ((Number) tokenRecord.get("tenant_id")).longValue(); + Long userId = ((Number) tokenRecord.get("user_id")).longValue(); + + int consumed = jdbcTemplate.update( + "UPDATE auth_password_setup_token " + + "SET used_at=CURRENT_TIMESTAMP, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE id=? AND used_at IS NULL AND is_deleted=0 AND expires_at>=CURRENT_TIMESTAMP", + userId, + tokenId + ); + if (consumed <= 0) { + throw new BusinessException(10001, "设置链接无效或已过期"); + } + + jdbcTemplate.update( + "UPDATE sys_user SET password_hash=?, status='ENABLED', updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND id=? AND is_deleted=0", + passwordCodecService.encode(newPassword), + userId, + tenantId, + userId + ); + jdbcTemplate.update( + "UPDATE auth_password_setup_token " + + "SET is_deleted=1, updated_by=?, updated_at=CURRENT_TIMESTAMP " + + "WHERE tenant_id=? AND user_id=? AND scenario=? AND id<>? AND is_deleted=0 AND used_at IS NULL", + userId, + tenantId, + userId, + SCENARIO_TENANT_ADMIN_SETUP, + tokenId + ); + + Map data = new LinkedHashMap(); + data.put("ok", Boolean.TRUE); + data.put("tenantCode", tokenRecord.get("tenant_code")); + return data; + } + + private Map loadAvailableTokenRecord(String tenantCode, String rawToken) { + String normalizedTenantCode = normalizeText(tenantCode); + String normalizedToken = normalizeText(rawToken); + if (normalizedTenantCode.isEmpty() || normalizedToken.isEmpty()) { + throw new BusinessException(10001, "设置链接无效或已过期"); + } + + List> rows = jdbcTemplate.queryForList( + "SELECT tkn.id, tkn.tenant_id, tkn.user_id, tkn.expires_at, tkn.used_at, " + + "t.tenant_code, t.tenant_name, u.user_name, u.phone " + + "FROM auth_password_setup_token tkn " + + "JOIN tenant t ON tkn.tenant_id=t.id " + + "JOIN sys_user u ON tkn.user_id=u.id AND tkn.tenant_id=u.tenant_id " + + "WHERE tkn.token_hash=? AND tkn.scenario=? AND tkn.is_deleted=0 " + + "AND t.is_deleted=0 AND u.is_deleted=0 " + + "LIMIT 1", + hashToken(normalizedToken), + SCENARIO_TENANT_ADMIN_SETUP + ); + if (rows.isEmpty()) { + throw new BusinessException(10001, "设置链接无效或已过期"); + } + + Map row = rows.get(0); + String rowTenantCode = normalizeText(row.get("tenant_code")); + if (!normalizedTenantCode.equalsIgnoreCase(rowTenantCode)) { + throw new BusinessException(10001, "设置链接无效或已过期"); + } + if (row.get("used_at") != null) { + throw new BusinessException(10001, "设置链接已失效,请联系平台重新发送"); + } + + LocalDateTime expiresAt = toLocalDateTime(row.get("expires_at")); + if (expiresAt == null || LocalDateTime.now().isAfter(expiresAt)) { + throw new BusinessException(10001, "设置链接无效或已过期"); + } + return row; + } + + private Map loadTenantAdminUser(Long tenantId, Long userId) { + List> rows = jdbcTemplate.queryForList( + "SELECT u.id, u.tenant_id, t.tenant_code " + + "FROM sys_user u " + + "JOIN tenant t ON u.tenant_id=t.id " + + "WHERE u.tenant_id=? AND u.id=? AND u.is_deleted=0 AND t.is_deleted=0 " + + "LIMIT 1", + tenantId, + userId + ); + if (rows.isEmpty()) { + throw new BusinessException(10003, "租户管理员不存在"); + } + return rows.get(0); + } + + private String buildSetupLink(String tenantCode, String rawToken) { + String baseUrl = normalizeBaseUrl(frontendBaseUrl); + String path = "/" + tenantCode + "/setup-password?token=" + urlEncode(rawToken); + return baseUrl.isEmpty() ? path : baseUrl + path; + } + + private String generateToken() { + byte[] bytes = new byte[32]; + secureRandom.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String hashToken(String rawToken) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(rawToken.getBytes("UTF-8")); + StringBuilder sb = new StringBuilder(); + for (byte item : hash) { + String hex = Integer.toHexString(item & 0xff); + if (hex.length() == 1) { + sb.append('0'); + } + sb.append(hex); + } + return sb.toString(); + } catch (Exception ex) { + throw new IllegalStateException("Failed to hash password setup token", ex); + } + } + + private String normalizeBaseUrl(String rawBaseUrl) { + String value = normalizeText(rawBaseUrl); + while (value.endsWith("/")) { + value = value.substring(0, value.length() - 1); + } + return value; + } + + private String urlEncode(String raw) { + try { + return URLEncoder.encode(raw, "UTF-8"); + } catch (Exception ex) { + throw new IllegalStateException("Failed to encode password setup link", ex); + } + } + + private String normalizeText(Object raw) { + return raw == null ? "" : String.valueOf(raw).trim(); + } + + private String formatDateTime(Object value) { + LocalDateTime dateTime = toLocalDateTime(value); + return dateTime == null ? "" : dateTime.format(DATE_TIME_FORMATTER); + } + + private LocalDateTime toLocalDateTime(Object value) { + if (value == null) { + return null; + } + if (value instanceof LocalDateTime) { + return (LocalDateTime) value; + } + if (value instanceof Timestamp) { + return ((Timestamp) value).toLocalDateTime(); + } + if (value instanceof java.util.Date) { + return new Timestamp(((java.util.Date) value).getTime()).toLocalDateTime(); + } + if (value instanceof String) { + String text = String.valueOf(value).trim(); + if (text.isEmpty()) { + return null; + } + try { + return LocalDateTime.parse(text.replace(' ', 'T')); + } catch (Exception ignored) { + return null; + } + } + return null; + } + + private String maskPhone(Object rawPhone) { + String phone = normalizeText(rawPhone); + if (phone.length() < 7) { + return phone; + } + return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4); + } + + private Long safeOperator(Long operatorUserId) { + return operatorUserId == null ? 0L : operatorUserId; + } +} diff --git a/backend/src/main/java/com/writeoff/security/PasswordStorageMigrationRunner.java b/backend/src/main/java/com/writeoff/security/PasswordStorageMigrationRunner.java new file mode 100644 index 0000000..d679c5a --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/PasswordStorageMigrationRunner.java @@ -0,0 +1,74 @@ +package com.writeoff.security; + +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +@Component +public class PasswordStorageMigrationRunner implements ApplicationRunner { + private final JdbcTemplate jdbcTemplate; + private final PasswordCodecService passwordCodecService; + + public PasswordStorageMigrationRunner(JdbcTemplate jdbcTemplate, PasswordCodecService passwordCodecService) { + this.jdbcTemplate = jdbcTemplate; + this.passwordCodecService = passwordCodecService; + } + + @Override + public void run(ApplicationArguments args) { + migrateTenantUsers(); + migratePlatformUsers(); + } + + private void migrateTenantUsers() { + List> rows = jdbcTemplate.queryForList( + "SELECT id, phone, password_hash, tenant_switch_account_key FROM sys_user WHERE is_deleted=0" + ); + Map sharedKeys = new LinkedHashMap(); + for (Map row : rows) { + String currentPassword = row.get("password_hash") == null ? "" : String.valueOf(row.get("password_hash")); + if (passwordCodecService.isEncoded(currentPassword)) { + continue; + } + String phone = row.get("phone") == null ? "" : String.valueOf(row.get("phone")).trim(); + String legacyKey = phone + "|" + currentPassword; + String existingSwitchKey = row.get("tenant_switch_account_key") == null ? "" : String.valueOf(row.get("tenant_switch_account_key")).trim(); + String sharedKey = sharedKeys.get(legacyKey); + if (sharedKey == null || sharedKey.isEmpty()) { + sharedKey = existingSwitchKey.isEmpty() + ? "acct_" + UUID.randomUUID().toString().replace("-", "").toUpperCase() + : existingSwitchKey; + sharedKeys.put(legacyKey, sharedKey); + } + jdbcTemplate.update( + "UPDATE sys_user SET password_hash=?, tenant_switch_account_key=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + passwordCodecService.encode(currentPassword), + sharedKey, + ((Number) row.get("id")).longValue() + ); + } + } + + private void migratePlatformUsers() { + List> rows = jdbcTemplate.queryForList( + "SELECT id, password_hash FROM platform_user WHERE is_deleted=0" + ); + for (Map row : rows) { + String currentPassword = row.get("password_hash") == null ? "" : String.valueOf(row.get("password_hash")); + if (passwordCodecService.isEncoded(currentPassword)) { + continue; + } + jdbcTemplate.update( + "UPDATE platform_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", + passwordCodecService.encode(currentPassword), + ((Number) row.get("id")).longValue() + ); + } + } +} diff --git a/backend/src/main/java/com/writeoff/security/PermissionDomain.java b/backend/src/main/java/com/writeoff/security/PermissionDomain.java new file mode 100644 index 0000000..59bdaef --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/PermissionDomain.java @@ -0,0 +1,6 @@ +package com.writeoff.security; + +public enum PermissionDomain { + TENANT, + PLATFORM +} diff --git a/backend/src/main/java/com/writeoff/security/PermissionMetadataGuard.java b/backend/src/main/java/com/writeoff/security/PermissionMetadataGuard.java new file mode 100644 index 0000000..c1801e7 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/PermissionMetadataGuard.java @@ -0,0 +1,96 @@ +package com.writeoff.security; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.mvc.method.RequestMappingInfo; +import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Component +public class PermissionMetadataGuard implements SmartInitializingSingleton { + private static final Logger log = LoggerFactory.getLogger(PermissionMetadataGuard.class); + + private final RequestMappingHandlerMapping handlerMapping; + + @Value("${writeoff.permission-metadata.strict:false}") + private boolean strictMode; + + public PermissionMetadataGuard(RequestMappingHandlerMapping handlerMapping) { + this.handlerMapping = handlerMapping; + } + + @Override + public void afterSingletonsInstantiated() { + List violations = new ArrayList<>(); + Map mapping = handlerMapping.getHandlerMethods(); + for (Map.Entry entry : mapping.entrySet()) { + HandlerMethod handlerMethod = entry.getValue(); + if (!isApiHandler(entry.getKey(), handlerMethod)) { + continue; + } + RequirePermission requirePermission = handlerMethod.getMethodAnnotation(RequirePermission.class); + if (requirePermission == null) { + violations.add(describe(entry.getKey(), handlerMethod) + " missing @RequirePermission"); + continue; + } + if (isBlank(requirePermission.value())) { + violations.add(describe(entry.getKey(), handlerMethod) + " missing permission code"); + } + if (isBlank(requirePermission.auditAction())) { + violations.add(describe(entry.getKey(), handlerMethod) + " missing auditAction"); + } + if (requirePermission.dataScope() == null) { + violations.add(describe(entry.getKey(), handlerMethod) + " missing dataScope"); + } + } + if (violations.isEmpty()) { + log.info("Permission metadata guard passed: all API handlers are compliant."); + return; + } + String message = "Permission metadata guard found " + violations.size() + " violation(s): " + String.join("; ", violations); + if (strictMode) { + throw new IllegalStateException(message); + } + log.warn(message); + } + + private boolean isApiHandler(RequestMappingInfo info, HandlerMethod method) { + String packageName = method.getBeanType().getPackage().getName(); + if (!packageName.startsWith("com.writeoff.module")) { + return false; + } + Set patterns = info.getPatternsCondition() == null ? java.util.Collections.emptySet() : info.getPatternsCondition().getPatterns(); + for (String pattern : patterns) { + if (!pattern.startsWith("/api/")) { + continue; + } + if (pattern.startsWith("/api/auth/login") + || pattern.startsWith("/api/auth/platform-login") + || pattern.startsWith("/api/auth/password-public-key") + || pattern.startsWith("/api/system/health") + || pattern.startsWith("/api/health") + || pattern.startsWith("/api/platform/menus/current")) { + continue; + } + return true; + } + return false; + } + + private String describe(RequestMappingInfo info, HandlerMethod method) { + Set patterns = info.getPatternsCondition() == null ? java.util.Collections.emptySet() : info.getPatternsCondition().getPatterns(); + return method.getBeanType().getSimpleName() + "#" + method.getMethod().getName() + patterns; + } + + private boolean isBlank(String value) { + return value == null || value.trim().isEmpty(); + } +} diff --git a/backend/src/main/java/com/writeoff/security/PermissionService.java b/backend/src/main/java/com/writeoff/security/PermissionService.java new file mode 100644 index 0000000..e8e5aa0 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/PermissionService.java @@ -0,0 +1,126 @@ +package com.writeoff.security; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Service +public class PermissionService { + private final JdbcTemplate jdbcTemplate; + + public PermissionService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public boolean hasPermission(Long userId, String permissionCode) { + Long tenantId = tenantId(); + if (hasPermissionDirect(userId, tenantId, permissionCode)) { + return true; + } + List principalUserIds = jdbcTemplate.queryForList( + "SELECT user_id FROM user_delegation " + + "WHERE tenant_id=? AND delegate_user_id=? AND is_deleted=0 AND status='ENABLED' " + + "AND effective_from<=CURRENT_TIMESTAMP AND effective_to>=CURRENT_TIMESTAMP", + Long.class, + tenantId, + userId + ); + for (Long principalUserId : principalUserIds) { + if (hasPermissionDirect(principalUserId, tenantId, permissionCode)) { + return true; + } + } + return false; + } + + public List getPermissions(Long userId) { + return getPermissions(userId, tenantId()); + } + + public List getPermissions(Long userId, Long tenantId) { + if (tenantId == null) { + throw new IllegalStateException("tenant context required"); + } + Long safeTenantId = tenantId; + Set result = new LinkedHashSet<>(getPermissionsDirect(userId, safeTenantId)); + List principalUserIds = jdbcTemplate.queryForList( + "SELECT user_id FROM user_delegation " + + "WHERE tenant_id=? AND delegate_user_id=? AND is_deleted=0 AND status='ENABLED' " + + "AND effective_from<=CURRENT_TIMESTAMP AND effective_to>=CURRENT_TIMESTAMP", + Long.class, + safeTenantId, + userId + ); + for (Long principalUserId : principalUserIds) { + result.addAll(getPermissionsDirect(principalUserId, safeTenantId)); + } + return new java.util.ArrayList<>(result); + } + + public boolean hasPlatformPermission(Long userId, String permissionCode) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM platform_user_role ur " + + "JOIN platform_role_permission rp ON ur.role_id=rp.role_id " + + "JOIN platform_permission p ON rp.permission_id=p.id " + + "WHERE ur.user_id=? AND p.permission_code=?", + Integer.class, + userId, + permissionCode + ); + return count != null && count > 0; + } + + public List getPlatformPermissions(Long userId) { + return jdbcTemplate.queryForList( + "SELECT DISTINCT p.permission_code FROM platform_user_role ur " + + "JOIN platform_role_permission rp ON ur.role_id=rp.role_id " + + "JOIN platform_permission p ON rp.permission_id=p.id " + + "WHERE ur.user_id=?", + String.class, + userId + ); + } + + public List getPlatformRoles(Long userId) { + return jdbcTemplate.queryForList( + "SELECT DISTINCT r.role_code FROM platform_user_role ur " + + "JOIN platform_role r ON ur.role_id=r.id " + + "WHERE ur.user_id=? AND r.is_deleted=0", + String.class, + userId + ); + } + + private boolean hasPermissionDirect(Long userId, Long tenantId, String permissionCode) { + Integer count = jdbcTemplate.queryForObject( + "SELECT COUNT(1) FROM user_role ur " + + "JOIN role_permission rp ON ur.role_id=rp.role_id AND ur.tenant_id=rp.tenant_id " + + "JOIN permission p ON rp.permission_id=p.id " + + "WHERE ur.user_id=? AND ur.tenant_id=? AND p.permission_code=?", + Integer.class, + userId, + tenantId, + permissionCode + ); + return count != null && count > 0; + } + + private List getPermissionsDirect(Long userId, Long tenantId) { + return jdbcTemplate.queryForList( + "SELECT DISTINCT p.permission_code FROM user_role ur " + + "JOIN role_permission rp ON ur.role_id=rp.role_id AND ur.tenant_id=rp.tenant_id " + + "JOIN permission p ON rp.permission_id=p.id " + + "WHERE ur.user_id=? AND ur.tenant_id=?", + String.class, + userId, + tenantId + ); + } + + private Long tenantId() { + return AuthContext.requireTenantId(); + } +} diff --git a/backend/src/main/java/com/writeoff/security/RateLimitFilter.java b/backend/src/main/java/com/writeoff/security/RateLimitFilter.java new file mode 100644 index 0000000..0d6394b --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/RateLimitFilter.java @@ -0,0 +1,86 @@ +package com.writeoff.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.writeoff.common.api.ApiErrorResponse; +import com.writeoff.common.exception.ErrorCodes; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import javax.servlet.Filter; +import javax.servlet.FilterChain; +import javax.servlet.ServletException; +import javax.servlet.ServletRequest; +import javax.servlet.ServletResponse; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +@Order(1) +public class RateLimitFilter implements Filter { + + private static final Logger log = LoggerFactory.getLogger(RateLimitFilter.class); + + private final RateLimitService rateLimitService; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public RateLimitFilter(RateLimitService rateLimitService) { + this.rateLimitService = rateLimitService; + } + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (!(request instanceof HttpServletRequest) || !(response instanceof HttpServletResponse)) { + chain.doFilter(request, response); + return; + } + HttpServletRequest httpRequest = (HttpServletRequest) request; + String path = httpRequest.getRequestURI(); + if (!path.startsWith("/api/")) { + chain.doFilter(request, response); + return; + } + + boolean isAuthPath = path.startsWith("/api/auth/login") || path.startsWith("/api/auth/platform-login"); + RateLimitService.RateLimitDecision decision; + try { + decision = rateLimitService.tryAcquire(getClientIp(httpRequest), isAuthPath); + } catch (Exception ex) { + log.warn("Rate limit degraded to allow request for path {}", path, ex); + chain.doFilter(request, response); + return; + } + + if (!decision.isAllowed()) { + HttpServletResponse httpResponse = (HttpServletResponse) response; + httpResponse.setStatus(429); + httpResponse.setContentType("application/json;charset=utf-8"); + httpResponse.setHeader("Retry-After", String.valueOf(decision.getRetryAfterSeconds())); + Map errors = new LinkedHashMap<>(); + errors.put("scope", decision.getScope()); + errors.put("limit", String.valueOf(decision.getLimit())); + errors.put("retryAfterSeconds", String.valueOf(decision.getRetryAfterSeconds())); + ApiErrorResponse body = ApiErrorResponse.of(ErrorCodes.RATE_LIMITED, "请求过于频繁,请稍后重试", errors); + httpResponse.getWriter().write(objectMapper.writeValueAsString(body)); + return; + } + chain.doFilter(request, response); + } + + private String getClientIp(HttpServletRequest request) { + String xff = request.getHeader("X-Forwarded-For"); + if (xff != null && !xff.isEmpty()) { + return xff.split(",")[0].trim(); + } + String realIp = request.getHeader("X-Real-IP"); + if (realIp != null && !realIp.isEmpty()) { + return realIp.trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/backend/src/main/java/com/writeoff/security/RateLimitService.java b/backend/src/main/java/com/writeoff/security/RateLimitService.java new file mode 100644 index 0000000..f496dce --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/RateLimitService.java @@ -0,0 +1,91 @@ +package com.writeoff.security; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.Timestamp; +import java.time.Duration; +import java.time.LocalDateTime; + +@Service +public class RateLimitService { + + private static final int GLOBAL_LIMIT_PER_MINUTE = 300; + private static final int LOGIN_LIMIT_PER_MINUTE = 20; + + private final JdbcTemplate jdbcTemplate; + + public RateLimitService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + @Transactional + public RateLimitDecision tryAcquire(String clientIp, boolean loginRequest) { + String scope = loginRequest ? "LOGIN" : "API"; + int limit = loginRequest ? LOGIN_LIMIT_PER_MINUTE : GLOBAL_LIMIT_PER_MINUTE; + LocalDateTime now = LocalDateTime.now(); + LocalDateTime windowStartAt = now.withSecond(0).withNano(0); + LocalDateTime expiresAt = windowStartAt.plusMinutes(2); + String bucketKey = scope + ":" + clientIp; + + jdbcTemplate.update( + "INSERT INTO api_rate_limit_counter " + + "(bucket_key, scope, client_ip, window_start_at, expires_at, request_count, created_at, updated_at) " + + "VALUES (?, ?, ?, ?, ?, LAST_INSERT_ID(1), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) " + + "ON DUPLICATE KEY UPDATE " + + "scope = VALUES(scope), " + + "client_ip = VALUES(client_ip), " + + "request_count = LAST_INSERT_ID(IF(window_start_at = VALUES(window_start_at), request_count + 1, 1)), " + + "window_start_at = VALUES(window_start_at), " + + "expires_at = VALUES(expires_at), " + + "updated_at = CURRENT_TIMESTAMP", + bucketKey, + scope, + clientIp, + Timestamp.valueOf(windowStartAt), + Timestamp.valueOf(expiresAt) + ); + + Integer currentCount = jdbcTemplate.queryForObject("SELECT LAST_INSERT_ID()", Integer.class); + int requestCount = currentCount == null ? 1 : currentCount.intValue(); + long retryAfterSeconds = Math.max(1, Duration.between(now, windowStartAt.plusMinutes(1)).getSeconds()); + return new RateLimitDecision(scope, limit, requestCount <= limit, requestCount, retryAfterSeconds); + } + + public static class RateLimitDecision { + private final String scope; + private final int limit; + private final boolean allowed; + private final int currentCount; + private final long retryAfterSeconds; + + RateLimitDecision(String scope, int limit, boolean allowed, int currentCount, long retryAfterSeconds) { + this.scope = scope; + this.limit = limit; + this.allowed = allowed; + this.currentCount = currentCount; + this.retryAfterSeconds = retryAfterSeconds; + } + + public String getScope() { + return scope; + } + + public int getLimit() { + return limit; + } + + public boolean isAllowed() { + return allowed; + } + + public int getCurrentCount() { + return currentCount; + } + + public long getRetryAfterSeconds() { + return retryAfterSeconds; + } + } +} diff --git a/backend/src/main/java/com/writeoff/security/RequirePermission.java b/backend/src/main/java/com/writeoff/security/RequirePermission.java new file mode 100644 index 0000000..407ad46 --- /dev/null +++ b/backend/src/main/java/com/writeoff/security/RequirePermission.java @@ -0,0 +1,13 @@ +package com.writeoff.security; + +import java.lang.annotation.*; + +@Target({ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface RequirePermission { + String value(); + DataScopeType dataScope() default DataScopeType.TENANT; + PermissionDomain domain() default PermissionDomain.TENANT; + String auditAction() default ""; +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml new file mode 100644 index 0000000..1fef7ae --- /dev/null +++ b/backend/src/main/resources/application.yml @@ -0,0 +1,71 @@ +server: + port: ${SERVER_PORT:8080} + +spring: + application: + name: writeoff-backend + datasource: + url: ${DB_URL:jdbc:mysql://localhost:3306/writeoff?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai} + username: ${DB_USERNAME:root} + password: ${DB_PASSWORD:root} + driver-class-name: com.mysql.cj.jdbc.Driver + flyway: + enabled: true + locations: classpath:db/migration + +logging: + level: + root: ${LOG_LEVEL:INFO} + org.springframework.jdbc.core.JdbcTemplate: INFO + org.springframework.jdbc.core.StatementCreatorUtils: INFO + org.springframework.jdbc.datasource: INFO + +app: + repository: + mode: ${APP_REPOSITORY_MODE:jdbc} + security: + jwt-secret: ${JWT_SECRET:replace-with-your-jwt-secret} + jwt-expire-minutes: ${JWT_EXPIRE_MINUTES:120} + access-expire-minutes: ${ACCESS_EXPIRE_MINUTES:${JWT_EXPIRE_MINUTES:120}} + idle-timeout-minutes: ${IDLE_TIMEOUT_MINUTES:60} + refresh-expire-days: ${REFRESH_EXPIRE_DAYS:14} + password-setup-expire-minutes: ${PASSWORD_SETUP_EXPIRE_MINUTES:1440} + refresh-cookie-name: ${REFRESH_COOKIE_NAME:refreshToken} + refresh-cookie-secure: ${REFRESH_COOKIE_SECURE:false} + refresh-cookie-same-site: ${REFRESH_COOKIE_SAME_SITE:Lax} + frontend-base-url: ${FRONTEND_BASE_URL:http://localhost:5173} + oss: + endpoint: ${OSS_ENDPOINT:https://oss-cn-beijing.aliyuncs.com} + bucket: ${OSS_BUCKET:write-off} + access-key-id: ${OSS_ACCESS_KEY_ID:LTAI5tAkZSLF5xbFGPVqmYeq} + access-key-secret: ${OSS_ACCESS_KEY_SECRET:ETrDjr35Ty2uMSqulOuk2Yky5R1R0Y} + sign-expire-seconds: ${OSS_SIGN_EXPIRE_SECONDS:600} + scheduler: + enabled: ${SCHEDULER_ENABLED:true} + poll-interval-ms: ${SCHEDULER_POLL_INTERVAL_MS:3000} + batch-size: ${SCHEDULER_BATCH_SIZE:100} + job-timeout-seconds: ${JOB_TIMEOUT_SECONDS:120} + max-retry: ${JOB_MAX_RETRY:3} + notification: + webhook-secret: ${NOTIFICATION_WEBHOOK_SECRET:change-me} + gateway-crypto-secret: ${NOTIFICATION_GATEWAY_CRYPTO_SECRET:${JWT_SECRET:replace-with-your-jwt-secret}} + tenant-admin-mail-subject-template: ${TENANT_ADMIN_MAIL_SUBJECT_TEMPLATE:Tenant admin account notification} + tenant-admin-mail-body-template: '${TENANT_ADMIN_MAIL_BODY_TEMPLATE:Action: {actionCn}\nTenant name: {tenantName}\nTenant code: {tenantCode}\nLogin path: {loginPath}\nAdmin phone: {phone}\nPassword setup link: {setupLink}\nPlease use the setup link to complete your password configuration before logging in.}' + mail: + default-subject: ${MAIL_DEFAULT_SUBJECT:绯荤粺閫氱煡} + runtime: + version: ${APP_VERSION:0.0.1-SNAPSHOT} + build-time: ${BUILD_TIME:unknown} + baidu-ocr: + api-key: ${BAIDU_OCR_API_KEY:VDOgQBXtfMH5YYudCIhUULge} + secret-key: ${BAIDU_OCR_SECRET_KEY:as6QiY79TOYm2kKTvdA3aEah1WawNGtT} + token-url: ${BAIDU_OCR_TOKEN_URL:https://aip.baidubce.com/oauth/2.0/token} + multiple-invoice-url: ${BAIDU_OCR_MULTIPLE_INVOICE_URL:https://aip.baidubce.com/rest/2.0/ocr/v1/multiple_invoice} + id-card-url: ${BAIDU_OCR_ID_CARD_URL:https://aip.baidubce.com/rest/2.0/ocr/v1/idcard} + bank-card-url: ${BAIDU_OCR_BANK_CARD_URL:https://aip.baidubce.com/rest/2.0/ocr/v1/bankcard} + document-extract-task-url: ${BAIDU_OCR_DOCUMENT_EXTRACT_TASK_URL:https://aip.baidubce.com/rest/2.0/brain/online/v1/extract/task} + document-extract-query-url: ${BAIDU_OCR_DOCUMENT_EXTRACT_QUERY_URL:https://aip.baidubce.com/rest/2.0/brain/online/v1/extract/query_task} + connect-timeout-ms: ${BAIDU_OCR_CONNECT_TIMEOUT_MS:5000} + read-timeout-ms: ${BAIDU_OCR_READ_TIMEOUT_MS:20000} + max-bytes: ${BAIDU_OCR_MAX_BYTES:3145728} + document-extract-max-bytes: ${BAIDU_OCR_DOCUMENT_EXTRACT_MAX_BYTES:52428800} diff --git a/backend/src/main/resources/db/data.sql b/backend/src/main/resources/db/data.sql new file mode 100644 index 0000000..423e7de --- /dev/null +++ b/backend/src/main/resources/db/data.sql @@ -0,0 +1,40 @@ +-- 初始租户与角色 +INSERT INTO tenant (id, tenant_name, status, created_by, updated_by) +VALUES (1, '默认单位', 'ACTIVE', 0, 0) +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; + +INSERT INTO role (id, tenant_id, role_code, role_name, status, created_by, updated_by) +VALUES + (101, 1, 'TENANT_ADMIN', '单位管理员', 'ENABLED', 0, 0), + (102, 1, 'PROJECT_OWNER', '项目负责人', 'ENABLED', 0, 0), + (103, 1, 'EXECUTOR', '项目执行人', 'ENABLED', 0, 0), + (104, 1, 'AUDITOR', '审核人', 'ENABLED', 0, 0), + (105, 1, 'FINANCE', '财务', 'ENABLED', 0, 0) +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; + +-- 权限字典 +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1001, 'project.create', '创建项目', 'project'), + (1002, 'project.freeze', '冻结项目', 'project'), + (1003, 'meeting.create', '创建会议', 'meeting'), + (1004, 'meeting.submit', '会议级提交', 'meeting'), + (1005, 'audit.approve', '审核通过', 'audit'), + (1006, 'audit.reject', '审核拒绝', 'audit'), + (1007, 'audit.return', '审核退回', 'audit'), + (1008, 'finance.payment.confirm', '支付确认', 'finance'), + (1009, 'meeting.read', '查看会议', 'meeting') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name); + +-- 错误码字典 +INSERT INTO error_code_dict (id, code, message, category) +VALUES + (1, 10001, '参数校验失败', 'COMMON'), + (2, 10002, '请求幂等冲突', 'COMMON'), + (3, 10003, '资源不存在', 'COMMON'), + (4, 20001, '无操作权限', 'PERMISSION'), + (5, 30001, '状态不允许流转', 'STATE'), + (6, 30003, '审核任务已处理', 'STATE'), + (7, 40003, '支付状态不允许', 'FINANCE'), + (8, 90001, '系统内部异常', 'SYSTEM') +ON DUPLICATE KEY UPDATE message = VALUES(message); diff --git a/backend/src/main/resources/db/migration/V100__project_meeting_change_log_permissions.sql b/backend/src/main/resources/db/migration/V100__project_meeting_change_log_permissions.sql new file mode 100644 index 0000000..b155bfc --- /dev/null +++ b/backend/src/main/resources/db/migration/V100__project_meeting_change_log_permissions.sql @@ -0,0 +1,56 @@ +SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission); +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT @next_permission_id, 'project.key-change-log.read', '查看项目关键变更日志', 'project' +FROM dual +WHERE NOT EXISTS ( + SELECT 1 + FROM permission + WHERE permission_code = 'project.key-change-log.read' +); + +SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission); +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT @next_permission_id, 'meeting.change-log.read', '查看会议变更记录', 'meeting' +FROM dual +WHERE NOT EXISTS ( + SELECT 1 + FROM permission + WHERE permission_code = 'meeting.change-log.read' +); + +SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + p.id AS permission_id +FROM role r +JOIN permission p ON p.permission_code = 'project.key-change-log.read' +WHERE r.role_code IN ('TENANT_ADMIN', 'PROJECT_OWNER', 'EXECUTOR', 'AUDITOR', 'FINANCE') + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + p.id AS permission_id +FROM role r +JOIN permission p ON p.permission_code = 'meeting.change-log.read' +WHERE r.role_code IN ('TENANT_ADMIN', 'PROJECT_OWNER', 'EXECUTOR', 'AUDITOR', 'FINANCE') + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); diff --git a/backend/src/main/resources/db/migration/V10__data_permission_policy.sql b/backend/src/main/resources/db/migration/V10__data_permission_policy.sql new file mode 100644 index 0000000..7a00c98 --- /dev/null +++ b/backend/src/main/resources/db/migration/V10__data_permission_policy.sql @@ -0,0 +1,52 @@ +CREATE TABLE IF NOT EXISTS data_permission_policy ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + policy_name VARCHAR(128) NOT NULL, + project_scope VARCHAR(32) NOT NULL DEFAULT 'ALL' COMMENT 'ALL/IDS', + project_ids_csv VARCHAR(2000) DEFAULT NULL, + meeting_scope VARCHAR(32) NOT NULL DEFAULT 'ALL' COMMENT 'ALL/IDS', + meeting_ids_csv VARCHAR(2000) DEFAULT NULL, + module_scope VARCHAR(255) DEFAULT NULL, + export_allowed TINYINT(1) NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_status (tenant_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS role_data_permission ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + policy_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_role_policy (tenant_id, role_id, policy_id), + KEY idx_policy (policy_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO data_permission_policy ( + id, tenant_id, policy_name, project_scope, meeting_scope, module_scope, export_allowed, status +) +VALUES + (1, 1, '默认全量策略', 'ALL', 'ALL', 'ALL', 1, 'ENABLED') +ON DUPLICATE KEY UPDATE + policy_name = VALUES(policy_name), + project_scope = VALUES(project_scope), + meeting_scope = VALUES(meeting_scope), + status = VALUES(status); + +INSERT INTO role_data_permission (id, tenant_id, role_id, policy_id) +VALUES (1, 1, 101, 1) +ON DUPLICATE KEY UPDATE policy_id = VALUES(policy_id); + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1025, 'data.permission.read', '查看数据权限策略', 'system'), + (1026, 'data.permission.manage', '管理数据权限策略', 'system') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (25, 1, 101, 1025), + (26, 1, 101, 1026) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V11__export_permission_seed.sql b/backend/src/main/resources/db/migration/V11__export_permission_seed.sql new file mode 100644 index 0000000..f3c6a0a --- /dev/null +++ b/backend/src/main/resources/db/migration/V11__export_permission_seed.sql @@ -0,0 +1,13 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1027, 'file.download', '下载文件', 'file'), + (1028, 'audit.export.opinions', '导出审核意见', 'audit'), + (1029, 'finance.ledger.export', '导出财务台账', 'finance') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (27, 1, 101, 1027), + (28, 1, 101, 1028), + (29, 1, 101, 1029) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V12__meeting_material_module.sql b/backend/src/main/resources/db/migration/V12__meeting_material_module.sql new file mode 100644 index 0000000..9ebae01 --- /dev/null +++ b/backend/src/main/resources/db/migration/V12__meeting_material_module.sql @@ -0,0 +1,31 @@ +CREATE TABLE IF NOT EXISTS meeting_material ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + module_code VARCHAR(64) NOT NULL, + content_json TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + version_no INT NOT NULL DEFAULT 1, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_meeting_module (tenant_id, meeting_id, module_code), + KEY idx_tenant_meeting (tenant_id, meeting_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS meeting_material_history ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + module_code VARCHAR(64) NOT NULL, + version_no INT NOT NULL, + action_type VARCHAR(32) NOT NULL COMMENT 'SAVE/SUBMIT', + content_json TEXT NOT NULL, + remark VARCHAR(500) DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_meeting_module (tenant_id, meeting_id, module_code), + KEY idx_tenant_meeting_ver (tenant_id, meeting_id, version_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V13__meeting_material_permission_seed.sql b/backend/src/main/resources/db/migration/V13__meeting_material_permission_seed.sql new file mode 100644 index 0000000..9870902 --- /dev/null +++ b/backend/src/main/resources/db/migration/V13__meeting_material_permission_seed.sql @@ -0,0 +1,15 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1030, 'meeting.material.read', '查看会议资料模块', 'meeting'), + (1031, 'meeting.material.save', '保存会议资料模块', 'meeting'), + (1032, 'meeting.material.submit', '提交会议资料模块', 'meeting'), + (1033, 'meeting.material.history.read', '查看会议资料历史', 'meeting') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (30, 1, 101, 1030), + (31, 1, 101, 1031), + (32, 1, 101, 1032), + (33, 1, 101, 1033) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V14__audit_material_read_permission.sql b/backend/src/main/resources/db/migration/V14__audit_material_read_permission.sql new file mode 100644 index 0000000..baead56 --- /dev/null +++ b/backend/src/main/resources/db/migration/V14__audit_material_read_permission.sql @@ -0,0 +1,10 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1034, 'audit.material.read', '审核端查看会议资料', 'audit') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (34, 1, 101, 1034), + (35, 1, 104, 1034) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V15__template_module.sql b/backend/src/main/resources/db/migration/V15__template_module.sql new file mode 100644 index 0000000..b3503c3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V15__template_module.sql @@ -0,0 +1,46 @@ +CREATE TABLE IF NOT EXISTS template ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + template_name VARCHAR(128) NOT NULL, + template_type VARCHAR(64) NOT NULL, + scope_type VARCHAR(32) NOT NULL DEFAULT 'ALL', + project_id BIGINT UNSIGNED DEFAULT NULL, + meeting_id BIGINT UNSIGNED DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + current_version_no INT NOT NULL DEFAULT 1, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_status (tenant_id, status), + KEY idx_tenant_type (tenant_id, template_type) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS template_version ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + template_id BIGINT UNSIGNED NOT NULL, + version_no INT NOT NULL, + object_key VARCHAR(512) NOT NULL, + version_status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + change_log VARCHAR(500) DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_template_version (tenant_id, template_id, version_no), + KEY idx_template (template_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS template_download_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + template_id BIGINT UNSIGNED NOT NULL, + version_no INT NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + object_key VARCHAR(512) NOT NULL, + downloaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + ip VARCHAR(64) DEFAULT NULL, + user_agent VARCHAR(255) DEFAULT NULL, + KEY idx_template_time (template_id, downloaded_at), + KEY idx_user_time (user_id, downloaded_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V16__template_permission_seed.sql b/backend/src/main/resources/db/migration/V16__template_permission_seed.sql new file mode 100644 index 0000000..4291ad7 --- /dev/null +++ b/backend/src/main/resources/db/migration/V16__template_permission_seed.sql @@ -0,0 +1,19 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1035, 'template.read', '模板查询', 'template'), + (1036, 'template.create', '模板上传', 'template'), + (1037, 'template.publish', '模板发布', 'template'), + (1038, 'template.disable', '模板停用', 'template'), + (1039, 'template.rollback', '模板版本回滚', 'template'), + (1040, 'template.download', '模板下载', 'template') +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), module=VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (36, 1, 101, 1035), + (37, 1, 101, 1036), + (38, 1, 101, 1037), + (39, 1, 101, 1038), + (40, 1, 101, 1039), + (41, 1, 101, 1040) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V17__template_type_standardize.sql b/backend/src/main/resources/db/migration/V17__template_type_standardize.sql new file mode 100644 index 0000000..7b006cc --- /dev/null +++ b/backend/src/main/resources/db/migration/V17__template_type_standardize.sql @@ -0,0 +1,11 @@ +UPDATE template +SET template_type = CASE + WHEN UPPER(template_type) LIKE '%AGENDA%' THEN 'AGENDA' + WHEN UPPER(template_type) LIKE '%SIGN%' THEN 'SIGN_IN' + WHEN UPPER(template_type) LIKE '%INVIT%' THEN 'INVITATION' + WHEN UPPER(template_type) = 'SIGN' THEN 'SIGN_IN' + WHEN UPPER(template_type) IN ('INVIT', 'INVITATION_LETTER') THEN 'INVITATION' + WHEN UPPER(template_type) IN ('AGENDA', 'SIGN_IN', 'INVITATION', 'OTHER') THEN UPPER(template_type) + ELSE 'OTHER' +END +WHERE tenant_id = 1; diff --git a/backend/src/main/resources/db/migration/V18__template_type_option.sql b/backend/src/main/resources/db/migration/V18__template_type_option.sql new file mode 100644 index 0000000..f7fff10 --- /dev/null +++ b/backend/src/main/resources/db/migration/V18__template_type_option.sql @@ -0,0 +1,20 @@ +CREATE TABLE IF NOT EXISTS template_type_option ( + type_code VARCHAR(64) NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 1, + type_name VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + sort_no INT NOT NULL DEFAULT 99, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_status (tenant_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO template_type_option (type_code, tenant_id, type_name, status, sort_no) +VALUES + ('AGENDA', 1, '会议日程', 'ENABLED', 1), + ('SIGN_IN', 1, '签到表', 'ENABLED', 2), + ('INVITATION', 1, '邀请函', 'ENABLED', 3), + ('OTHER', 1, '其他', 'ENABLED', 99) +ON DUPLICATE KEY UPDATE + type_name = VALUES(type_name), + sort_no = VALUES(sort_no); diff --git a/backend/src/main/resources/db/migration/V19__tenant_table.sql b/backend/src/main/resources/db/migration/V19__tenant_table.sql new file mode 100644 index 0000000..8df1f33 --- /dev/null +++ b/backend/src/main/resources/db/migration/V19__tenant_table.sql @@ -0,0 +1,59 @@ +CREATE TABLE IF NOT EXISTS tenant ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_code VARCHAR(64) NOT NULL, + tenant_name VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_code (tenant_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +SET @tenant_code_column_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'tenant' + AND COLUMN_NAME = 'tenant_code' +); +SET @add_tenant_code_sql = IF( + @tenant_code_column_exists = 0, + 'ALTER TABLE tenant ADD COLUMN tenant_code VARCHAR(64) NULL AFTER id', + 'SELECT 1' +); +PREPARE stmt_add_tenant_code FROM @add_tenant_code_sql; +EXECUTE stmt_add_tenant_code; +DEALLOCATE PREPARE stmt_add_tenant_code; + +UPDATE tenant +SET tenant_code = CONCAT('TENANT_', LPAD(id, 6, '0')) +WHERE tenant_code IS NULL OR tenant_code = ''; + +UPDATE tenant +SET tenant_code = 'TENANT_DEFAULT' +WHERE id = 1; + +SET @tenant_code_index_exists = ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'tenant' + AND INDEX_NAME = 'uk_tenant_code' +); +SET @add_tenant_code_index_sql = IF( + @tenant_code_index_exists = 0, + 'ALTER TABLE tenant ADD UNIQUE KEY uk_tenant_code (tenant_code)', + 'SELECT 1' +); +PREPARE stmt_add_tenant_code_index FROM @add_tenant_code_index_sql; +EXECUTE stmt_add_tenant_code_index; +DEALLOCATE PREPARE stmt_add_tenant_code_index; + +INSERT INTO tenant (id, tenant_code, tenant_name, status, is_deleted, created_by, updated_by) +VALUES (1, 'TENANT_DEFAULT', '默认单位主体', 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + tenant_code = VALUES(tenant_code), + tenant_name = VALUES(tenant_name), + status = VALUES(status); diff --git a/backend/src/main/resources/db/migration/V1__init_schema.sql b/backend/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 0000000..a2173c2 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,104 @@ +CREATE TABLE IF NOT EXISTS tenant ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_name VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_name (tenant_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS project ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_name VARCHAR(128) NOT NULL, + budget_cent BIGINT NOT NULL, + meeting_total INT NOT NULL, + status VARCHAR(32) NOT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_status (tenant_id, status), + KEY idx_tenant_updated (tenant_id, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS meeting ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_id BIGINT UNSIGNED NOT NULL, + topic VARCHAR(256) NOT NULL, + budget_cent BIGINT NOT NULL, + meeting_status VARCHAR(32) NOT NULL, + audit_status VARCHAR(32) NOT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_project (tenant_id, project_id), + KEY idx_tenant_audit (tenant_id, audit_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS audit_task ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + audit_node VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + opinion VARCHAR(500) DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_node_status (tenant_id, audit_node, status), + KEY idx_tenant_meeting (tenant_id, meeting_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS finance_payment ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + amount_cent BIGINT NOT NULL, + payment_status VARCHAR(32) NOT NULL, + voucher_oss_key VARCHAR(512) DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_project_status (tenant_id, project_id, payment_status), + KEY idx_tenant_meeting (tenant_id, meeting_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS async_job ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + job_type VARCHAR(64) NOT NULL, + payload TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'READY', + next_run_at DATETIME NOT NULL, + retry_count INT NOT NULL DEFAULT 0, + max_retry INT NOT NULL DEFAULT 3, + idempotency_key VARCHAR(128) DEFAULT NULL, + locked_by VARCHAR(128) DEFAULT NULL, + locked_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_status_next_run (status, next_run_at), + UNIQUE KEY uk_idempotency_key (idempotency_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS async_job_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + job_id BIGINT UNSIGNED NOT NULL, + execute_status VARCHAR(32) NOT NULL, + message VARCHAR(500) DEFAULT NULL, + executed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_job_time (job_id, executed_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V20__tenant_permission_seed.sql b/backend/src/main/resources/db/migration/V20__tenant_permission_seed.sql new file mode 100644 index 0000000..9326ab6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V20__tenant_permission_seed.sql @@ -0,0 +1,9 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1041, 'tenant.manage', '租户管理', 'system') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (42, 1, 101, 1041) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V21__operation_audit_log.sql b/backend/src/main/resources/db/migration/V21__operation_audit_log.sql new file mode 100644 index 0000000..93f48b6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V21__operation_audit_log.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS operation_audit_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + action_code VARCHAR(128) NOT NULL, + biz_type VARCHAR(64) DEFAULT NULL, + biz_id VARCHAR(64) DEFAULT NULL, + http_method VARCHAR(16) NOT NULL, + request_uri VARCHAR(255) NOT NULL, + request_query VARCHAR(1000) DEFAULT NULL, + status_code INT NOT NULL DEFAULT 200, + success TINYINT(1) NOT NULL DEFAULT 1, + error_message VARCHAR(500) DEFAULT NULL, + ip VARCHAR(64) DEFAULT NULL, + user_agent VARCHAR(255) DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_time (tenant_id, created_at), + KEY idx_user_time (user_id, created_at), + KEY idx_action_time (action_code, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1042, 'audit.log.read', '查看审计日志', 'system') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (43, 1, 101, 1042) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V22__audit_sla_transfer_enhance.sql b/backend/src/main/resources/db/migration/V22__audit_sla_transfer_enhance.sql new file mode 100644 index 0000000..68f3161 --- /dev/null +++ b/backend/src/main/resources/db/migration/V22__audit_sla_transfer_enhance.sql @@ -0,0 +1,41 @@ +ALTER TABLE audit_task + ADD COLUMN sla_deadline_at DATETIME DEFAULT NULL AFTER opinion, + ADD COLUMN timeout_level TINYINT NOT NULL DEFAULT 0 AFTER sla_deadline_at; + +CREATE TABLE IF NOT EXISTS audit_transfer_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + task_id BIGINT UNSIGNED NOT NULL, + from_user_id BIGINT UNSIGNED DEFAULT NULL, + to_user_id BIGINT UNSIGNED NOT NULL, + reason VARCHAR(500) DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_task_time (tenant_id, task_id, created_at), + KEY idx_tenant_to_user_time (tenant_id, to_user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +UPDATE audit_task +SET sla_deadline_at = DATE_ADD(created_at, INTERVAL 24 HOUR) +WHERE sla_deadline_at IS NULL; + +UPDATE audit_task +SET timeout_level = CASE + WHEN status='PENDING' AND sla_deadline_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) >= 24 THEN 3 + WHEN status='PENDING' AND sla_deadline_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) >= 12 THEN 2 + WHEN status='PENDING' AND sla_deadline_at IS NOT NULL AND TIMESTAMPDIFF(HOUR, sla_deadline_at, NOW()) >= 4 THEN 1 + ELSE 0 END; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1043, 'audit.transfer', '审核转审', 'audit'), + (1044, 'audit.remind', '审核催办', 'audit'), + (1045, 'audit.sla.read', '查看审核SLA统计', 'audit') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (44, 1, 101, 1043), + (45, 1, 101, 1044), + (46, 1, 101, 1045) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V23__finance_reconciliation_lock.sql b/backend/src/main/resources/db/migration/V23__finance_reconciliation_lock.sql new file mode 100644 index 0000000..7a857dd --- /dev/null +++ b/backend/src/main/resources/db/migration/V23__finance_reconciliation_lock.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS finance_reconciliation ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_id BIGINT UNSIGNED NOT NULL, + expected_amount_cent BIGINT NOT NULL DEFAULT 0, + actual_amount_cent BIGINT NOT NULL DEFAULT 0, + diff_amount_cent BIGINT NOT NULL DEFAULT 0, + result_status VARCHAR(32) NOT NULL DEFAULT 'MATCH', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_project_time (tenant_id, project_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS finance_lock_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_id BIGINT UNSIGNED NOT NULL, + lock_status VARCHAR(32) NOT NULL, + reason VARCHAR(500) DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_project_status (tenant_id, project_id, lock_status), + KEY idx_tenant_time (tenant_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1046, 'finance.reconciliation', '财务对账', 'finance'), + (1047, 'finance.lock', '财务锁账', 'finance'), + (1048, 'finance.unlock', '财务解锁', 'finance') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (47, 1, 101, 1046), + (48, 1, 101, 1047), + (49, 1, 101, 1048) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V24__template_archive_diff_permission.sql b/backend/src/main/resources/db/migration/V24__template_archive_diff_permission.sql new file mode 100644 index 0000000..4593339 --- /dev/null +++ b/backend/src/main/resources/db/migration/V24__template_archive_diff_permission.sql @@ -0,0 +1,9 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1049, 'template.archive', '模板归档', 'template') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (50, 1, 101, 1049) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V25__template_flow_linkage.sql b/backend/src/main/resources/db/migration/V25__template_flow_linkage.sql new file mode 100644 index 0000000..65fa518 --- /dev/null +++ b/backend/src/main/resources/db/migration/V25__template_flow_linkage.sql @@ -0,0 +1,34 @@ +ALTER TABLE template + ADD COLUMN biz_scene VARCHAR(64) NOT NULL DEFAULT 'MEETING_RECOMMEND' AFTER meeting_id; + +CREATE TABLE IF NOT EXISTS template_flow_link ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + scene_code VARCHAR(64) NOT NULL, + template_id BIGINT UNSIGNED NOT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_scene (tenant_id, scene_code), + KEY idx_tenant_template (tenant_id, template_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO template_flow_link (tenant_id, scene_code, template_id, created_by, updated_by) +SELECT t.tenant_id, 'MEETING_RECOMMEND', t.id, 0, 0 +FROM template t +WHERE t.tenant_id = 1 AND t.status='PUBLISHED' +ORDER BY t.id DESC +LIMIT 1 +ON DUPLICATE KEY UPDATE template_id = VALUES(template_id), updated_at = CURRENT_TIMESTAMP; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1050, 'template.flow.link', '模板流程联动绑定', 'template') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (51, 1, 101, 1050) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V26__expert_module.sql b/backend/src/main/resources/db/migration/V26__expert_module.sql new file mode 100644 index 0000000..2e4810f --- /dev/null +++ b/backend/src/main/resources/db/migration/V26__expert_module.sql @@ -0,0 +1,78 @@ +CREATE TABLE IF NOT EXISTS expert ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + expert_name VARCHAR(128) NOT NULL, + id_no VARCHAR(64) NOT NULL, + phone VARCHAR(32) NOT NULL, + title VARCHAR(128) DEFAULT NULL, + organization VARCHAR(255) DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_id_no (tenant_id, id_no), + KEY idx_tenant_name (tenant_id, expert_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS expert_bank_card ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + expert_id BIGINT UNSIGNED NOT NULL, + bank_name VARCHAR(128) NOT NULL, + bank_card_no VARCHAR(128) NOT NULL, + account_name VARCHAR(128) NOT NULL, + is_default CHAR(1) NOT NULL DEFAULT 'N', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_expert (tenant_id, expert_id), + KEY idx_tenant_card (tenant_id, bank_card_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS expert_merge_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + target_expert_id BIGINT UNSIGNED NOT NULL, + source_expert_id BIGINT UNSIGNED NOT NULL, + reason VARCHAR(500) DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_target (tenant_id, target_expert_id), + KEY idx_tenant_source (tenant_id, source_expert_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS meeting_expert_snapshot ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + expert_id BIGINT UNSIGNED NOT NULL, + snapshot_json TEXT NOT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_meeting (tenant_id, meeting_id), + KEY idx_tenant_expert (tenant_id, expert_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1051, 'expert.read', '查看专家', 'expert'), + (1052, 'expert.create', '创建专家', 'expert'), + (1053, 'expert.merge', '专家合并', 'expert'), + (1054, 'expert.import', '导入专家', 'expert'), + (1055, 'expert.export', '导出专家', 'expert'), + (1056, 'expert.card.manage', '管理专家银行卡', 'expert') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (52, 1, 101, 1051), + (53, 1, 101, 1052), + (54, 1, 101, 1053), + (55, 1, 101, 1054), + (56, 1, 101, 1055), + (57, 1, 101, 1056) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V27__notification_policy.sql b/backend/src/main/resources/db/migration/V27__notification_policy.sql new file mode 100644 index 0000000..2da898d --- /dev/null +++ b/backend/src/main/resources/db/migration/V27__notification_policy.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS notification_policy ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + policy_name VARCHAR(128) NOT NULL, + event_code VARCHAR(64) NOT NULL, + channel VARCHAR(32) NOT NULL, + receiver_type VARCHAR(64) NOT NULL, + template_id BIGINT UNSIGNED NOT NULL, + variables_json TEXT DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_event_status (tenant_id, event_code, status), + KEY idx_tenant_template (tenant_id, template_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS notification_policy_event ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + policy_id BIGINT UNSIGNED NOT NULL, + event_code VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_policy (tenant_id, policy_id), + KEY idx_tenant_event (tenant_id, event_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1057, 'notification.policy.read', '查看通知策略', 'notification'), + (1058, 'notification.policy.manage', '管理通知策略', 'notification') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (58, 1, 101, 1057), + (59, 1, 101, 1058) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V28__observability_alerting.sql b/backend/src/main/resources/db/migration/V28__observability_alerting.sql new file mode 100644 index 0000000..94c13f0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V28__observability_alerting.sql @@ -0,0 +1,63 @@ +CREATE TABLE IF NOT EXISTS observability_metric ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + metric_code VARCHAR(64) NOT NULL, + label_key VARCHAR(64) DEFAULT NULL, + label_value VARCHAR(255) DEFAULT NULL, + metric_value DOUBLE NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_metric_time (tenant_id, metric_code, created_at), + KEY idx_tenant_label (tenant_id, label_key, label_value) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS alert_rule ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + rule_code VARCHAR(64) NOT NULL, + rule_name VARCHAR(128) NOT NULL, + compare_op VARCHAR(8) NOT NULL DEFAULT '>=', + threshold_value DOUBLE NOT NULL DEFAULT 0, + window_minute INT NOT NULL DEFAULT 5, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_rule_code (tenant_id, rule_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS alert_event ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + rule_code VARCHAR(64) NOT NULL, + metric_code VARCHAR(64) NOT NULL, + current_value DOUBLE NOT NULL DEFAULT 0, + threshold_value DOUBLE NOT NULL DEFAULT 0, + alert_level VARCHAR(32) NOT NULL DEFAULT 'WARN', + message VARCHAR(500) DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_rule_time (tenant_id, rule_code, created_at), + KEY idx_tenant_metric_time (tenant_id, metric_code, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO alert_rule (tenant_id, rule_code, rule_name, compare_op, threshold_value, window_minute, status, created_by, updated_by) +VALUES + (1, 'API_5XX_RATE', 'API 5xx错误率', '>=', 5, 5, 'ENABLED', 0, 0), + (1, 'ASYNC_BACKLOG', '异步任务积压量', '>=', 50, 5, 'ENABLED', 0, 0), + (1, 'ASYNC_FAILED_RATE', '异步任务失败率', '>=', 10, 10, 'ENABLED', 0, 0) +ON DUPLICATE KEY UPDATE threshold_value = VALUES(threshold_value), window_minute = VALUES(window_minute), updated_at = CURRENT_TIMESTAMP; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1059, 'observability.read', '查看可观测性指标', 'observability'), + (1060, 'observability.manage', '管理可观测性告警', 'observability') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (60, 1, 101, 1059), + (61, 1, 101, 1060) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V29__observability_recovery_suppress.sql b/backend/src/main/resources/db/migration/V29__observability_recovery_suppress.sql new file mode 100644 index 0000000..1aeb3bd --- /dev/null +++ b/backend/src/main/resources/db/migration/V29__observability_recovery_suppress.sql @@ -0,0 +1,10 @@ +ALTER TABLE alert_rule + ADD COLUMN suppress_window_minute INT NOT NULL DEFAULT 0 AFTER window_minute; + +ALTER TABLE alert_event + ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' AFTER alert_level, + ADD COLUMN recovered_at DATETIME DEFAULT NULL AFTER created_at; + +UPDATE alert_rule +SET suppress_window_minute = 5 +WHERE rule_code IN ('API_5XX_RATE', 'ASYNC_FAILED_RATE') AND suppress_window_minute = 0; diff --git a/backend/src/main/resources/db/migration/V2__seed_data.sql b/backend/src/main/resources/db/migration/V2__seed_data.sql new file mode 100644 index 0000000..4c1ee1d --- /dev/null +++ b/backend/src/main/resources/db/migration/V2__seed_data.sql @@ -0,0 +1,7 @@ +INSERT INTO tenant (id, tenant_name, status, created_by, updated_by) +VALUES (1, '默认单位', 'ACTIVE', 0, 0) +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; + +INSERT INTO project (id, tenant_id, project_name, budget_cent, meeting_total, status, created_by, updated_by) +VALUES (1001, 1, 'MVP默认项目', 1000000, 3, 'WAITING', 0, 0) +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; diff --git a/backend/src/main/resources/db/migration/V30__meeting_withdraw_field_invoice.sql b/backend/src/main/resources/db/migration/V30__meeting_withdraw_field_invoice.sql new file mode 100644 index 0000000..8e7c223 --- /dev/null +++ b/backend/src/main/resources/db/migration/V30__meeting_withdraw_field_invoice.sql @@ -0,0 +1,56 @@ +CREATE TABLE IF NOT EXISTS meeting_field ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + field_code VARCHAR(64) NOT NULL, + field_name VARCHAR(128) NOT NULL, + field_values TEXT NOT NULL, + scope_type VARCHAR(32) NOT NULL DEFAULT 'GLOBAL', + project_id BIGINT UNSIGNED DEFAULT NULL, + sort_no INT NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_field_code (tenant_id, field_code), + KEY idx_tenant_status_sort (tenant_id, status, sort_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS invoice_profile ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + company_name VARCHAR(200) NOT NULL, + tax_no VARCHAR(64) NOT NULL, + bank_name VARCHAR(128) NOT NULL, + account_no VARCHAR(128) NOT NULL, + address VARCHAR(255) DEFAULT NULL, + phone VARCHAR(64) DEFAULT NULL, + default_project_id BIGINT UNSIGNED DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_tax_account (tenant_id, tax_no, account_no), + KEY idx_tenant_status_project (tenant_id, status, default_project_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1061, 'meeting.withdraw', '撤回会议提交', 'meeting'), + (1062, 'meeting.field.read', '查看会议字段配置', 'meeting'), + (1063, 'meeting.field.manage', '管理会议字段配置', 'meeting'), + (1064, 'invoice.profile.read', '查看发票抬头', 'invoice'), + (1065, 'invoice.profile.manage', '管理发票抬头', 'invoice') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (70, 1, 101, 1061), + (71, 1, 101, 1062), + (72, 1, 101, 1063), + (73, 1, 101, 1064), + (74, 1, 101, 1065) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V31__notification_dispatch_export_task_auto_eval.sql b/backend/src/main/resources/db/migration/V31__notification_dispatch_export_task_auto_eval.sql new file mode 100644 index 0000000..7fd9e6e --- /dev/null +++ b/backend/src/main/resources/db/migration/V31__notification_dispatch_export_task_auto_eval.sql @@ -0,0 +1,68 @@ +CREATE TABLE IF NOT EXISTS notification_task ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + policy_id BIGINT UNSIGNED NOT NULL, + event_code VARCHAR(64) NOT NULL, + channel VARCHAR(32) NOT NULL, + receiver_type VARCHAR(32) NOT NULL, + receiver_ref VARCHAR(128) DEFAULT NULL, + payload_json TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + retry_count INT NOT NULL DEFAULT 0, + max_retry INT NOT NULL DEFAULT 3, + error_message VARCHAR(500) DEFAULT NULL, + idempotency_key VARCHAR(128) DEFAULT NULL, + sent_at DATETIME DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_idempotency (tenant_id, idempotency_key), + KEY idx_tenant_event_status (tenant_id, event_code, status), + KEY idx_tenant_created (tenant_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS export_task ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + task_code VARCHAR(64) NOT NULL, + biz_type VARCHAR(64) NOT NULL, + biz_id VARCHAR(128) DEFAULT NULL, + filters_json TEXT, + file_name VARCHAR(255) NOT NULL, + file_oss_key VARCHAR(512) DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + retry_count INT NOT NULL DEFAULT 0, + max_retry INT NOT NULL DEFAULT 3, + error_message VARCHAR(500) DEFAULT NULL, + idempotency_key VARCHAR(128) DEFAULT NULL, + requested_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + finished_at DATETIME DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_export_idempotency (tenant_id, idempotency_key), + KEY idx_tenant_status_time (tenant_id, status, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +ALTER TABLE alert_event + ADD COLUMN recover_candidate_at DATETIME DEFAULT NULL AFTER recovered_at; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1066, 'notification.dispatch', '触发通知发送', 'notification'), + (1067, 'notification.task.read', '查看通知发送任务', 'notification'), + (1068, 'export.task.read', '查看导出任务', 'export'), + (1069, 'export.task.manage', '创建导出任务', 'export') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (75, 1, 101, 1066), + (76, 1, 101, 1067), + (77, 1, 101, 1068), + (78, 1, 101, 1069) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V32__deepen_receipt_export_download_dashboard.sql b/backend/src/main/resources/db/migration/V32__deepen_receipt_export_download_dashboard.sql new file mode 100644 index 0000000..68667ec --- /dev/null +++ b/backend/src/main/resources/db/migration/V32__deepen_receipt_export_download_dashboard.sql @@ -0,0 +1,35 @@ +ALTER TABLE notification_task + ADD COLUMN provider_message_id VARCHAR(128) DEFAULT NULL AFTER status, + ADD COLUMN receipt_code VARCHAR(64) DEFAULT NULL AFTER provider_message_id, + ADD COLUMN receipt_message VARCHAR(500) DEFAULT NULL AFTER receipt_code, + ADD COLUMN receipt_at DATETIME DEFAULT NULL AFTER sent_at; + +CREATE TABLE IF NOT EXISTS notification_receipt_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + task_id BIGINT UNSIGNED NOT NULL, + provider_message_id VARCHAR(128) NOT NULL, + receipt_code VARCHAR(64) NOT NULL, + receipt_message VARCHAR(500) DEFAULT NULL, + receipt_status VARCHAR(32) NOT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_task_time (tenant_id, task_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +ALTER TABLE export_task + ADD COLUMN download_token VARCHAR(128) DEFAULT NULL AFTER file_oss_key, + ADD COLUMN download_token_expire_at DATETIME DEFAULT NULL AFTER download_token, + ADD COLUMN download_count INT NOT NULL DEFAULT 0 AFTER download_token_expire_at; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1070, 'export.task.download', '下载导出结果', 'export'), + (1071, 'dashboard.read', '查看运营看板', 'dashboard') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (79, 1, 101, 1070), + (80, 1, 101, 1071) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V33__user_account_validity.sql b/backend/src/main/resources/db/migration/V33__user_account_validity.sql new file mode 100644 index 0000000..6f24e10 --- /dev/null +++ b/backend/src/main/resources/db/migration/V33__user_account_validity.sql @@ -0,0 +1,36 @@ +SET @valid_from_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_user' + AND COLUMN_NAME = 'valid_from' +); +SET @add_valid_from_sql = IF( + @valid_from_exists = 0, + 'ALTER TABLE sys_user ADD COLUMN valid_from DATETIME NULL AFTER status', + 'SELECT 1' +); +PREPARE stmt_add_valid_from FROM @add_valid_from_sql; +EXECUTE stmt_add_valid_from; +DEALLOCATE PREPARE stmt_add_valid_from; + +SET @valid_to_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_user' + AND COLUMN_NAME = 'valid_to' +); +SET @add_valid_to_sql = IF( + @valid_to_exists = 0, + 'ALTER TABLE sys_user ADD COLUMN valid_to DATETIME NULL AFTER valid_from', + 'SELECT 1' +); +PREPARE stmt_add_valid_to FROM @add_valid_to_sql; +EXECUTE stmt_add_valid_to; +DEALLOCATE PREPARE stmt_add_valid_to; + +UPDATE sys_user +SET valid_from = IFNULL(valid_from, created_at), + valid_to = IFNULL(valid_to, '2099-12-31 23:59:59') +WHERE is_deleted = 0; diff --git a/backend/src/main/resources/db/migration/V34__enterprise_and_project_enterprise_ref.sql b/backend/src/main/resources/db/migration/V34__enterprise_and_project_enterprise_ref.sql new file mode 100644 index 0000000..12c758d --- /dev/null +++ b/backend/src/main/resources/db/migration/V34__enterprise_and_project_enterprise_ref.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS enterprise ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + enterprise_name VARCHAR(128) NOT NULL, + enterprise_url VARCHAR(255) DEFAULT NULL, + logo_url VARCHAR(512) DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_enterprise_name (tenant_id, enterprise_name), + KEY idx_tenant_status (tenant_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +SET @project_enterprise_id_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'project' + AND COLUMN_NAME = 'enterprise_id' +); +SET @add_project_enterprise_id_sql = IF( + @project_enterprise_id_exists = 0, + 'ALTER TABLE project ADD COLUMN enterprise_id BIGINT UNSIGNED NULL AFTER project_name', + 'SELECT 1' +); +PREPARE stmt_add_project_enterprise_id FROM @add_project_enterprise_id_sql; +EXECUTE stmt_add_project_enterprise_id; +DEALLOCATE PREPARE stmt_add_project_enterprise_id; + +SET @project_enterprise_idx_exists = ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'project' + AND INDEX_NAME = 'idx_tenant_enterprise' +); +SET @add_project_enterprise_idx_sql = IF( + @project_enterprise_idx_exists = 0, + 'ALTER TABLE project ADD KEY idx_tenant_enterprise (tenant_id, enterprise_id)', + 'SELECT 1' +); +PREPARE stmt_add_project_enterprise_idx FROM @add_project_enterprise_idx_sql; +EXECUTE stmt_add_project_enterprise_idx; +DEALLOCATE PREPARE stmt_add_project_enterprise_idx; diff --git a/backend/src/main/resources/db/migration/V35__menu_management.sql b/backend/src/main/resources/db/migration/V35__menu_management.sql new file mode 100644 index 0000000..000e335 --- /dev/null +++ b/backend/src/main/resources/db/migration/V35__menu_management.sql @@ -0,0 +1,67 @@ +CREATE TABLE IF NOT EXISTS menu ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + menu_code VARCHAR(64) NOT NULL, + menu_name VARCHAR(64) NOT NULL, + route_path VARCHAR(128) NOT NULL, + sort_no INT NOT NULL DEFAULT 100, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_menu_code (tenant_id, menu_code), + KEY idx_tenant_status_sort (tenant_id, status, sort_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS role_menu ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + menu_id BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uk_tenant_role_menu (tenant_id, role_id, menu_id), + KEY idx_tenant_role (tenant_id, role_id), + KEY idx_tenant_menu (tenant_id, menu_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (1, 'project', '项目管理', '/projects', 10, 'ENABLED', 0, 0, 0), + (1, 'meeting', '会议管理', '/meetings', 20, 'ENABLED', 0, 0, 0), + (1, 'audit', '审核管理', '/audits', 30, 'ENABLED', 0, 0, 0), + (1, 'finance', '财务管理', '/finance', 40, 'ENABLED', 0, 0, 0), + (1, 'user', '用户管理', '/users', 50, 'ENABLED', 0, 0, 0), + (1, 'tenant', '租户管理', '/tenants', 60, 'ENABLED', 0, 0, 0), + (1, 'enterprise', '企业管理', '/enterprises', 70, 'ENABLED', 0, 0, 0), + (1, 'role', '角色管理', '/roles', 80, 'ENABLED', 0, 0, 0), + (1, 'menu', '菜单管理', '/menus', 90, 'ENABLED', 0, 0, 0), + (1, 'audit_flow', '审核流配置', '/audit-flows', 100, 'ENABLED', 0, 0, 0), + (1, 'data_permission', '数据权限管理', '/data-permissions', 110, 'ENABLED', 0, 0, 0), + (1, 'template', '模板管理', '/templates', 120, 'ENABLED', 0, 0, 0), + (1, 'template_download_log', '模板下载日志', '/template-download-logs', 130, 'ENABLED', 0, 0, 0), + (1, 'audit_log', '审计日志', '/audit-logs', 140, 'ENABLED', 0, 0, 0), + (1, 'expert', '专家管理', '/experts', 150, 'ENABLED', 0, 0, 0), + (1, 'meeting_field', '会议字段管理', '/meeting-fields', 160, 'ENABLED', 0, 0, 0), + (1, 'invoice_profile', '发票管理', '/invoice-profiles', 170, 'ENABLED', 0, 0, 0), + (1, 'notification_policy', '通知策略', '/notification-policies', 180, 'ENABLED', 0, 0, 0), + (1, 'export_task', '导出任务中心', '/export-tasks', 190, 'ENABLED', 0, 0, 0), + (1, 'observability', '可观测性', '/observability', 200, 'ENABLED', 0, 0, 0), + (1, 'operations_dashboard', '运营看板', '/operations-dashboard', 210, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (1000 + m.id) AS id, + 1 AS tenant_id, + 101 AS role_id, + m.id AS menu_id +FROM menu m +WHERE m.tenant_id = 1 +ON DUPLICATE KEY UPDATE menu_id = VALUES(menu_id); diff --git a/backend/src/main/resources/db/migration/V36__menu_permission_code.sql b/backend/src/main/resources/db/migration/V36__menu_permission_code.sql new file mode 100644 index 0000000..6a4ef62 --- /dev/null +++ b/backend/src/main/resources/db/migration/V36__menu_permission_code.sql @@ -0,0 +1,37 @@ +SET @menu_permission_code_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'menu' + AND COLUMN_NAME = 'permission_code' +); +SET @add_menu_permission_code_sql = IF( + @menu_permission_code_exists = 0, + 'ALTER TABLE menu ADD COLUMN permission_code VARCHAR(128) NULL AFTER route_path', + 'SELECT 1' +); +PREPARE stmt_add_menu_permission_code FROM @add_menu_permission_code_sql; +EXECUTE stmt_add_menu_permission_code; +DEALLOCATE PREPARE stmt_add_menu_permission_code; + +UPDATE menu SET permission_code='project.create' WHERE tenant_id=1 AND route_path='/projects'; +UPDATE menu SET permission_code='meeting.create' WHERE tenant_id=1 AND route_path='/meetings'; +UPDATE menu SET permission_code='audit.approve' WHERE tenant_id=1 AND route_path='/audits'; +UPDATE menu SET permission_code='finance.payment.confirm' WHERE tenant_id=1 AND route_path='/finance'; +UPDATE menu SET permission_code='user.read' WHERE tenant_id=1 AND route_path='/users'; +UPDATE menu SET permission_code='tenant.manage' WHERE tenant_id=1 AND route_path='/tenants'; +UPDATE menu SET permission_code='tenant.manage' WHERE tenant_id=1 AND route_path='/enterprises'; +UPDATE menu SET permission_code='role.read' WHERE tenant_id=1 AND route_path='/roles'; +UPDATE menu SET permission_code='role.permission.bind' WHERE tenant_id=1 AND route_path='/menus'; +UPDATE menu SET permission_code='audit.flow.read' WHERE tenant_id=1 AND route_path='/audit-flows'; +UPDATE menu SET permission_code='data.permission.read' WHERE tenant_id=1 AND route_path='/data-permissions'; +UPDATE menu SET permission_code='template.read' WHERE tenant_id=1 AND route_path='/templates'; +UPDATE menu SET permission_code='template.read' WHERE tenant_id=1 AND route_path='/template-download-logs'; +UPDATE menu SET permission_code='audit.log.read' WHERE tenant_id=1 AND route_path='/audit-logs'; +UPDATE menu SET permission_code='expert.read' WHERE tenant_id=1 AND route_path='/experts'; +UPDATE menu SET permission_code='meeting.field.read' WHERE tenant_id=1 AND route_path='/meeting-fields'; +UPDATE menu SET permission_code='invoice.profile.read' WHERE tenant_id=1 AND route_path='/invoice-profiles'; +UPDATE menu SET permission_code='notification.policy.read' WHERE tenant_id=1 AND route_path='/notification-policies'; +UPDATE menu SET permission_code='export.task.read' WHERE tenant_id=1 AND route_path='/export-tasks'; +UPDATE menu SET permission_code='observability.read' WHERE tenant_id=1 AND route_path='/observability'; +UPDATE menu SET permission_code='dashboard.read' WHERE tenant_id=1 AND route_path='/operations-dashboard'; diff --git a/backend/src/main/resources/db/migration/V37__user_delegation.sql b/backend/src/main/resources/db/migration/V37__user_delegation.sql new file mode 100644 index 0000000..d5ab70f --- /dev/null +++ b/backend/src/main/resources/db/migration/V37__user_delegation.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS user_delegation ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + delegate_user_id BIGINT UNSIGNED NOT NULL, + effective_from DATETIME NOT NULL, + effective_to DATETIME NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'PENDING', + reason VARCHAR(255) DEFAULT NULL, + disabled_reason VARCHAR(255) DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_user_status (tenant_id, user_id, status), + KEY idx_tenant_delegate_status (tenant_id, delegate_user_id, status), + KEY idx_tenant_effective_to (tenant_id, effective_to) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1052, 'user.delegation.manage', '代理授权管理', 'system') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (53, 1, 101, 1052) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V38__v2a_field_dictionary.sql b/backend/src/main/resources/db/migration/V38__v2a_field_dictionary.sql new file mode 100644 index 0000000..8f559f6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V38__v2a_field_dictionary.sql @@ -0,0 +1,372 @@ +-- V2A-DB-01: 字段级数据字典落库(6.10) +-- MySQL 5.7:通过 information_schema + PREPARE 方式做幂等加列 + +-- ========================= +-- project 扩展字段 +-- ========================= +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'host_enterprise_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN host_enterprise_id BIGINT UNSIGNED NULL AFTER enterprise_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'partner_enterprise_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN partner_enterprise_id BIGINT UNSIGNED NULL AFTER host_enterprise_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'host_owner_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN host_owner_user_id BIGINT UNSIGNED NULL AFTER partner_enterprise_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'host_executor_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN host_executor_user_id BIGINT UNSIGNED NULL AFTER host_owner_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'partner_owner_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN partner_owner_user_id BIGINT UNSIGNED NULL AFTER host_executor_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'partner_executor_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN partner_executor_user_id BIGINT UNSIGNED NULL AFTER partner_owner_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'allow_meeting_over_budget'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN allow_meeting_over_budget TINYINT(1) NOT NULL DEFAULT 0 AFTER partner_executor_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'over_budget_threshold_ratio'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN over_budget_threshold_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.100000 AFTER allow_meeting_over_budget', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'over_budget_approval_chain_json'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN over_budget_approval_chain_json TEXT NULL AFTER over_budget_threshold_ratio', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'meeting_completed_count'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN meeting_completed_count INT NOT NULL DEFAULT 0 AFTER meeting_total', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'budget_execution_ratio'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN budget_execution_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 AFTER meeting_completed_count', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'risk_flags_json'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN risk_flags_json TEXT NULL AFTER budget_execution_ratio', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND INDEX_NAME = 'idx_tenant_host_partner'); +SET @sql := IF(@idx = 0, 'ALTER TABLE project ADD KEY idx_tenant_host_partner (tenant_id, host_enterprise_id, partner_enterprise_id)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ========================= +-- meeting 扩展字段 +-- ========================= +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'sub_project_name'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN sub_project_name VARCHAR(128) NULL AFTER topic', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'meeting_category'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN meeting_category VARCHAR(64) NULL AFTER sub_project_name', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'meeting_form'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN meeting_form VARCHAR(64) NULL AFTER meeting_category', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'location'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN location VARCHAR(255) NULL AFTER meeting_form', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'start_time'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN start_time DATETIME NULL AFTER location', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'end_time'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN end_time DATETIME NULL AFTER start_time', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'labor_ratio'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN labor_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 AFTER budget_cent', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'catering_ratio'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN catering_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 AFTER labor_ratio', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'current_audit_node'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN current_audit_node VARCHAR(64) NULL AFTER audit_status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'last_submit_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN last_submit_at DATETIME NULL AFTER current_audit_node', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'last_reject_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN last_reject_reason VARCHAR(500) NULL AFTER last_submit_at', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'overdue_days'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN overdue_days INT NOT NULL DEFAULT 0 AFTER last_reject_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'risk_flags_json'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN risk_flags_json TEXT NULL AFTER overdue_days', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'is_frozen'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN is_frozen TINYINT(1) NOT NULL DEFAULT 0 AFTER risk_flags_json', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'freeze_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN freeze_reason VARCHAR(500) NULL AFTER is_frozen', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND INDEX_NAME = 'idx_tenant_time'); +SET @sql := IF(@idx = 0, 'ALTER TABLE meeting ADD KEY idx_tenant_time (tenant_id, start_time, end_time)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ========================= +-- meeting_material 扩展字段 +-- ========================= +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting_material' AND COLUMN_NAME = 'is_latest_version'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting_material ADD COLUMN is_latest_version TINYINT(1) NOT NULL DEFAULT 1 AFTER version_no', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting_material' AND COLUMN_NAME = 'audit_node_status'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting_material ADD COLUMN audit_node_status VARCHAR(32) NULL AFTER status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting_material' AND COLUMN_NAME = 'audit_aggregate_status'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting_material ADD COLUMN audit_aggregate_status VARCHAR(32) NULL AFTER audit_node_status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting_material' AND COLUMN_NAME = 'submit_remark'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting_material ADD COLUMN submit_remark VARCHAR(500) NULL AFTER audit_aggregate_status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting_material' AND COLUMN_NAME = 'reject_count'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting_material ADD COLUMN reject_count INT NOT NULL DEFAULT 0 AFTER submit_remark', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting_material' AND COLUMN_NAME = 'last_reject_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting_material ADD COLUMN last_reject_reason VARCHAR(500) NULL AFTER reject_count', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting_material' AND COLUMN_NAME = 'resubmit_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting_material ADD COLUMN resubmit_at DATETIME NULL AFTER last_reject_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ========================= +-- 发票结构化新表 +-- ========================= +CREATE TABLE IF NOT EXISTS meeting_material_invoice_item ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + material_id BIGINT UNSIGNED DEFAULT NULL, + expense_type VARCHAR(64) NOT NULL, + invoice_no VARCHAR(128) DEFAULT NULL, + invoice_amount_cent BIGINT NOT NULL DEFAULT 0, + tax_amount_cent BIGINT NOT NULL DEFAULT 0, + detail_amount_cent BIGINT NOT NULL DEFAULT 0, + vendor_name VARCHAR(255) DEFAULT NULL, + occur_date DATE DEFAULT NULL, + remark VARCHAR(500) DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_meeting_expense_date (tenant_id, meeting_id, expense_type, occur_date), + KEY idx_tenant_invoice_no (tenant_id, invoice_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS meeting_material_invoice_file ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + invoice_item_id BIGINT UNSIGNED DEFAULT NULL, + file_type VARCHAR(32) NOT NULL COMMENT 'INVOICE/DETAIL/PHOTO/TICKET/OTHER', + file_name VARCHAR(255) NOT NULL, + oss_key VARCHAR(512) NOT NULL, + content_type VARCHAR(128) DEFAULT NULL, + size BIGINT UNSIGNED NOT NULL DEFAULT 0, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_meeting_file_type (tenant_id, meeting_id, file_type), + KEY idx_tenant_invoice_item (tenant_id, invoice_item_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS meeting_invoice_summary ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + category_amount_cent_json TEXT NOT NULL, + meeting_total_amount_cent BIGINT NOT NULL DEFAULT 0, + is_over_budget TINYINT(1) NOT NULL DEFAULT 0, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_meeting (tenant_id, meeting_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +-- ========================= +-- audit_task 扩展字段 +-- ========================= +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_task' AND COLUMN_NAME = 'overtime_hours'); +SET @sql := IF(@c = 0, 'ALTER TABLE audit_task ADD COLUMN overtime_hours INT NOT NULL DEFAULT 0 AFTER timeout_level', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_task' AND COLUMN_NAME = 'is_overtime'); +SET @sql := IF(@c = 0, 'ALTER TABLE audit_task ADD COLUMN is_overtime TINYINT(1) NOT NULL DEFAULT 0 AFTER overtime_hours', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_task' AND COLUMN_NAME = 'transfer_from_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE audit_task ADD COLUMN transfer_from_user_id BIGINT UNSIGNED NULL AFTER assignee_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_task' AND COLUMN_NAME = 'transfer_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE audit_task ADD COLUMN transfer_reason VARCHAR(500) NULL AFTER transfer_from_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_task' AND COLUMN_NAME = 'return_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE audit_task ADD COLUMN return_reason VARCHAR(500) NULL AFTER transfer_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_task' AND COLUMN_NAME = 'reject_count'); +SET @sql := IF(@c = 0, 'ALTER TABLE audit_task ADD COLUMN reject_count INT NOT NULL DEFAULT 0 AFTER return_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_task' AND COLUMN_NAME = 'last_reject_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE audit_task ADD COLUMN last_reject_reason VARCHAR(500) NULL AFTER reject_count', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'audit_task' AND COLUMN_NAME = 'last_action_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE audit_task ADD COLUMN last_action_at DATETIME NULL AFTER last_reject_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ========================= +-- expert / expert_bank_card 扩展字段 +-- ========================= +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'gender'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN gender VARCHAR(16) NULL AFTER expert_name', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'birthday'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN birthday DATE NULL AFTER gender', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'id_card_valid_until'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN id_card_valid_until DATE NULL AFTER id_no', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'status_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN status_reason VARCHAR(500) NULL AFTER status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'status_changed_by'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN status_changed_by BIGINT UNSIGNED NULL AFTER status_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'status_changed_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN status_changed_at DATETIME NULL AFTER status_changed_by', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'export_restricted'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN export_restricted TINYINT(1) NOT NULL DEFAULT 1 AFTER status_changed_at', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert_bank_card' AND COLUMN_NAME = 'bank_province'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert_bank_card ADD COLUMN bank_province VARCHAR(64) NULL AFTER bank_name', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert_bank_card' AND COLUMN_NAME = 'bank_city'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert_bank_card ADD COLUMN bank_city VARCHAR(64) NULL AFTER bank_province', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert_bank_card' AND COLUMN_NAME = 'bank_branch_name'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert_bank_card ADD COLUMN bank_branch_name VARCHAR(255) NULL AFTER bank_city', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert_bank_card' AND COLUMN_NAME = 'card_status'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert_bank_card ADD COLUMN card_status VARCHAR(32) NOT NULL DEFAULT ''ENABLED'' AFTER is_default', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert_bank_card' AND COLUMN_NAME = 'inconsistent_name_approved'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert_bank_card ADD COLUMN inconsistent_name_approved TINYINT(1) NOT NULL DEFAULT 0 AFTER card_status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert_bank_card' AND COLUMN_NAME = 'change_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert_bank_card ADD COLUMN change_reason VARCHAR(500) NULL AFTER inconsistent_name_approved', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ========================= +-- template / template_version 扩展字段 +-- ========================= +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template' AND COLUMN_NAME = 'scope_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE template ADD COLUMN scope_id BIGINT UNSIGNED NULL AFTER scope_type', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template' AND COLUMN_NAME = 'effective_from'); +SET @sql := IF(@c = 0, 'ALTER TABLE template ADD COLUMN effective_from DATETIME NULL AFTER current_version_no', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template' AND COLUMN_NAME = 'effective_to'); +SET @sql := IF(@c = 0, 'ALTER TABLE template ADD COLUMN effective_to DATETIME NULL AFTER effective_from', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template' AND COLUMN_NAME = 'watermark_enabled'); +SET @sql := IF(@c = 0, 'ALTER TABLE template ADD COLUMN watermark_enabled TINYINT(1) NOT NULL DEFAULT 0 AFTER effective_to', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template' AND COLUMN_NAME = 'download_rate_limit_per_hour'); +SET @sql := IF(@c = 0, 'ALTER TABLE template ADD COLUMN download_rate_limit_per_hour INT NOT NULL DEFAULT 100 AFTER watermark_enabled', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_version' AND COLUMN_NAME = 'is_effective'); +SET @sql := IF(@c = 0, 'ALTER TABLE template_version ADD COLUMN is_effective TINYINT(1) NOT NULL DEFAULT 0 AFTER version_status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_version' AND COLUMN_NAME = 'rollback_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE template_version ADD COLUMN rollback_reason VARCHAR(500) NULL AFTER change_log', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- ========================= +-- finance_meeting_bill 新表 +-- ========================= +CREATE TABLE IF NOT EXISTS finance_meeting_bill ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + venue_amount_cent BIGINT NOT NULL DEFAULT 0, + build_amount_cent BIGINT NOT NULL DEFAULT 0, + hotel_amount_cent BIGINT NOT NULL DEFAULT 0, + catering_amount_cent BIGINT NOT NULL DEFAULT 0, + local_traffic_amount_cent BIGINT NOT NULL DEFAULT 0, + long_distance_traffic_amount_cent BIGINT NOT NULL DEFAULT 0, + material_amount_cent BIGINT NOT NULL DEFAULT 0, + design_amount_cent BIGINT NOT NULL DEFAULT 0, + labor_payable_amount_cent BIGINT NOT NULL DEFAULT 0, + labor_actual_amount_cent BIGINT NOT NULL DEFAULT 0, + finance_review_fee_cent BIGINT NOT NULL DEFAULT 0, + management_fee_cent BIGINT NOT NULL DEFAULT 0, + tax_fee_cent BIGINT NOT NULL DEFAULT 0, + custom_fee_json TEXT NULL, + paid_amount_cent BIGINT NOT NULL DEFAULT 0, + unpaid_amount_cent BIGINT NOT NULL DEFAULT 0, + reconciliation_result VARCHAR(32) DEFAULT NULL, + reconciliation_diff_amount_cent BIGINT NOT NULL DEFAULT 0, + reconciliation_diff_reason VARCHAR(500) DEFAULT NULL, + settlement_no VARCHAR(64) DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DRAFT', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_meeting (tenant_id, meeting_id), + UNIQUE KEY uk_tenant_settlement_no (tenant_id, settlement_no), + KEY idx_tenant_project_status (tenant_id, project_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V39__operation_audit_log_scope.sql b/backend/src/main/resources/db/migration/V39__operation_audit_log_scope.sql new file mode 100644 index 0000000..3db59b8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V39__operation_audit_log_scope.sql @@ -0,0 +1,36 @@ +SET @scope_col_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'operation_audit_log' + AND COLUMN_NAME = 'scope' +); + +SET @add_scope_col_sql = IF( + @scope_col_exists = 0, + 'ALTER TABLE operation_audit_log ADD COLUMN scope VARCHAR(16) NOT NULL DEFAULT ''TENANT'' AFTER user_id', + 'SELECT 1' +); +PREPARE stmt_add_scope_col FROM @add_scope_col_sql; +EXECUTE stmt_add_scope_col; +DEALLOCATE PREPARE stmt_add_scope_col; + +UPDATE operation_audit_log +SET scope = CASE WHEN tenant_id = 0 THEN 'PLATFORM' ELSE 'TENANT' END +WHERE scope IS NULL OR scope = ''; + +SET @scope_idx_exists = ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'operation_audit_log' + AND INDEX_NAME = 'idx_scope_time' +); +SET @add_scope_idx_sql = IF( + @scope_idx_exists = 0, + 'ALTER TABLE operation_audit_log ADD KEY idx_scope_time (scope, created_at)', + 'SELECT 1' +); +PREPARE stmt_add_scope_idx FROM @add_scope_idx_sql; +EXECUTE stmt_add_scope_idx; +DEALLOCATE PREPARE stmt_add_scope_idx; diff --git a/backend/src/main/resources/db/migration/V3__auth_rbac_seed.sql b/backend/src/main/resources/db/migration/V3__auth_rbac_seed.sql new file mode 100644 index 0000000..3aeefe3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V3__auth_rbac_seed.sql @@ -0,0 +1,94 @@ +CREATE TABLE IF NOT EXISTS sys_user ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_name VARCHAR(64) NOT NULL, + phone VARCHAR(32) NOT NULL, + email VARCHAR(128) DEFAULT NULL, + password_hash VARCHAR(255) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_phone (tenant_id, phone) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS role ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + role_code VARCHAR(64) NOT NULL, + role_name VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_role_code (tenant_id, role_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS permission ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + permission_code VARCHAR(128) NOT NULL, + permission_name VARCHAR(128) NOT NULL, + module VARCHAR(64) NOT NULL, + UNIQUE KEY uk_permission_code (permission_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS role_permission ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + permission_id BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uk_tenant_role_perm (tenant_id, role_id, permission_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS user_role ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uk_tenant_user_role (tenant_id, user_id, role_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO role (id, tenant_id, role_code, role_name, status, created_by, updated_by) +VALUES + (101, 1, 'TENANT_ADMIN', '閸楁洑缍呯粻锛勬倞閸?, 'ENABLED', 0, 0), + (102, 1, 'PROJECT_OWNER', '妞ゅ湱娲扮拹鐔荤煑娴?, 'ENABLED', 0, 0), + (103, 1, 'EXECUTOR', '妞ゅ湱娲伴幍褑顢戞禍?, 'ENABLED', 0, 0), + (104, 1, 'AUDITOR', '鐎光剝鐗虫禍?, 'ENABLED', 0, 0), + (105, 1, 'FINANCE', '鐠愩垹濮?, 'ENABLED', 0, 0) +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1001, 'project.create', '閸掓稑缂撴い鍦窗', 'project'), + (1002, 'project.freeze', '閸愯崵绮ㄦい鍦窗', 'project'), + (1003, 'meeting.create', '閸掓稑缂撴导姘愁唴', 'meeting'), + (1004, 'meeting.submit', '娴兼俺顔呯痪褎褰佹禍?, 'meeting'), + (1005, 'audit.approve', '鐎光剝鐗抽柅姘崇箖', 'audit'), + (1006, 'audit.reject', '鐎光剝鐗抽幏鎺旂卜', 'audit'), + (1007, 'audit.return', '鐎光剝鐗抽柅鈧崶?, 'audit'), + (1008, 'finance.payment.confirm', '閺€顖欑帛绾喛顓?, 'finance') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name); + +INSERT INTO sys_user (id, tenant_id, user_name, phone, email, password_hash, status, created_by, updated_by) +VALUES (1, 1, '缁狅紕鎮婇崨?, '13800000000', 'admin@writeoff.local', '123456', 'ENABLED', 0, 0) +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; + +INSERT INTO user_role (id, tenant_id, user_id, role_id) +VALUES (1, 1, 1, 101) +ON DUPLICATE KEY UPDATE role_id = VALUES(role_id); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (1, 1, 101, 1001), + (2, 1, 101, 1002), + (3, 1, 101, 1003), + (4, 1, 101, 1004), + (5, 1, 101, 1005), + (6, 1, 101, 1006), + (7, 1, 101, 1007), + (8, 1, 101, 1008) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V40__platform_admin_rbac.sql b/backend/src/main/resources/db/migration/V40__platform_admin_rbac.sql new file mode 100644 index 0000000..f16ff6f --- /dev/null +++ b/backend/src/main/resources/db/migration/V40__platform_admin_rbac.sql @@ -0,0 +1,77 @@ +CREATE TABLE IF NOT EXISTS platform_user ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_name VARCHAR(64) NOT NULL, + phone VARCHAR(32) NOT NULL, + email VARCHAR(128) DEFAULT NULL, + password_hash VARCHAR(255) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + valid_from DATETIME DEFAULT NULL, + valid_to DATETIME DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_platform_phone (phone) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS platform_role ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + role_code VARCHAR(64) NOT NULL, + role_name VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_platform_role_code (role_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS platform_permission ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + permission_code VARCHAR(128) NOT NULL, + permission_name VARCHAR(128) NOT NULL, + module VARCHAR(64) NOT NULL, + UNIQUE KEY uk_platform_permission_code (permission_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS platform_user_role ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uk_platform_user_role (user_id, role_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS platform_role_permission ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + role_id BIGINT UNSIGNED NOT NULL, + permission_id BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uk_platform_role_permission (role_id, permission_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO platform_role (id, role_code, role_name, status, is_deleted, created_by, updated_by) +VALUES (1, 'PLATFORM_SUPER_ADMIN', '缁崵绮虹搾鍛獓缁狅紕鎮婇崨?, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE role_name = VALUES(role_name), status = VALUES(status), updated_at = CURRENT_TIMESTAMP; + +INSERT INTO platform_permission (id, permission_code, permission_name, module) +VALUES + (1, 'platform.tenant.manage', '楠炲啿褰寸粔鐔稿煕缁狅紕鎮?, 'platform'), + (2, 'platform.user.manage', '楠炲啿褰寸拹锕€褰跨粻锛勬倞', 'platform'), + (3, 'platform.audit.read', '楠炲啿褰寸€孤ゎ吀閺屻儳婀?, 'platform') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO platform_role_permission (id, role_id, permission_id) +VALUES + (1, 1, 1), + (2, 1, 2), + (3, 1, 3) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); + +INSERT INTO platform_user (id, user_name, phone, email, password_hash, status, valid_from, valid_to, is_deleted, created_by, updated_by) +VALUES (1, '缁崵绮虹搾鍛獓缁狅紕鎮婇崨?, '13900000000', 'platform-admin@writeoff.local', '123456', 'ENABLED', NOW(), '2099-12-31 23:59:59', 0, 0, 0) +ON DUPLICATE KEY UPDATE user_name = VALUES(user_name), status = VALUES(status), updated_at = CURRENT_TIMESTAMP; + +INSERT INTO platform_user_role (id, user_id, role_id) +VALUES (1, 1, 1) +ON DUPLICATE KEY UPDATE role_id = VALUES(role_id); diff --git a/backend/src/main/resources/db/migration/V41__platform_menu_management.sql b/backend/src/main/resources/db/migration/V41__platform_menu_management.sql new file mode 100644 index 0000000..6a7f18e --- /dev/null +++ b/backend/src/main/resources/db/migration/V41__platform_menu_management.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS platform_menu ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + menu_code VARCHAR(64) NOT NULL, + menu_name VARCHAR(64) NOT NULL, + route_path VARCHAR(128) NOT NULL, + permission_code VARCHAR(128) DEFAULT NULL, + sort_no INT NOT NULL DEFAULT 100, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_platform_menu_code (menu_code), + KEY idx_platform_menu_status_sort (status, sort_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS platform_role_menu ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + role_id BIGINT UNSIGNED NOT NULL, + menu_id BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uk_platform_role_menu (role_id, menu_id), + KEY idx_platform_role_menu_role (role_id), + KEY idx_platform_role_menu_menu (menu_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO platform_menu (id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (1, 'platform_tenant', '租户管理', '/platform/tenants', 'platform.tenant.manage', 10, 'ENABLED', 0, 0, 0), + (2, 'platform_audit_log', '平台审计日志', '/platform/audit-logs', 'platform.audit.read', 20, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO platform_role_menu (id, role_id, menu_id) +VALUES + (1, 1, 1), + (2, 1, 2) +ON DUPLICATE KEY UPDATE menu_id = VALUES(menu_id); diff --git a/backend/src/main/resources/db/migration/V42__platform_menu_permission_seed.sql b/backend/src/main/resources/db/migration/V42__platform_menu_permission_seed.sql new file mode 100644 index 0000000..0c9590b --- /dev/null +++ b/backend/src/main/resources/db/migration/V42__platform_menu_permission_seed.sql @@ -0,0 +1,11 @@ +INSERT INTO platform_permission (id, permission_code, permission_name, module) +VALUES + (4, 'platform.menu.manage', '平台菜单管理', 'platform'), + (5, 'platform.role.read', '平台角色查看', 'platform') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO platform_role_permission (id, role_id, permission_id) +VALUES + (4, 1, 4), + (5, 1, 5) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V43__platform_menu_manage_seed.sql b/backend/src/main/resources/db/migration/V43__platform_menu_manage_seed.sql new file mode 100644 index 0000000..07863f0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V43__platform_menu_manage_seed.sql @@ -0,0 +1,16 @@ +INSERT INTO platform_menu (id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (3, 'platform_menu_manage', '平台菜单管理', '/platform/menus', 'platform.menu.manage', 30, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO platform_role_menu (id, role_id, menu_id) +VALUES + (3, 1, 3) +ON DUPLICATE KEY UPDATE menu_id = VALUES(menu_id); diff --git a/backend/src/main/resources/db/migration/V44__platform_iam_menu_seed.sql b/backend/src/main/resources/db/migration/V44__platform_iam_menu_seed.sql new file mode 100644 index 0000000..5f47947 --- /dev/null +++ b/backend/src/main/resources/db/migration/V44__platform_iam_menu_seed.sql @@ -0,0 +1,32 @@ +INSERT INTO platform_permission (id, permission_code, permission_name, module) +VALUES + (6, 'platform.role.manage', '平台角色管理', 'platform'), + (7, 'platform.permission.read', '平台权限查看', 'platform') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO platform_role_permission (id, role_id, permission_id) +VALUES + (6, 1, 6), + (7, 1, 7) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); + +INSERT INTO platform_menu (id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (4, 'platform_user_manage', '平台用户管理', '/platform/users', 'platform.user.manage', 15, 'ENABLED', 0, 0, 0), + (5, 'platform_role_manage', '平台角色管理', '/platform/roles', 'platform.role.read', 16, 'ENABLED', 0, 0, 0), + (6, 'platform_permission_manage', '平台权限管理', '/platform/permissions', 'platform.permission.read', 17, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO platform_role_menu (id, role_id, menu_id) +VALUES + (4, 1, 4), + (5, 1, 5), + (6, 1, 6) +ON DUPLICATE KEY UPDATE menu_id = VALUES(menu_id); diff --git a/backend/src/main/resources/db/migration/V45__migrate_auditlog_expert_to_platform.sql b/backend/src/main/resources/db/migration/V45__migrate_auditlog_expert_to_platform.sql new file mode 100644 index 0000000..3e2ae34 --- /dev/null +++ b/backend/src/main/resources/db/migration/V45__migrate_auditlog_expert_to_platform.sql @@ -0,0 +1,32 @@ +INSERT INTO platform_permission (id, permission_code, permission_name, module) +VALUES + (8, 'platform.expert.read', '平台专家查看', 'platform'), + (9, 'platform.expert.manage', '平台专家管理', 'platform') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO platform_role_permission (id, role_id, permission_id) +VALUES + (8, 1, 8), + (9, 1, 9) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); + +INSERT INTO platform_menu (id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (7, 'platform_expert', '专家管理', '/platform/experts', 'platform.expert.read', 18, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO platform_role_menu (id, role_id, menu_id) +VALUES + (7, 1, 7) +ON DUPLICATE KEY UPDATE menu_id = VALUES(menu_id); + +UPDATE menu +SET status='DISABLED', is_deleted=1, updated_at=CURRENT_TIMESTAMP +WHERE tenant_id=1 AND route_path IN ('/audit-logs', '/experts'); diff --git a/backend/src/main/resources/db/migration/V46__init_tenant_admin_role_menu_permission.sql b/backend/src/main/resources/db/migration/V46__init_tenant_admin_role_menu_permission.sql new file mode 100644 index 0000000..f5f5333 --- /dev/null +++ b/backend/src/main/resources/db/migration/V46__init_tenant_admin_role_menu_permission.sql @@ -0,0 +1,57 @@ +SET @next_role_id := (SELECT IFNULL(MAX(id), 0) FROM role); + +INSERT INTO role (id, tenant_id, role_code, role_name, status, is_deleted, created_by, updated_by) +SELECT + (@next_role_id := @next_role_id + 1) AS id, + t.id AS tenant_id, + 'TENANT_ADMIN' AS role_code, + '单位管理员' AS role_name, + 'ENABLED' AS status, + 0 AS is_deleted, + 0 AS created_by, + 0 AS updated_by +FROM tenant t +LEFT JOIN role r ON r.tenant_id=t.id AND r.role_code='TENANT_ADMIN' AND r.is_deleted=0 +WHERE t.is_deleted=0 AND r.id IS NULL; + +SET @template_role_id := ( + SELECT id FROM role + WHERE tenant_id=1 AND role_code='TENANT_ADMIN' AND is_deleted=0 + LIMIT 1 +); + +SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + target_role.tenant_id, + target_role.id AS role_id, + template_perm.permission_id +FROM role target_role +JOIN role_permission template_perm ON template_perm.tenant_id=1 AND template_perm.role_id=@template_role_id +LEFT JOIN role_permission existing_perm + ON existing_perm.tenant_id=target_role.tenant_id + AND existing_perm.role_id=target_role.id + AND existing_perm.permission_id=template_perm.permission_id +WHERE target_role.is_deleted=0 + AND target_role.role_code='TENANT_ADMIN' + AND existing_perm.id IS NULL; + +SET @next_role_menu_id := (SELECT IFNULL(MAX(id), 0) FROM role_menu); + +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (@next_role_menu_id := @next_role_menu_id + 1) AS id, + target_role.tenant_id, + target_role.id AS role_id, + template_menu.menu_id +FROM role target_role +JOIN role_menu template_menu ON template_menu.tenant_id=1 AND template_menu.role_id=@template_role_id +LEFT JOIN role_menu existing_menu + ON existing_menu.tenant_id=target_role.tenant_id + AND existing_menu.role_id=target_role.id + AND existing_menu.menu_id=template_menu.menu_id +WHERE target_role.is_deleted=0 + AND target_role.role_code='TENANT_ADMIN' + AND existing_menu.id IS NULL; diff --git a/backend/src/main/resources/db/migration/V47__tenant_permission_menu_seed.sql b/backend/src/main/resources/db/migration/V47__tenant_permission_menu_seed.sql new file mode 100644 index 0000000..55c23cc --- /dev/null +++ b/backend/src/main/resources/db/migration/V47__tenant_permission_menu_seed.sql @@ -0,0 +1,41 @@ +INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (1, 'permission', '权限管理', '/permissions', 'permission.read', 85, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (1000 + m.id) AS id, + 1 AS tenant_id, + 101 AS role_id, + m.id AS menu_id +FROM menu m +WHERE m.tenant_id = 1 + AND m.menu_code = 'permission' +ON DUPLICATE KEY UPDATE menu_id = VALUES(menu_id); + +SET @next_role_menu_id = (SELECT IFNULL(MAX(id), 0) FROM role_menu); +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (@next_role_menu_id := @next_role_menu_id + 1) AS id, + r.tenant_id, + r.id, + m.id +FROM role r +JOIN menu m ON m.tenant_id = r.tenant_id AND m.menu_code = 'permission' AND m.is_deleted = 0 +WHERE r.role_code = 'TENANT_ADMIN' + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_menu rm + WHERE rm.tenant_id = r.tenant_id + AND rm.role_id = r.id + AND rm.menu_id = m.id + ); diff --git a/backend/src/main/resources/db/migration/V48__project_user_binding.sql b/backend/src/main/resources/db/migration/V48__project_user_binding.sql new file mode 100644 index 0000000..118306f --- /dev/null +++ b/backend/src/main/resources/db/migration/V48__project_user_binding.sql @@ -0,0 +1,24 @@ +CREATE TABLE IF NOT EXISTS project_user_binding ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + bind_role_code VARCHAR(64) NOT NULL COMMENT 'PROJECT_OWNER/PROJECT_EXECUTOR', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_project_user_role (tenant_id, project_id, user_id, bind_role_code), + KEY idx_user_role (tenant_id, user_id, bind_role_code, is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES (20001, 'project.bind.user', '绑定项目人员', 'project') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT 20001, 1, r.id, 20001 +FROM role r +WHERE r.tenant_id = 1 AND r.role_code = 'TENANT_ADMIN' +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V49__data_permission_user_scope_owner.sql b/backend/src/main/resources/db/migration/V49__data_permission_user_scope_owner.sql new file mode 100644 index 0000000..0e62dc8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V49__data_permission_user_scope_owner.sql @@ -0,0 +1,7 @@ +ALTER TABLE data_permission_policy + ADD COLUMN user_scope VARCHAR(32) NOT NULL DEFAULT 'ALL' COMMENT 'ALL/IDS/OWNER' AFTER meeting_ids_csv, + ADD COLUMN user_ids_csv VARCHAR(2000) DEFAULT NULL AFTER user_scope; + +ALTER TABLE data_permission_policy + MODIFY COLUMN project_scope VARCHAR(32) NOT NULL DEFAULT 'ALL' COMMENT 'ALL/IDS/OWNER', + MODIFY COLUMN meeting_scope VARCHAR(32) NOT NULL DEFAULT 'ALL' COMMENT 'ALL/IDS/OWNER'; diff --git a/backend/src/main/resources/db/migration/V4__audit_flow_config.sql b/backend/src/main/resources/db/migration/V4__audit_flow_config.sql new file mode 100644 index 0000000..1567e54 --- /dev/null +++ b/backend/src/main/resources/db/migration/V4__audit_flow_config.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS audit_flow ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + flow_code VARCHAR(64) NOT NULL, + flow_name VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_flow_code (tenant_id, flow_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS audit_flow_node ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + flow_id BIGINT UNSIGNED NOT NULL, + node_code VARCHAR(32) NOT NULL, + node_name VARCHAR(64) NOT NULL, + sort_no INT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_flow_node_code (flow_id, node_code), + KEY idx_flow_sort (flow_id, sort_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO audit_flow (id, tenant_id, flow_code, flow_name, status) +VALUES (1, 1, 'DEFAULT', '默认三审流程', 'ENABLED') +ON DUPLICATE KEY UPDATE flow_name = VALUES(flow_name), status = VALUES(status); + +INSERT INTO audit_flow_node (id, flow_id, node_code, node_name, sort_no, status) +VALUES + (1, 1, 'INIT_REVIEW', '初审', 1, 'ENABLED'), + (2, 1, 'RE_REVIEW', '复审', 2, 'ENABLED'), + (3, 1, 'FINAL_REVIEW', '终审', 3, 'ENABLED') +ON DUPLICATE KEY UPDATE node_name = VALUES(node_name), sort_no = VALUES(sort_no), status = VALUES(status); diff --git a/backend/src/main/resources/db/migration/V50__project_bind_executor_permission.sql b/backend/src/main/resources/db/migration/V50__project_bind_executor_permission.sql new file mode 100644 index 0000000..2cb8f4d --- /dev/null +++ b/backend/src/main/resources/db/migration/V50__project_bind_executor_permission.sql @@ -0,0 +1,9 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES (20002, 'project.bind.executor_user', '绑定EXECUTOR项目人员', 'project') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT 20002, 1, r.id, 20002 +FROM role r +WHERE r.tenant_id = 1 AND r.role_code = 'TENANT_ADMIN' +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V51__meeting_expert_binding_table.sql b/backend/src/main/resources/db/migration/V51__meeting_expert_binding_table.sql new file mode 100644 index 0000000..251e9cb --- /dev/null +++ b/backend/src/main/resources/db/migration/V51__meeting_expert_binding_table.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS meeting_expert_binding ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + expert_id BIGINT UNSIGNED NOT NULL, + expert_name VARCHAR(128) NOT NULL, + phone VARCHAR(32) DEFAULT NULL, + title VARCHAR(128) DEFAULT NULL, + organization VARCHAR(255) DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_meeting (tenant_id, meeting_id), + KEY idx_tenant_expert (tenant_id, expert_id), + UNIQUE KEY uk_tenant_meeting_expert (tenant_id, meeting_id, expert_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V52__project_fields_and_comments.sql b/backend/src/main/resources/db/migration/V52__project_fields_and_comments.sql new file mode 100644 index 0000000..d282766 --- /dev/null +++ b/backend/src/main/resources/db/migration/V52__project_fields_and_comments.sql @@ -0,0 +1,55 @@ +-- 项目模块字段补齐(对齐产品文档)并补充字段注释 + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'sub_project_name'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN sub_project_name VARCHAR(128) NULL AFTER project_name', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'start_date'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN start_date DATE NULL AFTER sub_project_name', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'end_date'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN end_date DATE NULL AFTER start_date', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'payment_status'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN payment_status VARCHAR(32) NOT NULL DEFAULT ''WAIT_SUBMIT'' AFTER risk_flags_json', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'labor_fee_ratio'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN labor_fee_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 AFTER payment_status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'key_change_log_json'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN key_change_log_json TEXT NULL AFTER labor_fee_ratio', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND INDEX_NAME = 'idx_tenant_payment_status'); +SET @sql := IF(@idx = 0, 'ALTER TABLE project ADD KEY idx_tenant_payment_status (tenant_id, payment_status)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 字段注释(统一补齐) +ALTER TABLE project + MODIFY COLUMN project_name VARCHAR(128) NOT NULL COMMENT '项目名称', + MODIFY COLUMN sub_project_name VARCHAR(128) NULL COMMENT '子项目名称', + MODIFY COLUMN enterprise_id BIGINT UNSIGNED NULL COMMENT '合作企业ID(历史兼容)', + MODIFY COLUMN host_enterprise_id BIGINT UNSIGNED NULL COMMENT '主办单位ID', + MODIFY COLUMN partner_enterprise_id BIGINT UNSIGNED NULL COMMENT '合作企业ID', + MODIFY COLUMN host_owner_user_id BIGINT UNSIGNED NULL COMMENT '主办单位负责人用户ID', + MODIFY COLUMN host_executor_user_id BIGINT UNSIGNED NULL COMMENT '主办单位项目执行人用户ID', + MODIFY COLUMN partner_owner_user_id BIGINT UNSIGNED NULL COMMENT '合作企业负责人用户ID', + MODIFY COLUMN partner_executor_user_id BIGINT UNSIGNED NULL COMMENT '合作企业项目执行人用户ID', + MODIFY COLUMN start_date DATE NULL COMMENT '项目开始日期', + MODIFY COLUMN end_date DATE NULL COMMENT '项目结束日期', + MODIFY COLUMN budget_cent BIGINT NOT NULL COMMENT '项目总预算(分)', + MODIFY COLUMN meeting_total INT NOT NULL COMMENT '会议总期数', + MODIFY COLUMN meeting_completed_count INT NOT NULL DEFAULT 0 COMMENT '已完成核销期数', + MODIFY COLUMN allow_meeting_over_budget TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否允许单场超支', + MODIFY COLUMN over_budget_threshold_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.100000 COMMENT '超支阈值比例', + MODIFY COLUMN over_budget_approval_chain_json TEXT NULL COMMENT '超支审批链(JSON)', + MODIFY COLUMN budget_execution_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 COMMENT '预算执行率', + MODIFY COLUMN risk_flags_json TEXT NULL COMMENT '风险标记(JSON)', + MODIFY COLUMN payment_status VARCHAR(32) NOT NULL DEFAULT 'WAIT_SUBMIT' COMMENT '支付状态', + MODIFY COLUMN labor_fee_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 COMMENT '劳务费用占比', + MODIFY COLUMN key_change_log_json TEXT NULL COMMENT '关键变更日志(JSON)', + MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '项目状态'; diff --git a/backend/src/main/resources/db/migration/V53__project_more_fields_from_prd.sql b/backend/src/main/resources/db/migration/V53__project_more_fields_from_prd.sql new file mode 100644 index 0000000..76917f8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V53__project_more_fields_from_prd.sql @@ -0,0 +1,79 @@ +-- 项目模块二期字段补齐(核销状态/进度、审批人、备份执行人、中止冻结归档等) + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'write_off_status'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN write_off_status VARCHAR(32) NOT NULL DEFAULT ''WAITING'' AFTER payment_status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'write_off_not_started_count'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN write_off_not_started_count INT NOT NULL DEFAULT 0 AFTER write_off_status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'write_off_in_progress_count'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN write_off_in_progress_count INT NOT NULL DEFAULT 0 AFTER write_off_not_started_count', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'write_off_completed_count'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN write_off_completed_count INT NOT NULL DEFAULT 0 AFTER write_off_in_progress_count', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'allow_project_over_budget'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN allow_project_over_budget TINYINT(1) NOT NULL DEFAULT 0 AFTER labor_fee_ratio', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'invoice_info'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN invoice_info TEXT NULL AFTER allow_project_over_budget', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'expense_ratio_json'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN expense_ratio_json TEXT NULL AFTER invoice_info', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'host_backup_executor_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN host_backup_executor_user_id BIGINT UNSIGNED NULL AFTER host_executor_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'partner_backup_executor_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN partner_backup_executor_user_id BIGINT UNSIGNED NULL AFTER partner_executor_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'project_owner_approver_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN project_owner_approver_user_id BIGINT UNSIGNED NULL AFTER over_budget_approval_chain_json', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'finance_approver_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN finance_approver_user_id BIGINT UNSIGNED NULL AFTER project_owner_approver_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'terminated_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN terminated_reason VARCHAR(500) NULL AFTER status', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'freeze_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN freeze_reason VARCHAR(500) NULL AFTER terminated_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND COLUMN_NAME = 'archived_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN archived_at DATETIME NULL AFTER freeze_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 补充索引 +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'project' AND INDEX_NAME = 'idx_tenant_write_off_status'); +SET @sql := IF(@idx = 0, 'ALTER TABLE project ADD KEY idx_tenant_write_off_status (tenant_id, write_off_status)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 字段注释 +ALTER TABLE project + MODIFY COLUMN write_off_status VARCHAR(32) NOT NULL DEFAULT 'WAITING' COMMENT '核销状态', + MODIFY COLUMN write_off_not_started_count INT NOT NULL DEFAULT 0 COMMENT '核销进度-未开始场次', + MODIFY COLUMN write_off_in_progress_count INT NOT NULL DEFAULT 0 COMMENT '核销进度-核销中场次', + MODIFY COLUMN write_off_completed_count INT NOT NULL DEFAULT 0 COMMENT '核销进度-核销完成场次', + MODIFY COLUMN allow_project_over_budget TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否允许超过项目总费用', + MODIFY COLUMN invoice_info TEXT NULL COMMENT '发票信息快照(用于一键复制)', + MODIFY COLUMN expense_ratio_json TEXT NULL COMMENT '费用占比(JSON)', + MODIFY COLUMN host_backup_executor_user_id BIGINT UNSIGNED NULL COMMENT '主办单位备份执行人用户ID', + MODIFY COLUMN partner_backup_executor_user_id BIGINT UNSIGNED NULL COMMENT '合作企业备份执行人用户ID', + MODIFY COLUMN project_owner_approver_user_id BIGINT UNSIGNED NULL COMMENT '项目负责人审批人用户ID', + MODIFY COLUMN finance_approver_user_id BIGINT UNSIGNED NULL COMMENT '财务审批人用户ID', + MODIFY COLUMN terminated_reason VARCHAR(500) NULL COMMENT '项目中止原因', + MODIFY COLUMN freeze_reason VARCHAR(500) NULL COMMENT '项目冻结原因', + MODIFY COLUMN archived_at DATETIME NULL COMMENT '归档时间'; diff --git a/backend/src/main/resources/db/migration/V54__project_key_change_log_table.sql b/backend/src/main/resources/db/migration/V54__project_key_change_log_table.sql new file mode 100644 index 0000000..63a5d59 --- /dev/null +++ b/backend/src/main/resources/db/migration/V54__project_key_change_log_table.sql @@ -0,0 +1,20 @@ +-- 项目关键变更日志表:记录预算/周期/负责人/执行人/会议期数等字段前后值 + +CREATE TABLE IF NOT EXISTS project_key_change_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '租户ID', + project_id BIGINT UNSIGNED NOT NULL COMMENT '项目ID', + field_code VARCHAR(64) NOT NULL COMMENT '字段编码', + field_name VARCHAR(128) NOT NULL COMMENT '字段名称', + before_value VARCHAR(1000) NULL COMMENT '变更前值', + after_value VARCHAR(1000) NULL COMMENT '变更后值', + change_reason VARCHAR(500) NULL COMMENT '变更原因', + handover_at DATETIME NULL COMMENT '交接时间(负责人/执行人变更时)', + is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除', + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + KEY idx_tenant_project_time (tenant_id, project_id, created_at), + KEY idx_tenant_field_time (tenant_id, field_code, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='项目关键变更日志'; diff --git a/backend/src/main/resources/db/migration/V55__project_subproject_count_and_host_name.sql b/backend/src/main/resources/db/migration/V55__project_subproject_count_and_host_name.sql new file mode 100644 index 0000000..ee37528 --- /dev/null +++ b/backend/src/main/resources/db/migration/V55__project_subproject_count_and_host_name.sql @@ -0,0 +1,9 @@ +-- 直接按最新需求调整项目字段(不做兼容) + +ALTER TABLE project + ADD COLUMN sub_project_count INT NOT NULL DEFAULT 0 COMMENT '子项目数量', + ADD COLUMN host_enterprise_name VARCHAR(128) NULL COMMENT '主办单位(当前租户名称)'; + +ALTER TABLE project + MODIFY COLUMN sub_project_count INT NOT NULL DEFAULT 0 COMMENT '子项目数量', + MODIFY COLUMN host_enterprise_name VARCHAR(128) NULL COMMENT '主办单位(当前租户名称)'; diff --git a/backend/src/main/resources/db/migration/V56__project_remove_user_id_columns.sql b/backend/src/main/resources/db/migration/V56__project_remove_user_id_columns.sql new file mode 100644 index 0000000..7d1ffd0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V56__project_remove_user_id_columns.sql @@ -0,0 +1,12 @@ +-- 彻底改为通过 project_user_binding 管理项目人员,project 表不再存人员ID字段 + +ALTER TABLE project + DROP COLUMN host_enterprise_id, + DROP COLUMN host_owner_user_id, + DROP COLUMN host_executor_user_id, + DROP COLUMN partner_owner_user_id, + DROP COLUMN partner_executor_user_id, + DROP COLUMN host_backup_executor_user_id, + DROP COLUMN partner_backup_executor_user_id, + DROP COLUMN project_owner_approver_user_id, + DROP COLUMN finance_approver_user_id; diff --git a/backend/src/main/resources/db/migration/V57__project_hierarchy_parent_id.sql b/backend/src/main/resources/db/migration/V57__project_hierarchy_parent_id.sql new file mode 100644 index 0000000..1ac8ceb --- /dev/null +++ b/backend/src/main/resources/db/migration/V57__project_hierarchy_parent_id.sql @@ -0,0 +1,8 @@ +-- 子项目层级化:不存子项目数量,改为基于 parent_project_id 动态统计 + +ALTER TABLE project + ADD COLUMN parent_project_id BIGINT UNSIGNED NULL COMMENT '上级项目ID(为空表示一级项目)', + DROP COLUMN sub_project_count; + +ALTER TABLE project + ADD KEY idx_tenant_parent_project (tenant_id, parent_project_id); diff --git a/backend/src/main/resources/db/migration/V58__project_drop_enterprise_id.sql b/backend/src/main/resources/db/migration/V58__project_drop_enterprise_id.sql new file mode 100644 index 0000000..1a28514 --- /dev/null +++ b/backend/src/main/resources/db/migration/V58__project_drop_enterprise_id.sql @@ -0,0 +1,4 @@ +-- 项目表仅保留 partner_enterprise_id 作为合作企业ID + +ALTER TABLE project + DROP COLUMN enterprise_id; diff --git a/backend/src/main/resources/db/migration/V59__meeting_field_attributes_and_comments.sql b/backend/src/main/resources/db/migration/V59__meeting_field_attributes_and_comments.sql new file mode 100644 index 0000000..564b0d1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V59__meeting_field_attributes_and_comments.sql @@ -0,0 +1,93 @@ +-- 会议模块字段属性补齐:流程追溯、状态元数据、并发锁字段 + 字段注释统一 + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'current_auditor_user_id'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN current_auditor_user_id BIGINT UNSIGNED NULL AFTER freeze_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'node_deadline_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN node_deadline_at DATETIME NULL AFTER current_auditor_user_id', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'reject_count'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN reject_count INT NOT NULL DEFAULT 0 AFTER node_deadline_at', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'last_action_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN last_action_at DATETIME NULL AFTER reject_count', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'status_changed_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN status_changed_at DATETIME NULL AFTER last_action_at', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'status_changed_by'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN status_changed_by BIGINT UNSIGNED NULL AFTER status_changed_at', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'cancel_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN cancel_reason VARCHAR(500) NULL AFTER status_changed_by', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'postpone_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN postpone_reason VARCHAR(500) NULL AFTER cancel_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'withdraw_reason'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN withdraw_reason VARCHAR(500) NULL AFTER postpone_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'lock_version'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN lock_version INT NOT NULL DEFAULT 0 AFTER withdraw_reason', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'lock_at'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN lock_at DATETIME NULL AFTER lock_version', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND COLUMN_NAME = 'locked_by'); +SET @sql := IF(@c = 0, 'ALTER TABLE meeting ADD COLUMN locked_by BIGINT UNSIGNED NULL AFTER lock_at', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 查询优化:当前审核人和流程追溯字段索引 +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND INDEX_NAME = 'idx_tenant_auditor_node'); +SET @sql := IF(@idx = 0, 'ALTER TABLE meeting ADD KEY idx_tenant_auditor_node (tenant_id, current_auditor_user_id, current_audit_node)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'meeting' AND INDEX_NAME = 'idx_tenant_last_action'); +SET @sql := IF(@idx = 0, 'ALTER TABLE meeting ADD KEY idx_tenant_last_action (tenant_id, last_action_at)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 会议表字段注释统一补齐 +ALTER TABLE meeting + MODIFY COLUMN project_id BIGINT UNSIGNED NOT NULL COMMENT '所属项目ID', + MODIFY COLUMN topic VARCHAR(256) NOT NULL COMMENT '会议主题', + MODIFY COLUMN sub_project_name VARCHAR(128) NULL COMMENT '子项目名称', + MODIFY COLUMN meeting_category VARCHAR(64) NULL COMMENT '会议类别', + MODIFY COLUMN meeting_form VARCHAR(64) NULL COMMENT '会议形式', + MODIFY COLUMN location VARCHAR(255) NULL COMMENT '会议地点', + MODIFY COLUMN start_time DATETIME NULL COMMENT '会议开始时间', + MODIFY COLUMN end_time DATETIME NULL COMMENT '会议结束时间', + MODIFY COLUMN budget_cent BIGINT NOT NULL COMMENT '会议预算(分)', + MODIFY COLUMN labor_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 COMMENT '劳务费用占比(0~1)', + MODIFY COLUMN catering_ratio DECIMAL(8,6) NOT NULL DEFAULT 0.000000 COMMENT '餐费占比(0~1)', + MODIFY COLUMN meeting_status VARCHAR(32) NOT NULL COMMENT '会议状态', + MODIFY COLUMN audit_status VARCHAR(32) NOT NULL COMMENT '会议审核状态', + MODIFY COLUMN current_audit_node VARCHAR(64) NULL COMMENT '当前审核节点', + MODIFY COLUMN last_submit_at DATETIME NULL COMMENT '最后提交时间', + MODIFY COLUMN last_reject_reason VARCHAR(500) NULL COMMENT '最后驳回原因摘要', + MODIFY COLUMN overdue_days INT NOT NULL DEFAULT 0 COMMENT '逾期天数', + MODIFY COLUMN risk_flags_json TEXT NULL COMMENT '风险标记JSON', + MODIFY COLUMN is_frozen TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否冻结', + MODIFY COLUMN freeze_reason VARCHAR(500) NULL COMMENT '冻结原因', + MODIFY COLUMN current_auditor_user_id BIGINT UNSIGNED NULL COMMENT '当前审核人用户ID', + MODIFY COLUMN node_deadline_at DATETIME NULL COMMENT '当前节点SLA截止时间', + MODIFY COLUMN reject_count INT NOT NULL DEFAULT 0 COMMENT '累计驳回次数', + MODIFY COLUMN last_action_at DATETIME NULL COMMENT '最后一次流程动作时间', + MODIFY COLUMN status_changed_at DATETIME NULL COMMENT '状态最近变更时间', + MODIFY COLUMN status_changed_by BIGINT UNSIGNED NULL COMMENT '状态最近变更人用户ID', + MODIFY COLUMN cancel_reason VARCHAR(500) NULL COMMENT '取消原因', + MODIFY COLUMN postpone_reason VARCHAR(500) NULL COMMENT '延期原因', + MODIFY COLUMN withdraw_reason VARCHAR(500) NULL COMMENT '撤回原因', + MODIFY COLUMN lock_version INT NOT NULL DEFAULT 0 COMMENT '字段锁版本号', + MODIFY COLUMN lock_at DATETIME NULL COMMENT '字段锁定时间', + MODIFY COLUMN locked_by BIGINT UNSIGNED NULL COMMENT '字段锁定操作人用户ID'; diff --git a/backend/src/main/resources/db/migration/V5__user_role_permission_seed.sql b/backend/src/main/resources/db/migration/V5__user_role_permission_seed.sql new file mode 100644 index 0000000..737ae73 --- /dev/null +++ b/backend/src/main/resources/db/migration/V5__user_role_permission_seed.sql @@ -0,0 +1,15 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1009, 'user.read', '查看用户', 'system'), + (1010, 'user.create', '创建用户', 'system'), + (1011, 'role.read', '查看角色', 'system'), + (1012, 'user.role.assign', '用户分配角色', 'system') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (9, 1, 101, 1009), + (10, 1, 101, 1010), + (11, 1, 101, 1011), + (12, 1, 101, 1012) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V60__remove_meeting_field_module.sql b/backend/src/main/resources/db/migration/V60__remove_meeting_field_module.sql new file mode 100644 index 0000000..4c065db --- /dev/null +++ b/backend/src/main/resources/db/migration/V60__remove_meeting_field_module.sql @@ -0,0 +1,38 @@ +-- 全面下线会议字段管理模块:权限、菜单、关联关系与数据表 + +-- 1) 移除角色权限绑定 +DELETE rp +FROM role_permission rp +INNER JOIN permission p ON rp.permission_id = p.id +WHERE p.permission_code IN ('meeting.field.read', 'meeting.field.manage'); + +-- 2) 逻辑删除权限定义 +-- permission 表未定义 is_deleted,直接物理删除对应权限码 +DELETE FROM permission +WHERE permission_code IN ('meeting.field.read', 'meeting.field.manage'); + +-- 3) 移除菜单角色绑定 +DELETE rm +FROM role_menu rm +INNER JOIN menu m ON rm.menu_id = m.id +WHERE m.menu_code = 'meeting_field' OR m.route_path = '/meeting-fields'; + +-- 4) 逻辑删除菜单 +SET @menu_has_is_deleted := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'menu' + AND COLUMN_NAME = 'is_deleted' +); +SET @sql := IF( + @menu_has_is_deleted > 0, + 'UPDATE menu SET is_deleted = 1 WHERE menu_code = ''meeting_field'' OR route_path = ''/meeting-fields''', + 'DELETE FROM menu WHERE menu_code = ''meeting_field'' OR route_path = ''/meeting-fields''' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 5) 删除业务表 +DROP TABLE IF EXISTS meeting_field; diff --git a/backend/src/main/resources/db/migration/V61__meeting_remove_subproject_and_comment_update.sql b/backend/src/main/resources/db/migration/V61__meeting_remove_subproject_and_comment_update.sql new file mode 100644 index 0000000..c8e88c5 --- /dev/null +++ b/backend/src/main/resources/db/migration/V61__meeting_remove_subproject_and_comment_update.sql @@ -0,0 +1,19 @@ +-- 会议模块口径调整: +-- 1) 去除 sub_project_name 字段(会议归属以 project_id 为准) +-- 2) 补充地点/预算字段注释 + +SET @c := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'meeting' + AND COLUMN_NAME = 'sub_project_name' +); +SET @sql := IF(@c > 0, 'ALTER TABLE meeting DROP COLUMN sub_project_name', 'SELECT 1'); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +ALTER TABLE meeting + MODIFY COLUMN location VARCHAR(255) NULL COMMENT '会议地点(线上/线下/线上+线下)', + MODIFY COLUMN budget_cent BIGINT NOT NULL COMMENT '会议预算(分)'; diff --git a/backend/src/main/resources/db/migration/V62__platform_dictionary_and_expert_dict_ref.sql b/backend/src/main/resources/db/migration/V62__platform_dictionary_and_expert_dict_ref.sql new file mode 100644 index 0000000..521cd7a --- /dev/null +++ b/backend/src/main/resources/db/migration/V62__platform_dictionary_and_expert_dict_ref.sql @@ -0,0 +1,98 @@ +-- 平台共享字典(专家职称/医院)+ 专家表字典编码字段 + +CREATE TABLE IF NOT EXISTS platform_dictionary_item ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + dict_type VARCHAR(64) NOT NULL COMMENT '字典类型:EXPERT_TITLE/EXPERT_HOSPITAL', + dict_code VARCHAR(64) NOT NULL COMMENT '字典编码', + dict_name VARCHAR(128) NOT NULL COMMENT '字典名称', + sort_no INT NOT NULL DEFAULT 100 COMMENT '排序', + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:ENABLED/DISABLED', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除', + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_dict_type_code (dict_type, dict_code), + KEY idx_dict_type_status_sort (dict_type, status, sort_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台共享字典项'; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'title_code'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN title_code VARCHAR(64) NULL AFTER phone', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'hospital_code'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN hospital_code VARCHAR(64) NULL AFTER title', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND INDEX_NAME = 'idx_expert_title_code'); +SET @sql := IF(@idx = 0, 'ALTER TABLE expert ADD KEY idx_expert_title_code (title_code)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := (SELECT COUNT(1) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND INDEX_NAME = 'idx_expert_hospital_code'); +SET @sql := IF(@idx = 0, 'ALTER TABLE expert ADD KEY idx_expert_hospital_code (hospital_code)', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE expert + COMMENT = '平台共享专家主数据(tenant_id=0)'; + +ALTER TABLE expert + MODIFY COLUMN expert_name VARCHAR(128) NOT NULL COMMENT '专家姓名', + MODIFY COLUMN id_no VARCHAR(64) NOT NULL COMMENT '身份证号(敏感)', + MODIFY COLUMN phone VARCHAR(32) NOT NULL COMMENT '手机号(敏感)', + MODIFY COLUMN title_code VARCHAR(64) NULL COMMENT '职称字典编码', + MODIFY COLUMN title VARCHAR(128) DEFAULT NULL COMMENT '职称名称快照', + MODIFY COLUMN hospital_code VARCHAR(64) NULL COMMENT '医院字典编码', + MODIFY COLUMN organization VARCHAR(255) DEFAULT NULL COMMENT '医院名称快照', + MODIFY COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:DRAFT/ENABLED/DISABLED/BLACKLISTED'; + +ALTER TABLE expert_bank_card + COMMENT = '专家银行卡信息'; + +ALTER TABLE expert_bank_card + MODIFY COLUMN bank_name VARCHAR(128) NOT NULL COMMENT '开户银行', + MODIFY COLUMN bank_card_no VARCHAR(128) NOT NULL COMMENT '银行卡号(敏感)', + MODIFY COLUMN account_name VARCHAR(128) NOT NULL COMMENT '开户名'; + +INSERT INTO platform_permission (id, permission_code, permission_name, module) +VALUES + (10, 'platform.dictionary.read', '平台字典查看', 'platform'), + (11, 'platform.dictionary.manage', '平台字典管理', 'platform') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO platform_role_permission (id, role_id, permission_id) +VALUES + (10, 1, 10), + (11, 1, 11) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); + +INSERT INTO platform_menu (id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (8, 'platform_dictionary_manage', '平台字典管理', '/platform/dictionaries', 'platform.dictionary.read', 19, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO platform_role_menu (id, role_id, menu_id) +VALUES + (8, 1, 8) +ON DUPLICATE KEY UPDATE menu_id = VALUES(menu_id); + +INSERT INTO platform_dictionary_item (dict_type, dict_code, dict_name, sort_no, status, remark, created_by, updated_by) +VALUES + ('EXPERT_TITLE', 'CHIEF_PHYSICIAN', '主任医师', 10, 'ENABLED', '默认种子', 0, 0), + ('EXPERT_TITLE', 'ASSOCIATE_CHIEF_PHYSICIAN', '副主任医师', 20, 'ENABLED', '默认种子', 0, 0), + ('EXPERT_TITLE', 'ATTENDING_PHYSICIAN', '主治医师', 30, 'ENABLED', '默认种子', 0, 0), + ('EXPERT_HOSPITAL', 'PUMCH', '北京协和医院', 10, 'ENABLED', '默认种子', 0, 0), + ('EXPERT_HOSPITAL', 'PKUH', '北京大学第一医院', 20, 'ENABLED', '默认种子', 0, 0) +ON DUPLICATE KEY UPDATE + dict_name = VALUES(dict_name), + sort_no = VALUES(sort_no), + status = VALUES(status), + remark = VALUES(remark), + updated_at = CURRENT_TIMESTAMP; diff --git a/backend/src/main/resources/db/migration/V63__expert_identity_and_bank_card_image_columns.sql b/backend/src/main/resources/db/migration/V63__expert_identity_and_bank_card_image_columns.sql new file mode 100644 index 0000000..61c9097 --- /dev/null +++ b/backend/src/main/resources/db/migration/V63__expert_identity_and_bank_card_image_columns.sql @@ -0,0 +1,25 @@ +-- 专家证件与银行卡图片字段 + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'id_card_front_oss_key'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN id_card_front_oss_key VARCHAR(512) NULL AFTER id_card_valid_until', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert' AND COLUMN_NAME = 'id_card_back_oss_key'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert ADD COLUMN id_card_back_oss_key VARCHAR(512) NULL AFTER id_card_front_oss_key', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert_bank_card' AND COLUMN_NAME = 'bank_card_front_oss_key'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert_bank_card ADD COLUMN bank_card_front_oss_key VARCHAR(512) NULL AFTER bank_card_no', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := (SELECT COUNT(1) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'expert_bank_card' AND COLUMN_NAME = 'bank_card_back_oss_key'); +SET @sql := IF(@c = 0, 'ALTER TABLE expert_bank_card ADD COLUMN bank_card_back_oss_key VARCHAR(512) NULL AFTER bank_card_front_oss_key', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE expert + MODIFY COLUMN id_card_front_oss_key VARCHAR(512) NULL COMMENT '身份证正面图片OSSKey', + MODIFY COLUMN id_card_back_oss_key VARCHAR(512) NULL COMMENT '身份证反面图片OSSKey'; + +ALTER TABLE expert_bank_card + MODIFY COLUMN bank_card_front_oss_key VARCHAR(512) NULL COMMENT '银行卡正面图片OSSKey', + MODIFY COLUMN bank_card_back_oss_key VARCHAR(512) NULL COMMENT '银行卡反面图片OSSKey'; diff --git a/backend/src/main/resources/db/migration/V64__tenant_logo_url.sql b/backend/src/main/resources/db/migration/V64__tenant_logo_url.sql new file mode 100644 index 0000000..e4d11c0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V64__tenant_logo_url.sql @@ -0,0 +1,20 @@ +-- 租户Logo字段 + +SET @c := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'tenant' + AND COLUMN_NAME = 'logo_url' +); +SET @sql := IF( + @c = 0, + 'ALTER TABLE tenant ADD COLUMN logo_url VARCHAR(512) NULL AFTER tenant_name', + 'SELECT 1' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +ALTER TABLE tenant + MODIFY COLUMN logo_url VARCHAR(512) NULL COMMENT '租户Logo OSSKey/URL'; diff --git a/backend/src/main/resources/db/migration/V65__platform_file_download_permission.sql b/backend/src/main/resources/db/migration/V65__platform_file_download_permission.sql new file mode 100644 index 0000000..ad1b53b --- /dev/null +++ b/backend/src/main/resources/db/migration/V65__platform_file_download_permission.sql @@ -0,0 +1,19 @@ +-- 平台域补充文件下载权限(用于Logo等资源预览) + +INSERT INTO platform_permission (permission_code, permission_name, module) +VALUES ('file.download', '下载文件', 'file') +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), + module = VALUES(module); + +INSERT INTO platform_role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM platform_role r +JOIN platform_permission p ON p.permission_code = 'file.download' +WHERE r.role_code = 'PLATFORM_SUPER_ADMIN' + AND NOT EXISTS ( + SELECT 1 + FROM platform_role_permission rp + WHERE rp.role_id = r.id + AND rp.permission_id = p.id + ); diff --git a/backend/src/main/resources/db/migration/V66__operation_audit_log_request_id.sql b/backend/src/main/resources/db/migration/V66__operation_audit_log_request_id.sql new file mode 100644 index 0000000..358918c --- /dev/null +++ b/backend/src/main/resources/db/migration/V66__operation_audit_log_request_id.sql @@ -0,0 +1,35 @@ +SET @request_id_col_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'operation_audit_log' + AND COLUMN_NAME = 'request_id' +); + +SET @add_request_id_col_sql = IF( + @request_id_col_exists = 0, + 'ALTER TABLE operation_audit_log ADD COLUMN request_id VARCHAR(64) DEFAULT NULL AFTER request_query', + 'SELECT 1' +); + +PREPARE stmt_add_request_id_col FROM @add_request_id_col_sql; +EXECUTE stmt_add_request_id_col; +DEALLOCATE PREPARE stmt_add_request_id_col; + +SET @request_id_idx_exists = ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'operation_audit_log' + AND INDEX_NAME = 'idx_request_id_time' +); + +SET @add_request_id_idx_sql = IF( + @request_id_idx_exists = 0, + 'ALTER TABLE operation_audit_log ADD KEY idx_request_id_time (request_id, created_at)', + 'SELECT 1' +); + +PREPARE stmt_add_request_id_idx FROM @add_request_id_idx_sql; +EXECUTE stmt_add_request_id_idx; +DEALLOCATE PREPARE stmt_add_request_id_idx; diff --git a/backend/src/main/resources/db/migration/V67__audit_material_item_review_record.sql b/backend/src/main/resources/db/migration/V67__audit_material_item_review_record.sql new file mode 100644 index 0000000..251b3b1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V67__audit_material_item_review_record.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS audit_material_item_review ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + task_id BIGINT UNSIGNED NOT NULL, + review_node VARCHAR(32) NOT NULL, + module_code VARCHAR(32) NOT NULL, + item_key VARCHAR(128) NOT NULL, + item_label VARCHAR(255) NOT NULL, + review_result VARCHAR(16) NOT NULL, + review_reason VARCHAR(500) DEFAULT NULL, + reviewer_user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_task_node_module_item (tenant_id, task_id, review_node, module_code, item_key), + KEY idx_tenant_meeting_module (tenant_id, meeting_id, module_code), + KEY idx_tenant_task_node (tenant_id, task_id, review_node) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V68__in_app_notification_center.sql b/backend/src/main/resources/db/migration/V68__in_app_notification_center.sql new file mode 100644 index 0000000..d71c2e8 --- /dev/null +++ b/backend/src/main/resources/db/migration/V68__in_app_notification_center.sql @@ -0,0 +1,56 @@ +CREATE TABLE IF NOT EXISTS in_app_notification ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + receiver_ref VARCHAR(128) NOT NULL, + receiver_user_id BIGINT UNSIGNED DEFAULT NULL, + title VARCHAR(200) NOT NULL, + content TEXT, + payload_json TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'UNREAD', + read_at DATETIME DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_receiver_status_time (tenant_id, receiver_user_id, status, created_at), + KEY idx_tenant_receiver_ref_time (tenant_id, receiver_ref, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'notification.inapp.read', '查看站内通知', 'notification' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'notification.inapp.read' +); + +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'notification.inapp.mark-read', '标记站内通知已读', 'notification' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'notification.inapp.mark-read' +); + +UPDATE permission +SET permission_name = '查看站内通知', module = 'notification' +WHERE permission_code = 'notification.inapp.read'; + +UPDATE permission +SET permission_name = '标记站内通知已读', module = 'notification' +WHERE permission_code = 'notification.inapp.mark-read'; + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT t.next_id, 1, 101, p.id +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM role_permission) t +JOIN permission p ON p.permission_code = 'notification.inapp.read' +WHERE NOT EXISTS ( + SELECT 1 FROM role_permission rp WHERE rp.tenant_id = 1 AND rp.role_id = 101 AND rp.permission_id = p.id +); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT t.next_id, 1, 101, p.id +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM role_permission) t +JOIN permission p ON p.permission_code = 'notification.inapp.mark-read' +WHERE NOT EXISTS ( + SELECT 1 FROM role_permission rp WHERE rp.tenant_id = 1 AND rp.role_id = 101 AND rp.permission_id = p.id +); diff --git a/backend/src/main/resources/db/migration/V69__in_app_notification_menu_seed.sql b/backend/src/main/resources/db/migration/V69__in_app_notification_menu_seed.sql new file mode 100644 index 0000000..893e2b0 --- /dev/null +++ b/backend/src/main/resources/db/migration/V69__in_app_notification_menu_seed.sql @@ -0,0 +1,30 @@ +INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (1, 'in_app_notification', '站内通知', '/in-app-notifications', 'notification.inapp.read', 181, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +SET @next_role_menu_id = (SELECT IFNULL(MAX(id), 0) FROM role_menu); +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (@next_role_menu_id := @next_role_menu_id + 1) AS id, + r.tenant_id, + r.id, + m.id +FROM role r +JOIN menu m ON m.tenant_id = r.tenant_id AND m.menu_code = 'in_app_notification' AND m.is_deleted = 0 +WHERE r.role_code = 'TENANT_ADMIN' + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_menu rm + WHERE rm.tenant_id = r.tenant_id + AND rm.role_id = r.id + AND rm.menu_id = m.id + ); diff --git a/backend/src/main/resources/db/migration/V6__audit_flow_manage_enhance.sql b/backend/src/main/resources/db/migration/V6__audit_flow_manage_enhance.sql new file mode 100644 index 0000000..060102c --- /dev/null +++ b/backend/src/main/resources/db/migration/V6__audit_flow_manage_enhance.sql @@ -0,0 +1,18 @@ +ALTER TABLE audit_flow + ADD COLUMN is_default TINYINT(1) NOT NULL DEFAULT 0 AFTER status, + ADD COLUMN effective_start_at DATETIME DEFAULT NULL AFTER is_default, + ADD COLUMN effective_end_at DATETIME DEFAULT NULL AFTER effective_start_at; + +CREATE TABLE IF NOT EXISTS audit_flow_node_assignee ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + flow_node_id BIGINT UNSIGNED NOT NULL, + assignee_type VARCHAR(32) NOT NULL COMMENT 'USER/ROLE/PROJECT_OWNER', + assignee_ref_id BIGINT UNSIGNED DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_flow_node (flow_node_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +UPDATE audit_flow +SET is_default = 1 +WHERE tenant_id = 1 AND flow_code = 'DEFAULT'; diff --git a/backend/src/main/resources/db/migration/V70__notification_text_template_module.sql b/backend/src/main/resources/db/migration/V70__notification_text_template_module.sql new file mode 100644 index 0000000..4d5d59d --- /dev/null +++ b/backend/src/main/resources/db/migration/V70__notification_text_template_module.sql @@ -0,0 +1,82 @@ +CREATE TABLE IF NOT EXISTS notification_text_template ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + template_name VARCHAR(128) NOT NULL, + subject_template VARCHAR(255) DEFAULT NULL, + title_template VARCHAR(255) DEFAULT NULL, + content_template TEXT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_template_name (tenant_id, template_name), + KEY idx_tenant_status (tenant_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO notification_text_template (tenant_id, template_name, subject_template, title_template, content_template, status, is_deleted, created_by, updated_by) +SELECT 1, '默认通知文案', '系统通知', '系统通知', '您有一条新的系统通知,请及时处理。', 'ENABLED', 0, 0, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM notification_text_template WHERE tenant_id=1 AND template_name='默认通知文案' AND is_deleted=0 +); + +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'notification.text-template.read', '查看通知文案模板', 'notification' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'notification.text-template.read' +); + +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'notification.text-template.manage', '管理通知文案模板', 'notification' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'notification.text-template.manage' +); + +UPDATE permission SET permission_name='查看通知文案模板', module='notification' WHERE permission_code='notification.text-template.read'; +UPDATE permission SET permission_name='管理通知文案模板', module='notification' WHERE permission_code='notification.text-template.manage'; + +INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + (1, 'notification_text_template', '通知文案模板', '/notification-text-templates', 'notification.text-template.read', 179, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT t.next_id, 1, 101, p.id +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM role_permission) t +JOIN permission p ON p.permission_code = 'notification.text-template.read' +WHERE NOT EXISTS ( + SELECT 1 FROM role_permission rp WHERE rp.tenant_id=1 AND rp.role_id=101 AND rp.permission_id=p.id +); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT t.next_id, 1, 101, p.id +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM role_permission) t +JOIN permission p ON p.permission_code = 'notification.text-template.manage' +WHERE NOT EXISTS ( + SELECT 1 FROM role_permission rp WHERE rp.tenant_id=1 AND rp.role_id=101 AND rp.permission_id=p.id +); + +SET @next_role_menu_id = (SELECT IFNULL(MAX(id), 0) FROM role_menu); +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (@next_role_menu_id := @next_role_menu_id + 1) AS id, + r.tenant_id, + r.id, + m.id +FROM role r +JOIN menu m ON m.tenant_id=r.tenant_id AND m.menu_code='notification_text_template' AND m.is_deleted=0 +WHERE r.role_code='TENANT_ADMIN' + AND r.is_deleted=0 + AND NOT EXISTS ( + SELECT 1 FROM role_menu rm WHERE rm.tenant_id=r.tenant_id AND rm.role_id=r.id AND rm.menu_id=m.id + ); diff --git a/backend/src/main/resources/db/migration/V71__auth_refresh_token.sql b/backend/src/main/resources/db/migration/V71__auth_refresh_token.sql new file mode 100644 index 0000000..318e9d6 --- /dev/null +++ b/backend/src/main/resources/db/migration/V71__auth_refresh_token.sql @@ -0,0 +1,25 @@ +CREATE TABLE IF NOT EXISTS auth_refresh_token ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT UNSIGNED NOT NULL, + tenant_id BIGINT UNSIGNED NULL, + scope VARCHAR(32) NOT NULL, + token_hash CHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + issued_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + last_used_at DATETIME NULL, + rotated_to_id BIGINT UNSIGNED NULL, + revoked_at DATETIME NULL, + revoked_reason VARCHAR(128) NULL, + device_id VARCHAR(128) NULL, + ip_hash CHAR(64) NULL, + ua_hash CHAR(64) NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_token_hash (token_hash), + KEY idx_user_scope_status (user_id, scope, status), + KEY idx_expires_at (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V72__platform_auth_session_permission.sql b/backend/src/main/resources/db/migration/V72__platform_auth_session_permission.sql new file mode 100644 index 0000000..85a5eb1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V72__platform_auth_session_permission.sql @@ -0,0 +1,54 @@ +INSERT INTO platform_permission (permission_code, permission_name, module) +VALUES + ('platform.session.read', '平台会话查看', 'platform'), + ('platform.session.manage', '平台会话管理', 'platform') +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), + module = VALUES(module); + +SET @next_platform_role_permission_id = (SELECT IFNULL(MAX(id), 0) FROM platform_role_permission); +INSERT INTO platform_role_permission (id, role_id, permission_id) +SELECT + (@next_platform_role_permission_id := @next_platform_role_permission_id + 1) AS id, + 1 AS role_id, + p.id AS permission_id +FROM platform_permission p +WHERE p.permission_code = 'platform.session.read' + AND NOT EXISTS ( + SELECT 1 FROM platform_role_permission rp WHERE rp.role_id = 1 AND rp.permission_id = p.id + ); + +INSERT INTO platform_role_permission (id, role_id, permission_id) +SELECT + (@next_platform_role_permission_id := @next_platform_role_permission_id + 1) AS id, + 1 AS role_id, + p.id AS permission_id +FROM platform_permission p +WHERE p.permission_code = 'platform.session.manage' + AND NOT EXISTS ( + SELECT 1 FROM platform_role_permission rp WHERE rp.role_id = 1 AND rp.permission_id = p.id + ); + +INSERT INTO platform_menu (menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +VALUES + ('platform_auth_session_manage', '平台会话管理', '/platform/auth-sessions', 'platform.session.read', 20, 'ENABLED', 0, 0, 0) +ON DUPLICATE KEY UPDATE + menu_name = VALUES(menu_name), + route_path = VALUES(route_path), + permission_code = VALUES(permission_code), + sort_no = VALUES(sort_no), + status = VALUES(status), + is_deleted = VALUES(is_deleted), + updated_at = CURRENT_TIMESTAMP; + +SET @next_platform_role_menu_id = (SELECT IFNULL(MAX(id), 0) FROM platform_role_menu); +INSERT INTO platform_role_menu (id, role_id, menu_id) +SELECT + (@next_platform_role_menu_id := @next_platform_role_menu_id + 1) AS id, + 1 AS role_id, + m.id AS menu_id +FROM platform_menu m +WHERE m.menu_code = 'platform_auth_session_manage' + AND NOT EXISTS ( + SELECT 1 FROM platform_role_menu rm WHERE rm.role_id = 1 AND rm.menu_id = m.id + ); diff --git a/backend/src/main/resources/db/migration/V73__project_fee_json.sql b/backend/src/main/resources/db/migration/V73__project_fee_json.sql new file mode 100644 index 0000000..ae87ec9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V73__project_fee_json.sql @@ -0,0 +1,14 @@ +-- 项目费用设置 JSON(管理费/税费/到款金额/自定义费用) + +SET @c := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'project' + AND COLUMN_NAME = 'project_fee_json' +); +SET @sql := IF(@c = 0, 'ALTER TABLE project ADD COLUMN project_fee_json TEXT NULL AFTER expense_ratio_json', 'SELECT 1'); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +ALTER TABLE project + MODIFY COLUMN project_fee_json TEXT NULL COMMENT '项目费用设置(JSON)'; diff --git a/backend/src/main/resources/db/migration/V74__ocr_id_card_permission_seed.sql b/backend/src/main/resources/db/migration/V74__ocr_id_card_permission_seed.sql new file mode 100644 index 0000000..1461636 --- /dev/null +++ b/backend/src/main/resources/db/migration/V74__ocr_id_card_permission_seed.sql @@ -0,0 +1,49 @@ +-- 身份证OCR权限:租户域 + 平台域 + +SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission); +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT @next_permission_id, 'ocr.idcard', '身份证OCR识别', 'ocr' +FROM dual +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE permission_code = 'ocr.idcard'); + +UPDATE permission +SET permission_name = '身份证OCR识别', + module = 'ocr' +WHERE permission_code = 'ocr.idcard'; + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (SELECT IFNULL(MAX(rp.id), 0) + 1 FROM role_permission rp) AS id, + r.tenant_id, + r.id, + p.id +FROM role r +JOIN permission p ON p.permission_code = 'ocr.idcard' +WHERE r.tenant_id = 1 + AND r.role_code = 'TENANT_ADMIN' + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); + +INSERT INTO platform_permission (permission_code, permission_name, module) +VALUES ('platform.ocr.idcard', '平台身份证OCR识别', 'platform') +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), + module = VALUES(module); + +INSERT INTO platform_role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM platform_role r +JOIN platform_permission p ON p.permission_code = 'platform.ocr.idcard' +WHERE r.role_code = 'PLATFORM_SUPER_ADMIN' + AND NOT EXISTS ( + SELECT 1 + FROM platform_role_permission rp + WHERE rp.role_id = r.id + AND rp.permission_id = p.id + ); + diff --git a/backend/src/main/resources/db/migration/V75__ocr_bank_card_permission_seed.sql b/backend/src/main/resources/db/migration/V75__ocr_bank_card_permission_seed.sql new file mode 100644 index 0000000..06645f7 --- /dev/null +++ b/backend/src/main/resources/db/migration/V75__ocr_bank_card_permission_seed.sql @@ -0,0 +1,49 @@ +-- 银行卡OCR权限:租户域 + 平台域 + +SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission); +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT @next_permission_id, 'ocr.bankcard', '银行卡OCR识别', 'ocr' +FROM dual +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE permission_code = 'ocr.bankcard'); + +UPDATE permission +SET permission_name = '银行卡OCR识别', + module = 'ocr' +WHERE permission_code = 'ocr.bankcard'; + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (SELECT IFNULL(MAX(rp.id), 0) + 1 FROM role_permission rp) AS id, + r.tenant_id, + r.id, + p.id +FROM role r +JOIN permission p ON p.permission_code = 'ocr.bankcard' +WHERE r.tenant_id = 1 + AND r.role_code = 'TENANT_ADMIN' + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); + +INSERT INTO platform_permission (permission_code, permission_name, module) +VALUES ('platform.ocr.bankcard', '平台银行卡OCR识别', 'platform') +ON DUPLICATE KEY UPDATE + permission_name = VALUES(permission_name), + module = VALUES(module); + +INSERT INTO platform_role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM platform_role r +JOIN platform_permission p ON p.permission_code = 'platform.ocr.bankcard' +WHERE r.role_code = 'PLATFORM_SUPER_ADMIN' + AND NOT EXISTS ( + SELECT 1 + FROM platform_role_permission rp + WHERE rp.role_id = r.id + AND rp.permission_id = p.id + ); + diff --git a/backend/src/main/resources/db/migration/V76__platform_dictionary_type_management.sql b/backend/src/main/resources/db/migration/V76__platform_dictionary_type_management.sql new file mode 100644 index 0000000..c2c0486 --- /dev/null +++ b/backend/src/main/resources/db/migration/V76__platform_dictionary_type_management.sql @@ -0,0 +1,39 @@ +-- 平台字典类型管理:支持新增类型并维护字典项 + +CREATE TABLE IF NOT EXISTS platform_dictionary_type ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + dict_type VARCHAR(64) NOT NULL COMMENT '字典类型编码', + dict_name VARCHAR(128) NOT NULL COMMENT '字典类型名称', + sort_no INT NOT NULL DEFAULT 100 COMMENT '排序', + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:ENABLED/DISABLED', + remark VARCHAR(500) DEFAULT NULL COMMENT '备注', + is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除', + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人', + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + UNIQUE KEY uk_platform_dictionary_type (dict_type), + KEY idx_platform_dictionary_type_status_sort (status, sort_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='平台共享字典类型'; + +-- 将已有字典项中的类型补齐到类型表(避免升级后历史数据丢失类型) +INSERT INTO platform_dictionary_type (dict_type, dict_name, sort_no, status, remark, created_by, updated_by) +SELECT t.dict_type, t.dict_type, 100, 'ENABLED', '从历史字典项自动补齐', 0, 0 +FROM ( + SELECT DISTINCT dict_type + FROM platform_dictionary_item + WHERE is_deleted = 0 +) t +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; + +-- 覆盖默认种子类型展示名与排序 +INSERT INTO platform_dictionary_type (dict_type, dict_name, sort_no, status, remark, created_by, updated_by) +VALUES + ('EXPERT_TITLE', '专家职称', 10, 'ENABLED', '默认种子', 0, 0), + ('EXPERT_HOSPITAL', '专家医院', 20, 'ENABLED', '默认种子', 0, 0) +ON DUPLICATE KEY UPDATE + dict_name = VALUES(dict_name), + sort_no = VALUES(sort_no), + status = VALUES(status), + remark = VALUES(remark), + updated_at = CURRENT_TIMESTAMP; diff --git a/backend/src/main/resources/db/migration/V77__tenant_expert_menu_permission_restore.sql b/backend/src/main/resources/db/migration/V77__tenant_expert_menu_permission_restore.sql new file mode 100644 index 0000000..b148ac5 --- /dev/null +++ b/backend/src/main/resources/db/migration/V77__tenant_expert_menu_permission_restore.sql @@ -0,0 +1,77 @@ +-- 租户域恢复“专家列表”菜单,并为租户管理员补齐菜单与权限 + +SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission); +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT @next_permission_id, 'expert.read', '查看专家列表', 'expert' +FROM dual +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE permission_code = 'expert.read'); + +UPDATE permission +SET permission_name = '查看专家列表', + module = 'expert' +WHERE permission_code = 'expert.read'; + +UPDATE menu +SET menu_name = '专家列表', + route_path = '/experts', + permission_code = 'expert.read', + sort_no = 150, + status = 'ENABLED', + is_deleted = 0, + updated_at = CURRENT_TIMESTAMP +WHERE menu_code = 'expert'; + +INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +SELECT + t.id, + 'expert', + '专家列表', + '/experts', + 'expert.read', + 150, + 'ENABLED', + 0, + 0, + 0 +FROM tenant t +LEFT JOIN menu m ON m.tenant_id = t.id AND m.menu_code = 'expert' +WHERE t.is_deleted = 0 + AND m.id IS NULL; + +SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + p.id AS permission_id +FROM role r +JOIN permission p ON p.permission_code = 'expert.read' +WHERE r.role_code = 'TENANT_ADMIN' + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); + +SET @next_role_menu_id := (SELECT IFNULL(MAX(id), 0) FROM role_menu); +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (@next_role_menu_id := @next_role_menu_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + m.id AS menu_id +FROM role r +JOIN menu m ON m.tenant_id = r.tenant_id AND m.menu_code = 'expert' AND m.is_deleted = 0 +WHERE r.role_code = 'TENANT_ADMIN' + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_menu rm + WHERE rm.tenant_id = r.tenant_id + AND rm.role_id = r.id + AND rm.menu_id = m.id + ); diff --git a/backend/src/main/resources/db/migration/V78__data_permission_expert_scope.sql b/backend/src/main/resources/db/migration/V78__data_permission_expert_scope.sql new file mode 100644 index 0000000..3816546 --- /dev/null +++ b/backend/src/main/resources/db/migration/V78__data_permission_expert_scope.sql @@ -0,0 +1,3 @@ +ALTER TABLE data_permission_policy + ADD COLUMN expert_scope VARCHAR(32) NOT NULL DEFAULT 'ALL' COMMENT 'ALL/IDS/OWNER' AFTER user_ids_csv, + ADD COLUMN expert_ids_csv VARCHAR(2000) DEFAULT NULL AFTER expert_scope; diff --git a/backend/src/main/resources/db/migration/V79__meeting_invoice_config.sql b/backend/src/main/resources/db/migration/V79__meeting_invoice_config.sql new file mode 100644 index 0000000..e7a098d --- /dev/null +++ b/backend/src/main/resources/db/migration/V79__meeting_invoice_config.sql @@ -0,0 +1,28 @@ +ALTER TABLE meeting ADD COLUMN invoice_config_json VARCHAR(1000) DEFAULT NULL COMMENT '会议发票动态模块配置JSON'; + +-- 增加发票动态配置权限 +SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission); +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT @next_permission_id, 'meeting.invoice.config', '配置会议发票模块', 'meeting' +FROM dual +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE permission_code = 'meeting.invoice.config'); + +-- 为单位管理员(TENANT_ADMIN)默认绑定该权限 +SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + p.id AS permission_id +FROM role r +JOIN permission p ON p.permission_code = 'meeting.invoice.config' +WHERE r.role_code = 'TENANT_ADMIN' + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); diff --git a/backend/src/main/resources/db/migration/V7__user_role_manage_enhance.sql b/backend/src/main/resources/db/migration/V7__user_role_manage_enhance.sql new file mode 100644 index 0000000..95a5074 --- /dev/null +++ b/backend/src/main/resources/db/migration/V7__user_role_manage_enhance.sql @@ -0,0 +1,16 @@ +ALTER TABLE sys_user + ADD COLUMN effective_start_at DATETIME DEFAULT NULL AFTER status, + ADD COLUMN effective_end_at DATETIME DEFAULT NULL AFTER effective_start_at; + +CREATE TABLE IF NOT EXISTS user_role_history ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + old_role_id BIGINT UNSIGNED DEFAULT NULL, + new_role_id BIGINT UNSIGNED NOT NULL, + action_type VARCHAR(32) NOT NULL COMMENT 'ASSIGN/REMOVE/REPLACE', + action_reason VARCHAR(255) DEFAULT NULL, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_user_time (user_id, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V80__meeting_read_permission.sql b/backend/src/main/resources/db/migration/V80__meeting_read_permission.sql new file mode 100644 index 0000000..564591f --- /dev/null +++ b/backend/src/main/resources/db/migration/V80__meeting_read_permission.sql @@ -0,0 +1,26 @@ +-- 增加查看会议权限 +SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission); +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT @next_permission_id, 'meeting.read', '查看会议', 'meeting' +FROM dual +WHERE NOT EXISTS (SELECT 1 FROM permission WHERE permission_code = 'meeting.read'); + +-- 为各个常用角色默认绑定该权限 +SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + p.id AS permission_id +FROM role r +JOIN permission p ON p.permission_code = 'meeting.read' +WHERE r.role_code IN ('TENANT_ADMIN', 'PROJECT_OWNER', 'EXECUTOR', 'AUDITOR', 'FINANCE') + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); diff --git a/backend/src/main/resources/db/migration/V81__meeting_read_permission.sql b/backend/src/main/resources/db/migration/V81__meeting_read_permission.sql new file mode 100644 index 0000000..d5187ae --- /dev/null +++ b/backend/src/main/resources/db/migration/V81__meeting_read_permission.sql @@ -0,0 +1 @@ +UPDATE menu SET permission_code='meeting.read' WHERE tenant_id=1 AND route_path='/meetings'; \ No newline at end of file diff --git a/backend/src/main/resources/db/migration/V82__project_remove_status_fields.sql b/backend/src/main/resources/db/migration/V82__project_remove_status_fields.sql new file mode 100644 index 0000000..68dab46 --- /dev/null +++ b/backend/src/main/resources/db/migration/V82__project_remove_status_fields.sql @@ -0,0 +1,2 @@ +ALTER TABLE project DROP COLUMN payment_status; +ALTER TABLE project DROP COLUMN write_off_status; diff --git a/backend/src/main/resources/db/migration/V83__audit_flow_soft_delete.sql b/backend/src/main/resources/db/migration/V83__audit_flow_soft_delete.sql new file mode 100644 index 0000000..8760a4d --- /dev/null +++ b/backend/src/main/resources/db/migration/V83__audit_flow_soft_delete.sql @@ -0,0 +1,2 @@ +ALTER TABLE audit_flow + ADD COLUMN is_deleted TINYINT(1) NOT NULL DEFAULT 0 AFTER effective_end_at; diff --git a/backend/src/main/resources/db/migration/V84__security_state_mysql.sql b/backend/src/main/resources/db/migration/V84__security_state_mysql.sql new file mode 100644 index 0000000..5b79393 --- /dev/null +++ b/backend/src/main/resources/db/migration/V84__security_state_mysql.sql @@ -0,0 +1,28 @@ +CREATE TABLE IF NOT EXISTS auth_login_attempt ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + attempt_key VARCHAR(191) NOT NULL, + failure_count INT NOT NULL DEFAULT 0, + window_started_at DATETIME NOT NULL, + last_failed_at DATETIME NOT NULL, + locked_until DATETIME NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_attempt_key (attempt_key), + KEY idx_locked_until (locked_until), + KEY idx_last_failed_at (last_failed_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS api_rate_limit_counter ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + bucket_key VARCHAR(191) NOT NULL, + scope VARCHAR(32) NOT NULL, + client_ip VARCHAR(64) NOT NULL, + window_start_at DATETIME NOT NULL, + expires_at DATETIME NOT NULL, + request_count INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_bucket_key (bucket_key), + KEY idx_scope_client_ip (scope, client_ip), + KEY idx_expires_at (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V85__user_import_permission.sql b/backend/src/main/resources/db/migration/V85__user_import_permission.sql new file mode 100644 index 0000000..6e7b9c3 --- /dev/null +++ b/backend/src/main/resources/db/migration/V85__user_import_permission.sql @@ -0,0 +1,9 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1201, 'user.import', '导入用户', 'user') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (1202, 1, 101, 1201) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V86__tenant_switch_permission.sql b/backend/src/main/resources/db/migration/V86__tenant_switch_permission.sql new file mode 100644 index 0000000..f3948f2 --- /dev/null +++ b/backend/src/main/resources/db/migration/V86__tenant_switch_permission.sql @@ -0,0 +1,22 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1203, 'tenant.switch', '切换租户', 'system') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + p.id AS permission_id +FROM role r +JOIN permission p ON p.permission_code = 'tenant.switch' +LEFT JOIN role_permission rp + ON rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id +WHERE r.role_code = 'TENANT_ADMIN' + AND r.is_deleted = 0 + AND rp.id IS NULL; diff --git a/backend/src/main/resources/db/migration/V87__platform_notify_gateway.sql b/backend/src/main/resources/db/migration/V87__platform_notify_gateway.sql new file mode 100644 index 0000000..932b7a1 --- /dev/null +++ b/backend/src/main/resources/db/migration/V87__platform_notify_gateway.sql @@ -0,0 +1,113 @@ +CREATE TABLE IF NOT EXISTS platform_notify_gateway ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + channel_code VARCHAR(32) NOT NULL, + gateway_name VARCHAR(64) NOT NULL, + provider_code VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'DISABLED', + config_json TEXT DEFAULT NULL, + secret_config_cipher TEXT DEFAULT NULL, + remark VARCHAR(255) DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_platform_notify_gateway_channel (channel_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO platform_notify_gateway ( + channel_code, + gateway_name, + provider_code, + status, + config_json, + secret_config_cipher, + remark, + is_deleted, + created_by, + updated_by +) +SELECT 'EMAIL', '邮件网关', 'SMTP', 'DISABLED', '{}', '', '平台统一邮件网关配置', 0, 0, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM platform_notify_gateway WHERE channel_code = 'EMAIL' +); + +INSERT INTO platform_notify_gateway ( + channel_code, + gateway_name, + provider_code, + status, + config_json, + secret_config_cipher, + remark, + is_deleted, + created_by, + updated_by +) +SELECT 'SMS', '短信网关', 'MOCK', 'DISABLED', '{}', '', '平台统一短信网关配置', 0, 0, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM platform_notify_gateway WHERE channel_code = 'SMS' +); + +INSERT INTO platform_permission (permission_code, permission_name, module) +SELECT 'platform.notify-gateway.read', '平台通知网关查看', 'platform' +WHERE NOT EXISTS ( + SELECT 1 FROM platform_permission WHERE permission_code = 'platform.notify-gateway.read' +); + +INSERT INTO platform_permission (permission_code, permission_name, module) +SELECT 'platform.notify-gateway.manage', '平台通知网关管理', 'platform' +WHERE NOT EXISTS ( + SELECT 1 FROM platform_permission WHERE permission_code = 'platform.notify-gateway.manage' +); + +UPDATE platform_permission +SET permission_name = '平台通知网关查看', module = 'platform' +WHERE permission_code = 'platform.notify-gateway.read'; + +UPDATE platform_permission +SET permission_name = '平台通知网关管理', module = 'platform' +WHERE permission_code = 'platform.notify-gateway.manage'; + +INSERT INTO platform_role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM platform_role r +JOIN platform_permission p ON p.permission_code = 'platform.notify-gateway.read' +LEFT JOIN platform_role_permission rp ON rp.role_id = r.id AND rp.permission_id = p.id +WHERE r.role_code = 'PLATFORM_SUPER_ADMIN' + AND r.is_deleted = 0 + AND rp.id IS NULL; + +INSERT INTO platform_role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM platform_role r +JOIN platform_permission p ON p.permission_code = 'platform.notify-gateway.manage' +LEFT JOIN platform_role_permission rp ON rp.role_id = r.id AND rp.permission_id = p.id +WHERE r.role_code = 'PLATFORM_SUPER_ADMIN' + AND r.is_deleted = 0 + AND rp.id IS NULL; + +INSERT INTO platform_menu ( + menu_code, + menu_name, + route_path, + permission_code, + sort_no, + status, + is_deleted, + created_by, + updated_by +) +SELECT 'platform_notify_gateway', '通知网关配置', '/platform/notify-gateways', 'platform.notify-gateway.read', 65, 'ENABLED', 0, 0, 0 +WHERE NOT EXISTS ( + SELECT 1 FROM platform_menu WHERE menu_code = 'platform_notify_gateway' +); + +INSERT INTO platform_role_menu (role_id, menu_id) +SELECT r.id, m.id +FROM platform_role r +JOIN platform_menu m ON m.menu_code = 'platform_notify_gateway' +LEFT JOIN platform_role_menu rm ON rm.role_id = r.id AND rm.menu_id = m.id +WHERE r.role_code = 'PLATFORM_SUPER_ADMIN' + AND r.is_deleted = 0 + AND rm.id IS NULL; diff --git a/backend/src/main/resources/db/migration/V88__notification_delivery_protection.sql b/backend/src/main/resources/db/migration/V88__notification_delivery_protection.sql new file mode 100644 index 0000000..8bb715f --- /dev/null +++ b/backend/src/main/resources/db/migration/V88__notification_delivery_protection.sql @@ -0,0 +1,35 @@ +CREATE TABLE IF NOT EXISTS platform_notify_delivery_guard ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + channel_code VARCHAR(32) NOT NULL, + receiver_ref VARCHAR(128) NOT NULL, + stat_date DATE NOT NULL, + daily_count INT NOT NULL DEFAULT 0, + last_sent_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_platform_notify_delivery_guard (channel_code, receiver_ref, stat_date), + KEY idx_platform_notify_delivery_guard_date (stat_date, channel_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS platform_notify_circuit_breaker ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + channel_code VARCHAR(32) NOT NULL, + consecutive_failures INT NOT NULL DEFAULT 0, + breaker_until DATETIME DEFAULT NULL, + last_failure_message VARCHAR(500) DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_platform_notify_circuit_breaker (channel_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +INSERT INTO platform_notify_circuit_breaker (channel_code, consecutive_failures, breaker_until, last_failure_message) +SELECT 'EMAIL', 0, NULL, NULL +WHERE NOT EXISTS ( + SELECT 1 FROM platform_notify_circuit_breaker WHERE channel_code = 'EMAIL' +); + +INSERT INTO platform_notify_circuit_breaker (channel_code, consecutive_failures, breaker_until, last_failure_message) +SELECT 'SMS', 0, NULL, NULL +WHERE NOT EXISTS ( + SELECT 1 FROM platform_notify_circuit_breaker WHERE channel_code = 'SMS' +); diff --git a/backend/src/main/resources/db/migration/V89__user_ui_preferences.sql b/backend/src/main/resources/db/migration/V89__user_ui_preferences.sql new file mode 100644 index 0000000..aaed699 --- /dev/null +++ b/backend/src/main/resources/db/migration/V89__user_ui_preferences.sql @@ -0,0 +1,73 @@ +SET @sys_user_theme_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_user' + AND COLUMN_NAME = 'ui_theme_mode' +); +SET @sys_user_theme_sql = IF( + @sys_user_theme_exists = 0, + 'ALTER TABLE sys_user ADD COLUMN ui_theme_mode VARCHAR(16) NULL AFTER valid_to', + 'SELECT 1' +); +PREPARE stmt_sys_user_theme FROM @sys_user_theme_sql; +EXECUTE stmt_sys_user_theme; +DEALLOCATE PREPARE stmt_sys_user_theme; + +SET @sys_user_density_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_user' + AND COLUMN_NAME = 'ui_density' +); +SET @sys_user_density_sql = IF( + @sys_user_density_exists = 0, + 'ALTER TABLE sys_user ADD COLUMN ui_density VARCHAR(16) NULL AFTER ui_theme_mode', + 'SELECT 1' +); +PREPARE stmt_sys_user_density FROM @sys_user_density_sql; +EXECUTE stmt_sys_user_density; +DEALLOCATE PREPARE stmt_sys_user_density; + +SET @platform_user_theme_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'platform_user' + AND COLUMN_NAME = 'ui_theme_mode' +); +SET @platform_user_theme_sql = IF( + @platform_user_theme_exists = 0, + 'ALTER TABLE platform_user ADD COLUMN ui_theme_mode VARCHAR(16) NULL AFTER valid_to', + 'SELECT 1' +); +PREPARE stmt_platform_user_theme FROM @platform_user_theme_sql; +EXECUTE stmt_platform_user_theme; +DEALLOCATE PREPARE stmt_platform_user_theme; + +SET @platform_user_density_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'platform_user' + AND COLUMN_NAME = 'ui_density' +); +SET @platform_user_density_sql = IF( + @platform_user_density_exists = 0, + 'ALTER TABLE platform_user ADD COLUMN ui_density VARCHAR(16) NULL AFTER ui_theme_mode', + 'SELECT 1' +); +PREPARE stmt_platform_user_density FROM @platform_user_density_sql; +EXECUTE stmt_platform_user_density; +DEALLOCATE PREPARE stmt_platform_user_density; + +UPDATE sys_user +SET ui_theme_mode = IFNULL(NULLIF(ui_theme_mode, ''), 'SYSTEM'), + ui_density = IFNULL(NULLIF(ui_density, ''), 'COMFORTABLE') +WHERE is_deleted = 0; + +UPDATE platform_user +SET ui_theme_mode = IFNULL(NULLIF(ui_theme_mode, ''), 'SYSTEM'), + ui_density = IFNULL(NULLIF(ui_density, ''), 'COMFORTABLE') +WHERE is_deleted = 0; diff --git a/backend/src/main/resources/db/migration/V8__permission_seed_iter_a.sql b/backend/src/main/resources/db/migration/V8__permission_seed_iter_a.sql new file mode 100644 index 0000000..268da73 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__permission_seed_iter_a.sql @@ -0,0 +1,27 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1013, 'audit.flow.read', '查看审核流配置', 'audit-flow'), + (1014, 'audit.flow.manage', '管理审核流配置', 'audit-flow'), + (1015, 'user.enable', '启用用户', 'system'), + (1016, 'user.disable', '禁用用户', 'system'), + (1017, 'user.password.reset', '重置用户密码', 'system'), + (1018, 'user.role.history.read', '查看用户角色变更历史', 'system'), + (1019, 'role.create', '创建角色', 'system'), + (1020, 'role.update', '编辑角色', 'system'), + (1021, 'role.enable', '启用角色', 'system'), + (1022, 'role.disable', '禁用角色', 'system') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (13, 1, 101, 1013), + (14, 1, 101, 1014), + (15, 1, 101, 1015), + (16, 1, 101, 1016), + (17, 1, 101, 1017), + (18, 1, 101, 1018), + (19, 1, 101, 1019), + (20, 1, 101, 1020), + (21, 1, 101, 1021), + (22, 1, 101, 1022) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/migration/V90__tenant_switch_account_key.sql b/backend/src/main/resources/db/migration/V90__tenant_switch_account_key.sql new file mode 100644 index 0000000..a3b3c00 --- /dev/null +++ b/backend/src/main/resources/db/migration/V90__tenant_switch_account_key.sql @@ -0,0 +1,37 @@ +SET @sys_user_switch_account_key_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_user' + AND COLUMN_NAME = 'tenant_switch_account_key' +); + +SET @sys_user_switch_account_key_sql = IF( + @sys_user_switch_account_key_exists = 0, + 'ALTER TABLE sys_user ADD COLUMN tenant_switch_account_key VARCHAR(64) NULL AFTER password_hash', + 'SELECT 1' +); +PREPARE stmt_sys_user_switch_account_key FROM @sys_user_switch_account_key_sql; +EXECUTE stmt_sys_user_switch_account_key; +DEALLOCATE PREPARE stmt_sys_user_switch_account_key; + +SET @sys_user_switch_account_key_idx_exists = ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_user' + AND INDEX_NAME = 'idx_sys_user_switch_account_key' +); + +SET @sys_user_switch_account_key_idx_sql = IF( + @sys_user_switch_account_key_idx_exists = 0, + 'ALTER TABLE sys_user ADD KEY idx_sys_user_switch_account_key (tenant_switch_account_key, status, is_deleted)', + 'SELECT 1' +); +PREPARE stmt_sys_user_switch_account_key_idx FROM @sys_user_switch_account_key_idx_sql; +EXECUTE stmt_sys_user_switch_account_key_idx; +DEALLOCATE PREPARE stmt_sys_user_switch_account_key_idx; + +UPDATE sys_user +SET tenant_switch_account_key = CONCAT('acct_', UPPER(MD5(CONCAT(IFNULL(phone, ''), '|', IFNULL(password_hash, ''))))) +WHERE tenant_switch_account_key IS NULL OR tenant_switch_account_key = ''; diff --git a/backend/src/main/resources/db/migration/V91__auth_password_setup_token.sql b/backend/src/main/resources/db/migration/V91__auth_password_setup_token.sql new file mode 100644 index 0000000..f0a7b1e --- /dev/null +++ b/backend/src/main/resources/db/migration/V91__auth_password_setup_token.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS auth_password_setup_token ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + scenario VARCHAR(64) NOT NULL, + token_hash CHAR(64) NOT NULL, + expires_at DATETIME NOT NULL, + used_at DATETIME NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_password_setup_token_hash (token_hash), + KEY idx_password_setup_user (tenant_id, user_id, scenario, is_deleted), + KEY idx_password_setup_expire (expires_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/db/migration/V92__template_download_log_enhance.sql b/backend/src/main/resources/db/migration/V92__template_download_log_enhance.sql new file mode 100644 index 0000000..a884345 --- /dev/null +++ b/backend/src/main/resources/db/migration/V92__template_download_log_enhance.sql @@ -0,0 +1,83 @@ +SET @c := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_download_log' AND COLUMN_NAME = 'download_type' +); +SET @sql := IF( + @c = 0, + 'ALTER TABLE template_download_log ADD COLUMN download_type VARCHAR(32) NOT NULL DEFAULT ''NORMAL'' AFTER object_key', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_download_log' AND COLUMN_NAME = 'watermark_text' +); +SET @sql := IF( + @c = 0, + 'ALTER TABLE template_download_log ADD COLUMN watermark_text VARCHAR(255) NULL AFTER download_type', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_download_log' AND COLUMN_NAME = 'project_id' +); +SET @sql := IF( + @c = 0, + 'ALTER TABLE template_download_log ADD COLUMN project_id BIGINT UNSIGNED NULL AFTER watermark_text', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @c := ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_download_log' AND COLUMN_NAME = 'meeting_id' +); +SET @sql := IF( + @c = 0, + 'ALTER TABLE template_download_log ADD COLUMN meeting_id BIGINT UNSIGNED NULL AFTER project_id', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_download_log' AND INDEX_NAME = 'idx_tenant_template_time' +); +SET @sql := IF( + @idx = 0, + 'ALTER TABLE template_download_log ADD KEY idx_tenant_template_time (tenant_id, template_id, downloaded_at)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_download_log' AND INDEX_NAME = 'idx_tenant_user_time' +); +SET @sql := IF( + @idx = 0, + 'ALTER TABLE template_download_log ADD KEY idx_tenant_user_time (tenant_id, user_id, downloaded_at)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +SET @idx := ( + SELECT COUNT(1) + FROM information_schema.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'template_download_log' AND INDEX_NAME = 'idx_tenant_download_type_time' +); +SET @sql := IF( + @idx = 0, + 'ALTER TABLE template_download_log ADD KEY idx_tenant_download_type_time (tenant_id, download_type, downloaded_at)', + 'SELECT 1' +); +PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt; diff --git a/backend/src/main/resources/db/migration/V93__notification_user_created_policy.sql b/backend/src/main/resources/db/migration/V93__notification_user_created_policy.sql new file mode 100644 index 0000000..6931b2c --- /dev/null +++ b/backend/src/main/resources/db/migration/V93__notification_user_created_policy.sql @@ -0,0 +1,101 @@ +INSERT INTO notification_text_template ( + tenant_id, + template_name, + subject_template, + title_template, + content_template, + status, + is_deleted, + created_by, + updated_by +) +SELECT + 1, + '用户创建邮件通知', + '用户账号开通通知', + '用户账号开通通知', + CONCAT( + '您好,$', '{userName}:\n', + '您的系统账号已由管理员开通。\n', + '租户名称:$', '{tenantName}\n', + '租户编码:$', '{tenantCode}\n', + '登录地址:$', '{loginPath}\n', + '登录账号:$', '{phone}\n', + '通知邮箱:$', '{email}\n', + '账号有效期:$', '{validFrom} ~ $', '{validTo}\n', + '请首次登录后及时修改密码。' + ), + 'ENABLED', + 0, + 0, + 0 +WHERE NOT EXISTS ( + SELECT 1 + FROM notification_text_template + WHERE tenant_id = 1 + AND template_name = '用户创建邮件通知' + AND is_deleted = 0 +); + +INSERT INTO notification_policy ( + tenant_id, + policy_name, + event_code, + channel, + receiver_type, + template_id, + variables_json, + status, + is_deleted, + created_by, + updated_by +) +SELECT + 1, + '用户创建邮件通知策略', + 'USER_CREATED', + 'EMAIL', + 'TARGET_USER', + t.id, + NULL, + 'ENABLED', + 0, + 0, + 0 +FROM notification_text_template t +WHERE t.tenant_id = 1 + AND t.template_name = '用户创建邮件通知' + AND t.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM notification_policy p + WHERE p.tenant_id = 1 + AND p.policy_name = '用户创建邮件通知策略' + AND p.is_deleted = 0 + ); + +INSERT INTO notification_policy_event ( + tenant_id, + policy_id, + event_code, + status, + created_by, + updated_by +) +SELECT + 1, + p.id, + 'USER_CREATED', + p.status, + 0, + 0 +FROM notification_policy p +WHERE p.tenant_id = 1 + AND p.policy_name = '用户创建邮件通知策略' + AND p.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM notification_policy_event e + WHERE e.tenant_id = 1 + AND e.policy_id = p.id + ); diff --git a/backend/src/main/resources/db/migration/V94__template_menu_permission_split.sql b/backend/src/main/resources/db/migration/V94__template_menu_permission_split.sql new file mode 100644 index 0000000..cfa63bc --- /dev/null +++ b/backend/src/main/resources/db/migration/V94__template_menu_permission_split.sql @@ -0,0 +1,72 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'template.manage', '管理模板', 'template' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'template.manage' +); + +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'template.download.log.read.all', '查看全部模板下载日志', 'template' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'template.download.log.read.all' +); + +UPDATE permission SET permission_name='管理模板', module='template' WHERE permission_code='template.manage'; +UPDATE permission SET permission_name='查看全部模板下载日志', module='template' WHERE permission_code='template.download.log.read.all'; + +UPDATE menu +SET permission_code='template.manage', + updated_at=CURRENT_TIMESTAMP +WHERE tenant_id=1 AND route_path='/templates'; + +UPDATE menu +SET menu_name='模板查看下载', + permission_code='template.read', + updated_at=CURRENT_TIMESTAMP +WHERE tenant_id=1 AND route_path='/template-download-logs'; + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT t.next_id, 1, 101, p.id +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM role_permission) t +JOIN permission p ON p.permission_code = 'template.manage' +WHERE NOT EXISTS ( + SELECT 1 FROM role_permission rp WHERE rp.tenant_id=1 AND rp.role_id=101 AND rp.permission_id=p.id +); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT t.next_id, 1, 101, p.id +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM role_permission) t +JOIN permission p ON p.permission_code = 'template.download.log.read.all' +WHERE NOT EXISTS ( + SELECT 1 FROM role_permission rp WHERE rp.tenant_id=1 AND rp.role_id=101 AND rp.permission_id=p.id +); + +SET @next_role_menu_id = (SELECT IFNULL(MAX(id), 0) FROM role_menu); +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (@next_role_menu_id := @next_role_menu_id + 1) AS id, + r.tenant_id, + r.id, + m.id +FROM role r +JOIN menu m ON m.tenant_id=r.tenant_id AND m.route_path='/templates' AND m.is_deleted=0 +WHERE r.role_code='TENANT_ADMIN' + AND r.is_deleted=0 + AND NOT EXISTS ( + SELECT 1 FROM role_menu rm WHERE rm.tenant_id=r.tenant_id AND rm.role_id=r.id AND rm.menu_id=m.id + ); + +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (@next_role_menu_id := @next_role_menu_id + 1) AS id, + r.tenant_id, + r.id, + m.id +FROM role r +JOIN menu m ON m.tenant_id=r.tenant_id AND m.route_path='/template-download-logs' AND m.is_deleted=0 +WHERE r.role_code='TENANT_ADMIN' + AND r.is_deleted=0 + AND NOT EXISTS ( + SELECT 1 FROM role_menu rm WHERE rm.tenant_id=r.tenant_id AND rm.role_id=r.id AND rm.menu_id=m.id + ); diff --git a/backend/src/main/resources/db/migration/V95__meeting_material_export_permission.sql b/backend/src/main/resources/db/migration/V95__meeting_material_export_permission.sql new file mode 100644 index 0000000..62b7071 --- /dev/null +++ b/backend/src/main/resources/db/migration/V95__meeting_material_export_permission.sql @@ -0,0 +1,28 @@ +SET @next_permission_id := (SELECT IFNULL(MAX(id), 0) + 1 FROM permission); +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT @next_permission_id, 'meeting.material.export', '导出会议资料包', 'meeting' +FROM dual +WHERE NOT EXISTS ( + SELECT 1 + FROM permission + WHERE permission_code = 'meeting.material.export' +); + +SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + p.id AS permission_id +FROM role r +JOIN permission p ON p.permission_code = 'meeting.material.export' +WHERE r.role_code = 'PROJECT_OWNER' + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); diff --git a/backend/src/main/resources/db/migration/V96__meeting_location_comment_free_text.sql b/backend/src/main/resources/db/migration/V96__meeting_location_comment_free_text.sql new file mode 100644 index 0000000..653fcb4 --- /dev/null +++ b/backend/src/main/resources/db/migration/V96__meeting_location_comment_free_text.sql @@ -0,0 +1,2 @@ +ALTER TABLE meeting + MODIFY COLUMN location VARCHAR(255) NULL COMMENT '会议地点'; diff --git a/backend/src/main/resources/db/migration/V97__user_theme_scheme_preferences.sql b/backend/src/main/resources/db/migration/V97__user_theme_scheme_preferences.sql new file mode 100644 index 0000000..40f9d25 --- /dev/null +++ b/backend/src/main/resources/db/migration/V97__user_theme_scheme_preferences.sql @@ -0,0 +1,39 @@ +SET @sys_user_theme_scheme_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'sys_user' + AND COLUMN_NAME = 'ui_theme_scheme' +); +SET @sys_user_theme_scheme_sql = IF( + @sys_user_theme_scheme_exists = 0, + 'ALTER TABLE sys_user ADD COLUMN ui_theme_scheme VARCHAR(16) NULL AFTER ui_density', + 'SELECT 1' +); +PREPARE stmt_sys_user_theme_scheme FROM @sys_user_theme_scheme_sql; +EXECUTE stmt_sys_user_theme_scheme; +DEALLOCATE PREPARE stmt_sys_user_theme_scheme; + +SET @platform_user_theme_scheme_exists = ( + SELECT COUNT(1) + FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'platform_user' + AND COLUMN_NAME = 'ui_theme_scheme' +); +SET @platform_user_theme_scheme_sql = IF( + @platform_user_theme_scheme_exists = 0, + 'ALTER TABLE platform_user ADD COLUMN ui_theme_scheme VARCHAR(16) NULL AFTER ui_density', + 'SELECT 1' +); +PREPARE stmt_platform_user_theme_scheme FROM @platform_user_theme_scheme_sql; +EXECUTE stmt_platform_user_theme_scheme; +DEALLOCATE PREPARE stmt_platform_user_theme_scheme; + +UPDATE sys_user +SET ui_theme_scheme = IFNULL(NULLIF(UPPER(ui_theme_scheme), ''), 'SLATE') +WHERE is_deleted = 0; + +UPDATE platform_user +SET ui_theme_scheme = IFNULL(NULLIF(UPPER(ui_theme_scheme), ''), 'SLATE') +WHERE is_deleted = 0; diff --git a/backend/src/main/resources/db/migration/V98__enterprise_permission_cleanup.sql b/backend/src/main/resources/db/migration/V98__enterprise_permission_cleanup.sql new file mode 100644 index 0000000..8824c63 --- /dev/null +++ b/backend/src/main/resources/db/migration/V98__enterprise_permission_cleanup.sql @@ -0,0 +1,131 @@ +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'enterprise.read', '查看企业', 'enterprise' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'enterprise.read' +); + +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'enterprise.manage', '管理企业', 'enterprise' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'enterprise.manage' +); + +INSERT INTO permission (id, permission_code, permission_name, module) +SELECT t.next_id, 'enterprise.delete', '删除企业', 'enterprise' +FROM (SELECT IFNULL(MAX(id), 0) + 1 AS next_id FROM permission) t +WHERE NOT EXISTS ( + SELECT 1 FROM permission WHERE permission_code = 'enterprise.delete' +); + +UPDATE permission +SET permission_name = '查看企业', + module = 'enterprise' +WHERE permission_code = 'enterprise.read'; + +UPDATE permission +SET permission_name = '管理企业', + module = 'enterprise' +WHERE permission_code = 'enterprise.manage'; + +UPDATE permission +SET permission_name = '删除企业', + module = 'enterprise' +WHERE permission_code = 'enterprise.delete'; + +UPDATE menu +SET permission_code = 'enterprise.read', + updated_at = CURRENT_TIMESTAMP +WHERE route_path = '/enterprises'; + +SET @next_role_permission_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + src.tenant_id, + src.role_id, + p.id AS permission_id +FROM ( + SELECT DISTINCT rp.tenant_id, rp.role_id + FROM role_permission rp + JOIN permission legacy ON legacy.id = rp.permission_id + WHERE legacy.permission_code IN ('enterprises.read', 'enterprises.create', 'tenant.manage', 'project.create', 'enterprise.delete', 'enterprise.manage') +) src +JOIN permission p ON p.permission_code = 'enterprise.read' +WHERE NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = src.tenant_id + AND rp.role_id = src.role_id + AND rp.permission_id = p.id + ); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + src.tenant_id, + src.role_id, + p.id AS permission_id +FROM ( + SELECT DISTINCT rp.tenant_id, rp.role_id + FROM role_permission rp + JOIN permission legacy ON legacy.id = rp.permission_id + WHERE legacy.permission_code IN ('enterprises.create', 'tenant.manage', 'enterprise.manage') +) src +JOIN permission p ON p.permission_code = 'enterprise.manage' +WHERE NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = src.tenant_id + AND rp.role_id = src.role_id + AND rp.permission_id = p.id + ); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + src.tenant_id, + src.role_id, + p.id AS permission_id +FROM ( + SELECT DISTINCT rp.tenant_id, rp.role_id + FROM role_permission rp + JOIN permission legacy ON legacy.id = rp.permission_id + WHERE legacy.permission_code = 'enterprise.delete' +) src +JOIN permission p ON p.permission_code = 'enterprise.delete' +WHERE NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = src.tenant_id + AND rp.role_id = src.role_id + AND rp.permission_id = p.id + ); + +DELETE rp +FROM role_permission rp +JOIN permission legacy ON legacy.id = rp.permission_id +WHERE legacy.permission_code IN ('enterprises.read', 'enterprises.create'); + +DELETE FROM permission +WHERE permission_code IN ('enterprises.read', 'enterprises.create'); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_permission_id := @next_role_permission_id + 1) AS id, + r.tenant_id, + r.id AS role_id, + p.id AS permission_id +FROM role r +JOIN permission p ON p.permission_code = 'enterprise.read' +WHERE r.role_code IN ('TENANT_ADMIN', 'PROJECT_OWNER', 'EXECUTOR', 'AUDITOR', 'FINANCE') + AND r.is_deleted = 0 + AND NOT EXISTS ( + SELECT 1 + FROM role_permission rp + WHERE rp.tenant_id = r.tenant_id + AND rp.role_id = r.id + AND rp.permission_id = p.id + ); diff --git a/backend/src/main/resources/db/migration/V99__biz_change_log.sql b/backend/src/main/resources/db/migration/V99__biz_change_log.sql new file mode 100644 index 0000000..ea6ca26 --- /dev/null +++ b/backend/src/main/resources/db/migration/V99__biz_change_log.sql @@ -0,0 +1,22 @@ +CREATE TABLE IF NOT EXISTS biz_change_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + biz_type VARCHAR(64) NOT NULL, + biz_id BIGINT UNSIGNED NOT NULL, + change_type VARCHAR(64) NOT NULL, + field_code VARCHAR(64) DEFAULT NULL, + field_name VARCHAR(128) DEFAULT NULL, + before_value VARCHAR(2000) DEFAULT NULL, + after_value VARCHAR(2000) DEFAULT NULL, + related_user_id BIGINT UNSIGNED DEFAULT NULL, + related_user_name VARCHAR(128) DEFAULT NULL, + operator_user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + operator_user_name VARCHAR(128) DEFAULT NULL, + batch_id VARCHAR(64) DEFAULT NULL, + remark VARCHAR(500) DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + KEY idx_tenant_biz_time (tenant_id, biz_type, biz_id, created_at), + KEY idx_tenant_batch_time (tenant_id, batch_id, created_at), + KEY idx_tenant_type_time (tenant_id, change_type, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='统一业务变更日志'; diff --git a/backend/src/main/resources/db/migration/V9__audit_task_assignee_and_role_permission_bind.sql b/backend/src/main/resources/db/migration/V9__audit_task_assignee_and_role_permission_bind.sql new file mode 100644 index 0000000..04fea17 --- /dev/null +++ b/backend/src/main/resources/db/migration/V9__audit_task_assignee_and_role_permission_bind.sql @@ -0,0 +1,14 @@ +ALTER TABLE audit_task + ADD COLUMN assignee_user_id BIGINT UNSIGNED DEFAULT NULL AFTER audit_node; + +INSERT INTO permission (id, permission_code, permission_name, module) +VALUES + (1023, 'permission.read', '查看权限点', 'system'), + (1024, 'role.permission.bind', '角色绑定权限', 'system') +ON DUPLICATE KEY UPDATE permission_name = VALUES(permission_name), module = VALUES(module); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +VALUES + (23, 1, 101, 1023), + (24, 1, 101, 1024) +ON DUPLICATE KEY UPDATE permission_id = VALUES(permission_id); diff --git a/backend/src/main/resources/db/schema.sql b/backend/src/main/resources/db/schema.sql new file mode 100644 index 0000000..18fe265 --- /dev/null +++ b/backend/src/main/resources/db/schema.sql @@ -0,0 +1,211 @@ +-- MySQL 5.7 +-- 字符集建议:utf8mb4 + +CREATE TABLE IF NOT EXISTS tenant ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_name VARCHAR(128) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_name (tenant_name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS sys_user ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_name VARCHAR(64) NOT NULL, + phone VARCHAR(32) NOT NULL, + email VARCHAR(128) DEFAULT NULL, + password_hash VARCHAR(255) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_phone (tenant_id, phone), + KEY idx_tenant_status (tenant_id, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS role ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + role_code VARCHAR(64) NOT NULL, + role_name VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ENABLED', + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_tenant_role_code (tenant_id, role_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS permission ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + permission_code VARCHAR(128) NOT NULL, + permission_name VARCHAR(128) NOT NULL, + module VARCHAR(64) NOT NULL, + UNIQUE KEY uk_permission_code (permission_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS role_permission ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + permission_id BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uk_tenant_role_perm (tenant_id, role_id, permission_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS user_role ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + UNIQUE KEY uk_tenant_user_role (tenant_id, user_id, role_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS project ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_name VARCHAR(128) NOT NULL, + budget_cent BIGINT NOT NULL, + meeting_total INT NOT NULL, + status VARCHAR(32) NOT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_status (tenant_id, status), + KEY idx_tenant_updated (tenant_id, updated_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS meeting ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_id BIGINT UNSIGNED NOT NULL, + topic VARCHAR(256) NOT NULL, + budget_cent BIGINT NOT NULL, + meeting_status VARCHAR(32) NOT NULL, + audit_status VARCHAR(32) NOT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + invoice_config_json VARCHAR(1000) DEFAULT NULL COMMENT '会议发票动态模块配置JSON', + KEY idx_tenant_project (tenant_id, project_id), + KEY idx_tenant_audit (tenant_id, audit_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS audit_task ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + audit_node VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL, + opinion VARCHAR(500) DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_node_status (tenant_id, audit_node, status), + KEY idx_tenant_meeting (tenant_id, meeting_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS finance_payment ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + project_id BIGINT UNSIGNED NOT NULL, + meeting_id BIGINT UNSIGNED NOT NULL, + amount_cent BIGINT NOT NULL, + payment_status VARCHAR(32) NOT NULL, + voucher_oss_key VARCHAR(512) DEFAULT NULL, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + created_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_by BIGINT UNSIGNED NOT NULL DEFAULT 0, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_tenant_project_status (tenant_id, project_id, payment_status), + KEY idx_tenant_meeting (tenant_id, meeting_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS operation_audit_log ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + operator_id BIGINT UNSIGNED NOT NULL, + action_code VARCHAR(128) NOT NULL, + target_type VARCHAR(64) NOT NULL, + target_id BIGINT UNSIGNED NOT NULL, + before_data TEXT, + after_data TEXT, + request_id VARCHAR(64), + ip VARCHAR(64), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_tenant_operator_time (tenant_id, operator_id, created_at), + KEY idx_tenant_action_time (tenant_id, action_code, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS async_job ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + job_type VARCHAR(64) NOT NULL, + payload TEXT, + status VARCHAR(32) NOT NULL DEFAULT 'READY', + next_run_at DATETIME NOT NULL, + retry_count INT NOT NULL DEFAULT 0, + max_retry INT NOT NULL DEFAULT 3, + idempotency_key VARCHAR(128) DEFAULT NULL, + locked_by VARCHAR(128) DEFAULT NULL, + locked_at DATETIME DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY idx_status_next_run (status, next_run_at), + KEY idx_locked_at (locked_at), + UNIQUE KEY uk_idempotency_key (idempotency_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS async_job_log ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + job_id BIGINT UNSIGNED NOT NULL, + execute_status VARCHAR(32) NOT NULL, + message VARCHAR(500) DEFAULT NULL, + executed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + KEY idx_job_time (job_id, executed_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS error_code_dict ( + id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + code INT NOT NULL, + message VARCHAR(255) NOT NULL, + category VARCHAR(64) NOT NULL, + UNIQUE KEY uk_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS biz_change_log ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + tenant_id BIGINT UNSIGNED NOT NULL, + biz_type VARCHAR(64) NOT NULL, + biz_id BIGINT UNSIGNED NOT NULL, + change_type VARCHAR(64) NOT NULL, + field_code VARCHAR(64) DEFAULT NULL, + field_name VARCHAR(128) DEFAULT NULL, + before_value VARCHAR(2000) DEFAULT NULL, + after_value VARCHAR(2000) DEFAULT NULL, + related_user_id BIGINT UNSIGNED DEFAULT NULL, + related_user_name VARCHAR(128) DEFAULT NULL, + operator_user_id BIGINT UNSIGNED NOT NULL DEFAULT 0, + operator_user_name VARCHAR(128) DEFAULT NULL, + batch_id VARCHAR(64) DEFAULT NULL, + remark VARCHAR(500) DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_deleted TINYINT(1) NOT NULL DEFAULT 0, + KEY idx_tenant_biz_time (tenant_id, biz_type, biz_id, created_at), + KEY idx_tenant_batch_time (tenant_id, batch_id, created_at), + KEY idx_tenant_type_time (tenant_id, change_type, created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..2a291ce --- /dev/null +++ b/backend/src/main/resources/logback-spring.xml @@ -0,0 +1,28 @@ + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [reqId=%X{requestId}] - %msg%n + UTF-8 + + + + + logs/writeoff.log + + logs/writeoff.%d{yyyy-MM-dd}.log + 30 + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} [reqId=%X{requestId}] - %msg%n + UTF-8 + + + + + + + + diff --git a/backend/src/main/resources/templates/meeting-summary-template.docx b/backend/src/main/resources/templates/meeting-summary-template.docx new file mode 100644 index 0000000000000000000000000000000000000000..0abf95d2f087b77abd0e59cd1c8c434ba4e13469 GIT binary patch literal 110148 zcmeFXWmH^28z$Je1rP4QLJ02K5CQ}U?%KgESa9tC!5snwYdkm|+%32^PH=bk#+v1u z*_oZ)Ge7q1{G6?OuGg(|pQ`t%df%r?O$i0{H2@ue2><{X0FI@`WBy10fDSqUKn%b{ zHk5I4bhC7H18I3XTe=!^dO3Wh&qGCin*%_8$^ZYS|BY*)HhEa3o0~xP_S2)x7Kh?e zwG^hnaZo?eJ9WvhA zgX;Gff;9T!WbA<}c<7!#4T-yY_-AP?#C?K-wBND7?ERJ!{Y2j?T>KN9)fY)<7PP^3 zgZ2euckqvhFpEU#6)73AGmOIzSa4NL* zN7M=@uy{{+kP_f)mM@_WH@Jhm7yr^E9@*9UYvtM_`F}F@%pe`Rd-2MfyS^t9D_Uv( zUN-P-ezd$&Ji~@})G6=%SawJ>aldt!W1@iE1FG>R$(v#AZxHnTYkR3n4n_e0o}WbKHW zQ!B43%hsB_^_GGZKa7p<(das_p=R7)m_79{DmW@W5hM=1PJOp0`L%1pnwl z`7ob(u>YSa{nH1U&C>Ty+z2Zv3DeSw5_-o`lHwf#`0IM7Md{17Ik91tSn%;sl*zXvT`W2LIzomSGTn``UCL*CGyAoDmJr<-2^B6+e>V^gI5ibeZ=#wj09*0Hz860K%6#@^t!U!DZoO z?(Xn1(*0+MJ2EzO+Uz3<;2M9X$Mjzgzl|ag%lYV1(<#X9(Xf9RPOWgH!6Y5)r=U)+ zKxIKDYj~B&q8tPmlxdMwXSEIfQlruU{A~}t1-XsI|FjrPVNOr6JbLDik z8dxub9^bSL3C5ZUoe7mbvK!R}`HMLlb+o8I4N4q_>QwZg-r-#!(>ZC~Ive>rT!sO? z+>1_7Z*t=NHF608j8?IZW9;JM|Cp5Zdf?xxKPs-)<}9S*SR>bhG(XC zo>RBrqxpyT<5AZur_VA$kXQxY3mT0)KowN_f56 zT2XFKNbXNcH32lHZR9*HWro4Kcoo~sM0|x`avGNm5mTQGBn#L)4pjNP2+XosJc5ej zFmdIU4d`TVG1hUhso!kW8^f4=Ungnul5gGLx~hFt!X>=VtKkkZIK;ra`N%4RC0H6f z04>^fdNqu)e_i0`Ly^+C;x{vSa7DSoZ1TCfdP3Yk^|g>@?_Zw^Qq#B_^R<2nA!@s@ z02BW7f5TGs*6y7itHT$^KOQslJW}lVl1#~w#$*CSJ^ZEA|c5tc5){+6%xEK`W2Z+l&8MJ*8>AxZCzE2{PEK4A5f?$gCoOkowdTIUbByyF`FEgsG5f`KcJ%r;0@#=I1{cEOin zwJ7)V_G{_5i&lm7*Pl#sI%`S4tfZ=rCjTPQvV$NPnK$?yXc|+`R<~Z^|VbG zE>Upx3P=mH?brW~)G!aF@sj>IlQH=*_y-=6-=G zRHZ3bPq1Vb!O5~~MA+^@T*_&=lhfnIM>4>3O{{QI9jb-&Bi>VveL|e`~b6G3hHU(YaSvXSvEhe9$>AN-4w}c(CAu-EL7S*MC zMbUHH>v)5P^)v)TSC=WVOO9NgGn_O&7##ss8%{gyc#MA44~r`fwPdey1O|oOmy9zH zyg$7PdeGO-wBR0zrRv>Mk&-9pH(i#owfXtU+ig5dw&lG<)EE87wQKZ?EYrPFrk+L- z2Im1gA;!7DMb0*jXzWdP6B5laAR))wW-mp4uHw)0syn32-dd(Q_(&xgIveJK-!(sL zRyFDn`KI7Dl1Y1yYM|D2X-NzgaQV>0@x?J&r`Y`JRtcz4d*@I-(Jf7hJypNnWDJy{ z8kIoe4iUEPwvgugEIn>L)p2mSx`dtBQd#eaApK4RzIp2Je|Ui52V6g{J&|?zU2SyB z7rpiDW8~9|#nM80&x##d5}v45y_8%<%>?1CYBypr@)tV%wn+EUc&o*!ig`mw4cwbm zK71TCwN!R_gxnFC;&@DbFHt>%ZMecbvPx3c61R|}(Sp30$Dy`6G!M(oK*4#K{eQ zKBCSm$JIsE;MP;feHXfOX7rCaDG@2KGYjdf#=5aZkwMAUP4dPK$v4TA@r7y96<6K9 zVZ9Ie(8;2;kl#7K@;qp2^-9uo#_{L^?q@DNJBH1}aK33V)iG1p)$EOqBnB%jHmB5I zdwi)$Zk=rB+@WTV%o|d!`-96DQWr`XZ`-%!IXOwTMeQ2-evspk`d(?*izfP_rXPc= z9sV{PUDMcOrgy@@YTBTa70}S(79>$oo(2jPA(4cs<8qj;5zHDD{uZ zwl{r`iubuWbxwohBnnOSJH|OX#XXmf^?&uN4&%Q#xVr;EDwBx7|fZfbvog0^*nZP-dV1z74S ze^_ZcmGX*f>Dv`)h-Hn{htWxhFM&lxw8?kLhjTW|x!d)q`gi7&Smo*blC34x$7iuw z1-)|_$f_-V{r;-_YSt3FYS?5g&}fu4(#$I%%-jb(&*syo+2(&4v<=2|MNRx(MPFXy za2nk2EPrDEQ>v8ZT*sGk0_bPr?0ec(mgO_J5YgP7aewwUaoESLRR2*NJ7{T(p*zz3 z_;}VuhX=krsW}CXIniKW2r@(LDbh{C8`6HipA!8!)anL=7T1Ln9Ep)Tc@JclEf=18 zOB)xGM=rdzVHqAz&HJU8(SqH+i?9yEZ*Tqv`aE76I>R3su-de>HrZHNo*4a2GJR=?~^S+0)G!q%x^k%g`X zi~<`t8TM3nTaJ58OzPyseHprMKbF2tF>fPj8x#=k%3RJ&YU8QkOL`q@VsM&Vx8gj| zn-2n2FH0_py5bBivu1#ph4r!6!Vka^dw|Cq_Br5-WEOXFHXBOP4S}271JETcScx(q_$Y=Ze z4C;ts;A3+r338a4Ez7U}2z{r|xG6z~|C?YzYE9x3kksxbpSj6V!Ynj2l{~jZ_&j>v@C+e1Ib6y~a!O*J6cf<2 zIs4s3u`PmWKe`|fzCVF?WK+|M=eo|;Xy2##Tvc0_g_=5(mYHqeh7Oh3iU=52%r^Tk zv^u;3Wwwg#dAJzSTK_!YYh-H5xlhy7(jMoiO71oBihOt33}K7i{;aN@UwEeIx?%q+ zb78|Uk4NJdj_>34&4jE0AVMiXJ=VVggZ3`+PfbX3BcZ8VcCwkF@(VZj?tH<%h_k1e z4D@21D0_;X9<85qR0f(qO-1UAYDHD`SoF6K^q2ALJ8wn`9E%_U-oI8t-RHtM%Y+?2 zpG>TmRk!q=od~urGe>au5w!AQ|7HDfIMCoL-J>HWJ6ceVGDJo6XOlM|(I6#HNx2W- zG>ezQas=D&A?wXQUuPr(TWrGvTjqWG-!s_UdUeQxIR>_I-$aotcO!9#=k0ps{Y9t4 zvc(JE=;WGW^b2w@y!|GxvnZa(j1g73mIl%nE>c?M*y&)l5 z(3rGVyBtpg%0ob=|4*@gp{yh>wg>5&ut!e8J^oIXZYQ_V%ICbgSn0IzoFV#eu2LbD z%IJi@ZLTV5@dooe$>c5@8jOCY(Mz@D#jB70A+kXI!4m9sPoFt57Q{MW=j&+G1i?^q z21Oh+uJ-->JaqM$IoId&o6kqIOCC0}Vy|g)`iaO-FtI=IXc82+FGk!uemi= zY4q3Fman58^@1&UVO67fl4)T%&LPwh)D>@S#L|<{@=YO^EaywHft7{L9<~92#e2U+ zyfG6$E9W!)fzAI6=o(%piyzGobE5OVKbzkK(w=~Q9}zRzYuN$Sr)CRsY@u_tkiT_q^W5K?=K6&6s>v%L{eRj$lCx^+Qas%4WIVCG9BDvP;k9SF+_Oyr%qQpGj?Ur-4~+ z6A@W(L99}eBOt3FIBzRg*qMUdvVnZqSS1l|{;qsmL$g^cU#V2GDI~+UL}C3ddfYOr zd2Uj>J04Kxm-0TsmyI+90(^X!$bPOaRgw>4a1C`-4o@zQV1efneHs@c;w-f> zn@b&;s|l@!j@2+@_v`K+M)f=va%r$~X}Ipk2hQBlA}+a}e=LS-`vq^lv`w_(B!4&T zI;37SJ?RoNO#ekoMdtyI)&rq2#H1Ej=%59u1RHz^5Tj?>i%U#mJgAwbmHSC2RGL1r zlw$Eku(Wy1%5@pZw9D;4Q4Et3^B@c_=I3Z#uGe6p7c?*tWyWg3Jb6A+<7z zq&15kYe!X7k=)RPdZ5)r-O@h6(^s12B>POYrw!1(W|V1pIH}3DS$y z`I7s8?^K>N`X8z9$LXim;5yHZ0x9WY6)wyYT}=Enri>nf=K+tvRtcRl!4exrJjoM1 z$es9XGrX41MOj_d*{OTRT>2u7kQzdS6PVZT`lQ>rBH zOIXXJ4j%Kp{8&;Z`g4hSAqSf^ratk3dm)9=#Q%bH|BZ6$w2A~h@SG_lJ#cxtK`OFq z4czKJ6^@U{&2=iCf?*Q!6*Dy;SAY_cAbGG2;b9XWHrW@C&OItV?`If zgo@I?QP*?*5wE&r=7sV5PtHGg=u>zYC@cKBh666hu0Dyew)V{Jo0{mwueR=v{jLX5& z!q$|_*1^=;l84jI+0y#?-}4%PKv7OX4uJGxTS)&gu;)d9%*);XTKZ2R|7#_@eEr-B zAjEt*51=5u0U#41p%5ZH_WKiiVDXiG_{xlA(?OfQ*EKf{coS zhKBmWEhPV!_W)EvG$IBbX>?+BQ;at*uXzI#axob{RCSSPOq?_*)63f@_(w=+Sa?Ka zV$#p#l+<5o>3R7Dg};l6OG>M2YU}D78k?H`cK7u5^$!dVO-@bE%+CFrUs&JR+}hsR z-P=Dnzqq`*zPW|p-Twy{5&-4DSJ{6>_W!^|_<{==6%_>)<3G5NkUd{^6hc%q1|D=G zX>|-!7veX(ftas9B;;0gVKMS)oROG)o4_Vz;$LSz{|~hPg6#htu%Q1>$o@O9{|naw z;1vqeOXHys0)T+4)mL8Oq;%Ye2obvBE9kfJ>-7skFH~oR>wDtW$8%5ygczQJ13kqs zIl{j_zDCs@N6^6|DKvKm)~?C7qCIydf47So0_E@M)B?WmtuQD&{?S7}agEDCDY*y7 z_#mQ6{32EtB^s}u#9To*aQnWS24q3~pTd2*&I6Kphb#z7~Pb`sY45EM~ed@cX}@WocWTjX`21X)|DFY zeptlyKgNMz5cbf=nif?B&xB4e+FPO z8n7n>2FALAdViOAkt$$(pqcyao3Oul2CdE)o-R~zSx}3*^3Vmbx?hM@-<;}8oMaD* z2s{H42U&NrKa3C)NUd#lurfA?TyNByW#d4E`z3$vPb28mjrhrAJVkby@Q24;5)!8F zbfq1*zaW|AT+`oRKmzh5#O}3+t@oY*j1$c#?N{#Opm&7H=5e1l;}9KAru&BR2UP&1 zhWKXyWVeAF(X>%4ZJIw&Qll@mt;-O)M1;hc)Q4|UbY2neCw>uk<*y+`^Br4Z%i|ev zeY0<*EY?PQnt#H#w;BYh6Y2wfr=%@ehlZN48nbr$tW>x|S8a4tTukOAp~d}9G}lLt zee8P%#~cob347cP+lzr1{(2|=>H&oQW*o&K9ig~!864KbW6NVtd6ZHEs*EbovoWwu zocued60<&eHNbI{<4s>1X;{J-uHfl0wX)ROpi8X8%EW&x$Bi*h(POGLP!!gOewt4R z5w(h`j0vM6cSp6xh?n2NEVQ0~8o?!rz@&~Feg;$(c`FUpyJNs5?!JKDhUL|4y%Lv=aK!@2!PBRZKo$^qUZPF6Q9Uw z!|tYnsx~n$axjsT_V0G@tn!of3)SXxjR!EMoi#oKxy@4JvxMzEz!m1~BLsdZc=!t{e#-WgJ!d-JWM` zJOd*6PYL0k>-c}))@q*9^^#dl`g@vaHBX9jSd*rw_m%iRrKIXi*)4>|+tbI7Lpmn&K zT5ZkKezs+b_n~zp6`fIo_1~8cCeA?ig-O-nITt!#nj28~)FLO)iXm$=9{PeUve6v9;oSn{;86vHRPxN3a6B`=2@x z`l;GyK(z@BbqVx0JyPsM`;VG=^pN0_;knbMYQ!_Zx&t51w?Wr=8Z`%@KZC{Q&7D-X zQ+fq|n5)Cbg##5P1USUHqj0Ov1F~>xj6qnIf(E1|??nZAM=P_P>u+_1fn=bA7jqB_ z6pqWUDb|`BgX9}Ol#^_7P}nq))T7`l z;a^AS6jX7xQK(1m+?Wh!>vCw_K`jgO>z~L{-_`96O;@{z3q#WE#(lqcB^CHp?n3_o-QQ*CHj7rKn;N?RRE2Mz|h^gltj zP9E*MRjSWU^0K=)d1aL@U?qHgrzu5Guf z`380d#JNCzcJp%3a$i`E@o=%DbU($Wz@_&IdNR32iBkGprje67nT=uLb(as7-;D2vL?;cJiQ}YDO!C7 z%-nfEe{H8)-}3F9v_RX?JOk+9pmVZb1N4p!Cp$tk2Cmn8n|j zKcMvQNx3CRL1X$bAeFe{m6qF7|5KuEv^CLiN%lRjxb;={+UfD&`YL&Q);c&cRi~Zg zT%ap<4Azq?-hq2i^Hy8kH^^dwHF-d7%6q}v6WzW=4`F_H=()CvZPSflF=3o`7rPdh zFIub!lEhy|TS2J!SO)a;+-J^-c~|VEd$X!cxcOqJ2YVJfw9k;cEt!KG4qEyj*cyP{ z-_cAB@xv{#>svWa~_~YHq}Ew6eSB z+TP36xEC%QlIhw3>tfnQdw7=?3)jItE(7i9dK=Nnq`er{+Ni(d=2RCY8#ebnOeY zZhtD4`!qd05yVQTJK%(4Nd|a{i_ot_7J`(g`kB zcO6Ej^JM#ps#8J2P-1xD<}rJ>Bvtb{XGy7%z0L})jM!fK=ebKo$tj7tCLGC}!J@tk zK1Pw+#&lAitpYI1X|TA=gRy->;Z!&6>pv6H$VZLHIav6Krlbew~ z5euD)?gy=Shl{7TquXsNKJysVmr1i}Os`fSBGq$wDMPCueoIrDEYRB2X3?PxCYB9T za%Vkszcai9n!RibV(U-OfaAjlLbz}Dee!IbZ+XDnGoVH%mh*i%)L@pO)bHyVN2q0M z;>v<>u8XtMd*UHx3-x!kHrkPQG6<~2wYU6E@VB~06wG}My9Ydoh1KfTQ3u@eIh?n( z@a;9j2JMVdwfwKIaDEEoW)<0@R+eJNWId%kd4qPm(_#dR6y6T{LO&L?PtMHyHT=7! zLD@hGFFMt8I|250>?fEsvG`OTsh2YpNiP}ry_zi_RVZGAi zDvv{lM=g#d?8JJXECj(*`!3q?W8kMj-q&C6cbRBi7O8c|CCTCYIA8JI(0Sr7e9VD@ zN3>Z3EFF~Y9dtH6R#pDC>omUFbAA#IT&bpI2cjr@yvxPf?~jWN5AJ8`>annO#0KTG zE~BlTV_G9Tt`?s*I#PK3*@SyflI|GvD`w(W9WXXSQW)obs{*MD8YRNS z^jtFC*ET2H7P~>z)x<(2ZwI!N>Z;;JcQI%rIw%p|{JEJe>-jgHiV^0}gv9Iz7P75u^6QFEm6UC;T$ z2(5}L1%0A?fHzHyzy2C*0YxF6AxT~D0z+TIKVtuqHuD}Ep;nu{;Hy#f?l&A3+vJxp z=8PvTjM45NYd>8ZxA36WU~}%J8~=%S6C>`MYxb0c{a8h+gttcGDoAMA{&2PUKMr&D z^xQGcx%|iThNGy54|}SvJEw5%4P~9VVgDXGN$MajLVZEx#1CLg#gSIW&cVBvP|GC0 z%sWxck*Vsvs?pZ<{urCCXeY{sVrP>UzReP=tIK2=sItN1Gfo#MY>HO4sZWEeffnP)t%yP=6S@>0oP zMZ~4i0rmmk&$Z9hV8Vnj2--*+JXh3E=aI75QsdjA-~NOzXDla)ElEzdj&i=`0=w+P zaILc}H|}j1YD!i?$4m&Mpb?Ez!*}#?fgxElogjVUgM?MOu125tPf;A$e`zlxr*a4# zM|Yj0dBVTDS074>}J6$7Lcv1JVY>Q3mKC zGp!3lcEVzbY*Z+(kn3HdPX<`y(M61C!#Y9??A{)I9A)JXJB^O@!Nb~zG*Ka+ zC)gbvw z!?{}$K4i*vld~1y{F(_Ob~$P;&JIRf?nsrtwI@43$W@hL*%Txlt+ZSJP9oY$i-4J7 zz|XJ6wZJwZ=;9kL)u+J(;@?~@{lV(` z{wBt&2sG8l$lb6@`DXw!XvgE2`-?~BWBJlnCKssFvvic5oi`zfr+@AYXmah^X|UT( z!F8Gk&u5KYo3N+?{~0~Ir$P2N77!Txz61?FaX7{e6Uj0Ii<}{EQdAmh%$7d`YOm>z zh`%k6W$yGGzIi>1oBPcn{g|EudFhvUjqs0Ot;>G}*=lFxv0%O}4%Hf9AqsYf%RAhc z04+OTF3ec-&(%yJ$PX#MQMV*`5A;E*ejP z39kSQnK5-Ibwh>pH++z2YdVqq;i|CCRB_T!k?|6qvOjH#c}y2LlkM~YdG3%9{-q^N zE{RICkKp@1?D3Sjpbc>k)}o@X2)%oa`clL1{SeWYuRy!k6|+>bURTG@fQ2-Z0I=sH zN~wMKz_2yli2u9HxE+fV){bWYbQ|I4%s1H-+UCJ@Y6{QZ0z3mGr}Iu!aw9ONub=o9 z;>rr=!JzfmnMb$tR~J`;)mocCh|3*e_LKnq*K5Wz);mJIVQ2W50;~zqRD#?6>dS7N z<}+Z9;*kfgGnto??S2j7;1?m|b39KufhEr$?mq?i#7dE$PW$>!p(M;DAbe+i22>@y zA6?;|B|ma#4bLQetXHcrBLAu@NYv^ogF$J^0?qc*(=nIP{_@u^gTj+tbKK$hnb*rD z0_xMRk4%8o=MvlIYkp`zSTE(+A3R5K0ED%<6b-YV4zu6SXVkgPxtrgCo(gNlvRF66 z`X$g8WhW{vTHr++hjr7}m|M1PzTWPOhlLNX13c&B7B#kYwtv?{r2h2Z=yMFl4{p{| zGNq{T8-!fV7uGh}J_59kj_#1I>R{732x@D6C@L~s{ zxJYpUVM!)Dg^plR+>XYyf!n^GOlX7L`(hGTgH`R{`)+`b*7?u2bd6Rcol#?(#O74% zT_)x`vabWMouZ)VJ2ZG9YoBkEpNEw{zxM)=p9xnnCSA!(MlVk-8ihOQ4(3ZjXbF}=zGpZzFXj@)7(vE_3W>~)T`>xbr3|g5h)>~c&m>Bpy z4jDYCn3x#apAE~8ZdXC*IFbV#?J_f4n?)UU+nxa(REDVtWFSodSwJ_K)=wuvbtB9A zGu$Pgyt}ZydBQ8z(Ca=qxLd78#!*THtN6gcXyhBCyt@nCK}T0ysDW-57`c1RpY#TC zPgt^QcdOe_HE)2EC8KfCKEvFeZHX;!iGst@m)A0c3uRXK(w$a=lC@cX;~BnCl=_B?qV$?E3a|?T+b~hwZah zU$#OxqVKH3!8SeZx>2fAj&-;N#}D4F7I;_~M;uSF#E{SFCWXbv4~r^-*&MU!3#so; zrod7123ZQV`ThpF3N6N`g{hi~_&?sG`zdTxP9UQZWAwo7Km)V8tSpc4M} zyj%4kSG4Yg3oq`bUY8Llh39{o(fI4SaH*^$HqK47Ef^%8p4N_@?ol?jv0%$oVeMeN zaN1#BgPW{XF*-=0<sNS zQsd!%7Z&Lh*TodTa{To@Y2~|XH5-;8Xiv{Pe7dS+6JJI|o?t{C+#k{4Gi+lGU*po!72yCc#fk||BA)HC4Q zBncax%->!*>TLC$xv{spYHrinTteOK4FSeSwRPF9r4)ugCX)ro3h$Cqhc0|h%n#;V zBb4MTx(ce&m%>SR`TQz^Do9ceSXFuR!8cj;!d5truDE*6MWy~N(yt-{{0+f^3)uNd@F?nho8Sdmzy|1fe)Z)sVDUYpN^qDO0d+jfu@GY~7R zU!4MSv?`sci>t4#lG`@iJ_EiG!gaM1<+5d`xy+1p*cFOutAFfmY9#!q=|b-E7AGB; zs($lv-NFVTb>`FwzjBXU0ix{brvJ|Np@PkXTyIHjGW8Z34DWpESWY}uvn56u;l$EL z#LIVyQC%gek`7djAP)r*kjhg09mre7%NIw7v9z0=IdxU=byLc+lC(QNydYY!|Foj*VDf}VEfck+nEfsa za&oJh>f~-Pd%5(uG#)^Dc+220YhQ*Xio=07!|!FTfo_gQ0BYu5E70XEN74~f!j4mi z^ZmB{C~!SxSe=v7O$}p_}qu*pAUKO~;kG(2O*aVZ|av${3iU*&sYm|s&2%YK9k z|NOH+V_>F73!q^3XMG@jS9DzWz?@gCt-4@+2%0!o$w0LZpc0((hj zq+679n-3o7IMO$MmTEjrPV$0?d=-FOQ}@=F4Rq00nUAT3{`e`~zkSRFe1K&8yL9Kg zotr>L>r4bVd$KhF8B!LwN{Fv&`r@LM**l=%w|6xI%sW}n`yG96%MmTRbcb917}HL* zW)I#sv%3w8Ug?8B@f-T2QCvI;&s>6o;hvi-(C&?o4PvuiEB_KEKeCR0qY50uqP~Pl z1gOteo|IGkkP&7D+@i!e4KuD5x&qG<4mKGF!=s-8Fc}!|)a6d6s&~{sBGl4tuYsN}W~8b!X=%UC=>UzSTFZ`4o;e7EP|XB>d}X5+Kx zAo&`e3iah;;M_SBrUWKAh$noAz)-!`9Rci)LlD`?j*BnOp;{bHvwIgH_*?&Yth3Kq zK$!4;mU(Q{3fH!6fC?~qgx%SL_h<4?X6vVW&pBfb=BcK8X8gEHGj&7)F)iQEc-OA2 z!0Qtqw~Ds`jANMiOsFnNJB_B}$mk*x!UN*EyW0&t62>A*QJuWPWLWeVQy8qY^DwC0 zeDH=!Wh455P9JZzEY)tpbO=nPh{k1n_N?cxA*e{c@L><})V>KR!rinj^b=o6zi51! z{!*-Y6kB?FRSS>6F|}y^RKVGYr5sldpy_ZjdJ;OPjxwpP$Fq+BsX+k;kyd}1&t4ZtM2ia z+d2fOBHK06TaG0Ifvb1>H1#rlkh)uP?@HQ{(?lg;FR9{jx2|VSey4aQ(RkSJYRG+X zZ;!uxK=5$f65?Z?&=lA&oxP_VV2&Si%k!Ois^Z@l{FIZ|R*Tc?-@nL7z$8lK`@P0@ zlqt8X#?0HoeOzOjVDzq6gW}qKg_1UV?aYsDPoHU#tM}%7|0;n~HLrPTI^2Nn)gf&HLrT7k%jU`cv-Lpd-?Tt zbJ3ALw{V;#+@MSlmJ5%(pHdn~o1b|G1x0S0)F&n#g=vhr1dS zpDKFdlvrk4SS!P}`OYEyOVFea_cmIyk& zq49}$@t@yk@vfZ#D~&Y3B7?{1v(Z*I^BpntztKU7_yN+V$8*t>t8mCJL6oqKfUiSc8Al2szrSp(^{j zjL5A&1-E5aoE=Ot48rQKT@Je>TOq#J$2uT`)Y|$^_pNxTihtldVkwBaa-*tw!yYtLLFgpvQmB*=CIo z1V`IRqab~7!?C|f6w*}H6Lw`C7kMH&%soD~BNe0JvJ9(ixF6f+tojw*+~Y<^rY)xu zbq#eAq$LBgb%9Yt;Rp-X9u{*WhjnaEZy`i(kTZc+$=H5#=T^Ksbxn<&1_rltPnL)N zS9QUNH?K*v)k`Qm?zd`F9k>nVvnNewGOuG)XMR2dMA4yd$uR+R$B_t!nj_$8@-qON z*S~`SK>|Kk7R3j_Bd4>T0S8HmkHA@D2~mv4j|NZNt_`X;E7@>4utN5N^+P@a5qDn1 zS#*gX|Du7Wl(hua*5X^r*v3}%WVEzVhc+GI3lTDdmDW{z3~ zO7>SM&K?cKd2DYI8X_v)Idr_~>jIAu!tkO8F$Aog#37)6eWz{nG#q>uXHQp48b#NG z%h$h(Vs>WLC{A)N_|Of+_fxja2RnfOD%<9*z&ZQ+P7NqWQ(~TKD(nq7PJ^AC{P=t= zZ&tg$o#wG(TD3EsXNSN+M6RdTVL_z&nu29O{KKi?(*G=d9dVxF{*|7oie_9&s8Ya4 z)ep&N+5L3yC+Cu3`t38GHwa(awCWk4f5Tn$ORk_}zU+abL?Bql@jSW7u_@CeVFz5H zR2=h@``d6})cFAB2A=tmIg zRaA&eyRYM%qQQY`lkWabCTAP)$^&JMIn{~0bM4(W{G1zggY4Qk+H-2tfu$@JK%%ol z9j35Ml(mLt`u5A32|7;7XHao~Crm!y1BgKj6QMsWl7Sj)C>X)t?7SQtaD_h~rDsb}Qq#JzP+p9`pWuDt~W z>d-tfi9D5ctGsEM2es5xnL_`PcD?778lX;?YoJCTXVVsQOD$nZyiR~fAW>9gi3E`B z4>hDe1EMPH$h5V6jU|YX- zSwR?5eYh|bd`zBK`f$e8qe{NP^~y_ErwuifjM%U)7NQ=0x2nFcHn7B2JOvg~9m z)Bc)$P>oz6bF;!Gm=^BT{j{nwF?re3T+#X+Fvw3pBRMQ`Cmp4p%!@z50~>3;J+|D4 zv5Xc}TdXyW>{}Q;{5%ZPs;_!@w>Z>F0HyqmAV1#n-VG06hvyqDxyKP3Ep*-iztr1H z(Dq&a(R^k3)zV@Q%9Hq+I1P(wE=g0cdm`Za6FV@JIJ%%^Sr&9!Bg(d|SN^$eK{FC; zSK!THzv^w60j_a9 zilwg8sP;h!9{iJkM-4NHkADkJmV+Fu>A!|0wg_wsC{i#8%QkYyXgJPFqB%BKfGoe4;RZ zy6E+`ux3ZU;7PVcqPpV3p{H*}IjmA(bpfZh`Mb?khLX~&UGrX|0gpuKOLGW^qRjv` zihc2Zzc78Q%SJjZ)=#!fmr9R>}8T-^5s(k)bkEcE=jrdo9*RKLM`1};l zwwyv|I0X!=znN79`F|z_US2+B+xomFe$@3Oh~1vn>{z#~7<#W`Lerf788>^uM7gjX z*yiAF>t1QR2-)1Krr&+nh7t1~#@jQbgb`R==P1C=WlaSNu#4phNbtNCxKGgzt?4mi z)G;A1BcnpV04l#O0r{-k#kH$AD|QP4g?&)(V!Pww{rj)! z2`wGzhE{2t8lMH(C1#T-s4W?)LsEZZkYrcEs>t-BzrL0J-SoEaA})FL-9@w04sp+y z5_Z!+MzDh4KXuyNce4E_1JbhuR&sL<%#ZJT(}hvCn$pSZ-uN7UGEX_LBfE-uFGM_r z5q}6K->dJe%r3BR?(9OUxSHo5$e^}*20S?01R9wm#5ux`w_~T@i;FoG^;NxC--lvM zwLN;5c<^=_UKC`}G(2gER<4|Ml)#w)xyOQ4LKN8RQXWep>(ziW?fPvP%~^-<1O*>_kmG9bfVGnH40 zZhQQ5C?~ZRF{|8TXuhm}@n6A{`p6_r8p^XYrldM1jBHmes~e{NGuz@$NaY{9GC8?A z%t2CH)iTI2EDrW>*M?=qNW`MgqGSfJLhiQa7U`<$BOE+8fTa6mm~k9CA*6+0%bRdm z5@-v;d%m1J0~Dq?)OliR6o_}Xy1ko5sX2+?_h=P`I%mH(Dmpd{IPgsJOPy$Gsde9; zu^*<93;JfVyrLp{aUr~Lrqgz}Tsc#I+alK3VOdZ!^Qda)P@siaXlau96PI!_HfKn_ zNyjrCMuUZu&}X-mU9+U*ge|VYF5~1J^-$Ay$%rRB=Q9YKLf-syAA`FDB(se z0zWcSMIBG&1-Nz=TlV+-9feT%lWKgJo@Se6D~O$pJs)5RmT(zsr=kH=1{QhL$`QhW zfju$woOw15=4u7UA`CvRU7gg=0D9#wUS*r1u}Mqbte7Km0D9le z?_e1iIW#(e!B09v`y6#nu)69;-uImIDMrZxl)G0RyK{&MGrg=1NqA%GBIxsc$ZFqB+QcTggdkN=^(XxHqICD$tMLKOD~C9L{sN zZ|>*1zTeOFeTZhCO%%_f^?-SQSr#0a2ig>JXfJ7As*{AqR7Zc>Nj(unUHZOw8$BkN zp|vcKV2oq0xp%6>)o;X?*yrYCG8ud8mWxkCYdUl{4;Ln2;~&UBj-+SC>`J3b!1 z+qw6XeyNc>4o39POJz}PvazEI$|S@qds#}vGIukJsQ49kt&QlKtA>AeC0}2lu+A=N ziKEEI-+{s?OfRp0xrNgypr>`hTcH>JE&qFP0&&z2*z)&_2>yLy@Z-dtq9Dl-@u!$A!v=mbu+bPgKY(NO zDf)Q}2XqEl=fHlAF%GO>6y*lc6agH0p+#*z1LgBGzZo1z8tYQM;CzX4n5S|-a2cj) z=F|I!=ht5r00Rv)MTxzag0w70M;=v{|1)B&k6xoOY=RZ8T4-Sbh`%hZ?T?y<#elu7 z$8djRVp59{g3gTt-Ydf;Vm^$n<0 zGHU-r$G!E4!m=~or=d-0RU*>< zR<#^}1KrgPp}56x*4KAARs2Va>z^vUdU+cBG6D|(lmUp5b`yVDE>yo*B>iQHdwzPM zE!x<1LRqR^==}IV!|hTsD^Ae^(L54~OG-_Z%X8P&GjaVBV&b|rS2x#r>aQ^@|Q*VCpnCy{BE})94F8#&}jQgVJiDgmCCtAUG84W$fHB-<7eyo=O)4q zTeATj-<)*h`%pQ)0dE`Jrr$4z{!ad8^@!FXzb>yV^oDsoeZqYUam;2V!kuX5&+AQ3 zVZ=gMS(F2VFf)(VwlFUyk#DUw#es>)XJac~6T37`@3P$}r+=>cr#$M@?vft8V|K#` zl3{c7M7&COmhKzny7IcTDq!KJ^gVfahq~fzS3LFTRbJ~%8V9x62RN%r(cD-K=TSknt0aEW5CJ@wT8>`Vq0 z!BPC^*R}?i=D~qIIoa>ON4!9F1kqf|QAe?1J$-M`ztT>4rq89(&}kvikYUK$Vx`zW z>sea0owX9a0$&!y76Lo33U!{c@*J$zO41EpE*=W;|E5=M{IY*PnrSRl%SeECr8$Kk0m#fQqX1WcY+|3%|jM03Krorr;#T{iF*3B~r9^QGl;E+Sh_!<4LV zt{m@|4Ssjy0kfta4b5%$l7?_^A@@(u#ZqGFa0)$3E*$^Rz%m-hiIPFAI4L32k#1S4e`jO;qS-V_ar)t9>n5QCS4V! zws&{YS&R?%c%c_s7Nwr@Hoac|h5d6{V_Bh4Z3-DMcq5IF2jqrR%R5 z?E58xY^tte6j3=L`&EGySt*~1=Ck7Gz}Yt0-&@aaWvhS(cX5vr=?!!Zka7v zWb9H}e-B@ZasOWy-n~GWR|Vgq%kW-YN@hMXB@B4}FH5&OQO|RY8jJMN7t`)cM`C=d zxV$av#*yu4#s&0Z;AJp^a&`jpfH{f6(*xvWOt>me>_V^8o=_*rYlK1Iy1wq@Au>f9 z*Y6^MnODMvRw8*e(U$^fe2WX0e8rW2H8f5UYe~Qa_INc#v!}npxMW=!Lv#V5(?|QA z=LwtvnwpFASBcJE)^HM*{ki`$)mdAYoqbVEq842Gr)uIv#max}wh~Ik?+AfDg!rD- z{}2jG%E`Cpt8%Ck)L1pu*6Ev8eOT@-LhXO3ThH%#(iDdv3lQUX zl9nX6fzUBo$}CyBznTC)N7Y%9cvr2q{{?)EigE%Oo0G6Tbk?58tZKrX%8(iEaL3pf z$PHGBXxE*W7M^7MhszeupQNvK!TD(CmXNWXD!5JwX1kJ>06Djm#x+-3fF}IEw8V3x zOPm3qbBA>czB$PnM=wiPVkgTA-Blkf1AUKPWm7WDy~EhXU|;h1G2e)@@c7!}ep%@u zRdJagK z&VPzsL&dyJb3Wxs(U(bwaOJoLoO|4e;DE^ox zf5fqf+-Ba&uhY|DKIlZK0;wG>h%ykT$&aehK?kMH)CZ+BWzyAZqS;@Ts~g$-;8RKx z$twl1t}myrL=~#0M%j#qro9uti_^8N*;+W)u=j4^5=~kl3B?8MMSnz}VQ^5}C~?&K zMQqIc`Fpx1OD=#@?U7B8WS@3lT(0oH`?H&o4f;IHTVDby$y}y?{;pD(qD@16*~V2% z>{oxyoL6by(dBp=&qew*EzQJoFe1mTqlPbDSFR7&?JYFt*zi@?tZOCxXYJwjEM@Q1 z?q~~9(#yVnZnRwH1@lvT%aiS%_z+#TI&JGOYfk#57pZ=7TUp6llSQRFH@;exq%OZPcKkIwHE?jGTKX6Sz@rERC`s>G_nY?GVQr~nh>#9Dq zdNf^JsWiojV88a-@W(Rm2e2+~*DkqJWeZ*F9mlkU1AhYv{=Tkf$hhsH2oftXJdNf+ z`h(xaVkn*0r%*_$iT*WL2XLj4xGV`rQ{Z8q3f<;KU`+?==OVDme&1DBmiEzxz<3H& zfaE=0Ngf+FBpdhY42N5d9UP;Cwr@1$Ec)V=ii7Kce)!DJk6+eFGgSYFX!|gpOa7Wu zts1FKmz+~LRyR-v`KOEfZyJ4=w~(>~U4hY=mJ z;}l%#HJNo(=NNuJB+)K76*m`p+iI?kMUa*CbtL!epwzXK$%)sg!75Qf41=}f(DaR4 zA6{hy-|1R9VAcP;v@m{OPetjxc7chcL6-6Ho{7=fxfQmV$kVpY*3QnFH3QsogH>i0 zTm*$CDW+;@c?4YWqKz0FD0P0W7Oawb#&SR0y;kyr-toyBT4h`PsAB&G4qPEde2s;2 zu!fN3pTzq#Vtg*PFV$kD7;NN;WZZcT`z7 zo54HXvGNdc=XL1jkKh=&^DDwNvy8)9sLL`n`JW$K6Xi?{PIv_>J&l@cexP-}H|>-6 zUUc=DHL*wiD(3+KCk=LF(`{02AldqR6 z{9WfqRGeG?vS@11@4gBjpASo$;kZBgd)$(2@%j0gF0%{LZ9&P)=Nex@PFVm!F1E4c zAK-;9fli$Zoa}M(?C?N8vsMFM?bzeXmfiHK^=aBGO08jt0@+bRp|~yM z(Nd^OS77(~XZE*la;wPGo}{o5JLoS^D`QixQH)&wVtSxNJTke|B9qf@QW$B*tGV4# zpHnvT6i@k71y?nnJ5k+W0z*)|gs|=dys2`o&`Zl*UCMr?yYR=&I}D?~mR$W< z#f$IZd~%$8`F2U-G5S8$#%EeP6$w3UjgA%9P5oy;MU;s_emKJ}h=fvAOMN@p=*3Wp z4a(USg)jqdKb5*+nWHSHAug|9=XLHKm!O^-ijikzherwpSEkz_Pr9GW%Q7ut{bG@l zz0ehG@rHjV2K`C;aPUs#Z#|&X&4chAD*PolsV6|4%l6R_S9S<`u-9AV5iNatK7T|{ zezIK$;j9r;YP{bZ^~l;+0XEsc`Q0NgdbfFN*r>wss;{)Cl&kW806pPz&=4%y`(Voj z@pjh@q%o%P1o|`ZNf7>4It%TL7VCZ=lvSZWz^c8VdM+elr{!qiMP~McQSl9u=HlF1 zs=7|dmCKsHl`M^J8oc6|_IMuggRPPkD>?BL-6+tV^^3-2J&uX;cd6D0rxB(uvu@OL z!oMl5B7Aqd#8z-g^Z+?-Fm2Y;sxu8J9|8&KhZttDc8 zaIS2)8!7*4I>z^QoIQ8({7ke_Z^1R(_9qr@e#Px#n~hiwd^@)wR#smFd?9Mk&Mf!o zOw#7b{HY7&&rwKKx{^Suo0!;mduY^Q)J~=GQozSjW6_^okA7G$e0T`6GOmeQDvcEX z=pm5&(n$*ge4JX`vQeOOG1+&)`%^~qY~)gnHvJA9Tjn1QNMmzvVOheJMWg%C5c8XZ zas#qbSwl5LB!e3ilR*`~oj-Gjf!J_r!OWrrS50xWh-uK3`*TkVZo3Mpre)VGvqkG1 z9W>LFMTMU`^bI-;RXA|pe?6sL)#zT(awb}jDRNyPC>riu6?A{0n-F;Y?(!AyCt9+3 z288BFTtu0F3I*gS3mW4JvH2jnpET4z5i|`yY(Ln7J1&FfHjko#A)U8JqdEkB|2fb~ zF}wFiuoA{?EQ@&jN|N>NiMO7-sH2pz;QF0@J$4u{U@i)0UA5`-8{VROxW=9wy0sa> zs-H*ZTlj#H6*C+-z$|U0vP*gM=Z*VYq%t9N+i3XgaBQ3|l3hsb)NazC{=vR4{L;Zd zp&QSPg3Pzf*IhmjR^b&tTbQl?!sG0_BWFgmx~Rv>18fyiI~#i#@r9IV4pNL2NT=6T zv_-K2=Ihvv&3mp>39S8rDL6lqt%ytMQQxN-TQKztd>-`fGygw8sHe!r6a@eR7)vH1 zatL{cR@ri^wkuPbsJ(qu8jgn_TyQ7K>w*pWd55ID*@&q{*}?d0vzpB0!e&%J5y6${*UHY ze=y>AKr0k6$hbKbBjj9gMmG5@z%7r(cx!;zUK=pnTQg!Zpe4olESZKwGV7^?@4jM^ zFJgyoyS|GoxRVxwG2N&9=hQ6nE%*Wrc0HLBw|+bC>&-u!chzzdGiN}qc;FxX`~I0j zMOJ=$4~nB-sjt19^~Q;c2Bqc)jl;Kq*%FOcnR0AGDSOTHFmy9!ECrpE7DY*`pIVl#lrVmRyoyft z#xOB?gO>|=A{G0KP$l`tS*?&X9jXxV6N-u?@V#8~T$!6HI3M&0jVe%lmq%k#Vd)D# zd&AH#)%bTh!TOe43*#q;JM&Bm`uf+#Z07v(1-~~xPBq^j+-29}J><82al$FOpTnG4 zwmQcfH&^p!;!R$1nCKdNfPpqWwV_nLr73i;ni0k9OJ5le!yTbLC(M021ElC$=b?AZ zPW38-BP9}{0)hrc3sZ?}Xtx9#bb!S4^0FJ+Px<$nmaYfQne=Tpn%z$x!Mz|k6H0Mu zbv}Nvdds#f6W5}c3-lkv5S0IKxulN2dr`uh7Xxo|gg&CshQ(^N3;$_ad|4np zrmkZ)?MN9UPKnHzMQvSJe*6IDebc9JveJ9)HI~2k40-4~jq`a+Eo<4sajf+(i692d zk)o`4`wYv@V&s|c-^JKjczVX#a(#aN#zTtH`}};pgYinK&bU)sRP~DdZnP^;0;A&o z75(C!yZkMk^#jqDsZgXmXbyhoR&shCyh4bkB~b>=tPZSj=}r0 zr@7(3njyVK3=P}Vd*x5rAKG{XT&$}QyvBQ=H#gbN$?A0Px1@A<kv!%&zsw{aM?N zRD>@I7VdY3ZsIOBoaOVzv`Y8T#0((Xh4+ha-G- z)&ljQVSvrUsc&lnfMd%Oe@*hwXvEHZVYz1;5Qb_ z`u`DlHz)pdkrTlNLfTAZ$>w-Hy~kPCc=jf%!m@kvOD(|-`(ZZaqoS0~a)g2F-Rc{M zrlY8-yxK;3)yqkuT$my=MPVqKAKau^9k%U`YCk%qeV7COv>J56L@9k~T~1EUxB}14 z%m1<{{5qUqfujaQg;k@0j!@o{~h*Xun0^iQOrKgpk+C0fkE)A{bZMI_dza zao*r}6QRGj9Bg602f01fE`X$&laIQ8>~u;S1`!`nJ>Z)L+oxn!Ip%l0)Ag;+u^A?% zW3xFuo~nxG^2GKM|6p9x1STp{O3j2W{ug1>Gm6muRBW3WshkBJ=dzVMV8S)+kT}_d zJEGSCTye3|Z;*LJIn$7^=X<(Ex090H%umPCOni{lJFxZKE+FX%NOP$v6$bjta-Q*{ zKz}rJ?=`FrxP6W+JifRWrz=Siv+HqHCTAzN>k#y`nX{*NnRCY#>sjFxhA=f;H}dH9 zLEyzr)_*^23M1kmmPO`7|LEstX&ew^(h}pMB6*AQlPNR#Fr!QC^`&U9$n5d+r{Ce6 z?H3k#F0Sjdkw)Nz4%K^gWP0{{nsU{UV5^I_&4h$=E4nVb7luD6C`u6(ptOocik0@h zBGLRwaEb%{6OH)$p@#OA@{{r{Jyyy1!BvQ)sSHx!d&o*KUhOSp=zohm(j z=vN?x13|nIV5BqE^9%C5fu9-AMQ}D@Ev8cee08Z;pO@+;o2o7*gC*=Gh4F~fKmTQ6 z+a$re_oinE2pYe!={IsFc1M*2C+wy%Kl;l;O$e$6!yZt>s!hK7cPff2kmKLCUoSN$ zVTg8pf$s{gkpVq(fp#d8QfU|go!EJkluMoIIVsea5|)nftKN5&1p6|DKlYb6{#9qO zbbvngCYe3v7;|w*bI3(>xBXk5Z%?zhR7KLTZ@>Lruiv|xT3tA#`u#9Z+mcVdR>kp% ze-22iZl9maucY@b`CI4qxUWAijLj&1u)s!$Unw1uHSfNY1NdAjw3}Z5$ccC#)WqLB~0YV7_ocaMl8myTjidM)vM?M8Dtbl%yiM92dIxxTrk6u6)rt zfTV6TrwmyfL88nxnCSr6HWW`-C3cKSJVeHuQ%7nx3vm zz|*e6+**&QcZq@T4N!qG%-u0VV04fH7Qb}wLvPRo#tcB9D5l;?Vy?0T4~7)~h{WDw zAWBmrO(DvC76jFp3oc;rLLzaYj=g`6m1<2`h=qA5;{gQQVv@+>5=4W_M;<#Ry2dQ= z@PXS5Nm<3jJppRu(2`x(dx$(?kIlDYY2hk$s~6vt+-^iV?cXT}Ge;bszJ+oAroy}N z&q!XSJ5>23vatkl;q02I@kcV-?EQm*{ z7De5M!l?pUy$XR-U#*L3FZWLNokmpM)^ctVx&Ntrutswen|o=?>SdM)uSG+Pi>T7| zr)z*L=RZZo#j82Nmc3*wjUP5ZTA4~CdH1+#k{-6{A-fk?7r;Z0$k=2x1HMh*1>L(f z;B2CDpTv1&QlKtu31s&erwb%o_l^U>PDHOC=MCe42qZn7pN{N+Rwt!3Eta(9Qqf&_Uq<2$&Qje`L@V)w$ zZvz-L9Oe4!95SY1*sfRn<|stcAh`j^>9cl}#0*p+Il_KB+0Z|8KnFy-B+SCkAAHg*gBE%i@#WKz98|_Fsg)AtSp^^5Z@; zk4^&WFtf9eboAYN^Q7!GLy;WBJQ&m9q#)$>FgfO5dg9_F%nKkut+!<=u5Caj$1M)h zH7kPXj6vkfX;!4QZXO}uPC*pa!Oe{b7u>RPfxbeGZM9*;dZ>d}OH+rk?Iu1uoAV?Mf3Nf$fzKPpBo-69%WnZD$W+yiM7p+f-h`k`5cR1_CF)@%RMQOYn0?w z;@lE?PZTv5bp_@1@FpN0xJ?FDY6nr&_O>g^qr6G_p2JZM<8>yI$0`#LOZTYNe;RV% z9}zDdX*#OyC!J61%{2<~C!}aEqVgx-vHttXl+S3auxYG;D#<$>9`d73-s2n#-}kLI z1!r`<$7Q#G6NAiKYoNjNov1GKm4iKDnh=$#kE5%oYfIUEd+f4r6d3$eZQvE^=@KmI zFAE45pEHucBv_9G=B3CMF<+R#Y#mR(!S5l0YyJ|GI1G0sXJsmPCo%t(=^L)%B;$A# z;4%cZXh6NWu)PvUQtpzdTTAKWl@Ng_kD)#1OfkK!MCi~$T@{cQVsjcqUFc<89z(%X z>b3+v%vN`xdjPK^0}4q?-+@=wIzAG!Q*`Z0eHjMSp5m>eli@ye9L$Trykf}x{_X}j zsua*nBX!_N5_oNW&aC#^Zq5xNh^NlTTRP!<=SUXw+TB&ATI!MLkKgwPbH zd+yZ2NB_zMRI0Z%{2fG@m?i{90!O#2m`pFdG^CJ}_~nuql#FEck?6x2!@p3WhKPv>nN z{U+KZex~LVa)19LE#l8)YAQK{3cgEvOBfxfPa7_p#j*YjO%}a+8s&QuP1+dy{t;@p z$uli@;vs5xn9IbRx||tjsWH7w`^lVmY~Z)RvH?{j1#ZtRBH6$nyM*E27uaa7xKN&V zmmuy_`ZuUC{jW%l;E&w}T<|`|9co+YG?tKd)@Q1*HH%3I_h`=}h+_Jyf7ZH<$TO50 zdM(-;%iOjNcKv}Vcwnq9AfyyxOx{HimAM?Zf_lWn=WxFGLFydFqp<%^5p$@q$?zOKGFFyf6|6J**sgZdQxFwZ~8TquifoW1Vx!E%k9?WklYmZj(U(9iQGk4ZxwB) z4NT-a*i>K1?3f(z-NA|}AGi6E=7sL>w6vsSAEeF`4GWs3emk=8R2Zd}h(6t0ba^-= zqy8atz-5zN?=Bn9kAov*l^PtMwrD-;*o6dh^b5cG93qf^ZYQ<42w&yCqp4A<^sq9d zbf)iydabYG*e)n>aOSsz31wLFF;#&4Vu^rDQ>(9joH#YlM~=-$$Dt}e3*~xtq?UYa z%>Fv^aQdgMaNqVoaQ!_}jF7K^ai3Sf+xV&54#|?1Bjc(36N$?!At4z#s835ZJDP>$ zL#^!Fg|2zE$F`d1V%jrJf5nPgBfPofqBuR~N~1*(7E_>Ge%Y&aGR3Sf+6T9v#ULM? zc7R7zqVJyA1}16bzQkmna;51OG&Pxqq}~-g-+4mL{vLB~uCJ<5;A+$DTH~gy%k|}( z!n&`Oimp}4_d>=HI%52#2D}izF{kb$J4Y=mab<2J@Rtf(`aA-H4qbeEwxFF9@&ifO zL-axPV8s;eM)7Lx^oUhE--niKNoy705zSSE+EH@VNhKoWgteO!!}Zxa2<-Q((zKJi zGS+_BMCsr0q}bzPoq1J+`h_lxuVH^MZ93L&N1%u0*iQAq!q=ZYGi(Gug#hWBkU z8gIBDwAcvAGv}}O)`QGMJsMb#{D2w1yOA?zhN`FTkpNExpA5NqVDyZ(a!|!efM8`~ z7S{bJoCG1_*M_ieS0^Qxvv+?=u!)n5dky31MJDWVWhXPZHXZu0S5b+LEP@SFq4He= zLV}t*3l+K^G0sByvOtdarDD-#d;%uuTiu$tQP4aFku_i>X5LuTlD`K+e#Idzat@8R z#ZJyQHoR=u0;~h$hQCGo@ z&M*=9WwR*73Ab6pAP%)^*kh@w9)zWBHAaY;2T4~_3wIrV@ixBqmvmmOS+5bU- zBgY_m{vw=z-U#KYlN8NvlQaKR_DqJ)HeRKKbC=s#vUcgfiLyqAxAT$=h!Q>ax_7*z zr3*^jKYLHDy*;7q+ZQn780bm1SvqDuf!oz#%G_YDN1>}tse@ccXF0(0yT*az9qfxd z+b%`E>6>;y7RYrXJ)!|5V&9=%wQ#8&RIv%F`28S=3zJ^StfMnli9}V=?cw)Xw~7+@ zt<)XqZ##|BzO3wgDO9ie@)6QBh4a%>|Lu!>3iUPC?wZQWzvZfbBp)!a)i~^1G`9;g z(Dr@@G3QLU`ek0|*UAq=?hZ2hcWwR!HnAkPuhhh;6O%Kd55(#RUp^e+3T+jCo6SuA zERqley9JiDrCIvCh);r;QnsWsOX2COV4ZI0joNG#hat_J>@R70#XcVcju-{)>LlFa zt2bh(wYQyTAl$$H>l2Y)g_GL4(--=wTq^@%5oInwq35z}!!Jjw^FD;Ytr{i|b?&VA zKJPI~=(N6?93|csC{>vh}pVUEsY$%Zj#x4r~=B#y$ zPfDFQ6)#k5=eUf2zNB+Uh&AJp*8f9?f`dj>hog{jC|Lc_Gozhbx{tY|-WF?NpWUHy z1_9+ZTqWa-L;?Z1>7`rFtB6lGO`uj(-cx@nW{D^HAT563 z5>(JnpKB`Yhtk?8%FG{RJJr3h1M=v|nRn7NHBb?Z&ErwCGf|a%=k``3of>ETJ!0}j zR$1h?Z-{s1t-d2F71m2@zOE`3Oo2Ao!VUycpldW;ag(zt;O5O%a})fU7&sqdOO4>K z+M(pM%>_hPe7)lrubIr23&U11%($Hvci z9(z~R%!|P+hwCtw^ZIyQ+)+`rdh}kDOZ6^g!D#4b^fp_I{;gZVGvTjxYTyz3k8k^( zoSR$PXjN!^l}GMpjzQ21O4M0Dv3WniQX=|cO3g@CIs#|~M{;Vdb|%6ufCnG-2Ip%(Wx3^I?UZdTag*hl?$7pTpv41GyHrRZNv2w$ zXMuN16Iq&GGTjqQp6=IVi=#}|rXyx9v9KwhJixrT*5yvWW2QGC_b~5zGD7a7Det?N zr0@(A2a~7Kj`dB}Q;3`6F(9)Xb90jgugnY+y*Z{u)0F~oHxv5sVd`f_ zP~L81YwF98?Rsv&Jsv-L)~LG8M7u$uABW6?`INHZ4~9%3pi&F5HEz(!S|P-8f+MVS ztbOJhtPi>L;S6Kq+v_NIoocKeT@U5}7^vsc<5}p67=&eV zUJemc{hXL%wHhfaCb3d@z`l;$meiy%2Nkw`+TO@gs_FUJ;uzvuY^0_0(YBbR=Faa^ z!IcE||9`-4l!77d#d6=Q{)X;xWlr~wS?+&swW!u9q5lp*w?p+TjgOhESmvGgU1C(m z^Y$o2{o|AVoH_MP2exOy+T6sp+~@jzzCZS6BNO-MmVPndOD=tFX|?#5QIsR`!vSW^ zoDE|l!3?}~4gSOrnDJnshZ`ZQg;BRD>-qsX^L>qWVq+r1*7QULyoB3&y8Pdng5b!Y zJ!bRhit1;r@tikQY-0=8r7w^$D08-QEf(<-kM#9B*d_Zv=CAEz(pEmE{J2Z`^mrXBkEH0jpBcdjv5$Ijy4a zzPFS_Zd0(~#v#MTf@o!^wO))yf8$>e!+oUUaxDUI*D*2WHYUl%54vcL&2#3Bk0Pb5Yv%&&C`9mv)Inwa@d$o-APa7R6y5%yzv zWXFQl1(|Dkq72qUVvVC;tuNV8uR6938+h)g>d>nX^O(^6k$udyw^7008gAH_7nZ1; zV-NNy|M7uV1rvv&9LF6_`QTLga(b?=$7=w?f)!pHa@2x3kDh}6&iGhNEL|+iK50C5 zY=ul}#bxG?Z`h#KueQ!XRf(WmPYcX^wf?mCHGB+j`#$4U?XQ1YutQKz1zrlh00JEV zFG5r&OJaTu&O%tIdPLu4|CvlweZs;MYIM1)TE@WYbKlZWjpG>>Atz=eb_RzgnVk|s z3Qz46b%uyjt(GvtX>S7B&E3ODA%yb@{^5a({yWbZgP5nJsW+s(`|;Lst4sD7Q4uKb zR`e)Rp?degsZvs-l>aZw@?3Tw4-d0S`e9@~d5Ia$0k2go@^dtpQGb)W+ELdzxudYx z;Kb|cS?%hB%Q9y08Sr`UG<6DXxdtsUb2lQHMn`oRNVuP51LvM44M&U&9B-~DA-U*HAXbwfa=jqLB6jpX|l zMJw!5y{_NsZ(_b9u{^0T zl0({i7{77=63~r(ofR~zst^QZ48!Z!-v9gL!XM&_s(z|4HXlOEE zM`8&k>ab<2FT+#@zc#iIqmBABHCvPQWpFZYnRyJVilcpoewG2t{SBEs(b*7&Ms#2CWt?)g)1|7Vl9q@yt9mZBZ3p6JoF^CVgGDDeE39%b7R8M~0`Nw7_qZMN^V@8dUWl!cFqu%}(m6FSn-F?F5qK@vDp9AT zZ^GaOAW?V%#={hGul?#21;0Haz~c@iE>O)Jryx>+ht;x1q_W~AEgcVn2GcD+7U8w z%^A8@JS1i?Fav5iEy0Kw*v(*C$MfrnagoUXb4|T ze)^X`GSgS6@4{ z2*iduK*T2fqUpx;35UlVtV4xp2BT16YLM=Ag1Jr)hsK-2q3|G3NHr>TLmlKrBr;b0 zD>s(yhaN$({sFG;${iW2&L+u~+7U=Dx8O*%NonDR5HRuhW7w4z-=1?O23{MZ0sFC& zW|hss?a?_1Atqp1uw_1G>*Dlr_aqb-GV)^S+@~{>+4@x3sScy=lhB|lTvhJIKvs4J9q*~s5NAYe!5>QV8mMRs;QNX=SY|S2sBaU zG(1Ncc##i{+t4U#j-iq_NOsU^QiL5wMszBm0!e~yd zpu?go04qOJZtfQ+a=4sZ_Sch>e&KRUrlA3c(vh|gPz0$l*>s;HK^=udfBMdxZ&ZMCQKhtzrNWPp0S6u}?XzTR$?A$$Z#fPs4HyyRE zCG4es!EK*wFNy}(`5(M-yFXle@cH7%l1&|xhfdA9iXitpZxoMafkv>>E;s!=w=DcT7pzY^WjX7Qv+IDU z;@2-pP83fnR|Uj_non9F0OQ5wMbbp6@Q1&))zzBPU3nT^)g0sBXJ~$q?iiDuQFpm2 z_|AHrAvPMuv7;?A@Y%hCfuXs5@qyQ~ciB**PYpKkc5l@T;gOe+qB5n)=E6gl)c&hZ{CJw=|85y73Yw~;K78iH5KVjI5IJSds2#-s98gFJI`EBSbyQ}7!2Gaoc@knNDIWQJl3FNuXd?HWU zZHQqL$nwQ13HMqp7t8PGdL#TpeBGopIf*H-^A!IoNz_RY=2&ArKzkVX1t(xcSC(%R1`YZWXNY#fFQ=51e%v|Muzq;AUsC0#XR1wY!#Ax5wk3D? zm#j}+pU&o+PA6yqxGb$Qtd4FW-Pu#Qyo2*$Rdziic`ot~Gwt5Lf^76#F29Xgv8@N~ z!D8#ld*5w=Vrpp@dAMj$5>HCQI%w(Yyf8UDq5V#^uVeq~Q;(bYj!BxT9wn>oMpXIk z-kws`qGA*xBFD6S==k{5u?U%_B-s&$>KLaJUQ_1zj3Co3i?W`2HrzsEcH;cZw1f9Vv{9)Z-jxZYHNf&7PcEK|U;8d=9%wQv*^`%tevKt!O3WkeOdZe9TZ& zTtk4#esz1^{`Y@<64&4_(va-cs>FAvpmw2`B{3z`qSgB$gAL^MdhZV}AJwtzdb2k1 zE5Cgm$mI(=*wQ=jMv5r?X3@`kc~>HR_!$lG3C`0_mHmOM77f$lTR-~44X8l5-kBlS z44M^OcAPiq10qzKfLp=Rng9_~Gc$|8Xf+Ux(ummFt?ahf*FcsF-DK`aC54|jsHLhz|3=VXhRO29n zg81-rda2ChFF7ZdF1@@j+Rjdc7k8Fn?t8+JyF7$yJ0k8a3i;kuh$#wM%$gPX

jc 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 literal 0 HcmV?d00001 diff --git a/backend/src/test/java/com/writeoff/AsyncJobServiceTest.java b/backend/src/test/java/com/writeoff/AsyncJobServiceTest.java new file mode 100644 index 0000000..14c8527 --- /dev/null +++ b/backend/src/test/java/com/writeoff/AsyncJobServiceTest.java @@ -0,0 +1,18 @@ +package com.writeoff; + +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.scheduler.repository.InMemoryAsyncJobRepository; +import com.writeoff.module.scheduler.service.AsyncJobService; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class AsyncJobServiceTest { + + @Test + void shouldEnforceIdempotency() { + AsyncJobService asyncJobService = new AsyncJobService(new InMemoryAsyncJobRepository()); + asyncJobService.enqueue("AUDIT_REMIND", "meetingId=1", "idem-job-1"); + Assertions.assertThrows(BusinessException.class, () -> + asyncJobService.enqueue("AUDIT_REMIND", "meetingId=1", "idem-job-1")); + } +} diff --git a/backend/src/test/java/com/writeoff/MvpFlowIntegrationTest.java b/backend/src/test/java/com/writeoff/MvpFlowIntegrationTest.java new file mode 100644 index 0000000..3422a6a --- /dev/null +++ b/backend/src/test/java/com/writeoff/MvpFlowIntegrationTest.java @@ -0,0 +1,180 @@ +package com.writeoff; + +import com.writeoff.module.audit.dto.AuditActionRequest; +import com.writeoff.module.audit.model.AuditTask; +import com.writeoff.module.audit.repository.InMemoryAuditTaskRepository; +import com.writeoff.module.audit.service.AuditService; +import com.writeoff.module.finance.dto.ConfirmPaymentRequest; +import com.writeoff.module.finance.repository.InMemoryPaymentRepository; +import com.writeoff.module.finance.service.FinanceService; +import com.writeoff.module.meeting.dto.CreateMeetingRequest; +import com.writeoff.module.meeting.dto.SubmitMeetingRequest; +import com.writeoff.module.meeting.model.Meeting; +import com.writeoff.module.meeting.repository.InMemoryMeetingRepository; +import com.writeoff.module.meeting.service.MeetingService; +import com.writeoff.module.project.dto.CreateProjectRequest; +import com.writeoff.module.project.model.Project; +import com.writeoff.module.project.repository.InMemoryProjectRepository; +import com.writeoff.module.project.service.ProjectService; +import com.writeoff.module.scheduler.repository.InMemoryAsyncJobRepository; +import com.writeoff.module.scheduler.service.AsyncJobService; +import com.writeoff.security.AuthContext; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.time.LocalDate; +import java.util.List; + +class MvpFlowIntegrationTest { + + @Test + void shouldFinishCoreFlow() throws Exception { + AuthContext.set(1001L, 1L); + try { + InMemoryProjectRepository projectRepository = new InMemoryProjectRepository(); + InMemoryMeetingRepository meetingRepository = new InMemoryMeetingRepository(); + InMemoryAuditTaskRepository auditTaskRepository = new InMemoryAuditTaskRepository(); + InMemoryPaymentRepository paymentRepository = new InMemoryPaymentRepository(); + AsyncJobService asyncJobService = new AsyncJobService(new InMemoryAsyncJobRepository()); + + ProjectService projectService = new ProjectService(projectRepository); + MeetingService meetingService = new MeetingService(meetingRepository, projectService, auditTaskRepository, asyncJobService); + AuditService auditService = new AuditService(auditTaskRepository, meetingService, asyncJobService); + FinanceService financeService = new FinanceService(paymentRepository, meetingService); + + Project project = projectService.create(new CreateProjectRequestWrapper("P1", 1000000L, 3).toRequest()); + Meeting meeting = meetingService.create(new CreateMeetingRequestWrapper(project.getId(), "M1", 300000L).toRequest()); + meetingService.submit(meeting.getId(), new SubmitMeetingRequestWrapper("submit-key-1", "提交审核").toRequest()); + + List tasks = auditService.listTasks().getList(); + Assertions.assertFalse(tasks.isEmpty()); + + AuditTask first = tasks.get(0); + auditService.approve(first.getId(), new AuditActionRequestWrapper("audit-key-1", "初审通过").toRequest()); + AuditTask second = auditService.listTasks().getList().stream().filter(t -> t.getId() > first.getId()).findFirst().orElseThrow(IllegalStateException::new); + auditService.approve(second.getId(), new AuditActionRequestWrapper("audit-key-2", "复审通过").toRequest()); + AuditTask third = auditService.listTasks().getList().stream().filter(t -> t.getId() > second.getId()).findFirst().orElseThrow(IllegalStateException::new); + auditService.approve(third.getId(), new AuditActionRequestWrapper("audit-key-3", "终审通过").toRequest()); + + Object paymentResult = financeService.confirmPayment(new ConfirmPaymentRequestWrapper( + "pay-key-1", project.getId(), meeting.getId(), 200000L, "oss/voucher.pdf").toRequest()); + Assertions.assertNotNull(paymentResult); + } finally { + AuthContext.clear(); + } + } + + private static class CreateProjectRequestWrapper { + private final String name; + private final long budgetCent; + private final int meetingTotal; + + private CreateProjectRequestWrapper(String name, long budgetCent, int meetingTotal) { + this.name = name; + this.budgetCent = budgetCent; + this.meetingTotal = meetingTotal; + } + + private CreateProjectRequest toRequest() throws Exception { + CreateProjectRequest request = new CreateProjectRequest(); + setField(request, "name", name); + setField(request, "startDate", LocalDate.of(2026, 1, 1)); + setField(request, "endDate", LocalDate.of(2026, 12, 31)); + setField(request, "budgetCent", budgetCent); + setField(request, "meetingTotal", meetingTotal); + return request; + } + } + + private static class CreateMeetingRequestWrapper { + private final long projectId; + private final String topic; + private final long budgetCent; + + private CreateMeetingRequestWrapper(long projectId, String topic, long budgetCent) { + this.projectId = projectId; + this.topic = topic; + this.budgetCent = budgetCent; + } + + private CreateMeetingRequest toRequest() throws Exception { + CreateMeetingRequest request = new CreateMeetingRequest(); + setField(request, "projectId", projectId); + setField(request, "topic", topic); + setField(request, "budgetCent", budgetCent); + setField(request, "meetingCategory", "学术会"); + setField(request, "meetingForm", "线下"); + setField(request, "location", "线下"); + setField(request, "startTime", "2026-01-10 09:00:00"); + setField(request, "endTime", "2026-01-10 18:00:00"); + return request; + } + } + + private static class SubmitMeetingRequestWrapper { + private final String key; + private final String remark; + + private SubmitMeetingRequestWrapper(String key, String remark) { + this.key = key; + this.remark = remark; + } + + private SubmitMeetingRequest toRequest() throws Exception { + SubmitMeetingRequest request = new SubmitMeetingRequest(); + setField(request, "idempotencyKey", key); + setField(request, "remark", remark); + return request; + } + } + + private static class AuditActionRequestWrapper { + private final String key; + private final String opinion; + + private AuditActionRequestWrapper(String key, String opinion) { + this.key = key; + this.opinion = opinion; + } + + private AuditActionRequest toRequest() throws Exception { + AuditActionRequest request = new AuditActionRequest(); + setField(request, "idempotencyKey", key); + setField(request, "opinion", opinion); + return request; + } + } + + private static class ConfirmPaymentRequestWrapper { + private final String key; + private final long projectId; + private final long meetingId; + private final long amount; + private final String voucher; + + private ConfirmPaymentRequestWrapper(String key, long projectId, long meetingId, long amount, String voucher) { + this.key = key; + this.projectId = projectId; + this.meetingId = meetingId; + this.amount = amount; + this.voucher = voucher; + } + + private ConfirmPaymentRequest toRequest() throws Exception { + ConfirmPaymentRequest request = new ConfirmPaymentRequest(); + setField(request, "idempotencyKey", key); + setField(request, "projectId", projectId); + setField(request, "meetingId", meetingId); + setField(request, "amountCent", amount); + setField(request, "paymentVoucherOssKey", voucher); + return request; + } + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/backend/src/test/java/com/writeoff/module/system/service/SystemUserServicePasswordTest.java b/backend/src/test/java/com/writeoff/module/system/service/SystemUserServicePasswordTest.java new file mode 100644 index 0000000..f86f544 --- /dev/null +++ b/backend/src/test/java/com/writeoff/module/system/service/SystemUserServicePasswordTest.java @@ -0,0 +1,63 @@ +package com.writeoff.module.system.service; + +import com.writeoff.security.AuthContext; +import com.writeoff.security.PasswordCodecService; +import com.writeoff.security.PasswordPolicyService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentMatcher; +import org.springframework.jdbc.core.JdbcTemplate; + +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class SystemUserServicePasswordTest { + + @AfterEach + void tearDown() { + AuthContext.clear(); + } + + @Test + void shouldAcceptLegacyPlaintextOldPasswordAndUpgradeToHashWhenChangingPassword() { + JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class); + PasswordCodecService passwordCodecService = new PasswordCodecService(); + SystemUserService systemUserService = new SystemUserService( + jdbcTemplate, + null, + new PasswordPolicyService(), + passwordCodecService + ); + AuthContext.set(1001L, 2001L); + when(jdbcTemplate.queryForObject( + eq("SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0"), + eq(Integer.class), + eq(2001L), + eq(1001L) + )).thenReturn(1); + when(jdbcTemplate.queryForObject( + eq("SELECT password_hash FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0 LIMIT 1"), + eq(String.class), + eq(2001L), + eq(1001L) + )).thenReturn("legacy-plain-password"); + + systemUserService.changeMyPassword(1001L, "legacy-plain-password", "Abcd1234!"); + + verify(jdbcTemplate).update( + eq("UPDATE sys_user SET password_hash=?, updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?"), + argThat(matchesEncodedPassword("Abcd1234!", passwordCodecService)), + eq(2001L), + eq(1001L) + ); + } + + private ArgumentMatcher matchesEncodedPassword(String rawPassword, PasswordCodecService passwordCodecService) { + return value -> value instanceof String + && passwordCodecService.isEncoded((String) value) + && passwordCodecService.matches(rawPassword, (String) value); + } +} diff --git a/backend/src/test/java/com/writeoff/module/system/service/UserDelegationServiceValidationTest.java b/backend/src/test/java/com/writeoff/module/system/service/UserDelegationServiceValidationTest.java new file mode 100644 index 0000000..c8d43e2 --- /dev/null +++ b/backend/src/test/java/com/writeoff/module/system/service/UserDelegationServiceValidationTest.java @@ -0,0 +1,85 @@ +package com.writeoff.module.system.service; + +import com.writeoff.common.exception.BusinessException; +import com.writeoff.module.system.dto.CreateUserDelegationRequest; +import com.writeoff.security.AuthContext; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.lang.reflect.Field; +class UserDelegationServiceValidationTest { + + @AfterEach + void tearDown() { + AuthContext.clear(); + } + + @Test + void shouldRejectSelfDelegation() throws Exception { + JdbcTemplate jdbcTemplate = Mockito.mock(JdbcTemplate.class); + UserDelegationService service = new UserDelegationService(jdbcTemplate); + AuthContext.set(1L, 1L); + + Mockito.when(jdbcTemplate.queryForObject( + Mockito.eq("SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0"), + Mockito.eq(Integer.class), + Mockito.eq(1L), + Mockito.eq(10L) + )).thenReturn(1); + + CreateUserDelegationRequest request = new CreateUserDelegationRequest(); + setField(request, "delegateUserId", 10L); + setField(request, "effectiveFrom", "2026-03-10 10:00:00"); + setField(request, "effectiveTo", "2026-03-10 18:00:00"); + + Assertions.assertThrows(BusinessException.class, () -> service.create(10L, request)); + } + + @Test + void shouldRejectInvalidTimeWindow() throws Exception { + JdbcTemplate jdbcTemplate = Mockito.mock(JdbcTemplate.class); + UserDelegationService service = new UserDelegationService(jdbcTemplate); + AuthContext.set(1L, 1L); + + Mockito.when(jdbcTemplate.queryForObject( + Mockito.eq("SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0"), + Mockito.eq(Integer.class), + Mockito.eq(1L), + Mockito.eq(10L) + )).thenReturn(1); + Mockito.when(jdbcTemplate.queryForObject( + Mockito.eq("SELECT COUNT(1) FROM sys_user WHERE tenant_id=? AND id=? AND is_deleted=0"), + Mockito.eq(Integer.class), + Mockito.eq(1L), + Mockito.eq(20L) + )).thenReturn(1); + + CreateUserDelegationRequest request = new CreateUserDelegationRequest(); + setField(request, "delegateUserId", 20L); + setField(request, "effectiveFrom", "2026-03-10 18:00:00"); + setField(request, "effectiveTo", "2026-03-10 10:00:00"); + + Assertions.assertThrows(BusinessException.class, () -> service.create(10L, request)); + Mockito.verify(jdbcTemplate, Mockito.never()).update( + Mockito.contains("INSERT INTO user_delegation"), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any(), + Mockito.any() + ); + } + + private static void setField(Object target, String fieldName, Object value) throws Exception { + Field field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } +} diff --git a/backend/src/test/java/com/writeoff/security/LoginPasswordCryptoServiceTest.java b/backend/src/test/java/com/writeoff/security/LoginPasswordCryptoServiceTest.java new file mode 100644 index 0000000..f9d1b57 --- /dev/null +++ b/backend/src/test/java/com/writeoff/security/LoginPasswordCryptoServiceTest.java @@ -0,0 +1,49 @@ +package com.writeoff.security; + +import org.junit.jupiter.api.Test; + +import javax.crypto.Cipher; +import javax.crypto.spec.OAEPParameterSpec; +import javax.crypto.spec.PSource; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.MGF1ParameterSpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Base64; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class LoginPasswordCryptoServiceTest { + + private static final OAEPParameterSpec OAEP_SHA256_MGF1_SHA256 = new OAEPParameterSpec( + "SHA-256", + "MGF1", + MGF1ParameterSpec.SHA256, + PSource.PSpecified.DEFAULT + ); + + @Test + void shouldUnwrapBrowserCompatibleEncryptedPassword() throws Exception { + LoginPasswordCryptoService service = new LoginPasswordCryptoService(); + service.init(); + + String rawPassword = "Abcd1234!"; + PublicKey publicKey = KeyFactory.getInstance("RSA").generatePublic( + new X509EncodedKeySpec(Base64.getDecoder().decode(service.getEncodedPublicKey())) + ); + Cipher cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding"); + cipher.init(Cipher.ENCRYPT_MODE, publicKey, OAEP_SHA256_MGF1_SHA256); + String encryptedPassword = LoginPasswordCryptoService.PASSWORD_PREFIX + + Base64.getEncoder().encodeToString(cipher.doFinal(rawPassword.getBytes(StandardCharsets.UTF_8))); + + assertEquals(rawPassword, service.unwrapPassword(encryptedPassword)); + } + + @Test + void shouldKeepPlainPasswordUntouched() { + LoginPasswordCryptoService service = new LoginPasswordCryptoService(); + + assertEquals("123456", service.unwrapPassword("123456")); + } +} diff --git a/backend/src/test/java/com/writeoff/security/PasswordCodecServiceTest.java b/backend/src/test/java/com/writeoff/security/PasswordCodecServiceTest.java new file mode 100644 index 0000000..1e7d818 --- /dev/null +++ b/backend/src/test/java/com/writeoff/security/PasswordCodecServiceTest.java @@ -0,0 +1,32 @@ +package com.writeoff.security; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PasswordCodecServiceTest { + + private final PasswordCodecService passwordCodecService = new PasswordCodecService(); + + @Test + void shouldEncodePasswordWithSaltAndMatch() { + String rawPassword = "Abcd1234!"; + + String first = passwordCodecService.encode(rawPassword); + String second = passwordCodecService.encode(rawPassword); + + assertTrue(passwordCodecService.isEncoded(first)); + assertTrue(passwordCodecService.matches(rawPassword, first)); + assertTrue(passwordCodecService.matches(rawPassword, second)); + assertNotEquals(first, second); + assertFalse(passwordCodecService.matches("wrong-password", first)); + } + + @Test + void shouldRemainCompatibleWithLegacyPlaintextPassword() { + assertTrue(passwordCodecService.matches("123456", "123456")); + assertFalse(passwordCodecService.isEncoded("123456")); + } +} diff --git a/backend/src/test/java/com/writeoff/security/PermissionServiceDelegationTest.java b/backend/src/test/java/com/writeoff/security/PermissionServiceDelegationTest.java new file mode 100644 index 0000000..1de09ad --- /dev/null +++ b/backend/src/test/java/com/writeoff/security/PermissionServiceDelegationTest.java @@ -0,0 +1,102 @@ +package com.writeoff.security; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.jdbc.core.JdbcTemplate; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +class PermissionServiceDelegationTest { + + @AfterEach + void tearDown() { + AuthContext.clear(); + } + + @Test + void shouldInheritPermissionFromPrincipalWhenDelegationEnabled() { + JdbcTemplate jdbcTemplate = Mockito.mock(JdbcTemplate.class); + PermissionService permissionService = new PermissionService(jdbcTemplate); + AuthContext.set(2001L, 1L); + + Mockito.when(jdbcTemplate.queryForObject( + Mockito.eq("SELECT COUNT(1) FROM user_role ur " + + "JOIN role_permission rp ON ur.role_id=rp.role_id AND ur.tenant_id=rp.tenant_id " + + "JOIN permission p ON rp.permission_id=p.id " + + "WHERE ur.user_id=? AND ur.tenant_id=? AND p.permission_code=?"), + Mockito.eq(Integer.class), + Mockito.eq(2001L), + Mockito.eq(1L), + Mockito.eq("meeting.approve") + )).thenReturn(0); + + Mockito.when(jdbcTemplate.queryForList( + Mockito.eq("SELECT user_id FROM user_delegation " + + "WHERE tenant_id=? AND delegate_user_id=? AND is_deleted=0 AND status='ENABLED' " + + "AND effective_from<=CURRENT_TIMESTAMP AND effective_to>=CURRENT_TIMESTAMP"), + Mockito.eq(Long.class), + Mockito.eq(1L), + Mockito.eq(2001L) + )).thenReturn(Collections.singletonList(1001L)); + + Mockito.when(jdbcTemplate.queryForObject( + Mockito.eq("SELECT COUNT(1) FROM user_role ur " + + "JOIN role_permission rp ON ur.role_id=rp.role_id AND ur.tenant_id=rp.tenant_id " + + "JOIN permission p ON rp.permission_id=p.id " + + "WHERE ur.user_id=? AND ur.tenant_id=? AND p.permission_code=?"), + Mockito.eq(Integer.class), + Mockito.eq(1001L), + Mockito.eq(1L), + Mockito.eq("meeting.approve") + )).thenReturn(1); + + boolean ok = permissionService.hasPermission(2001L, "meeting.approve"); + Assertions.assertTrue(ok); + } + + @Test + void shouldMergePermissionsFromSelfAndDelegationWithoutDuplicates() { + JdbcTemplate jdbcTemplate = Mockito.mock(JdbcTemplate.class); + PermissionService permissionService = new PermissionService(jdbcTemplate); + AuthContext.set(2001L, 1L); + + Mockito.when(jdbcTemplate.queryForList( + Mockito.eq("SELECT DISTINCT p.permission_code FROM user_role ur " + + "JOIN role_permission rp ON ur.role_id=rp.role_id AND ur.tenant_id=rp.tenant_id " + + "JOIN permission p ON rp.permission_id=p.id " + + "WHERE ur.user_id=? AND ur.tenant_id=?"), + Mockito.eq(String.class), + Mockito.eq(2001L), + Mockito.eq(1L) + )).thenReturn(Arrays.asList("user.read", "user.delegation.manage")); + + Mockito.when(jdbcTemplate.queryForList( + Mockito.eq("SELECT user_id FROM user_delegation " + + "WHERE tenant_id=? AND delegate_user_id=? AND is_deleted=0 AND status='ENABLED' " + + "AND effective_from<=CURRENT_TIMESTAMP AND effective_to>=CURRENT_TIMESTAMP"), + Mockito.eq(Long.class), + Mockito.eq(1L), + Mockito.eq(2001L) + )).thenReturn(Collections.singletonList(1001L)); + + Mockito.when(jdbcTemplate.queryForList( + Mockito.eq("SELECT DISTINCT p.permission_code FROM user_role ur " + + "JOIN role_permission rp ON ur.role_id=rp.role_id AND ur.tenant_id=rp.tenant_id " + + "JOIN permission p ON rp.permission_id=p.id " + + "WHERE ur.user_id=? AND ur.tenant_id=?"), + Mockito.eq(String.class), + Mockito.eq(1001L), + Mockito.eq(1L) + )).thenReturn(Arrays.asList("meeting.approve", "user.read")); + + List perms = permissionService.getPermissions(2001L); + Assertions.assertTrue(perms.contains("user.read")); + Assertions.assertTrue(perms.contains("user.delegation.manage")); + Assertions.assertTrue(perms.contains("meeting.approve")); + Assertions.assertEquals(3, perms.size()); + } +} diff --git a/docs/MVP_试运行与发布回滚预案.md b/docs/MVP_试运行与发布回滚预案.md new file mode 100644 index 0000000..1a91ef8 --- /dev/null +++ b/docs/MVP_试运行与发布回滚预案.md @@ -0,0 +1,35 @@ +# MVP试运行与发布回滚预案 + +## 1. 试运行前检查 +- 数据库:`schema.sql`、`data.sql` 已执行完成。 +- 配置:`DB/OSS/JWT/SCHEDULER` 环境变量已配置。 +- 服务检查:后端健康检查 `/api/system/health` 返回 `UP`。 +- 前端构建:`npm run build` 成功。 + +## 2. 核心验证用例(上线阻断项) +- 创建项目 -> 创建会议 -> 会议级提交 -> 初审/复审/终审 -> 支付确认 全链路可用。 +- 越权访问接口返回 `20001/20002`。 +- 幂等冲突返回 `10002`。 +- 未终审通过支付确认返回 `40003`。 +- 调度任务可执行并支持失败重试。 + +## 3. 灰度发布策略 +- 第1阶段:仅内部租户(10%流量)观察 30 分钟。 +- 第2阶段:扩大到 50% 租户观察 1 小时。 +- 第3阶段:全量发布。 +- 监控阈值 + - 5xx 错误率连续 5 分钟 > 1% 触发回滚。 + - 核心接口 P95 延迟连续 10 分钟劣化 > 30% 触发回滚评估。 + - 审核/支付关键失败率 > 2% 触发 P1 告警。 + +## 4. 回滚策略 +- 应用回滚:回滚到最近稳定版本(保留最近2版)。 +- 数据回滚:通过备份+binlog 恢复,禁止手工改生产数据。 +- 紧急开关 + - 关闭调度:`SCHEDULER_ENABLED=false` + - 暂停支付确认入口(前端隐藏+后端网关拦截) + +## 5. 试运行周期建议 +- 试运行 3-5 天。 +- 每日输出问题清单(功能、性能、权限、财务口径)。 +- 试运行结束召开上线评审,确认是否转正式运行。 diff --git a/docs/平台超级管理员双域鉴权开发文档.md b/docs/平台超级管理员双域鉴权开发文档.md new file mode 100644 index 0000000..7177678 --- /dev/null +++ b/docs/平台超级管理员双域鉴权开发文档.md @@ -0,0 +1,103 @@ +# 平台超级管理员与双域鉴权开发文档 + +## 1. 背景与目标 + +当前系统采用租户内 RBAC(`用户 -> 角色 -> 权限`),但“系统超级管理员”职责属于平台级,不应绑定任何租户。 +本迭代目标是在不破坏现有租户业务能力的前提下,建立“平台域 + 租户域”的双域鉴权模型。 + +## 2. 设计原则 + +- 平台域与租户域严格隔离,避免越权访问。 +- 平台账号不落入任何租户,不依赖 `tenant_id`。 +- 租户业务接口只允许租户令牌访问。 +- 平台管理接口只允许平台令牌访问。 +- 兼容现有租户登录接口与权限模型,按迭代逐步替换。 + +## 3. 总体方案 + +### 3.1 身份域模型 + +- `TENANT`:租户业务身份(必须携带 `tenantId`)。 +- `PLATFORM`:平台管理身份(不携带 `tenantId`)。 + +### 3.2 Token 约定 + +- 租户令牌 claims:`uid`、`tid`、`scope=TENANT`。 +- 平台令牌 claims:`uid`、`scope=PLATFORM`。 + +### 3.3 权限注解扩展 + +`@RequirePermission` 新增 `domain` 字段: + +- `domain=TENANT`(默认) +- `domain=PLATFORM` + +### 3.4 平台 RBAC 数据模型 + +新增表: + +- `platform_user` +- `platform_role` +- `platform_permission` +- `platform_user_role` +- `platform_role_permission` + +## 4. 迭代拆分 + +### Iteration 1(已启动) + +目标:落地双域鉴权底座,打通平台登录和租户管理平台化访问。 + +- [x] 增加鉴权域枚举:`AuthScope`、`PermissionDomain` +- [x] `AuthContext` 增加 `scope` 上下文 +- [x] JWT 支持租户/平台两类 token +- [x] 新增平台登录接口:`POST /api/auth/platform-login` +- [x] 拦截器按 `scope + domain` 双维度鉴权 +- [x] 租户管理接口切换到平台域权限:`platform.tenant.manage` +- [x] 增加平台 RBAC 初始化迁移:`V40__platform_admin_rbac.sql` + +### Iteration 2(进行中) + +目标:收敛租户默认兜底逻辑,修正平台日志归属。 + +- [x] 清理所有 `tenantId == null ? 1L : tenantId` 兜底逻辑 +- [x] 平台操作审计增加 `scope` 字段(`TENANT/PLATFORM`)并完成查询分流 +- [x] 平台接口统一路由前缀(新增 `/api/platform/tenants`、`/api/platform/audit-logs`) +- [x] 为平台接口补齐权限码与元数据校验(`domain=PLATFORM`) + +### Iteration 3(待开发) + +目标:前后端联动与灰度上线。 + +- [x] 前端增加登录入口切换(平台 / 租户) +- [x] 平台工作台页面(已接入租户管理、平台审计日志基础页面) +- [x] 平台菜单动态化(`platform_menu`、`platform_role_menu` + `/api/platform/menus/current`) +- [x] 平台菜单管理页(新增/编辑/排序/菜单绑定角色) +- [x] 平台 IAM 页面(平台用户管理、平台角色管理、平台权限查看) +- [ ] 双域登录回归测试与权限压测 +- [ ] 发布灰度与回滚预案 + +## 5. 初始账号与权限 + +初始化数据(`V40`): + +- 平台角色:`PLATFORM_SUPER_ADMIN` +- 平台权限:`platform.tenant.manage`、`platform.user.manage`、`platform.audit.read`、`platform.menu.manage`、`platform.role.read`、`platform.role.manage`、`platform.permission.read` +- 平台管理员账号: + - 手机号:`13900000000` + - 密码:`123456` + +> 上线前必须改密,并接入密码加密存储。 + +## 6. 风险与注意事项 + +- 已完成租户默认 `1L` 兜底清理;后续仍需补齐平台独立审计模型,避免平台操作与租户审计混用。 +- `tenant.manage` 与 `platform.tenant.manage` 属于不同域权限码,前后端配置需同步。 +- 建议后续引入统一 `domain` 中间件拦截,禁止跨域访问。 + +## 7. 验收标准(Iteration 1) + +- 平台管理员可通过 `/api/auth/platform-login` 获取 `scope=PLATFORM` token。 +- 平台管理员可访问租户管理接口(`/api/tenants`)。 +- 租户 token 访问平台域接口被拒绝。 +- 平台 token 访问租户域接口被拒绝。 diff --git a/docs/新租户初始化清单SQL.sql b/docs/新租户初始化清单SQL.sql new file mode 100644 index 0000000..44ec8d1 --- /dev/null +++ b/docs/新租户初始化清单SQL.sql @@ -0,0 +1,165 @@ +-- 新租户初始化清单 SQL(按模板租户复制) +-- 用法: +-- 1) 先创建租户拿到 @target_tenant_id +-- 2) 以 tenant_id=1 作为模板租户执行 +-- 3) 该脚本尽量幂等(重复执行不会重复插入) + +SET @template_tenant_id := 1; +SET @target_tenant_id := 2; -- TODO: 改成目标租户ID + +-- ===================================================== +-- A. 必须初始化(权限与菜单链路) +-- 表:role/menu/role_permission/role_menu +-- ===================================================== + +-- A1) 复制模板租户角色(role_code 维度) +SET @next_role_id := (SELECT IFNULL(MAX(id), 0) FROM role); + +INSERT INTO role (id, tenant_id, role_code, role_name, status, is_deleted, created_by, updated_by) +SELECT + (@next_role_id := @next_role_id + 1) AS id, + @target_tenant_id AS tenant_id, + tr.role_code, + tr.role_name, + tr.status, + 0 AS is_deleted, + 0 AS created_by, + 0 AS updated_by +FROM role tr +LEFT JOIN role er + ON er.tenant_id=@target_tenant_id + AND er.role_code=tr.role_code + AND er.is_deleted=0 +WHERE tr.tenant_id=@template_tenant_id + AND tr.is_deleted=0 + AND er.id IS NULL; + +-- A2) 复制模板租户菜单 +INSERT INTO menu (tenant_id, menu_code, menu_name, route_path, permission_code, sort_no, status, is_deleted, created_by, updated_by) +SELECT + @target_tenant_id AS tenant_id, + tm.menu_code, + tm.menu_name, + tm.route_path, + tm.permission_code, + tm.sort_no, + tm.status, + 0 AS is_deleted, + 0 AS created_by, + 0 AS updated_by +FROM menu tm +LEFT JOIN menu em + ON em.tenant_id=@target_tenant_id + AND em.menu_code=tm.menu_code + AND em.is_deleted=0 +WHERE tm.tenant_id=@template_tenant_id + AND tm.is_deleted=0 + AND em.id IS NULL; + +-- A3) 复制 role_permission(按 role_code 映射) +SET @next_role_perm_id := (SELECT IFNULL(MAX(id), 0) FROM role_permission); + +INSERT INTO role_permission (id, tenant_id, role_id, permission_id) +SELECT + (@next_role_perm_id := @next_role_perm_id + 1) AS id, + @target_tenant_id AS tenant_id, + target_role.id AS role_id, + rp.permission_id +FROM role_permission rp +JOIN role template_role + ON template_role.tenant_id=rp.tenant_id + AND template_role.id=rp.role_id + AND template_role.is_deleted=0 +JOIN role target_role + ON target_role.tenant_id=@target_tenant_id + AND target_role.role_code=template_role.role_code + AND target_role.is_deleted=0 +LEFT JOIN role_permission existing_rp + ON existing_rp.tenant_id=@target_tenant_id + AND existing_rp.role_id=target_role.id + AND existing_rp.permission_id=rp.permission_id +WHERE rp.tenant_id=@template_tenant_id + AND existing_rp.id IS NULL; + +-- A4) 复制 role_menu(按 role_code + menu_code 映射) +SET @next_role_menu_id := (SELECT IFNULL(MAX(id), 0) FROM role_menu); + +INSERT INTO role_menu (id, tenant_id, role_id, menu_id) +SELECT + (@next_role_menu_id := @next_role_menu_id + 1) AS id, + @target_tenant_id AS tenant_id, + target_role.id AS role_id, + target_menu.id AS menu_id +FROM role_menu rm +JOIN role template_role + ON template_role.tenant_id=rm.tenant_id + AND template_role.id=rm.role_id + AND template_role.is_deleted=0 +JOIN menu template_menu + ON template_menu.tenant_id=rm.tenant_id + AND template_menu.id=rm.menu_id + AND template_menu.is_deleted=0 +JOIN role target_role + ON target_role.tenant_id=@target_tenant_id + AND target_role.role_code=template_role.role_code + AND target_role.is_deleted=0 +JOIN menu target_menu + ON target_menu.tenant_id=@target_tenant_id + AND target_menu.menu_code=template_menu.menu_code + AND target_menu.is_deleted=0 +LEFT JOIN role_menu existing_rm + ON existing_rm.tenant_id=@target_tenant_id + AND existing_rm.role_id=target_role.id + AND existing_rm.menu_id=target_menu.id +WHERE rm.tenant_id=@template_tenant_id + AND existing_rm.id IS NULL; + +-- ===================================================== +-- B. 可选初始化(建议) +-- ===================================================== + +-- B1) 模板类型开关(template_type_option) +-- 注意:type_code 是主键。若库里是“全局唯一”,目标租户可能已存在同 code。 +-- 这里按 (type_code, tenant_id) 语义做幂等,重复时仅更新展示字段。 +INSERT INTO template_type_option (type_code, tenant_id, type_name, status, sort_no) +SELECT + tto.type_code, + @target_tenant_id AS tenant_id, + tto.type_name, + tto.status, + tto.sort_no +FROM template_type_option tto +WHERE tto.tenant_id=@template_tenant_id +ON DUPLICATE KEY UPDATE + type_name=VALUES(type_name), + status=VALUES(status), + sort_no=VALUES(sort_no), + updated_at=CURRENT_TIMESTAMP; + +-- B2) 会议字段字典(meeting_field) +INSERT INTO meeting_field (tenant_id, field_code, field_name, field_values, scope_type, project_id, sort_no, status, is_deleted, created_by, updated_by) +SELECT + @target_tenant_id AS tenant_id, + mf.field_code, + mf.field_name, + mf.field_values, + mf.scope_type, + NULL AS project_id, + mf.sort_no, + mf.status, + 0 AS is_deleted, + 0 AS created_by, + 0 AS updated_by +FROM meeting_field mf +LEFT JOIN meeting_field emf + ON emf.tenant_id=@target_tenant_id + AND emf.field_code=mf.field_code + AND emf.is_deleted=0 +WHERE mf.tenant_id=@template_tenant_id + AND mf.is_deleted=0 + AND emf.id IS NULL; + +-- ===================================================== +-- C. 不建议初始化(运行数据表,仅业务发生时产生) +-- project/meeting/audit*/finance*/notification_task/export_task/operation_audit_log 等 +-- ===================================================== diff --git a/docs/未开发项开发迭代清单.md b/docs/未开发项开发迭代清单.md new file mode 100644 index 0000000..6718f6d --- /dev/null +++ b/docs/未开发项开发迭代清单.md @@ -0,0 +1,558 @@ +# 会议核销SaaS 未开发项迭代清单(2026-03 最新) + +## 一、现状结论 + +- 已完成:项目/会议/审核/财务主链路、审核流配置化、用户角色增强、数据权限、模板管理(上传/发布/停用/回滚/下载留痕/类型配置)、会议资料六大模块(`BASIC_INFO`、`WRITE_OFF_DOCS`、`ONSITE_PHOTO`、`LABOR_PROTOCOL`、`INVOICE_DETAIL`、`EXPERT_PROFILE`)、通知策略中心、可观测性告警中心(含抑制与恢复)。 +- 主要缺口:通知外部通道真实SDK接入(厂商鉴权/模板审核/限流策略)与经营分析报表(多维钻取)。 +- 本清单同步维护“状态看板 + 迭代任务”,用于区分已完成、进行中与未开始项。 + +### 1.1 状态看板(2026-03-10) + +| 模块/任务 | 当前状态 | 说明 | +|---|---|---| +| T1 租户管理(C1) | 已完成(待QA执行) | 后端接口、前端页面、启停与权限控制已接通;仍需按测试单执行并回填结果。 | +| T2 企业管理(C2) | 已完成(待QA执行) | 企业主数据与项目引用联动已落地;仍需回填回归结果。 | +| T3 菜单管理(C3) | 已完成(待QA执行) | 菜单维护、角色绑定、排序与动态加载已落地;仍需补最终回归记录。 | +| T4 代理授权(C4) | 已完成(待手工QA执行) | 生命周期、鉴权继承、自动化单测与回归模板已完成;手工用例执行结果待回填。 | +| T5 账号有效期(C5) | 功能已完成(QA未闭环) | 字段迁移、登录/会话拦截、前端展示已实现;缺少任务卡级进展与QA执行记录。 | +| P2-5 通知真实SDK接入 | 未开始 | 真实供应商鉴权、模板审核、失败原因标准化仍为主要缺口。 | +| 经营分析报表(多维钻取) | 未开始 | 当前仅有运营看板汇总,未形成报表钻取能力。 | + +## 二、P0(必须补齐) + +### P0-1 多租户真实化改造(替换硬编码 tenant_id=1) + +- 范围 + - 租户上下文从登录态注入,业务查询/写入全部走动态 `tenant_id`。 + - 新增租户管理(平台管理员):创建、启停、查询。 +- 后端 API + - `GET /api/tenants` + - `POST /api/tenants` + - `POST /api/tenants/{id}/enable` + - `POST /api/tenants/{id}/disable` +- DDL + - `tenant`(若未创建) + - 业务高频表补充复合索引(`tenant_id + 业务键`) +- 验收 + - 同一账号跨租户不可见数据。 + - 导出/下载/搜索均走租户隔离。 +- 人天 + - 后端 8d + 前端 2d + 测试 3d = **13d** + +### P0-2 审计日志中心(关键动作可检索/导出) + +- 范围 + - 登录、权限变更、审核动作、支付确认、导出下载等关键操作留痕。 + - 支持条件检索与导出。 +- 后端 API + - `GET /api/audit-logs` + - `GET /api/audit-logs/export` +- 前端 + - 新增“审计日志”页面(筛选、列表、导出)。 +- DDL + - `operation_audit_log`(落表 + 索引优化) +- 验收 + - 高风险动作 100% 可追溯到人/时间/对象。 +- 人天 + - 后端 4d + 前端 2d + 测试 2d = **8d** + +## 三、P1(高优先级业务补齐) + +### P1-1 会议资料四大模块补齐 + +- 范围 + - 新增模块:`ONSITE_PHOTO`、`LABOR_PROTOCOL`、`INVOICE_DETAIL`、`EXPERT_PROFILE`。 + - 当前进度:四个新增模块已全部完成(`ONSITE_PHOTO`、`LABOR_PROTOCOL`、`INVOICE_DETAIL`、`EXPERT_PROFILE`)。 + - 模块级保存/提交/历史/审核查看对齐现有两模块能力。 +- 后端 API + - 复用现有 `/materials/{moduleCode}/save|submit|history|current` + - 扩展模块校验规则与字段结构 +- 前端 + - 会议资料页补齐四个模块录入与历史对比 + - 审核端“查看资料”补齐结构化展示 +- DDL + - 复用 `meeting_material` / `meeting_material_history` +- 验收 + - 六大资料模块都支持提交前必填校验与历史可追溯。 +- 人天 + - 后端 5d + 前端 6d + 测试 3d = **14d** + +### P1-2 审核管理增强(转审 + SLA + 批量) + +- 范围 + - 转审、批量催办、SLA 超时升级。 + - 当前进度:已完成后端 API、DDL 与前端审核页能力(转审弹窗、批量催办、SLA 统计与超时标识)。 +- 后端 API + - `POST /api/audits/tasks/{id}/transfer` + - `POST /api/audits/tasks/batch-remind` + - `GET /api/audits/tasks/sla-stat` +- 前端 + - 审核页增加转审弹窗、批量催办、SLA 标识 +- DDL + - `audit_task` 补 `sla_deadline_at`、`timeout_level` + - `audit_transfer_log` +- 验收 + - 超时 4h/12h/24h 升级可见,转审链路有日志。 +- 人天 + - 后端 4d + 前端 3d + 测试 2d = **9d** + +### P1-3 财务对账与锁账 + +- 范围 + - 对账工单、锁账/解锁、差异追踪。 + - 当前进度:已完成后端 API、DDL、支付确认拦截(锁账)与前端财务页对账/锁账记录展示。 +- 后端 API + - `POST /api/finance/reconciliation` + - `POST /api/finance/lock` + - `POST /api/finance/unlock` + - `GET /api/finance/reconciliation/list` +- 前端 + - 财务页补“对账结果”“锁账状态”“解锁记录” +- DDL + - `finance_reconciliation` + - `finance_lock_log` +- 验收 + - 锁账期间禁止关键财务字段写入。 +- 人天 + - 后端 5d + 前端 3d + 测试 2d = **10d** + +### P1-4 模板管理剩余闭环 + +- 范围 + - 归档状态、版本差异追踪、水印下载、流程模板联动(通知模板/结算模板)。 + - 当前进度:已完成归档、版本差异追踪、水印下载、流程模板联动(会议推荐/审核通知/结算模板绑定)。 +- 后端 API + - `POST /api/templates/{id}/archive` + - `GET /api/templates/{id}/versions/diff` + - `GET /api/templates/{id}/download-watermark` +- 前端 + - 模板页补“归档”“版本差异查看” +- 验收 + - 已归档模板不可再发布,差异可视化可用。 +- 人天 + - 后端 3d + 前端 2d + 测试 1d = **6d** + +## 四、P2(治理与效率) + +### P2-1 专家(参会人)管理全量能力 + +- 范围 + - 主档案、多卡、去重合并、导入导出、会议快照。 + - 当前进度:已完成主档案、多卡、去重合并、导入导出与专家管理页面;会议提交已接入专家快照写入链路。 +- 后端 API + - `GET/POST /api/experts` + - `POST /api/experts/{id}/merge` + - `POST /api/experts/import` + - `GET /api/experts/export` +- 前端 + - 专家管理页面 + 合并记录 + 银行卡管理 +- DDL + - `expert`、`expert_bank_card`、`expert_merge_log`、`meeting_expert_snapshot` +- 验收 + - 身份证唯一校验,历史会议读快照。 +- 人天 + - 后端 8d + 前端 6d + 测试 4d = **18d** + +### P2-2 通知策略中心 + +- 范围 + - 事件->渠道->对象可配置,模板变量统一管理。 + - 当前进度:已完成策略中心 DDL、后端 `GET/POST/PUT /api/notification-policies` 与前端通知策略页面。 +- 后端 API + - `GET/POST/PUT /api/notification-policies` +- 前端 + - 通知策略页面 +- DDL + - `notification_policy`、`notification_policy_event` +- 验收 + - 策略即时生效,可追溯变更。 +- 人天 + - 后端 4d + 前端 2d + 测试 2d = **8d** + +### P2-3 可观测性与告警 + +- 范围 + - 指标、日志、告警阈值、任务失败告警闭环。 + - 当前进度:已完成指标埋点(API、异步任务、导出)与告警规则中心(规则配置、手动评估、事件查询、抑制窗口、自动恢复)。 +- 交付 + - 指标埋点(API、异步任务、导出) + - 告警规则(5xx、任务积压、超时率) +- 验收 + - 告警可触发、可恢复、可追踪。 +- 人天 + - 后端 3d + 运维 2d + 测试 1d = **6d** + +### P2-4 补缺闭环(文档对照新增) + +- 范围 + - 已完成:会议撤回提交(状态机与审计联动) + - 已完成:会议字段管理(字典配置) + - 已完成:发票管理(抬头主数据) + - 已完成:通知执行引擎(任务化发送、重试、回执) + - 已完成:导出任务中心(任务状态、下载安全) + - 已完成:可观测性自动评估与连续恢复判定 +- 后端 API(建议) + - `POST /api/meetings/{id}/withdraw` + - `GET/POST/PUT /api/meeting-fields` + - `GET/POST/PUT /api/invoice-profiles` + - `POST /api/notifications/dispatch` + - `POST /api/notifications/receipts` + - `GET /api/notifications/tasks` + - `GET/POST /api/export-tasks` + - `POST /api/export-tasks/{id}/refresh-token` + - `GET /api/export-tasks/{id}/download` + - `POST /api/observability/alert-rules/evaluate/auto` + - `GET /api/operations/dashboard` +- 验收 + - 文档与实现接口 1:1 对齐,新增模块可独立上线并通过回归。 +- 人天 + - 后端 8d + 前端 6d + 测试 4d = **18d** + +### P2-5 深化治理(进行中) + +- 范围 + - 已完成:通知回执标准化(消息ID、回执码、回执日志) + - 已完成:导出下载鉴权与过期策略(令牌刷新、过期控制、下载计数) + - 已完成:运营看板趋势与TOP(通知/导出/告警) + - 待完成:真实供应商SDK接入与失败原因规范化字典 +- 后端 API(新增) + - `POST /api/notifications/receipts` + - `POST /api/notifications/receipts/webhook` + - `POST /api/export-tasks/{id}/refresh-token` + - `GET /api/export-tasks/{id}/download` + - `GET /api/operations/dashboard` + +## 五、建议迭代顺序(接下来 4 个 Sprint) + +- Sprint E(2周):P0-1 多租户真实化 + P0-2 审计日志中心 +- Sprint F(2周):P1-1 资料四模块补齐 + P1-2 审核增强 +- Sprint G(2周):P1-3 财务对账锁账 + P1-4 模板闭环 +- Sprint H(2-3周):P2-1 专家管理 + P2-2 通知策略 + P2-3 可观测性 + +## 六、DoD(本清单统一完成标准) + +- 接口文档:请求/响应/错误码补齐。 +- 数据迁移:Flyway 脚本可重复执行,预发验证通过。 +- 权限校验:菜单权限 + 动作权限 + 数据权限全链路生效。 +- 测试:单测 + 关键集成测试 + 最少 1 条回归脚本。 +- 留痕:关键动作有审计日志(操作者、对象、前后值、时间)。 + +## 七、“有概念、无模块化定义”缺口补全(本轮新增) + +### C1 租户管理模块化补全(平台级) + +- 缺口来源 + - 业务文档已有“平台超级管理员可创建/启停单位主体”概念,但缺页面与流程化定义。 + - 技术文档“接口实施状态”提到已实现租户能力,但接口清单未显式列出租户分组。 +- 页面定义(前端) + - 新增“租户管理”菜单与页面:列表、创建、启用、停用。 + - 字段:`tenantCode`、`tenantName`、`status`、`createdAt`。 +- 后端 API + - `GET /api/tenants` + - `POST /api/tenants` + - `POST /api/tenants/{id}/enable` + - `POST /api/tenants/{id}/disable` +- 数据与约束 + - `tenant_code` 唯一,状态仅允许 `ENABLED/DISABLED`。 + - 启停租户需写入审计日志(动作码建议:`tenant.manage`)。 +- 验收 + - 平台管理员可在页面完成全流程;无 `tenant.manage` 权限用户不可操作。 + - 两份主文档均包含模块、接口、权限点与页面入口说明。 +- 人天 + - 后端 1d + 前端 1d + 测试 1d = **3d** + +### C2 企业管理模块化补全(系统设置) + +- 缺口来源 + - 业务文档有“企业管理”字段要求,但技术文档无对应模块定义/接口清单。 +- 页面定义(前端) + - 列表、详情、创建、编辑、启停(如业务确认需要)。 + - 字段:企业名称、网址、Logo、状态、更新时间。 +- 后端 API(建议) + - `GET /api/enterprises` + - `POST /api/enterprises` + - `PUT /api/enterprises/{id}` + - `POST /api/enterprises/{id}/enable` + - `POST /api/enterprises/{id}/disable` +- 数据与约束 + - 新增 `enterprise` 主表;名称唯一,Logo 必填校验。 + - 项目模块“合作企业”字段改为引用企业主数据。 +- 验收 + - 项目创建/编辑可引用企业主数据,禁止手填脏数据。 + - 企业启停后在项目选择器实时生效。 +- 人天 + - 后端 3d + 前端 2d + 测试 2d = **7d** + +### C3 菜单管理模块化补全(权限可见性) + +- 缺口来源 + - 业务文档有“菜单管理”概念,技术文档未定义菜单数据模型和权限映射策略。 +- 页面定义(前端) + - 菜单列表(树形)、角色菜单绑定、菜单启停与排序。 +- 后端 API(建议) + - `GET /api/menus` + - `POST /api/menus` + - `PUT /api/menus/{id}` + - `POST /api/roles/{id}/menus` + - `GET /api/roles/{id}/menus` +- 数据与约束 + - `menu`、`role_menu` 表;菜单与 `permissionCode` 建立映射。 + - 前端“可见性”与后端“动作权限”双校验,禁止只做前端隐藏。 +- 验收 + - 不同角色登录看到不同菜单树,且后端越权访问仍被拦截。 +- 人天 + - 后端 4d + 前端 3d + 测试 2d = **9d** + +### C4 代理授权模块化补全(用户生命周期) + +- 缺口来源 + - 业务文档明确要求代理授权生效/失效与留痕,技术文档仅概念提及。 +- 页面定义(前端) + - 用户详情新增“代理授权”配置:代理人、生效时间、失效时间、原因。 + - 代理记录列表:状态(待生效/生效中/已失效)。 +- 后端 API(建议) + - `POST /api/users/{id}/delegations` + - `GET /api/users/{id}/delegations` + - `POST /api/delegations/{id}/disable` +- 数据与约束 + - 新增 `user_delegation` 表,记录授权窗口、状态、创建人与失效原因。 + - 审计记录动作码建议:`user.delegation.manage`。 +- 验收 + - 到达失效时间自动失效;代理操作可完整追溯授权链。 +- 人天 + - 后端 3d + 前端 2d + 测试 2d = **7d** + +### C5 账号有效期模块化补全(鉴权拦截) + +- 缺口来源 + - 业务文档要求账号有效期必填,技术文档缺字段规范与认证拦截定义。 +- 页面定义(前端) + - 用户新增/编辑增加“有效期开始/结束”。 + - 到期账号列表筛选与状态提示。 +- 后端 API(建议) + - 复用 `POST/PUT /api/users` 增加 `validFrom`、`validTo` 字段。 + - 登录接口增加有效期校验失败错误码返回(建议 `11004`)。 +- 数据与约束 + - `sys_user` 增加 `valid_from`、`valid_to`。 + - 登录、刷新令牌、关键写操作统一做有效期检查。 +- 验收 + - 过期账号无法登录且返回统一错误码;在有效期内恢复正常。 +- 人天 + - 后端 2d + 前端 1d + 测试 1d = **4d** + +## 八、缺口补全里程碑(建议) + +| 里程碑 | Sprint | 交付模块 | 目标结果 | +|---|---|---|---| +| M1 | Sprint I(1周) | C1 租户管理、C5 账号有效期 | 平台可创建/启停租户;账号到期拦截口径统一 | +| M2 | Sprint J(2周) | C2 企业管理、C4 代理授权 | 企业主数据可维护并被项目引用;代理授权可配置并自动失效 | +| M3 | Sprint K(2周) | C3 菜单管理 | 角色菜单树可配置;菜单可见性与动作权限一体化生效 | +| M4 | Sprint L(1周) | 文档与验收收口 | 两份主文档与实现清单 1:1 对齐,补齐回归与审计验证 | + +### 里程碑验收门槛(统一) + +- 接口与页面:每个模块至少 1 条端到端回归脚本通过。 +- 权限链路:登录鉴权、动作权限、数据权限、有效期校验全部生效。 +- 数据治理:Flyway 脚本可重复执行,升级/回滚路径可验证。 +- 审计追溯:创建、启停、授权、失效、权限变更全量留痕可检索。 + +## 九、开发任务卡(可直接排期) + +### 9.0 任务状态总览(2026-03-10) + +| 任务 | 状态 | 备注 | +|---|---|---| +| T1 | 已完成(待QA执行) | 功能已落地,测试结果待回填。 | +| T2 | 已完成(待QA执行) | 功能已落地,测试结果待回填。 | +| T3 | 已完成(待QA执行) | 功能已落地,测试结果待回填。 | +| T4 | 已完成(待手工QA执行) | 自动化基线已补,手工回归待执行。 | +| T5 | 功能已完成(QA未闭环) | 缺任务卡进展说明与回归结果。 | + +### T1 租户管理(平台级) + +- 目标 + - 提供租户列表、创建、启用、停用能力,并纳入统一权限与审计链路。 +- 后端任务 + - 完成 `TenantController/TenantService` 接口稳定化与参数校验。 + - 增加错误码映射:重复租户编码、状态非法、无权限。 + - 审计日志接入:`tenant.manage`(创建/启停分动作码)。 +- 前端任务 + - 新增 `TenantPage`(列表、创建弹窗、启停按钮)。 + - 在菜单中增加平台入口(仅具备权限用户可见)。 + - 按钮权限:`tenant.manage`。 +- SQL/Flyway + - 确认 `tenant` 表字段完整:`tenant_code` 唯一、`status`、审计字段。 + - 历史数据修复脚本:空 `tenant_code` 回填与唯一约束检查。 +- 测试用例 + - 正常创建租户、重复编码拦截、启停状态切换、无权限拦截。 + - 停用租户下账号登录失败验证。 +- DoD + - 页面可操作、接口稳定、日志可检索、回归通过。 + +- 当前实现进展(2026-03-10) + - 已完成:租户列表/创建/启停接口与前端页面,菜单入口与 `tenant.manage` 按钮权限控制。 + - 已完成:Flyway 兼容修复与 `tenant_code` 约束补齐。 + - 待完成:按 `TASK-T1-QA-01` 执行手工回归并回填结果。 + +### T2 企业管理(系统设置) + +- 目标 + - 企业主数据独立维护,并可在项目模块引用,杜绝手填脏数据。 +- 后端任务 + - 新增企业 CRUD 与启停接口。 + - 项目创建/编辑改为企业 ID 引用校验。 + - 约束:企业停用后不可被新增项目引用。 +- 前端任务 + - 新增企业管理页(列表、详情、创建、编辑、启停)。 + - 项目页面“合作企业”改为下拉选择企业主数据。 +- SQL/Flyway + - 新增 `enterprise` 表及唯一索引(企业名称/编码按最终口径确定)。 + - 项目表增加 `enterprise_id`(若当前为名称存储需迁移脚本)。 +- 测试用例 + - 企业启停与项目引用联动、重复名称校验、历史项目兼容查询。 +- DoD + - 企业数据可维护,项目联动稳定,历史数据可回溯。 + +- 当前实现进展(2026-03-10) + - 已完成:企业表迁移、企业 CRUD/启停接口、项目绑定企业引用校验。 + - 已完成:企业管理页与项目“合作企业”下拉联动。 + - 待完成:按 `TASK-T2-QA-01` 执行联调回归并回填结果。 + +### T3 菜单管理(角色菜单树) + +- 目标 + - 菜单可见性可配置,且与动作权限统一治理。 +- 后端任务 + - 提供菜单树查询、菜单维护、角色菜单绑定接口。 + - 校验角色绑定数据合法性(菜单存在、状态可用、层级正确)。 +- 前端任务 + - 新增菜单管理页(树形、排序、启停、角色绑定)。 + - 登录后按角色加载菜单树,未授权菜单不展示。 +- SQL/Flyway + - 新增 `menu`、`role_menu` 表,建立索引(`role_id`、`menu_id`)。 + - 菜单与 `permission_code` 字段对齐。 +- 测试用例 + - 角色切换后菜单变化、越权接口后端拦截、菜单停用即时生效。 +- DoD + - 菜单可配置、权限一致、越权不可达。 + +- 当前实现进展(2026-03-10) + - 已完成:菜单管理接口、角色菜单绑定、`permission_code` 对齐与批量排序保存。 + - 已完成:前端菜单管理页、角色绑菜单、登录后按权限动态加载菜单。 + - 待完成:按 `TASK-T3-QA-01` 执行越权与可见性回归并回填结果。 + +### T4 代理授权(用户生命周期) + +- 目标 + - 支持代理授权时间窗配置、自动失效、全链路审计。 +- 后端任务 + - 提供授权创建/查询/停用接口。 + - 增加定时任务:过期授权自动置为失效。 + - 鉴权侧接入代理上下文判定(仅在有效窗口内生效)。 +- 前端任务 + - 用户详情新增代理授权配置与记录列表。 + - 显示授权状态(待生效/生效中/已失效/手动停用)。 +- SQL/Flyway + - 新增 `user_delegation` 表及状态索引(`status`,`effective_to`)。 +- 测试用例 + - 授权生效、过期自动失效、手动停用、审计记录完整。 +- DoD + - 授权链路可追溯、时间窗行为正确、异常场景可控。 + +- 当前实现进展(2026-03-10) + - 已完成:`V37__user_delegation.sql`(表结构+索引+权限种子)、授权创建/查询/停用接口、过期自动失效定时任务、用户页代理授权弹窗(新增/列表/停用)。 + - 已完成:鉴权链路代理上下文生效(代理人可在有效窗口内继承被代理人权限),并补充了后端自动化测试覆盖关键路径。 + - 已完成:`TASK-T4-QA-01` 回归用例模板与自动化单测基线(代理权限继承、时间窗参数校验)。 + +- T4-QA 回归执行清单(可直接拷贝到测试单) + +| 用例ID | 场景 | 前置条件 | 操作步骤 | 预期结果 | 执行结果 | +|---|---|---|---|---|---| +| T4-QA-001 | 授权立即生效 | 用户A、用户B均启用;A具有业务权限;B不具备该权限 | 在用户页为A配置代理人B,生效时间=当前前,失效时间=未来 | 授权记录状态为`ENABLED`;B可访问A对应权限接口 | 待执行 | +| T4-QA-002 | 授权待生效 | 用户A、用户B均启用 | 配置生效时间=未来,失效时间=更未来 | 记录状态为`PENDING`;B暂不可继承A权限 | 待执行 | +| T4-QA-003 | 授权自动过期 | 已存在`ENABLED`授权记录,失效时间即将到达 | 等待定时任务窗口(或手工触发任务) | 状态自动切为`EXPIRED`;B失去继承权限 | 待执行 | +| T4-QA-004 | 手动停用授权 | 已存在`ENABLED`或`PENDING`授权记录 | 在代理授权列表点击“停用”并填写原因 | 状态切为`DISABLED`;停用原因可查询 | 待执行 | +| T4-QA-005 | 禁止自代理 | 用户A存在 | 为A创建代理,代理人也选择A | 创建失败,返回业务错误“代理人不能与被代理人相同” | 待执行 | +| T4-QA-006 | 非法时间窗拦截 | 用户A、用户B存在 | 创建授权:`effectiveTo <= effectiveFrom` | 创建失败,返回业务错误“失效时间必须晚于生效时间” | 待执行 | +| T4-QA-007 | 权限闭环审计 | 已开启操作审计 | 完成一次“创建授权->停用授权”链路 | 审计日志存在对应 API 调用记录与状态变更痕迹 | 待执行 | + +- T4-QA 自动化测试映射(当前已落地) + - `PermissionServiceDelegationTest`:覆盖代理权限继承与权限集合去重。 + - `UserDelegationServiceValidationTest`:覆盖自代理拦截、时间窗非法拦截。 + +### T5 账号有效期(统一鉴权拦截) + +- 目标 + - 在登录、令牌刷新、关键写操作统一校验账号有效期。 +- 后端任务 + - `sys_user` 引入 `valid_from/valid_to` 并在认证链路校验。 + - 新增错误码(建议 `11004`)与统一错误文案。 +- 前端任务 + - 用户创建/编辑增加有效期字段及校验。 + - 到期状态在用户列表显式标记。 +- SQL/Flyway + - `sys_user` 加字段与默认值迁移;历史账号回填策略。 +- 测试用例 + - 有效期前不可登录、有效期内可登录、过期后自动拦截。 + - 关键写接口(非登录)也进行有效期拒绝校验。 +- DoD + - 各链路校验一致,错误码统一,回归通过。 + +- 当前实现进展(2026-03-10) + - 已完成:`V33__user_account_validity.sql` 字段迁移与历史回填(`valid_from/valid_to`)。 + - 已完成:登录与鉴权拦截统一有效期校验,错误码 `11004` 接入前端自动登出处理。 + - 已完成:用户页面有效期录入与“已过期”状态展示。 + - 待完成:补齐 `TASK-T5-QA-01` 回归执行清单与结果回填。 + +## 十、Jira/禅道建单模板(可导入) + +### 10.1 使用说明 + +- 建议先创建 5 个 Epic:`EPIC-T1`~`EPIC-T5`(对应 T1~T5)。 +- 下面“任务导入表”可直接复制为 CSV(逗号分隔)导入。 +- `依赖` 列用于排期时设置“前置任务”,避免并行冲突。 + +### 10.2 Epic 列表 + +| Epic ID | Epic 名称 | 目标 | +|---|---|---| +| EPIC-T1 | 租户管理平台化 | 完成租户创建/启停/审计全链路 | +| EPIC-T2 | 企业管理主数据化 | 企业主数据维护并联动项目引用 | +| EPIC-T3 | 菜单权限一体化 | 菜单树与动作权限统一治理 | +| EPIC-T4 | 代理授权生命周期 | 授权配置、自动失效、可审计 | +| EPIC-T5 | 账号有效期统一校验 | 登录与关键写操作统一拦截 | + +### 10.3 任务导入表(CSV列头) + +`IssueKey,Summary,IssueType,EpicLink,OwnerRole,EstimateDays,Priority,DependsOn,AcceptanceCriteria` + +`TASK-T1-BE-01,租户管理后端接口与校验,Task,EPIC-T1,后端,1,P0,,实现GET/POST tenants与启停接口并通过单测` +`TASK-T1-FE-01,租户管理前端页面与菜单入口,Task,EPIC-T1,前端,1,P0,TASK-T1-BE-01,完成列表创建启停页面并接通权限控制` +`TASK-T1-QA-01,租户管理联调与回归,Task,EPIC-T1,测试,1,P0,TASK-T1-BE-01;TASK-T1-FE-01,覆盖创建重复编码启停无权限用例` + +`TASK-T2-DB-01,企业主数据表设计与迁移脚本,Task,EPIC-T2,后端,1,P1,,新增enterprise表并完成迁移验证` +`TASK-T2-BE-01,企业管理接口与项目引用校验,Task,EPIC-T2,后端,2,P1,TASK-T2-DB-01,实现企业CRUD启停并联动项目引用约束` +`TASK-T2-FE-01,企业管理页面与项目企业下拉,Task,EPIC-T2,前端,2,P1,TASK-T2-BE-01,企业页可维护且项目页改为企业下拉` +`TASK-T2-QA-01,企业管理联调回归,Task,EPIC-T2,测试,2,P1,TASK-T2-BE-01;TASK-T2-FE-01,覆盖启停联动重复名称历史数据兼容` + +`TASK-T3-DB-01,菜单与角色菜单关系表迁移,Task,EPIC-T3,后端,1,P1,,新增menu与role_menu并建索引` +`TASK-T3-BE-01,菜单树与角色绑定后端接口,Task,EPIC-T3,后端,2,P1,TASK-T3-DB-01,实现菜单树查询维护与角色绑定接口` +`TASK-T3-FE-01,菜单管理页面与动态菜单加载,Task,EPIC-T3,前端,3,P1,TASK-T3-BE-01,角色切换后菜单可见性正确生效` +`TASK-T3-QA-01,菜单权限回归与越权验证,Task,EPIC-T3,测试,2,P1,TASK-T3-BE-01;TASK-T3-FE-01,覆盖前端可见性与后端越权拦截` + +`TASK-T4-DB-01,代理授权表与状态索引迁移,Task,EPIC-T4,后端,1,P1,,新增user_delegation表并通过迁移验证` +`TASK-T4-BE-01,代理授权接口与自动失效任务,Task,EPIC-T4,后端,2,P1,TASK-T4-DB-01,实现授权创建查询停用与过期自动失效` +`TASK-T4-FE-01,用户详情代理授权UI,Task,EPIC-T4,前端,2,P1,TASK-T4-BE-01,支持授权配置与状态展示` +`TASK-T4-QA-01,代理授权链路回归,Task,EPIC-T4,测试,2,P1,TASK-T4-BE-01;TASK-T4-FE-01,覆盖生效过期停用与审计追溯` + +`TASK-T5-DB-01,用户有效期字段迁移,Task,EPIC-T5,后端,1,P0,,sys_user新增valid_from与valid_to并回填策略` +`TASK-T5-BE-01,认证链路有效期统一校验,Task,EPIC-T5,后端,1,P0,TASK-T5-DB-01,登录刷新关键写操作统一拦截并返回11004` +`TASK-T5-FE-01,用户有效期表单与状态展示,Task,EPIC-T5,前端,1,P0,TASK-T5-BE-01,新增有效期字段与到期标识展示` +`TASK-T5-QA-01,有效期全链路回归,Task,EPIC-T5,测试,1,P0,TASK-T5-BE-01;TASK-T5-FE-01,覆盖生效前有效期内过期后三段场景` + +### 10.4 建议前置依赖图 + +- `T1` 与 `T5` 可并行,建议优先完成(P0)。 +- `T2` 依赖企业表迁移后再做项目联动。 +- `T3` 建议在 `T1/T5` 稳定后推进,避免权限链路改动冲突。 +- `T4` 可与 `T2/T3` 并行,但需与鉴权改造保持分支隔离。 diff --git a/docs/未开发项开发迭代清单V2.md b/docs/未开发项开发迭代清单V2.md new file mode 100644 index 0000000..94c4a70 --- /dev/null +++ b/docs/未开发项开发迭代清单V2.md @@ -0,0 +1,131 @@ +# 会议核销SaaS 未开发项开发迭代清单 V2(2026-03) + +## 一、V2目标与范围 + +- 目标:基于 `会议核销SaaS系统_技术开发文档.md` 新增 `6.10/7.7/7.8`,完成“字段级口径 + 接口闭环 + 权限矩阵”落地。 +- 范围:仅覆盖当前确认缺口,不回滚已完成能力。 +- 原则:先补齐 P0 闭环,再推进 P1 增强;所有新增能力必须带审计、权限、测试。 + +## 二、当前迭代状态(V2启动) + +| 迭代项 | 优先级 | 状态 | 说明 | +|---|---|---|---| +| V2-P0-1 字段级数据字典落库(6.10) | P0 | 已完成(待预发验证) | 已新增 `V38__v2a_field_dictionary.sql`,包含加列/新表/索引。 | +| V2-P0-2 接口缺口补齐(7.7) | P0 | 已完成(后端待QA,前端待联调) | 角色、数据权限、审计日志、资料包、会议总结、通知策略补齐。 | +| V2-P0-3 权限矩阵落地(7.8) | P0 | 进行中 | 接口三元绑定(`permissionCode/dataScopeType/auditActionCode`)统一治理。 | +| V2-P1-1 发票结构化明细全链路 | P1 | 未开始 | 明细项、附件、汇总、校验规则与财务联动。 | +| V2-P1-2 审核SLA与批量能力增强 | P1 | 未开始 | 催办、批量催办、超时升级、审计回溯增强。 | + +## 三、迭代批次规划(建议 3 个 Sprint) + +- Sprint V2-A(本期,1.5~2周) + - V2-P0-1、V2-P0-2、V2-P0-3 全部完成可提测版本。 +- Sprint V2-B(下期,2周) + - V2-P1-1 发票结构化明细 + 财务分类费用打通。 +- Sprint V2-C(下下期,1~1.5周) + - V2-P1-2 审核SLA批量能力增强 + 回归收口。 + +## 四、第一轮迭代(已启动) + +### 4.1 V2-P0-1 字段级数据字典落库(6.10) + +- 范围 + - `project`、`meeting`、`meeting_material`、`audit_task`、`audit_action_log`、`expert`、`expert_bank_card`、`template/template_version`、`finance_meeting_bill` 字段补齐。 + - 新增建议表:`meeting_material_invoice_item`、`meeting_material_invoice_file`、`meeting_invoice_summary`。 +- 后端任务 + - 完成实体/DO/DTO 字段扩展与向后兼容映射。 + - 对关键写接口补充字段校验(金额单位“分”、比例范围、状态枚举合法性)。 + - 当前进展:已完成 `meeting_material` 发票结构化链路、`audit_task` 新字段读写链路(SLA/超时/转审扩展)、`meeting/project` 新字段基础映射、`finance_meeting_bill` 读写接口、`expert/template` 新字段映射。 +- DB/Flyway任务 + - 已完成:`backend/src/main/resources/db/migration/V38__v2a_field_dictionary.sql`(幂等加列 + 新表 + 索引)。 + - 待完成:预发执行验证、回填策略脚本(如需)与回滚演练记录。 +- 验收标准 + - 新增字段不破坏现有接口。 + - 发票明细可结构化保存并查询。 + - 审核、财务、专家关键字段可检索可追溯。 + +### 4.2 V2-P0-2 接口缺口补齐(7.7) + +- 范围 + - 角色管理、数据权限管理、审计日志查询导出、会议资料包导出、会议总结生成下载、通知策略配置侧。 +- 目标接口组(本期必须交付) + - 角色:`GET/POST/PUT /api/roles`、`POST /api/roles/{id}/enable|disable`、`POST /api/roles/{id}/permissions` + - 数据权限:`GET/POST/PUT /api/data-scope-policies`、`POST /api/data-scope-policies/{id}/copy|assign-roles|enable|disable` + - 审计日志:`GET /api/audit-logs`、`GET/POST /api/audit-logs/export-tasks` + - 会议资料:`POST /api/meetings/{id}/materials/{module}/submit|resubmit`、`POST /api/meetings/{id}/materials/export` + - 会议总结:`POST /api/meetings/{id}/summary/generate`、`GET /api/meetings/{id}/summary/download` + - 通知策略配置:`GET/POST/PUT /api/notification-policies`、`POST /api/notification-policies/{id}/events|enable|disable` +- 验收标准 + - 接口文档、权限码、错误码、审计动作码齐全。 + - 前端按钮权限与后端接口权限一致。 + - 导出/下载能力遵循数据权限范围。 +- 当前进展 + - 已补齐通知策略配置侧接口:`POST /api/notification-policies/{id}/events|enable|disable`。 + - 已补齐会议字段配置启停接口:`POST /api/meeting-fields/{id}/enable|disable`。 + - 已补齐发票抬头配置启停接口与别名路由:`POST /api/invoice-profiles/{id}/enable|disable`、`/api/invoice-heads`。 + - 前端系统设置页已接入上述能力:数据权限(复制/启停)、通知策略(绑定事件/启停)、会议字段(启停)、发票抬头(启停)。 + - 审计日志页已接入导出任务能力:`GET/POST /api/audit-logs/export-tasks`。 + - 会议页已接入资料包导出与会议总结入口:资料包导出、总结生成、总结令牌刷新、总结下载。 + +### 4.3 V2-P0-3 权限矩阵落地(7.8) + +- 范围 + - 全量接口补三元绑定:`permissionCode`、`dataScopeType`、`auditActionCode`。 + - 接口发布前增加权限元数据检查门禁。 +- 后端任务 + - 新增权限元数据校验组件(启动校验 + 单测校验)。 + - 统一数据范围枚举:`TENANT/PROJECT/MEETING/MEETING_MODULE/GLOBAL_READONLY`。 +- 当前进展 + - 已完成注解扩展:`@RequirePermission` 支持 `dataScopeType/auditActionCode` 元数据。 + - 已新增启动校验门禁组件:`PermissionMetadataGuard`(支持 `writeoff.permission-metadata.strict` 严格模式)。 + - 已在核心接口组补齐三元标注:通知策略、会议字段、发票抬头、数据权限、审计日志、会议资料包/会议总结接口。 + - 已批量补齐存量控制器三元标注:角色/菜单/用户/租户/企业、模板、专家、审核流/审核任务、财务、导出任务、通知分发、可观测、文件下载等。 +- 前端任务 + - 按钮权限码与接口权限码同源配置。 + - 无权限状态统一“置灰+原因提示”。 +- 当前进展 + - 已新增前端权限常量单源:`frontend/src/constants/permissions.ts`。 + - 已完成页面侧 `hasPermission("...")` 到 `hasPermission(PERMS.xxx)` 的统一替换(系统设置、审核、财务、模板、专家、导出、通知、可观测等模块)。 + - 已补齐主要操作按钮的“无权限置灰 + 原因提示(title)”展示规范。 +- 验收标准 + - 缺权限元数据的接口不可发布。 + - 越权请求稳定返回统一错误码(`20001/20002`)。 + +## 五、任务卡(可直接建单) + +| 任务ID | 模块 | 任务 | 负责人 | 预计人天 | 状态 | 依赖 | +|---|---|---|---|---:|---|---| +| V2A-DB-01 | 字段落库 | 6.10 加列与新表迁移脚本 | 后端 | 2 | 已完成(待预发验证) | - | +| V2A-BE-01 | 字段落库 | 实体/接口字段扩展与校验 | 后端 | 3 | 已完成(待QA) | V2A-DB-01 | +| V2A-QA-01 | 字段落库 | 兼容性回归(存量接口) | 测试 | 1.5 | 未开始 | V2A-BE-01 | +| V2A-BE-02 | 接口补齐 | 角色+数据权限+审计日志接口组 | 后端 | 3 | 已完成(待QA) | - | +| V2A-FE-01 | 接口补齐 | 系统设置页面能力补齐 | 前端 | 3 | 已完成(待QA) | V2A-BE-02 | +| V2A-BE-03 | 接口补齐 | 会议资料包/会议总结接口组 | 后端 | 2 | 已完成(待QA) | - | +| V2A-FE-02 | 接口补齐 | 资料包导出与会议总结入口 | 前端 | 2 | 已完成(待QA) | V2A-BE-03 | +| V2A-BE-04 | 权限矩阵 | 三元绑定校验门禁 | 后端 | 1.5 | 已完成(待QA) | - | +| V2A-FE-03 | 权限矩阵 | 按钮权限同源配置改造 | 前端 | 1.5 | 已完成(待QA) | V2A-BE-04 | +| V2A-QA-02 | 全链路 | 权限+审计+导出下载回归 | 测试 | 2 | 未开始 | V2A-FE-01;V2A-FE-02;V2A-FE-03 | + +## 六、风险与阻塞位 + +- 风险1:历史数据缺失(如旧会议无结构化发票明细)导致回填不完整。 + - 处理:回填脚本允许空值,前端按“旧数据兼容展示”策略处理。 +- 风险2:权限码重构影响存量按钮显隐。 + - 处理:灰度发布,先日志观测再全量切换。 +- 风险3:导出/下载接口并发高峰导致任务堆积。 + - 处理:复用导出任务中心,增加限流与重试监控。 + +## 七、统一DoD(V2) + +- 文档:OpenAPI 与技术文档同步更新,接口示例可直接联调。 +- 数据:Flyway 迁移可重复执行,预发演练通过。 +- 安全:权限、数据范围、审计动作三链路全部生效。 +- 质量:单测覆盖新增校验与状态流转;至少 1 条端到端回归通过。 +- 发布:灰度策略、回滚策略、监控告警规则完成配置。 + +## 八、下一步(默认执行顺序) + +- 第1步:先完成 `V2A-DB-01` + `V2A-BE-01`,锁定字段口径。 +- 第2步:并行推进 `V2A-BE-02`、`V2A-BE-03`、`V2A-BE-04`。 +- 第3步:前端接入 `V2A-FE-01/02/03`,随后执行 `V2A-QA-01/02`。 +- 第4步:输出 Sprint V2-A 版本说明与上线检查单。 diff --git a/docs/租户域模板管理模块优化方案清单.md b/docs/租户域模板管理模块优化方案清单.md new file mode 100644 index 0000000..4a37343 --- /dev/null +++ b/docs/租户域模板管理模块优化方案清单.md @@ -0,0 +1,420 @@ +# 租户域模板管理模块优化方案清单 + +## 1. 适用范围 + +- 本清单仅针对**租户域模板管理模块**。 +- 不包含平台域统一模板治理、跨租户模板审计、平台级模板库等能力。 +- 目标是把租户域模板管理从“能上传、能发布”升级为“可治理、可检索、可追踪、可联动”。 + +## 2. 优化目标 + +- 提升模板管理页面的信息架构与使用效率。 +- 提升模板生命周期治理能力,包括草稿、发布、停用、归档、回滚。 +- 提升模板与会议、审核、结算等业务场景的联动准确性。 +- 提升模板下载留痕、权限控制、时效控制和风险控制能力。 +- 提升后端接口一致性、可扩展性和并发安全。 +- 提升数据库模型的多租户适配性与查询性能。 + +## 3. 现状问题归纳 + +### 3.1 前端页面布局 + +- 一个页面同时承载模板类型开关、模板列表、流程绑定、版本回滚、版本差异、上传建版,信息密度过高。 +- 缺少统一筛选区,用户只能翻表找模板。 +- 创建模板时需要手填 `projectId`、`meetingId`、`objectKey`,使用门槛高。 +- 模板列表缺少关键治理字段展示,例如生效时间、水印开关、下载限制、最近更新时间、维护人。 + +### 3.2 显示内容 + +- 当前列表更偏“技术字段展示”,不够业务化。 +- 版本信息展示不完整,无法快速判断“当前发布版”和“历史草稿版”差异。 +- 下载日志页展示维度少,问题排查效率低。 + +### 3.3 用户操作 + +- 缺少按业务对象选择模板适用范围的交互。 +- 流程绑定操作没有足够的场景提示和影响提示。 +- 水印下载、普通下载、发布、回滚等高风险操作缺少确认与说明。 +- 缺少预览、复制模板、批量筛选、快速查看版本历史等高频能力。 + +### 3.4 后端逻辑 + +- 模板流程绑定与业务消费链路没有完全闭环。 +- 模板创建和新增版本存在并发安全风险。 +- 有效期、水印、下载限流等字段虽然存在,但未完整落地到业务逻辑。 +- 下载日志缺少标准分页、标准筛选和关联名称字段返回。 + +### 3.5 数据库设计 + +- 模板类型配置表的主键设计与多租户语义不完全一致。 +- 日志和版本表索引偏弱,后续数据量上来后容易退化。 +- 模板主表、版本表、流程绑定表的约束还不够完整。 + +## 4. 租户域模板管理优化方案 + +## 4.1 前端页面布局优化 + +### 4.1.1 页面结构重构 + +- 将当前单页改为“三级结构”: + - 一级:模板管理首页 + - 二级:模板详情页 + - 二级:模板下载日志页 +- 模板管理首页仅保留: + - 查询筛选区 + - 模板列表区 + - 快捷操作区 +- 版本历史、版本差异、流程绑定改为详情抽屉或详情页的子区块,不与主列表混排。 + +### 4.1.2 首页布局建议 + +- 顶部操作栏: + - 新建模板 + - 刷新 + - 导出模板清单 +- 查询筛选区: + - 模板名称 + - 模板类型 + - 模板状态 + - 适用范围 + - 业务场景 + - 是否启用水印 + - 生效状态 + - 最近更新时间范围 +- 列表区: + - 默认按“最近更新时间倒序” + - 支持分页 + - 支持空态提示 + +### 4.1.3 详情页布局建议 + +- 基本信息卡片: + - 模板名称、类型、状态、业务场景、适用范围、生效时间、水印、下载限制 +- 当前版本卡片: + - 当前版本号、对象文件、变更说明、创建时间、创建人 +- 版本历史区: + - 版本号、状态、是否生效、变更说明、回滚原因、创建时间 +- 流程联动区: + - 当前已绑定场景 + - 是否允许绑定 + - 绑定影响说明 +- 下载记录区: + - 最近下载记录摘要 + +## 4.2 显示内容优化 + +### 4.2.1 模板列表字段建议 + +- 模板名称 +- 模板类型 +- 业务场景 +- 适用范围 +- 关联项目/会议名称 +- 当前版本号 +- 当前状态 +- 生效开始时间 +- 生效结束时间 +- 水印开关 +- 下载限流 +- 最近更新时间 +- 更新人 + +### 4.2.2 版本列表字段建议 + +- 版本号 +- 版本状态 +- 是否当前生效 +- 文件名 +- 对象存储路径 +- 变更说明 +- 回滚原因 +- 创建人 +- 创建时间 + +### 4.2.3 下载日志字段建议 + +- 模板名称 +- 模板版本号 +- 下载人姓名 +- 下载人账号/手机号 +- 下载时间 +- IP +- User-Agent +- 下载方式 + - 普通下载 + - 水印下载 +- 水印文案 +- 关联项目/会议 + +## 4.3 用户操作优化 + +### 4.3.1 新建模板 + +- 取消手填 `objectKey` 为主的方式,改为: + - 选择模板类型 + - 选择业务场景 + - 选择适用范围 + - 若为项目级,选择项目 + - 若为会议级,选择会议 + - 上传文件 + - 填写变更说明 + - 配置生效时间、水印、下载限流 +- 上传成功后由系统自动回填对象存储路径。 + +### 4.3.2 模板列表操作 + +- 为每条模板提供分层操作: + - 查看详情 + - 新增版本 + - 发布 + - 停用 + - 归档 + - 下载 + - 水印下载 + - 查看版本差异 + - 查看下载记录 + - 复制模板 +- 对危险操作增加二次确认: + - 发布 + - 回滚 + - 停用 + - 归档 + +### 4.3.3 版本管理 + +- 新增版本时默认继承模板基础配置,不重复填写范围与场景。 +- 增加“与当前发布版对比”快捷入口。 +- 回滚时必须填写回滚原因。 +- 已归档模板不可新增版本。 + +### 4.3.4 流程联动 + +- 流程绑定独立为一个区块或独立弹窗。 +- 绑定时只允许选择: + - 已发布 + - 未停用 + - 场景一致 + - 当前生效 + 的模板。 +- 显示绑定影响提示,例如: + - 绑定后将用于会议推荐 + - 绑定后将用于审核通知 + - 绑定后将用于结算材料 + +### 4.3.5 下载日志 + +- 支持按以下条件筛选: + - 模板名称 + - 模板 ID + - 下载人 + - 版本号 + - 下载时间范围 + - IP + - 下载方式 +- 支持跳转查看“该模板全部下载记录”。 + +## 4.4 后端逻辑优化 + +### 4.4.1 查询接口 + +- 模板列表接口增加标准分页与筛选参数: + - `templateName` + - `templateType` + - `status` + - `scopeType` + - `bizScene` + - `watermarkEnabled` + - `effectiveStatus` + - `pageNo` + - `pageSize` +- 下载日志接口增加标准分页与筛选参数,并返回关联名称字段,不再依赖前端二次查用户和模板列表补名。 + +### 4.4.2 模板生命周期校验 + +- 发布校验: + - 当前版本文件存在 + - 模板类型已启用 + - 若配置有效期,开始时间不能晚于结束时间 +- 停用校验: + - 若被流程联动使用,需提示影响 +- 归档校验: + - 已归档模板不可再发布、不可新增版本 +- 回滚校验: + - 只能回滚到本模板已有版本 + - 回滚原因必填 + +### 4.4.3 流程联动闭环 + +- 流程绑定时增加强校验: + - `sceneCode` 必须与模板 `bizScene` 一致 + - 模板必须为 `PUBLISHED` + - 模板必须在生效期内 +- 业务消费链路按联动关系读模板,不允许页面配置和实际业务读取脱节。 +- 会议推荐、审核通知、结算模板获取逻辑统一经过模板服务,不各自散落实现。 + +### 4.4.4 下载能力治理 + +- 普通下载与水印下载分开记录下载类型。 +- 若模板启用下载限流,则下载前校验单位时间内下载次数。 +- 若模板配置有效期,则过期后禁止下载。 +- 若模板启用水印下载,则需要真正生成带水印文件或带水印预签名资源,而不是只回传文案。 + +### 4.4.5 并发与事务安全 + +- 创建模板后获取主键不要再用 `MAX(id)`,改为标准主键回填。 +- 新增版本号不要再用 `MAX(version_no)+1` 裸算,改为: + - 悲观锁 + - 或唯一约束重试 + - 或独立版本号分配策略 +- 发布、回滚、建版操作保持事务一致性,避免主表状态和版本表状态不一致。 + +### 4.4.6 可测试性 + +- 增加自动化测试覆盖: + - 创建模板 + - 并发新增版本 + - 发布/停用/归档/回滚 + - 流程绑定场景校验 + - 有效期校验 + - 下载限流 + - 日志分页与筛选 + +## 4.5 数据库设计优化 + +### 4.5.1 `template` + +- 建议补充或强化约束: + - `tenant_id + template_name + scope_type + scope_id + biz_scene` 可考虑唯一性策略 + - 状态字段增加明确枚举约束 +- 建议补充索引: + - `(tenant_id, status, updated_at)` + - `(tenant_id, template_type, status)` + - `(tenant_id, biz_scene, status)` + - `(tenant_id, scope_type, scope_id)` + +### 4.5.2 `template_version` + +- 建议保留唯一约束: + - `(tenant_id, template_id, version_no)` +- 建议新增索引: + - `(tenant_id, template_id, is_effective)` + - `(tenant_id, template_id, created_at)` +- 建议增加字段: + - `file_name` + - `file_size` + - `content_type` + - `checksum` + +### 4.5.3 `template_download_log` + +- 建议新增字段: + - `download_type` + - `watermark_text` + - `project_id` + - `meeting_id` +- 建议新增索引: + - `(tenant_id, template_id, downloaded_at)` + - `(tenant_id, user_id, downloaded_at)` + - `(tenant_id, downloaded_at)` + +### 4.5.4 `template_type_option` + +- 现有主键建议调整为适合多租户的联合唯一约束: + - 主键独立 `id` + - 唯一键 `(tenant_id, type_code)` +- 避免 `type_code` 全局唯一导致多租户扩展受限。 + +### 4.5.5 `template_flow_link` + +- 建议保留: + - `(tenant_id, scene_code)` 唯一 +- 建议增加: + - 绑定来源 + - 绑定说明 + - 最近绑定人 + - 最近绑定时间 + +## 4.6 权限与风控优化 + +- 继续保留租户域权限拆分: + - 查询 + - 创建 + - 发布 + - 停用 + - 归档 + - 回滚 + - 下载 + - 流程绑定 +- 补充细粒度操作审计: + - 模板创建 + - 版本新增 + - 发布 + - 回滚 + - 流程绑定 + - 普通下载 + - 水印下载 +- 对高风险操作输出审计原因字段: + - 回滚原因 + - 归档原因 + - 水印下载说明 + +## 4.7 交互与体验优化 + +- 统一中文文案,避免技术字段直接暴露给业务用户。 +- 统一状态文案与颜色: + - 草稿 + - 已发布 + - 已停用 + - 已归档 +- 对空数据场景增加引导: + - 暂无模板,请先创建 + - 暂无下载记录 +- 对失败场景返回可理解提示: + - 模板已归档,不能新增版本 + - 模板未在生效期内,不能绑定流程 + - 当前小时下载次数已达上限 + +## 5. 分期实施建议 + +## 5.1 P0:必须先做 + +- 模板列表筛选与标准分页 +- 下载日志标准分页与筛选 +- 创建模板交互优化,取消手填 `objectKey` +- 流程绑定强校验 +- 模板创建/新增版本并发安全修复 +- 发布/停用/归档/回滚状态机校验补齐 + +## 5.2 P1:强烈建议做 + +- 模板详情页与版本历史重构 +- 有效期控制落地 +- 水印下载真实落地 +- 下载限流落地 +- 下载日志返回关联名称,去掉前端补查 +- 复制模板能力 + +## 5.3 P2:体验增强 + +- 模板预览 +- 模板变更摘要对比优化 +- 关联业务影响提示 +- 模板清单导出 +- 模板治理仪表盘 + +## 6. 验收标准建议 + +- 用户可以在 3 步内完成模板创建,不需要手填对象存储路径。 +- 用户可以在列表中快速筛出“某场景、某状态、某范围”的模板。 +- 流程绑定后,业务读取到的模板与绑定配置一致。 +- 并发建版不会出现版本号冲突或串模板。 +- 下载日志可分页、可筛选、可定位到具体用户与版本。 +- 有效期、水印、下载限流配置能被真实执行,而不是只存库不生效。 + +## 7. 推荐实施顺序 + +1. 先改后端查询、状态机、并发安全、日志接口。 +2. 再改前端页面结构与创建流程。 +3. 再补流程联动闭环、水印、限流、有效期。 +4. 最后补预览、复制、导出、治理报表等增强能力。 + diff --git a/fix_btns.js b/fix_btns.js new file mode 100644 index 0000000..ac423b1 --- /dev/null +++ b/fix_btns.js @@ -0,0 +1,6 @@ +const fs = require('fs'); +const filepath = 'd:\\haomi\\cursor_projects\\writeOff\\frontend\\src\\views\\modules\\meeting-page\\MeetingMaterialDrawer.vue'; +let text = fs.readFileSync(filepath, 'utf-8'); +text = text.replace(//g, ''); +fs.writeFileSync(filepath, text); +console.log('Fixed buttons'); diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..4954cdb --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,158 @@ + + + + + + + + 会议核销系统 + + + +
+
+
+
+ + +
+
+ 会议核销系统 + 正在初始化工作台... +
+ +
+
+
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..bbdc2d1 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1873 @@ +{ + "name": "writeoff-frontend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "writeoff-frontend", + "version": "0.0.1", + "dependencies": { + "axios": "^1.7.7", + "compressorjs": "^1.3.0", + "element-plus": "^2.8.4", + "pinia": "^3.0.4", + "vue": "^3.5.10", + "vue-router": "^4.4.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.4", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.29.tgz", + "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.29", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", + "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", + "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.29", + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", + "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.29.tgz", + "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.29.tgz", + "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", + "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.29", + "@vue/runtime-core": "3.5.29", + "@vue/shared": "3.5.29", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.29.tgz", + "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29" + }, + "peerDependencies": { + "vue": "3.5.29" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.29.tgz", + "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/blueimp-canvas-to-blob": { + "version": "3.29.0", + "resolved": "https://registry.npmmirror.com/blueimp-canvas-to-blob/-/blueimp-canvas-to-blob-3.29.0.tgz", + "integrity": "sha512-0pcSSGxC0QxT+yVkivxIqW0Y4VlO2XSDPofBAqoJ1qJxgH9eiUDLv50Rixij2cDuEfx4M6DpD9UGZpRhT5Q8qg==", + "license": "MIT" + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compressorjs": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/compressorjs/-/compressorjs-1.3.0.tgz", + "integrity": "sha512-TsvzkRgDm/6mIRUdxJbrTH7kfSW3oJzOw8b1xU60fziQSosTML5TczpO6Z4H1LGF0yRmTotk6r5UNhuRxEwA1A==", + "license": "MIT", + "dependencies": { + "blueimp-canvas-to-blob": "^3.29.0", + "is-blob": "^2.1.0" + } + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/element-plus": { + "version": "2.13.5", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.5.tgz", + "integrity": "sha512-dmY24fhSREfZN/PuUt0YZigMso7wWzl+B5o+YKNN15kQIn/0hzamsPU+ebj9SES0IbUqsLX1wkrzYmzU8VrVOQ==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-blob": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-blob/-/is-blob-2.1.0.tgz", + "integrity": "sha512-SZ/fTft5eUhQM6oF/ZaASFDEdbFVe89Imltn9uZr03wdKMcWNVYSMjQPFtg05QuNkt5l5c135ElvXEQG0rk4tw==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/pinia/node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.29", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.29.tgz", + "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-sfc": "3.5.29", + "@vue/runtime-dom": "3.5.29", + "@vue/server-renderer": "3.5.29", + "@vue/shared": "3.5.29" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5c19abb --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,24 @@ +{ + "name": "writeoff-frontend", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.7.7", + "compressorjs": "^1.3.0", + "element-plus": "^2.8.4", + "pinia": "^3.0.4", + "vue": "^3.5.10", + "vue-router": "^4.4.5" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.4", + "typescript": "^5.6.2", + "vite": "^5.4.8" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..f020f50 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend/src/api/http.ts b/frontend/src/api/http.ts new file mode 100644 index 0000000..421751f --- /dev/null +++ b/frontend/src/api/http.ts @@ -0,0 +1,148 @@ +import axios from "axios"; +import { ElMessage } from "element-plus"; +import { pinia } from "../stores"; +import { useAuthStore } from "../stores/auth"; +import { resolveLoginPath } from "../utils/authNavigation"; + +const http = axios.create({ + baseURL: "/api", + timeout: 30000, +}); + +const FORCE_LOGOUT_CODES = new Set([11001, 11003, 11004, 11005, 11006, 11007]); +let refreshPromise: Promise | null = null; +let pendingForceLogout = false; +const getAuthStore = () => useAuthStore(pinia); + +const isAuthSessionEndpoint = (url: string): boolean => { + return /\/auth\/(login|platform-login|refresh|logout|logout-all)/.test(url); +}; + +const resolveLoginPathByScope = (): string => { + const authStore = getAuthStore(); + return resolveLoginPath(authStore.scope, authStore.tenantCode); +}; + +const getForceLogoutMessage = (code: number): string => { + if (code === 11003) { + return "会话失效,请重新登录"; + } + if (code === 11004) { + return "账号有效期异常,请重新登录"; + } + if (code === 11005 || code === 11006 || code === 11007) { + return "登录状态异常,请重新登录"; + } + return "登录状态已失效,请重新登录"; +}; + +const forceLogoutAndRedirect = (message?: string) => { + if (pendingForceLogout) { + return; + } + pendingForceLogout = true; + const nextLoginPath = resolveLoginPathByScope(); + if (message) { + ElMessage.error(message); + } + // Delay cleanup/redirect so business catch can receive response details first. + window.setTimeout(() => { + getAuthStore().clearAuthStorage(); + window.location.href = nextLoginPath; + }, 600); +}; + +const ensureRefreshedToken = async (): Promise => { + if (refreshPromise) { + return refreshPromise; + } + refreshPromise = axios.post("/api/auth/refresh", null, { timeout: 10000 }) + .then((resp) => { + const payload = resp?.data?.data || {}; + const token = String(payload?.token || "").trim(); + if (!token) { + throw new Error("refresh token missing"); + } + getAuthStore().saveAuthPayload(payload); + return token; + }) + .finally(() => { + refreshPromise = null; + }); + return refreshPromise; +}; + +http.interceptors.request.use((config) => { + const token = getAuthStore().token; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +http.interceptors.response.use((resp) => resp.data, (error) => { + const requestUrl = String(error?.config?.url || ""); + const businessCode = Number(error?.response?.data?.code || 0); + const isAuthRequest = isAuthSessionEndpoint(requestUrl); + const originalRequest = error?.config as any; + if (!isAuthRequest && businessCode === 11002 && !originalRequest?._retry) { + originalRequest._retry = true; + return ensureRefreshedToken() + .then((token) => { + originalRequest.headers = originalRequest.headers || {}; + originalRequest.headers.Authorization = `Bearer ${token}`; + return http.request(originalRequest); + }) + .catch((refreshError) => { + forceLogoutAndRedirect("会话已过期,请重新登录"); + return Promise.reject(refreshError); + }); + } + if ( + !isAuthRequest && + FORCE_LOGOUT_CODES.has(businessCode) + ) { + const backendMessage = error?.response?.data?.message || error?.response?.data?.msg || error?.response?.data?.error; + const fallbackMessage = backendMessage || getForceLogoutMessage(businessCode); + if (!error?.response) { + error.response = { + status: 422, + data: { + code: businessCode || 11003, + message: fallbackMessage, + }, + }; + } else if (!error.response.data) { + error.response.data = { + code: businessCode || 11003, + message: fallbackMessage, + }; + } + forceLogoutAndRedirect(fallbackMessage); + return Promise.reject(error); + } + const backendMessage = error?.response?.data?.message || error?.response?.data?.msg || error?.response?.data?.error; + let errorMessage = backendMessage; + if (!errorMessage) { + if (error?.code === "ECONNABORTED") { + errorMessage = "请求超时,请稍后重试"; + } else if (error?.message === "Network Error") { + errorMessage = "网络异常,请检查网络连接"; + } else if (error?.response?.status) { + errorMessage = `请求失败(${error.response.status})`; + } else { + errorMessage = "请求失败,请稍后重试"; + } + } + // 从响应头或响应体中解析 requestId,附加到错误提示 + const requestId = error?.response?.headers?.["x-request-id"] + || error?.response?.data?.requestId + || ""; + if (requestId) { + errorMessage = `${errorMessage}(RequestId: ${requestId})`; + } + ElMessage.error(errorMessage); + return Promise.reject(error); +}); + +export default http; diff --git a/frontend/src/api/modules.ts b/frontend/src/api/modules.ts new file mode 100644 index 0000000..f19caff --- /dev/null +++ b/frontend/src/api/modules.ts @@ -0,0 +1,1079 @@ +import http from "./http"; +import { encryptLoginPassword } from "../utils/authCrypto"; + +export const fetchCaptcha = () => http.get("/captcha"); +export const login = async (payload: { tenantCode: string; phone: string; password: string; captchaId?: string; captchaCode?: string }) => + http.post("/auth/login", { + ...payload, + password: await encryptLoginPassword(payload.password), + }); +export const platformLogin = async (payload: { phone: string; password: string; captchaId?: string; captchaCode?: string }) => + http.post("/auth/platform-login", { + ...payload, + password: await encryptLoginPassword(payload.password), + }); +export const verifyPasswordSetupToken = (params: { tenantCode: string; token: string }) => + http.get("/auth/password-setup/verify", { params }); +export const completePasswordSetup = (payload: { tenantCode: string; token: string; newPassword: string }) => + http.post("/auth/password-setup/complete", payload); +export const refreshAuth = () => http.post("/auth/refresh"); +export const fetchSwitchableTenants = () => http.get("/auth/switchable-tenants"); +export const switchTenant = (payload: { tenantId: number }) => http.post("/auth/switch-tenant", payload); +export const logoutAuth = () => http.post("/auth/logout"); +export const logoutAllAuth = () => http.post("/auth/logout-all"); +export const fetchGlobalSearch = (params: { q: string; limitPerType?: number }) => http.get("/search/global", { params }); + +export const fetchProjects = (params?: { parentOnly?: boolean; includeDeleted?: boolean }) => http.get("/projects", { params }); +export const fetchProjectChildren = (id: number, params?: { includeDeleted?: boolean }) => http.get(`/projects/${id}/children`, { params }); +export const createProject = (payload: { + name: string; + parentProjectId?: number; + startDate?: string; + endDate?: string; + hostEnterpriseName?: string; + partnerEnterpriseId?: number; + budgetCent: number; + meetingTotal: number; + allowMeetingOverBudget?: boolean; + overBudgetThresholdRatio?: number; + overBudgetApprovalChainJson?: string; + paymentStatus?: string; + writeOffStatus?: string; + laborFeeRatio?: number; + allowProjectOverBudget?: boolean; + invoiceInfo?: string; + expenseRatioJson?: string; + projectFeeJson?: string; +}) => http.post("/projects", payload); +export const updateProject = ( + id: number, + payload: { + name: string; + parentProjectId?: number; + startDate?: string; + endDate?: string; + hostEnterpriseName?: string; + partnerEnterpriseId?: number; + budgetCent: number; + meetingTotal: number; + allowMeetingOverBudget?: boolean; + overBudgetThresholdRatio?: number; + overBudgetApprovalChainJson?: string; + paymentStatus?: string; + writeOffStatus?: string; + laborFeeRatio?: number; + allowProjectOverBudget?: boolean; + invoiceInfo?: string; + expenseRatioJson?: string; + projectFeeJson?: string; + }, +) => http.put(`/projects/${id}`, payload); +export const freezeProject = (id: number, reason: string) => + http.post(`/projects/${id}/freeze`, null, { params: { reason } }); +export const unfreezeProject = (id: number, payload: { reason: string }) => + http.post(`/projects/${id}/unfreeze`, payload); +export const archiveProject = (id: number) => + http.post(`/projects/${id}/archive`); +export const fetchProjectBindingCandidates = () => http.get("/projects/binding-candidates"); +export const fetchProjectBindings = (id: number) => http.get(`/projects/${id}/bindings`); +export const fetchProjectKeyChangeLogs = (id: number) => http.get(`/projects/${id}/key-change-logs`); +export const fetchProjectChangeLogs = (id: number) => http.get(`/projects/${id}/key-change-logs`); +export const saveProjectBindings = ( + id: number, + payload: { ownerUserIds: number[]; executorUserIds: number[]; legacyExecutorUserIds?: number[] }, +) => http.post(`/projects/${id}/bindings`, payload); + +export const fetchMeetings = (params?: { + projectId?: number; + projectName?: string; + topic?: string; + meetingStatus?: string; + auditStatus?: string; + currentAuditNode?: string; + currentAuditorUserId?: number; + meetingStartFrom?: string; + meetingStartTo?: string; + lastSubmitFrom?: string; + lastSubmitTo?: string; + includeDeleted?: boolean; +}) => http.get("/meetings", { params }); +export const fetchMeetingPlatformExperts = (params?: { keyword?: string }) => http.get("/meetings/tenant-experts", { params }); +export const createMeetingPlatformExpert = (payload: { + expertName: string; + idNo: string; + phone: string; + titleCode?: string; + title?: string; + hospitalCode?: string; + organization?: string; + idCardFrontOssKey?: string; + idCardBackOssKey?: string; +}) => http.post("/meetings/tenant-experts", payload); +export const addMeetingTenantExpertBankCard = ( + expertId: number, + payload: { + bankName: string; + bankProvince?: string; + bankCity?: string; + bankBranchName?: string; + bankCardNo: string; + bankCardFrontOssKey?: string; + bankCardBackOssKey?: string; + accountName: string; + isDefault?: boolean; + }, +) => http.post(`/meetings/tenant-experts/${expertId}/bank-cards`, payload); +export const submitMeetingLaborAgreementExtractTask = ( + meetingId: number, + payload: { objectKey: string; fileName: string }, +) => http.post(`/meetings/${meetingId}/labor-agreement-extract/task`, payload); +export const queryMeetingLaborAgreementExtract = ( + meetingId: number, + payload: { taskId: string }, +) => http.post(`/meetings/${meetingId}/labor-agreement-extract/query`, payload); +export const applyMeetingLaborAgreementExtract = ( + meetingId: number, + payload: { + taskId: string; + existingExpertId?: number; + updateExistingExpert?: boolean; + objectKey?: string; + fileName?: string; + }, +) => http.post(`/meetings/${meetingId}/labor-agreement-extract/apply`, payload); +export const fetchMeetingExpertBindings = (meetingId: number) => http.get(`/meetings/${meetingId}/experts`); +export const bindMeetingExperts = (meetingId: number, payload: { expertIds: number[] }) => + http.post(`/meetings/${meetingId}/experts/bind`, payload); +export const unbindMeetingExpert = (meetingId: number, expertId: number) => + http.delete(`/meetings/${meetingId}/experts/${expertId}`); +export const createMeeting = (payload: { + projectId: number; + topic: string; + budgetCent: number; + meetingCategory?: string; + meetingForm?: string; + location?: string; + startTime?: string; + endTime?: string; + laborRatio?: number; + cateringRatio?: number; +}) => http.post("/meetings", payload); +export const fetchMeetingDetail = (id: number) => http.get(`/meetings/${id}`); +export const fetchMeetingChangeLogs = (id: number) => http.get(`/meetings/${id}/change-logs`); +export const updateMeeting = ( + id: number, + payload: { + projectId: number; + topic: string; + budgetCent: number; + meetingCategory?: string; + meetingForm?: string; + location?: string; + startTime?: string; + endTime?: string; + laborRatio?: number; + cateringRatio?: number; + }, +) => http.put(`/meetings/${id}`, payload); +export const submitMeeting = (id: number, payload: { idempotencyKey: string; remark: string }) => + http.post(`/meetings/${id}/submit`, payload); +export const withdrawMeeting = (id: number, payload: { idempotencyKey: string; reason: string }) => + http.post(`/meetings/${id}/withdraw`, payload); +export const deleteMeeting = (id: number) => + http.post(`/meetings/${id}/delete`); +export const cancelMeeting = (id: number, payload: { reason: string }) => + http.post(`/meetings/${id}/cancel`, payload); +export const updateMeetingInvoiceConfig = (id: number, payload: { invoiceModules: string[] }) => + http.put(`/meetings/${id}/invoice-config`, payload); +export const fetchMeetingMaterials = (meetingId: number) => + http.get(`/meetings/${meetingId}/materials`); +export const fetchMeetingMaterialCurrent = ( + meetingId: number, + moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE", +) => http.get(`/meetings/${meetingId}/materials/${moduleCode}/current`); +export const saveMeetingMaterial = ( + meetingId: number, + moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE", + payload: { contentJson: string; remark?: string }, +) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/save`, payload); +export const submitMeetingMaterial = ( + meetingId: number, + moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE", + payload: { contentJson: string; remark?: string }, +) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/submit`, payload); +export const fetchMeetingMaterialUploadSign = ( + meetingId: number, + moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE", + payload: { fileName: string; contentType?: string }, +) => http.post(`/meetings/${meetingId}/materials/${moduleCode}/upload-sign`, payload); +export const fetchMeetingMaterialHistory = ( + meetingId: number, + moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE", +) => http.get(`/meetings/${meetingId}/materials/${moduleCode}/history`); +export const fetchFilePresignDownload = (params: { objectKey: string }) => + http.get("/files/presign-download", { params }); + +export const recognizeMultipleInvoice = (payload: { objectKey: string }) => + http.post("/ocr/multiple-invoice", payload); +export const recognizeIdCard = (payload: { objectKey: string; idCardSide: "front" | "back" }) => + http.post("/ocr/id-card", payload); +export const recognizePlatformIdCard = (payload: { objectKey: string; idCardSide: "front" | "back" }) => + http.post("/platform/ocr/id-card", payload); +export const recognizeBankCard = (payload: { objectKey: string }) => + http.post("/ocr/bank-card", payload); +export const recognizePlatformBankCard = (payload: { objectKey: string }) => + http.post("/platform/ocr/bank-card", payload); +export const submitDocumentExtractTask = (payload: { + objectKey?: string; + fileName?: string; + fileUrls?: string[]; + manifestVersionId?: string; + manifest?: Array<{ + key: string; + parentKey?: string; + description?: string; + }>; + removeDuplicates?: boolean; + pageRange?: string; + extractSeal?: boolean; + eraseWatermark?: boolean; +}) => http.post("/ocr/document-extract/task", payload); +export const queryDocumentExtractTask = (payload: { taskId: string }) => + http.post("/ocr/document-extract/query-task", payload); +export const fetchMeetingMatchedTemplates = (meetingId: number) => + http.get(`/meetings/${meetingId}/matched-templates`); +export const createMeetingMaterialsExportTask = ( + meetingId: number, + payload: { idempotencyKey: string; fileName?: string }, +) => http.post(`/meetings/${meetingId}/materials/export`, payload); +export const generateMeetingSummaryTask = ( + meetingId: number, + payload: { idempotencyKey: string; fileName?: string }, +) => http.post(`/meetings/${meetingId}/summary/generate`, payload); +export const fetchMeetingSummaryTaskStatus = ( + meetingId: number, + params?: { taskId?: number }, +) => http.get(`/meetings/${meetingId}/summary/task-status`, { params }); +export const refreshMeetingSummaryToken = ( + meetingId: number, + params: { taskId: number }, +) => http.post(`/meetings/${meetingId}/summary/refresh-token`, null, { params }); +export const downloadMeetingSummary = ( + meetingId: number, + params: { taskId: number; token: string }, +) => http.get(`/meetings/${meetingId}/summary/download`, { params }); + +export const fetchAuditTasks = (params?: boolean | { + mine?: boolean; + scope?: string; + meetingId?: number; + pageNo?: number; + pageSize?: number; + sortBy?: string; + order?: "asc" | "desc"; +}) => { + if (typeof params === "boolean") { + return http.get("/audits/tasks", { params: { mine: params } }); + } + return http.get("/audits/tasks", { + params: { + mine: !!params?.mine, + scope: params?.scope, + meetingId: params?.meetingId, + pageNo: params?.pageNo, + pageSize: params?.pageSize, + sortBy: params?.sortBy, + order: params?.order, + }, + }); +}; +export const exportAuditOpinions = () => http.get("/audits/export-opinions"); +export const readAuditTaskMaterial = ( + taskId: number, + moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_LIST" | "MEETING_INVOICE" | "EXPERT_PROFILE", +) => + http.get(`/audits/tasks/${taskId}/material`, { params: { moduleCode } }); +export const approveAuditMaterialModule = ( + taskId: number, + payload: { + idempotencyKey: string; + moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE"; + }, +) => http.post(`/audits/tasks/${taskId}/material/approve-module`, payload); +export const rejectAuditMaterialItem = ( + taskId: number, + payload: { + idempotencyKey: string; + moduleCode: "BASIC_INFO" | "WRITE_OFF_DOCS" | "EXPERT_PROFILE" | "EXPERT_LIST" | "MEETING_INVOICE"; + itemKey: string; + itemLabel: string; + reason: string; + }, +) => http.post(`/audits/tasks/${taskId}/material/reject-item`, payload); +export const approveAuditTask = (id: number, payload: { idempotencyKey: string; opinion: string }) => + http.post(`/audits/tasks/${id}/approve`, payload); +export const rejectAuditTask = (id: number, payload: { idempotencyKey: string; opinion: string }) => + http.post(`/audits/tasks/${id}/reject`, payload); +export const returnAuditTask = (id: number, payload: { idempotencyKey: string; opinion: string }) => + http.post(`/audits/tasks/${id}/return`, payload); +export const transferAuditTask = ( + id: number, + payload: { idempotencyKey: string; toUserId: number; reason: string }, +) => http.post(`/audits/tasks/${id}/transfer`, payload); +export const batchRemindAuditTasks = (payload: { idempotencyKey: string; taskIds?: number[] }) => + http.post("/audits/tasks/batch-remind", payload); +export const batchApproveAuditTasks = (payload: { idempotencyKey: string; taskIds: number[]; opinion: string }) => + http.post("/audits/tasks/batch-approve", payload); +export const batchRejectAuditTasks = (payload: { idempotencyKey: string; taskIds: number[]; opinion: string }) => + http.post("/audits/tasks/batch-reject", payload); +export const fetchAuditSlaStat = () => http.get("/audits/tasks/sla-stat"); + +export const fetchAuditFlows = (params?: { pageNo?: number; pageSize?: number }) => + http.get("/audit-flows", { params }); +export const createAuditFlow = (payload: { + flowCode: string; + flowName: string; + effectiveStartAt?: string; + effectiveEndAt?: string; + nodes: Array<{ nodeCode: string; nodeName: string; sortNo: number; assigneeType?: string; assigneeRefId?: number }>; +}) => http.post("/audit-flows", payload); +export const updateAuditFlow = ( + id: number, + payload: { + flowCode: string; + flowName: string; + effectiveStartAt?: string; + effectiveEndAt?: string; + nodes: Array<{ nodeCode: string; nodeName: string; sortNo: number; assigneeType?: string; assigneeRefId?: number }>; + }, +) => http.put(`/audit-flows/${id}`, payload); +export const copyAuditFlow = (id: number) => http.post(`/audit-flows/${id}/copy`); +export const setDefaultAuditFlow = (id: number) => http.post(`/audit-flows/${id}/default`); +export const enableAuditFlow = (id: number) => http.post(`/audit-flows/${id}/enable`); +export const disableAuditFlow = (id: number) => http.post(`/audit-flows/${id}/disable`); + +export const fetchFinanceProjects = () => http.get("/finance/projects"); +export const exportFinanceLedger = () => http.get("/finance/ledger/export"); +export const reconcileFinance = (payload: { idempotencyKey: string; projectId: number; expectedAmountCent: number }) => + http.post("/finance/reconciliation", payload); +export const lockFinance = (payload: { idempotencyKey: string; projectId: number; reason: string }) => + http.post("/finance/lock", payload); +export const unlockFinance = (payload: { idempotencyKey: string; projectId: number; reason: string }) => + http.post("/finance/unlock", payload); +export const fetchFinanceReconciliationList = (params?: { projectId?: number }) => + http.get("/finance/reconciliation/list", { params }); +export const confirmPayment = (payload: { + idempotencyKey: string; + projectId: number; + meetingId: number; + amountCent: number; + paymentVoucherOssKey: string; +}) => http.post("/finance/payments", payload); + +export const fetchUsers = (params?: { pageNo?: number; pageSize?: number; includeDeleted?: boolean; keyword?: string }) => http.get("/users", { params }); +export const fetchTenants = () => http.get("/tenants"); +export const fetchPlatformTenants = () => http.get("/platform/tenants"); +export const createTenant = (payload: { tenantCode: string; tenantName: string; logoUrl?: string }) => + http.post("/tenants", payload); +export const updateTenant = ( + id: number, + payload: { tenantName: string; logoUrl?: string }, +) => http.put(`/tenants/${id}`, payload); +export const createPlatformTenant = (payload: { tenantCode: string; tenantName: string; logoUrl?: string }) => + http.post("/platform/tenants", payload); +export const updatePlatformTenant = ( + id: number, + payload: { tenantName: string; logoUrl?: string }, +) => http.put(`/platform/tenants/${id}`, payload); +export const fetchTenantLogoUploadSign = (payload: { fileName: string; contentType?: string }) => + http.post("/tenants/logo-upload-sign", payload); +export const fetchPlatformTenantLogoUploadSign = (payload: { fileName: string; contentType?: string }) => + http.post("/platform/tenants/logo-upload-sign", payload); +export const enableTenant = (id: number) => http.post(`/tenants/${id}/enable`); +export const disableTenant = (id: number) => http.post(`/tenants/${id}/disable`); +export const enablePlatformTenant = (id: number) => http.post(`/platform/tenants/${id}/enable`); +export const disablePlatformTenant = (id: number) => http.post(`/platform/tenants/${id}/disable`); +export const deletePlatformTenant = (id: number) => http.post(`/platform/tenants/${id}/delete`); +export const fetchPlatformTenantAdmin = (id: number) => http.get(`/platform/tenants/${id}/admin`); +export const initPlatformTenantBaseline = (id: number) => http.post(`/platform/tenants/${id}/init-baseline`); +export const setPlatformTenantAdmin = ( + id: number, + payload: { userName: string; phone: string; email?: string; roleCode?: string }, +) => http.post(`/platform/tenants/${id}/admin`, payload); +export const fetchEnterprises = (params?: { pageNo?: number; pageSize?: number }) => + http.get("/enterprises", { params }); +export const createEnterprise = (payload: { enterpriseName: string; enterpriseUrl?: string; logoUrl?: string }) => + http.post("/enterprises", payload); +export const fetchEnterpriseLogoUploadSign = (payload: { fileName: string; contentType?: string }) => + http.post("/enterprises/logo-upload-sign", payload); +export const updateEnterprise = ( + id: number, + payload: { enterpriseName: string; enterpriseUrl?: string; logoUrl?: string }, +) => http.put(`/enterprises/${id}`, payload); +export const enableEnterprise = (id: number) => http.post(`/enterprises/${id}/enable`); +export const disableEnterprise = (id: number) => http.post(`/enterprises/${id}/disable`); +export const deleteEnterprise = (id: number) => http.post(`/enterprises/${id}/delete`); +export const createUser = (payload: { + userName: string; + phone: string; + password: string; + email?: string; + validFrom?: string; + validTo?: string; +}) => http.post("/users", payload); +export const updateUser = ( + id: number, + payload: { + userName: string; + phone: string; + email?: string; + validFrom?: string; + validTo?: string; + password?: string; + }, +) => http.put(`/users/${id}`, payload); +export const assignUserRole = (payload: { userId: number; roleId: number }) => + http.post("/users/assign-role", payload); +export const enableUser = (id: number) => http.post(`/users/${id}/enable`); +export const disableUser = (id: number) => http.post(`/users/${id}/disable`); +export const deleteUser = (id: number) => http.post(`/users/${id}/delete`); +export const resetUserPassword = (id: number, payload: { newPassword: string }) => + http.post(`/users/${id}/reset-password`, payload); +export const batchImportUsers = (payload: { + users: Array<{ + userName: string; + phone: string; + password: string; + email?: string; + validFrom?: string; + validTo?: string; + roleCode?: string; + }>; +}) => http.post("/users/import", payload); +export const fetchUserRoleHistory = (id: number) => http.get(`/users/${id}/role-history`); +export const fetchUserDelegations = (id: number) => http.get(`/users/${id}/delegations`); +export const createUserDelegation = ( + id: number, + payload: { delegateUserId: number; effectiveFrom: string; effectiveTo: string; reason?: string }, +) => http.post(`/users/${id}/delegations`, payload); +export const disableUserDelegation = (id: number, payload: { reason: string }) => + http.post(`/delegations/${id}/disable`, payload); +export const changeMyPassword = (payload: { oldPassword: string; newPassword: string }) => + http.post("/profile/change-password", payload); +export const fetchProfilePreferences = () => http.get("/profile/preferences"); +export const saveProfilePreferences = (payload: { themeMode: string; density: string; themeScheme: string }) => + http.put("/profile/preferences", payload); + +export const fetchRoles = (params?: { pageNo?: number; pageSize?: number }) => + http.get("/roles", { params }); +export const createRole = (payload: { roleCode: string; roleName: string }) => + http.post("/roles", payload); +export const updateRole = (id: number, payload: { roleName: string }) => + http.put(`/roles/${id}`, payload); +export const enableRole = (id: number) => http.post(`/roles/${id}/enable`); +export const disableRole = (id: number) => http.post(`/roles/${id}/disable`); +export const deleteRole = (id: number) => http.post(`/roles/${id}/delete`); +export const fetchPermissions = () => http.get("/permissions"); +export const fetchRolePermissions = (id: number) => http.get(`/roles/${id}/permissions`); +export const bindRolePermissions = (id: number, payload: { permissionIds: number[] }) => + http.post(`/roles/${id}/permissions`, payload); +export const fetchRoleMenus = (id: number) => http.get(`/roles/${id}/menus`); +export const bindRoleMenus = (id: number, payload: { menuIds: number[] }) => + http.post(`/roles/${id}/menus`, payload); +export const fetchMenus = () => http.get("/menus"); +export const fetchCurrentMenus = () => http.get("/menus/current"); +export const fetchPlatformCurrentMenus = () => http.get("/platform/menus/current"); +export const fetchMenuRoles = (id: number) => http.get(`/menus/${id}/roles`); +export const reorderMenus = (payload: { menus: Array<{ id: number; sortNo: number }> }) => + http.post("/menus/reorder", payload); +export const createMenu = (payload: { + menuCode: string; + menuName: string; + routePath: string; + permissionCode: string; + sortNo: number; +}) => + http.post("/menus", payload); +export const updateMenu = ( + id: number, + payload: { + menuName: string; + routePath: string; + permissionCode: string; + sortNo: number; + status: "ENABLED" | "DISABLED"; + }, +) => http.put(`/menus/${id}`, payload); + +export const fetchDataPermissions = () => http.get("/data-permissions"); +export const createDataPermission = (payload: { + policyName: string; + projectScope: string; + projectIdsCsv?: string; + meetingScope: string; + meetingIdsCsv?: string; + userScope: string; + userIdsCsv?: string; + expertScope: string; + expertIdsCsv?: string; + moduleScope?: string; + exportAllowed?: boolean; +}) => http.post("/data-permissions", payload); +export const updateDataPermission = ( + id: number, + payload: { + policyName: string; + projectScope: string; + projectIdsCsv?: string; + meetingScope: string; + meetingIdsCsv?: string; + userScope: string; + userIdsCsv?: string; + expertScope: string; + expertIdsCsv?: string; + moduleScope?: string; + exportAllowed?: boolean; + }, +) => http.put(`/data-permissions/${id}`, payload); +export const assignDataPermissionRoles = (id: number, payload: { roleIds: number[]; assignMode?: "APPEND" | "REPLACE" }) => + http.post(`/data-permissions/${id}/assign-roles`, payload); +export const copyDataPermission = (id: number) => http.post(`/data-permissions/${id}/copy`); +export const enableDataPermission = (id: number) => http.post(`/data-permissions/${id}/enable`); +export const disableDataPermission = (id: number) => http.post(`/data-permissions/${id}/disable`); +export const fetchDataPermissionRoles = (id: number) => http.get(`/data-permissions/${id}/roles`); +export const fetchCurrentDataScope = () => http.get("/data-permissions/current-scope"); +export const fetchAuditLogs = (params?: { userId?: number; actionCode?: string; pageNo?: number; pageSize?: number }) => + http.get("/audit-logs", { params }); +export const fetchPlatformAuditLogs = (params?: { + userId?: number; + actionCode?: string; + tenantId?: number; + scope?: "TENANT" | "PLATFORM"; + pageNo?: number; + pageSize?: number; +}) => + http.get("/platform/audit-logs", { params }); +export const fetchPlatformRoles = () => http.get("/platform/roles"); +export const createPlatformRole = (payload: { roleCode: string; roleName: string }) => http.post("/platform/roles", payload); +export const updatePlatformRole = (id: number, payload: { roleName: string }) => http.put(`/platform/roles/${id}`, payload); +export const enablePlatformRole = (id: number) => http.post(`/platform/roles/${id}/enable`); +export const disablePlatformRole = (id: number) => http.post(`/platform/roles/${id}/disable`); +export const fetchPlatformRolePermissions = (id: number) => http.get(`/platform/roles/${id}/permissions`); +export const bindPlatformRolePermissions = (id: number, payload: { permissionIds: number[] }) => + http.post(`/platform/roles/${id}/permissions`, payload); +export const fetchPlatformRoleMenus = (id: number) => http.get(`/platform/roles/${id}/menus`); +export const bindPlatformRoleMenus = (id: number, payload: { menuIds: number[] }) => + http.post(`/platform/roles/${id}/menus`, payload); +export const fetchPlatformPermissions = () => http.get("/platform/permissions"); +export const fetchPlatformUsers = (params?: { keyword?: string }) => http.get("/platform/users", { params }); +export const createPlatformUser = (payload: { + userName: string; + phone: string; + password: string; + email?: string; + validFrom?: string; + validTo?: string; +}) => http.post("/platform/users", payload); +export const batchImportPlatformUsers = (payload: { + users: Array<{ + userName: string; + phone: string; + password: string; + email?: string; + validFrom?: string; + validTo?: string; + roleCode?: string; + }>; +}) => http.post("/platform/users/import", payload); +export const assignPlatformUserRole = (payload: { userId: number; roleId: number }) => + http.post("/platform/users/assign-role", payload); +export const enablePlatformUser = (id: number) => http.post(`/platform/users/${id}/enable`); +export const disablePlatformUser = (id: number) => http.post(`/platform/users/${id}/disable`); +export const resetPlatformUserPassword = (id: number, payload: { newPassword: string }) => + http.post(`/platform/users/${id}/reset-password`, payload); +export const fetchPlatformMenus = () => http.get("/platform/menus"); +export const createPlatformMenu = (payload: { + menuCode: string; + menuName: string; + routePath: string; + permissionCode: string; + sortNo: number; +}) => http.post("/platform/menus", payload); +export const updatePlatformMenu = ( + id: number, + payload: { + menuName: string; + routePath: string; + permissionCode: string; + sortNo: number; + status: "ENABLED" | "DISABLED"; + }, +) => http.put(`/platform/menus/${id}`, payload); +export const reorderPlatformMenus = (payload: { menus: Array<{ id: number; sortNo: number }> }) => + http.post("/platform/menus/reorder", payload); +export const fetchPlatformMenuRoles = (id: number) => http.get(`/platform/menus/${id}/roles`); +export const bindPlatformMenuRoles = (id: number, payload: { roleIds: number[] }) => + http.post(`/platform/menus/${id}/roles`, payload); +export const exportAuditLogs = (params?: { userId?: number; actionCode?: string }) => + http.get("/audit-logs/export", { params }); +export const fetchAuditLogExportTasks = () => http.get("/audit-logs/export-tasks"); +export const createAuditLogExportTask = (payload: { + idempotencyKey: string; + userId?: number; + actionCode?: string; + fileName?: string; +}) => http.post("/audit-logs/export-tasks", payload); +export const fetchInvoiceProfiles = () => http.get("/invoice-profiles"); +export const createInvoiceProfile = (payload: { + companyName: string; + taxNo: string; + bankName: string; + accountNo: string; + address?: string; + phone?: string; + defaultProjectId?: number; + status?: "ENABLED" | "DISABLED"; +}) => http.post("/invoice-profiles", payload); +export const updateInvoiceProfile = ( + id: number, + payload: { + companyName: string; + taxNo: string; + bankName: string; + accountNo: string; + address?: string; + phone?: string; + defaultProjectId?: number; + status?: "ENABLED" | "DISABLED"; + }, +) => http.put(`/invoice-profiles/${id}`, payload); +export const enableInvoiceProfile = (id: number) => http.post(`/invoice-profiles/${id}/enable`); +export const disableInvoiceProfile = (id: number) => http.post(`/invoice-profiles/${id}/disable`); + +export const fetchTemplates = (params?: { + templateName?: string; + templateType?: string; + status?: string; + scopeType?: string; + bizScene?: string; + watermarkEnabled?: boolean; + effectiveStatus?: string; + pageNo?: number; + pageSize?: number; +}) => + http.get("/templates", { params }); +export const fetchPublishedTemplateOptions = (params?: { bizScene?: string }) => + http.get("/templates/published-options", { params }); +export const fetchTemplateTypeOptions = () => http.get("/templates/type-options"); +export const fetchTemplateFlowSceneOptions = () => http.get("/templates/flow-scene-options"); +export const fetchTemplateFlowLinks = () => http.get("/templates/flow-links"); +export const bindTemplateFlowLink = (sceneCode: string, payload: { templateId: number }) => + http.post(`/templates/flow-links/${sceneCode}/bind`, payload); +export const enableTemplateTypeOption = (typeCode: string) => http.post(`/templates/type-options/${typeCode}/enable`); +export const disableTemplateTypeOption = (typeCode: string) => http.post(`/templates/type-options/${typeCode}/disable`); +export const createTemplate = (payload: { + templateName: string; + templateType: string; + scopeType: "ALL" | "PROJECT" | "MEETING"; + projectId?: number; + meetingId?: number; + bizScene?: "MEETING_RECOMMEND" | "AUDIT_NOTIFY" | "SETTLEMENT"; + objectKey: string; + changeLog?: string; + effectiveFrom?: string; + effectiveTo?: string; + watermarkEnabled?: boolean; + downloadRateLimitPerHour?: number; +}) => http.post("/templates", payload); +export const fetchTemplateUploadSign = (payload: { + fileName: string; + contentType?: string; + templateType?: string; +}) => http.post("/templates/upload-sign", payload); +export const fetchTemplateVersions = (id: number) => http.get(`/templates/${id}/versions`); +export const addTemplateVersion = ( + id: number, + payload: { + objectKey: string; + changeLog?: string; + }, +) => http.post(`/templates/${id}/versions`, payload); +export const publishTemplate = (id: number) => http.post(`/templates/${id}/publish`); +export const disableTemplate = (id: number) => http.post(`/templates/${id}/disable`); +export const archiveTemplate = (id: number) => http.post(`/templates/${id}/archive`); +export const rollbackTemplate = (id: number, payload: { versionNo: number; rollbackReason: string }) => + http.post(`/templates/${id}/rollback`, payload); +export const downloadTemplate = (id: number) => http.get(`/templates/${id}/download`); +export const downloadTemplateWatermark = (id: number, params?: { watermarkText?: string }) => + http.get(`/templates/${id}/download-watermark`, { params }); +export const fetchTemplateVersionDiff = (id: number, params?: { leftVersionNo?: number; rightVersionNo?: number }) => + http.get(`/templates/${id}/versions/diff`, { params }); +export const fetchTemplateDownloadLogs = (params?: { + templateId?: number; + templateName?: string; + userId?: number; + userKeyword?: string; + versionNo?: number; + downloadType?: "NORMAL" | "WATERMARK"; + ip?: string; + downloadedFrom?: string; + downloadedTo?: string; + pageNo?: number; + pageSize?: number; +}) => + http.get("/templates/download-logs", { params }); + +export const fetchExperts = (params?: { keyword?: string; pageNo?: number; pageSize?: number }) => http.get("/experts", { params }); +export const fetchPlatformExperts = (params?: { keyword?: string }) => http.get("/platform/experts", { params }); +export const fetchExpertDetail = (id: number) => http.get(`/experts/${id}`); +export const fetchPlatformExpertDetail = (id: number) => http.get(`/platform/experts/${id}`); +export const fetchExpertUploadSign = (payload: { fileName: string; contentType?: string }) => + http.post("/experts/upload-sign", payload); +export const fetchPlatformExpertUploadSign = (payload: { fileName: string; contentType?: string }) => + http.post("/platform/experts/upload-sign", payload); +export const createExpert = (payload: { + expertName: string; + idNo: string; + phone: string; + titleCode?: string; + title?: string; + hospitalCode?: string; + organization?: string; +}) => http.post("/experts", payload); +export const createPlatformExpert = (payload: { + expertName: string; + idNo: string; + phone: string; + titleCode?: string; + title?: string; + hospitalCode?: string; + organization?: string; +}) => http.post("/platform/experts", payload); +export const updateExpert = ( + id: number, + payload: { + expertName: string; + idNo: string; + phone: string; + titleCode?: string; + title?: string; + hospitalCode?: string; + organization?: string; + gender?: string; + birthday?: string; + idCardValidUntil?: string; + statusReason?: string; + }, +) => http.put(`/experts/${id}`, payload); +export const updatePlatformExpert = ( + id: number, + payload: { + expertName: string; + idNo: string; + phone: string; + titleCode?: string; + title?: string; + hospitalCode?: string; + organization?: string; + gender?: string; + birthday?: string; + idCardValidUntil?: string; + statusReason?: string; + }, +) => http.put(`/platform/experts/${id}`, payload); +export const importExperts = (payload: { + experts: Array<{ expertName: string; idNo: string; phone: string; titleCode?: string; title?: string; hospitalCode?: string; organization?: string }>; +}) => http.post("/experts/import", payload); +export const importPlatformExperts = (payload: { + experts: Array<{ expertName: string; idNo: string; phone: string; titleCode?: string; title?: string; hospitalCode?: string; organization?: string }>; +}) => http.post("/platform/experts/import", payload); +export const exportExperts = () => http.get("/experts/export"); +export const exportPlatformExperts = () => http.get("/platform/experts/export"); +export const mergeExpert = (id: number, payload: { sourceExpertId: number; reason: string }) => + http.post(`/experts/${id}/merge`, payload); +export const mergePlatformExpert = (id: number, payload: { sourceExpertId: number; reason: string }) => + http.post(`/platform/experts/${id}/merge`, payload); +export const fetchExpertBankCards = (id: number) => http.get(`/experts/${id}/bank-cards`); +export const fetchPlatformExpertBankCards = (id: number) => http.get(`/platform/experts/${id}/bank-cards`); +export const fetchExpertBankCardDetail = (id: number, cardId: number) => http.get(`/experts/${id}/bank-cards/${cardId}`); +export const fetchPlatformExpertBankCardDetail = (id: number, cardId: number) => + http.get(`/platform/experts/${id}/bank-cards/${cardId}`); +export const addExpertBankCard = ( + id: number, + payload: { + bankName: string; + bankProvince?: string; + bankCity?: string; + bankBranchName?: string; + bankCardNo: string; + bankCardFrontOssKey?: string; + bankCardBackOssKey?: string; + accountName: string; + isDefault?: boolean; + }, +) => http.post(`/experts/${id}/bank-cards`, payload); +export const addPlatformExpertBankCard = ( + id: number, + payload: { + bankName: string; + bankProvince?: string; + bankCity?: string; + bankBranchName?: string; + bankCardNo: string; + bankCardFrontOssKey?: string; + bankCardBackOssKey?: string; + accountName: string; + isDefault?: boolean; + }, +) => http.post(`/platform/experts/${id}/bank-cards`, payload); +export const updateExpertBankCard = ( + id: number, + cardId: number, + payload: { + bankName: string; + bankProvince?: string; + bankCity?: string; + bankBranchName?: string; + bankCardNo: string; + bankCardFrontOssKey?: string; + bankCardBackOssKey?: string; + accountName: string; + isDefault?: boolean; + }, +) => http.put(`/experts/${id}/bank-cards/${cardId}`, payload); +export const updatePlatformExpertBankCard = ( + id: number, + cardId: number, + payload: { + bankName: string; + bankProvince?: string; + bankCity?: string; + bankBranchName?: string; + bankCardNo: string; + bankCardFrontOssKey?: string; + bankCardBackOssKey?: string; + accountName: string; + isDefault?: boolean; + }, +) => http.put(`/platform/experts/${id}/bank-cards/${cardId}`, payload); +export const fetchExpertDictionaryOptions = () => http.get("/dictionaries/expert-options"); +export const fetchDictionaries = (params?: { dictType?: string; enabledOnly?: boolean }) => + http.get("/dictionaries", { params }); +export const fetchPlatformDictionaries = (params?: { dictType?: string; enabledOnly?: boolean }) => + http.get("/platform/dictionaries", { params }); +export const fetchPlatformDictionaryTypes = (params?: { enabledOnly?: boolean }) => + http.get("/platform/dictionaries/types", { params }); +export const createPlatformDictionaryType = (payload: { + dictType: string; + dictName: string; + sortNo: number; + remark?: string; +}) => http.post("/platform/dictionaries/types", payload); +export const createPlatformDictionary = (payload: { + dictType: string; + dictCode: string; + dictName: string; + sortNo: number; + remark?: string; +}) => http.post("/platform/dictionaries", payload); +export const updatePlatformDictionary = ( + id: number, + payload: { dictName: string; sortNo: number; status: "ENABLED" | "DISABLED"; remark?: string }, +) => http.put(`/platform/dictionaries/${id}`, payload); +export const enablePlatformDictionary = (id: number) => http.post(`/platform/dictionaries/${id}/enable`); +export const disablePlatformDictionary = (id: number) => http.post(`/platform/dictionaries/${id}/disable`); +export const fetchPlatformAuthSessions = (params?: { + scope?: "TENANT" | "PLATFORM"; + status?: "ACTIVE" | "ROTATED" | "REVOKED" | "EXPIRED"; + userId?: number; + tenantId?: number; +}) => http.get("/platform/auth-sessions", { params }); +export const revokePlatformAuthSession = (id: number) => http.post(`/platform/auth-sessions/${id}/revoke`); +export const revokePlatformPrincipalSessions = (payload: { + userId: number; + scope: "TENANT" | "PLATFORM"; + tenantId?: number; +}) => http.post("/platform/auth-sessions/revoke-principal", payload); +export const fetchPlatformNotifyGateways = () => http.get("/platform/notify-gateways"); +export const savePlatformNotifyGateway = ( + channelCode: string, + payload: { + gatewayName: string; + providerCode: string; + status: "ENABLED" | "DISABLED"; + remark?: string; + config: Record; + }, +) => http.put(`/platform/notify-gateways/${channelCode}`, payload); +export const testPlatformNotifyGateway = ( + channelCode: string, + payload: { + receiverRef: string; + subject?: string; + content?: string; + }, +) => http.post(`/platform/notify-gateways/${channelCode}/test`, payload); + +export const fetchNotificationPolicies = (params?: { pageNo?: number; pageSize?: number }) => + http.get("/notification-policies", { params }); +export const fetchNotificationTextTemplates = (params?: { pageNo?: number; pageSize?: number }) => + http.get("/notification-text-templates", { params }); +export const createNotificationTextTemplate = (payload: { + templateName: string; + subjectTemplate?: string; + titleTemplate?: string; + contentTemplate: string; + status?: "ENABLED" | "DISABLED"; +}) => http.post("/notification-text-templates", payload); +export const updateNotificationTextTemplate = ( + id: number, + payload: { + templateName: string; + subjectTemplate?: string; + titleTemplate?: string; + contentTemplate: string; + status?: "ENABLED" | "DISABLED"; + }, +) => http.put(`/notification-text-templates/${id}`, payload); +export const enableNotificationTextTemplate = (id: number) => http.post(`/notification-text-templates/${id}/enable`); +export const disableNotificationTextTemplate = (id: number) => http.post(`/notification-text-templates/${id}/disable`); +export const createNotificationPolicy = (payload: { + policyName: string; + eventCode: string; + channel: string; + receiverType: string; + templateId: number; + variablesJson?: string; + status?: "ENABLED" | "DISABLED"; +}) => http.post("/notification-policies", payload); +export const updateNotificationPolicy = ( + id: number, + payload: { + policyName: string; + eventCode: string; + channel: string; + receiverType: string; + templateId: number; + variablesJson?: string; + status?: "ENABLED" | "DISABLED"; + }, +) => http.put(`/notification-policies/${id}`, payload); +export const bindNotificationPolicyEvents = (id: number, payload: { eventCode: string }) => + http.post(`/notification-policies/${id}/events`, payload); +export const enableNotificationPolicy = (id: number) => http.post(`/notification-policies/${id}/enable`); +export const disableNotificationPolicy = (id: number) => http.post(`/notification-policies/${id}/disable`); +export const dispatchNotification = (payload: { + idempotencyKey: string; + eventCode: string; + bizType?: string; + bizId?: string; + variablesJson?: string; + policyId?: number; +}) => http.post("/notifications/dispatch", payload); +export const fetchNotificationTasks = (params?: { pageNo?: number; pageSize?: number }) => + http.get("/notifications/tasks", { params }); +export const ingestNotificationReceipt = (payload: { + taskId: number; + providerMessageId: string; + receiptCode: string; + receiptMessage?: string; + delivered?: boolean; +}) => http.post("/notifications/receipts", payload); +export const fetchInAppNotifications = (params?: { ts?: number }) => http.get("/in-app-notifications", { params }); +export const markInAppNotificationRead = (id: number) => http.post(`/in-app-notifications/${id}/read`); +export const markAllInAppNotificationsRead = () => http.post("/in-app-notifications/read-all"); +export const fetchExportTasks = () => http.get("/export-tasks"); +export const createExportTask = (payload: { + idempotencyKey: string; + taskCode: string; + bizType: string; + bizId?: string; + filtersJson?: string; + fileName?: string; +}) => http.post("/export-tasks", payload); +export const refreshExportTaskToken = (id: number) => http.post(`/export-tasks/${id}/refresh-token`); +export const downloadExportTask = (id: number, params: { token: string }) => + http.get(`/export-tasks/${id}/download`, { params }); +export const fetchOperationsDashboard = () => http.get("/operations/dashboard"); + +export const fetchObservabilityMetrics = (params: { metricCode: string; minutes?: number }) => + http.get("/observability/metrics", { params }); +export const fetchObservabilityExportMetrics = (params?: { minutes?: number }) => + http.get("/observability/metrics/export", { params }); +export const fetchAlertRules = () => http.get("/observability/alert-rules"); +export const createAlertRule = (payload: { + ruleCode: string; + ruleName: string; + compareOp: string; + thresholdValue: number; + windowMinute: number; + suppressWindowMinute?: number; + status?: "ENABLED" | "DISABLED"; +}) => http.post("/observability/alert-rules", payload); +export const updateAlertRule = ( + id: number, + payload: { + ruleCode: string; + ruleName: string; + compareOp: string; + thresholdValue: number; + windowMinute: number; + suppressWindowMinute?: number; + status?: "ENABLED" | "DISABLED"; + }, +) => http.put(`/observability/alert-rules/${id}`, payload); +export const evaluateAlertRules = () => http.post("/observability/alert-rules/evaluate"); +export const evaluateAlertRulesAuto = (params?: { recoveryWindowMinute?: number }) => + http.post("/observability/alert-rules/evaluate/auto", null, { params }); +export const fetchAlertEvents = () => http.get("/observability/alert-events"); + +// ========== Phase Zero: Dashboard Stats ========== +export const fetchDashboardStats = () => http.get("/dashboard/stats"); + +// ========== Phase Zero: Soft Delete & Lifecycle ========== +export const deleteAuditFlow = (id: number) => http.post(`/audit-flows/${id}/delete`); +export const deleteNotificationPolicy = (id: number) => http.post(`/notification-policies/${id}/delete`); +export const deleteNotificationTextTemplate = (id: number) => http.post(`/notification-text-templates/${id}/delete`); + +// ========== Phase Zero: Platform User Update ========== +export const updatePlatformUser = ( + id: number, + payload: { + userName: string; + phone: string; + email?: string; + validFrom?: string; + validTo?: string; + password?: string; + }, +) => http.put(`/platform/users/${id}`, payload); + +// ========== Phase Zero: Data Export ========== +export const exportMeetings = (payload: { + idempotencyKey: string; + filtersJson?: string; + fileName?: string; +}) => http.post("/meetings/export", payload); +export const exportUsers = (payload: { + idempotencyKey: string; + fileName?: string; +}) => http.post("/users/export", payload); +export const exportProjects = (payload: { + idempotencyKey: string; + fileName?: string; +}) => http.post("/projects/export", payload); + +// ========== Phase Zero: Tenant Dictionary ========== +export const createDictionary = (payload: { + dictType: string; + dictCode: string; + dictName: string; + sortNo: number; + remark?: string; +}) => http.post("/dictionaries", payload); +export const updateDictionary = ( + id: number, + payload: { dictName: string; sortNo: number; status: "ENABLED" | "DISABLED"; remark?: string }, +) => http.put(`/dictionaries/${id}`, payload); +export const enableDictionary = (id: number) => http.post(`/dictionaries/${id}/enable`); +export const disableDictionary = (id: number) => http.post(`/dictionaries/${id}/disable`); diff --git a/frontend/src/components/BreadcrumbNav.vue b/frontend/src/components/BreadcrumbNav.vue new file mode 100644 index 0000000..68d8809 --- /dev/null +++ b/frontend/src/components/BreadcrumbNav.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/frontend/src/components/GlobalSearchLauncher.vue b/frontend/src/components/GlobalSearchLauncher.vue new file mode 100644 index 0000000..f5e3fc6 --- /dev/null +++ b/frontend/src/components/GlobalSearchLauncher.vue @@ -0,0 +1,364 @@ + + + + + diff --git a/frontend/src/components/PageContainer.vue b/frontend/src/components/PageContainer.vue new file mode 100644 index 0000000..c995588 --- /dev/null +++ b/frontend/src/components/PageContainer.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/frontend/src/components/PasswordStrengthBar.vue b/frontend/src/components/PasswordStrengthBar.vue new file mode 100644 index 0000000..cf1a489 --- /dev/null +++ b/frontend/src/components/PasswordStrengthBar.vue @@ -0,0 +1,110 @@ + + + + + diff --git a/frontend/src/components/QueryToolbar.vue b/frontend/src/components/QueryToolbar.vue new file mode 100644 index 0000000..234fddf --- /dev/null +++ b/frontend/src/components/QueryToolbar.vue @@ -0,0 +1,15 @@ + + + + + diff --git a/frontend/src/components/SectionTitle.vue b/frontend/src/components/SectionTitle.vue new file mode 100644 index 0000000..54aa75f --- /dev/null +++ b/frontend/src/components/SectionTitle.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/frontend/src/constants/permissions.ts b/frontend/src/constants/permissions.ts new file mode 100644 index 0000000..db07789 --- /dev/null +++ b/frontend/src/constants/permissions.ts @@ -0,0 +1,155 @@ +export const PERMS = { + project: { + create: "project.create", + keyChangeLogRead: "project.key-change-log.read", + freeze: "project.freeze", + unfreeze: "project.unfreeze", + archive: "project.archive", + bindUser: "project.bind.user", + bindExecutorUser: "project.bind.executor_user", + }, + meeting: { + read: "meeting.read", + manage: "meeting.manage", + create: "meeting.create", + delete: "meeting.delete", + cancel: "meeting.cancel", + submit: "meeting.submit", + withdraw: "meeting.withdraw", + materialSave: "meeting.material.save", + materialSubmit: "meeting.material.submit", + materialHistoryRead: "meeting.material.history.read", + materialExport: "meeting.material.export", + invoiceConfig: "meeting.invoice.config", + changeLogRead: "meeting.change-log.read", + }, + audit: { + read: "audit.read", + approve: "audit.approve", + reject: "audit.reject", + back: "audit.return", + transfer: "audit.transfer", + remind: "audit.remind", + slaRead: "audit.sla.read", + exportOpinions: "audit.export.opinions", + materialRead: "audit.material.read", + flowRead: "audit.flow.read", + flowManage: "audit.flow.manage", + logRead: "audit.log.read", + }, + finance: { + paymentConfirm: "finance.payment.confirm", + ledgerExport: "finance.ledger.export", + reconciliation: "finance.reconciliation", + lock: "finance.lock", + unlock: "finance.unlock", + }, + user: { + read: "user.read", + create: "user.create", + import: "user.import", + update: "user.update", + delete: "user.delete", + roleAssign: "user.role.assign", + enable: "user.enable", + disable: "user.disable", + resetPassword: "user.password.reset", + roleHistoryRead: "user.role.history.read", + delegationManage: "user.delegation.manage", + }, + role: { + read: "role.read", + create: "role.create", + update: "role.update", + delete: "role.delete", + enable: "role.enable", + disable: "role.disable", + permissionBind: "role.permission.bind", + }, + permission: { + read: "permission.read", + }, + tenant: { + manage: "tenant.manage", + switch: "tenant.switch", + }, + dataPermission: { + read: "data.permission.read", + manage: "data.permission.manage", + }, + template: { + read: "template.read", + manage: "template.manage", + create: "template.create", + publish: "template.publish", + disable: "template.disable", + archive: "template.archive", + rollback: "template.rollback", + download: "template.download", + downloadLogReadAll: "template.download.log.read.all", + flowLink: "template.flow.link", + }, + expert: { + read: "expert.read", + create: "expert.create", + merge: "expert.merge", + import: "expert.import", + export: "expert.export", + cardManage: "expert.card.manage", + idCardOcr: "ocr.idcard", + bankCardOcr: "ocr.bankcard", + }, + notification: { + policyRead: "notification.policy.read", + policyManage: "notification.policy.manage", + textTemplateRead: "notification.text-template.read", + textTemplateManage: "notification.text-template.manage", + dispatch: "notification.dispatch", + taskRead: "notification.task.read", + inAppRead: "notification.inapp.read", + inAppMarkRead: "notification.inapp.mark-read", + }, + observability: { + read: "observability.read", + manage: "observability.manage", + }, + invoiceProfile: { + read: "invoice.profile.read", + manage: "invoice.profile.manage", + }, + exportTask: { + read: "export.task.read", + manage: "export.task.manage", + }, + dashboard: { + read: "dashboard.read", + }, + enterprise: { + read: "enterprise.read", + manage: "enterprise.manage", + delete: "enterprise.delete", + }, + dictionary: { + read: "dictionary.read", + manage: "dictionary.manage", + }, + platform: { + tenantManage: "platform.tenant.manage", + userManage: "platform.user.manage", + auditRead: "platform.audit.read", + menuManage: "platform.menu.manage", + roleRead: "platform.role.read", + roleManage: "platform.role.manage", + permissionRead: "platform.permission.read", + expertRead: "platform.expert.read", + expertManage: "platform.expert.manage", + idCardOcr: "platform.ocr.idcard", + bankCardOcr: "platform.ocr.bankcard", + dictionaryRead: "platform.dictionary.read", + dictionaryManage: "platform.dictionary.manage", + sessionRead: "platform.session.read", + sessionManage: "platform.session.manage", + notifyGatewayRead: "platform.notify-gateway.read", + notifyGatewayManage: "platform.notify-gateway.manage", + }, +} as const; diff --git a/frontend/src/constants/ui.ts b/frontend/src/constants/ui.ts new file mode 100644 index 0000000..e4512d1 --- /dev/null +++ b/frontend/src/constants/ui.ts @@ -0,0 +1,50 @@ +/** + * UI 常量 — 弹窗/抽屉尺寸 & 表单控件标准宽度 + * P0-03 & P0-04 + */ + +/** Drawer 标准宽度档位 */ +export const DRAWER_SIZE = { + /** 480px — 简单表单(新增角色、基本信息等) */ + sm: "480px", + /** 640px — 中等表单(用户编辑、策略编辑等) */ + md: "640px", + /** 55% — 稍大表单(项目编辑、详情预览等) */ + xl: "55%", + /** 80% — 大型内容(银行卡列表、资料详情等) */ + lg: "80%", +} as const; + +/** Dialog 标准宽度档位 */ +export const DIALOG_WIDTH = { + /** 520px — 简单确认/输入 */ + sm: "520px", + /** 680px — 中等表单/列表 */ + md: "680px", + /** 860px — 大型复杂表单 */ + lg: "860px", +} as const; + +/** 表单控件标准宽度档位 */ +export const INPUT_WIDTH = { + /** 100px — 极窄(比较符等) */ + xs: "100px", + /** 160px — 搜索栏下拉/状态选择 */ + sm: "160px", + /** 220px — 标准表单下拉 */ + md: "220px", + /** 280px — 宽表单下拉 */ + lg: "280px", + /** 100% — 充满父容器 */ + full: "100%", +} as const; + +/** 标准 label-width(el-form) */ +export const LABEL_WIDTH = { + /** 简洁表单 */ + sm: "70px", + /** 标准表单 */ + md: "90px", + /** 宽标签表单 */ + lg: "110px", +} as const; diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..cbdcda1 --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1,124 @@ +import { createApp } from "vue"; +import ElementPlus from "element-plus"; +import { ElMessage } from "element-plus"; +import "element-plus/dist/index.css"; +import "element-plus/theme-chalk/dark/css-vars.css"; +import "./styles/variables.css"; +import "./styles/theme.css"; +import "./styles/utilities.css"; +import App from "./App.vue"; +import router from "./router"; +import { pinia } from "./stores"; +import { useAppearanceStore } from "./stores/appearance"; +import { useAuthStore } from "./stores/auth"; +import { logoutAuth } from "./api/modules"; +import { resolveLoginPath } from "./utils/authNavigation"; + +const IDLE_TIMEOUT_MINUTES = Number(import.meta.env.VITE_IDLE_TIMEOUT_MINUTES || 60); +const IDLE_TIMEOUT_MS = (Number.isFinite(IDLE_TIMEOUT_MINUTES) && IDLE_TIMEOUT_MINUTES > 0 ? IDLE_TIMEOUT_MINUTES : 60) * 60 * 1000; +const IDLE_ACTIVITY_EVENTS = ["mousedown", "keydown", "scroll", "touchstart"] as const; +const PUBLIC_ENTRY_PATH_PATTERNS = [/^\/login$/, /^\/[^/]+\/login$/, /^\/[^/]+\/setup-password$/]; + +let idleTimer: number | null = null; +let lastActivityAt = Date.now(); +let idleLogoutPending = false; +let previousAuthLoggedIn = false; + +const stopIdleTimer = () => { + if (idleTimer !== null) { + window.clearTimeout(idleTimer); + idleTimer = null; + } +}; + +const scheduleIdleTimeout = () => { + stopIdleTimer(); + const authStore = useAuthStore(pinia); + if (!authStore.token) { + previousAuthLoggedIn = false; + return; + } + const elapsed = Date.now() - lastActivityAt; + const delay = Math.max(0, IDLE_TIMEOUT_MS - elapsed); + idleTimer = window.setTimeout(() => { + void handleIdleTimeout(); + }, delay); +}; + +const markUserActivity = () => { + const authStore = useAuthStore(pinia); + if (!authStore.token) { + stopIdleTimer(); + previousAuthLoggedIn = false; + return; + } + lastActivityAt = Date.now(); + scheduleIdleTimeout(); +}; + +async function handleIdleTimeout() { + const authStore = useAuthStore(pinia); + if (!authStore.token || idleLogoutPending) { + return; + } + idleLogoutPending = true; + const nextLoginPath = resolveLoginPath(authStore.scope, authStore.tenantCode); + try { + await logoutAuth(); + } catch (_error) { + // ignore logout failure during idle cleanup + } + authStore.clearAuthStorage(); + window.location.href = nextLoginPath; +} + +const handleAuthStateChanged = () => { + const authStore = useAuthStore(pinia); + const loggedIn = Boolean(authStore.token); + if (!loggedIn) { + stopIdleTimer(); + previousAuthLoggedIn = false; + idleLogoutPending = false; + return; + } + if (!previousAuthLoggedIn) { + lastActivityAt = Date.now(); + } + previousAuthLoggedIn = true; + scheduleIdleTimeout(); +}; + +const app = createApp(App); +app.use(pinia); +const appearanceStore = useAppearanceStore(pinia); +appearanceStore.initAppearance(); + +const isPublicEntryPath = (path: string) => PUBLIC_ENTRY_PATH_PATTERNS.some((pattern) => pattern.test(path)); + +// 全局错误边界:捕获未处理的组件异常,防止白屏 +app.config.errorHandler = (err, _instance, info) => { + console.error("[全局错误边界]", info, err); + ElMessage.error("页面发生异常,请尝试刷新页面"); +}; + +app.use(router).use(ElementPlus).mount("#app"); + +router.isReady().then(() => { + if (isPublicEntryPath(router.currentRoute.value.path)) { + appearanceStore.markBootstrapped(); + } + appearanceStore.syncFromServer(); + for (const eventName of IDLE_ACTIVITY_EVENTS) { + window.addEventListener(eventName, markUserActivity, { passive: true }); + } + window.addEventListener("auth:token-updated", handleAuthStateChanged as EventListener); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") { + markUserActivity(); + } + }); + router.afterEach(() => { + markUserActivity(); + }); + handleAuthStateChanged(); +}); diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..3c5d41c --- /dev/null +++ b/frontend/src/router/index.ts @@ -0,0 +1,151 @@ +import { createRouter, createWebHistory } from "vue-router"; +import AppLayout from "../views/layout/AppLayout.vue"; +import TenantDashboardPage from "../views/modules/TenantDashboardPage.vue"; +import ProfilePage from "../views/modules/ProfilePage.vue"; +import ProjectPage from "../views/modules/ProjectPage.vue"; +import MeetingPage from "../views/modules/MeetingPage.vue"; +import AuditPage from "../views/modules/AuditPage.vue"; +import FinancePage from "../views/modules/FinancePage.vue"; +import UserPage from "../views/modules/UserPage.vue"; +import RolePage from "../views/modules/RolePage.vue"; +import TenantPage from "../views/modules/TenantPage.vue"; +import EnterprisePage from "../views/modules/EnterprisePage.vue"; +import MenuPage from "../views/modules/MenuPage.vue"; +import PlatformLoginPage from "../views/modules/PlatformLoginPage.vue"; +import TenantLoginPage from "../views/modules/TenantLoginPage.vue"; +import TenantPasswordSetupPage from "../views/modules/TenantPasswordSetupPage.vue"; +import AuditFlowPage from "../views/modules/AuditFlowPage.vue"; +import DataPermissionPage from "../views/modules/DataPermissionPage.vue"; +import TemplatePage from "../views/modules/TemplatePage.vue"; +import TemplateDownloadLogPage from "../views/modules/TemplateDownloadLogPage.vue"; +import AuditLogPage from "../views/modules/AuditLogPage.vue"; +import ExpertPage from "../views/modules/ExpertPage.vue"; +import NotificationPolicyPage from "../views/modules/NotificationPolicyPage.vue"; +import NotificationTextTemplatePage from "../views/modules/NotificationTextTemplatePage.vue"; +import InAppNotificationPage from "../views/modules/InAppNotificationPage.vue"; +import ObservabilityPage from "../views/modules/ObservabilityPage.vue"; +import InvoiceProfilePage from "../views/modules/InvoiceProfilePage.vue"; +import ExportTaskPage from "../views/modules/ExportTaskPage.vue"; +import OperationsDashboardPage from "../views/modules/OperationsDashboardPage.vue"; +import PlatformMenuPage from "../views/modules/PlatformMenuPage.vue"; +import PlatformUserPage from "../views/modules/PlatformUserPage.vue"; +import PlatformRolePage from "../views/modules/PlatformRolePage.vue"; +import PlatformDictionaryPage from "../views/modules/PlatformDictionaryPage.vue"; +import PlatformSessionPage from "../views/modules/PlatformSessionPage.vue"; +import PlatformNotifyGatewayPage from "../views/modules/PlatformNotifyGatewayPage.vue"; +import NotFoundPage from "../views/modules/NotFoundPage.vue"; +import { refreshAuth } from "../api/modules"; +import { pinia } from "../stores"; +import { useAuthStore } from "../stores/auth"; +import { resolveLoginPath } from "../utils/authNavigation"; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { + path: "/login", + component: PlatformLoginPage, + meta: { title: "平台管理端登录" }, + }, + { + path: "/:tenantCode/login", + component: TenantLoginPage, + meta: { title: "租户登录" }, + }, + { + path: "/:tenantCode/setup-password", + component: TenantPasswordSetupPage, + meta: { title: "租户管理员密码设置" }, + }, + { + path: "/", + component: AppLayout, + children: [ + { path: "", redirect: "/dashboard" }, + { path: "/dashboard", component: TenantDashboardPage, meta: { title: "工作台" } }, + { path: "/profile", component: ProfilePage, meta: { title: "个人设置" } }, + { path: "/projects", component: ProjectPage, meta: { title: "项目管理" } }, + { path: "/meetings", component: MeetingPage, meta: { title: "会议管理" } }, + { path: "/audits", component: AuditPage, meta: { title: "审核管理" } }, + { path: "/finance", component: FinancePage, meta: { title: "财务管理" } }, + { path: "/users", component: UserPage, meta: { title: "用户管理" } }, + { path: "/tenants", component: TenantPage, meta: { title: "租户管理" } }, + { path: "/enterprises", component: EnterprisePage, meta: { title: "企业管理" } }, + { path: "/menus", component: MenuPage, meta: { title: "菜单与权限" } }, + { path: "/roles", component: RolePage, meta: { title: "角色管理" } }, + { path: "/permissions", redirect: { path: "/menus", query: { tab: "permissions" } } }, + { path: "/audit-flows", component: AuditFlowPage, meta: { title: "审核流配置" } }, + { path: "/data-permissions", component: DataPermissionPage, meta: { title: "数据权限管理" } }, + { path: "/templates", component: TemplatePage, meta: { title: "模板管理" } }, + { path: "/template-download-logs", component: TemplateDownloadLogPage, meta: { title: "模板查看下载" } }, + { path: "/audit-logs", component: AuditLogPage, meta: { title: "审计日志" } }, + { path: "/experts", component: ExpertPage, meta: { title: "专家管理" } }, + { path: "/invoice-profiles", component: InvoiceProfilePage, meta: { title: "发票管理" } }, + { path: "/export-tasks", component: ExportTaskPage, meta: { title: "导出任务中心" } }, + { path: "/notification-policies", component: NotificationPolicyPage, meta: { title: "通知策略中心" } }, + { path: "/notification-text-templates", component: NotificationTextTemplatePage, meta: { title: "通知文案模板" } }, + { path: "/in-app-notifications", component: InAppNotificationPage, meta: { title: "站内通知中心" } }, + { path: "/observability", component: ObservabilityPage, meta: { title: "可观测性与告警" } }, + { path: "/operations-dashboard", component: OperationsDashboardPage, meta: { title: "运营看板" } }, + { path: "/platform/tenants", component: TenantPage, meta: { title: "平台租户管理" } }, + { path: "/platform/audit-logs", component: AuditLogPage, meta: { title: "平台审计日志" } }, + { path: "/platform/menus", component: PlatformMenuPage, meta: { title: "平台菜单与权限" } }, + { path: "/platform/users", component: PlatformUserPage, meta: { title: "平台用户管理" } }, + { path: "/platform/roles", component: PlatformRolePage, meta: { title: "平台角色管理" } }, + { path: "/platform/dictionaries", component: PlatformDictionaryPage, meta: { title: "平台字典管理" } }, + { path: "/platform/auth-sessions", component: PlatformSessionPage, meta: { title: "平台会话管理" } }, + { path: "/platform/notify-gateways", component: PlatformNotifyGatewayPage, meta: { title: "通知网关配置" } }, + { path: "/platform/permissions", redirect: { path: "/platform/menus", query: { tab: "permissions" } } }, + { path: "/platform/experts", component: ExpertPage, meta: { title: "平台专家管理" } }, + { path: "/:pathMatch(.*)*", component: NotFoundPage, meta: { title: "页面未找到" } }, + ], + }, + ], +}); + +router.beforeEach((to, _from, next) => { + const authStore = useAuthStore(pinia); + if (to.path === "/login" || /^\/[^/]+\/login$/.test(to.path) || /^\/[^/]+\/setup-password$/.test(to.path)) { + return next(); + } + const proceedWithTokenCheck = async () => { + const token = authStore.token; + if (!token) { + try { + const resp = await refreshAuth(); + const refreshedToken = String(resp?.data?.token || "").trim(); + if (!refreshedToken) { + return next(resolveLoginPath(authStore.scope, authStore.tenantCode)); + } + authStore.saveAuthPayload(resp?.data || null); + } catch (_e) { + return next(resolveLoginPath(authStore.scope, authStore.tenantCode)); + } + } + const scope = authStore.scope; + const isPlatformPath = to.path.startsWith("/platform/"); + const isSharedProfilePath = to.path === "/profile"; + if (scope === "PLATFORM") { + if (!isPlatformPath && !isSharedProfilePath) { + return next("/platform/tenants"); + } + return next(); + } + if (to.path === "/audit-logs") { + return next("/dashboard"); + } + if (isPlatformPath) { + return next("/dashboard"); + } + next(); + }; + proceedWithTokenCheck(); +}); + +router.afterEach((to) => { + const meta = to.meta as Record | undefined; + const pageTitle = String(meta?.title || "").trim(); + document.title = pageTitle ? `${pageTitle} - 会议核销系统` : "会议核销系统"; +}); + +export default router; diff --git a/frontend/src/static/专题授课.png b/frontend/src/static/专题授课.png new file mode 100644 index 0000000000000000000000000000000000000000..cd3ebc9a9c9447b7210568875d0dade3c4052cef GIT binary patch literal 5362 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 q_VCZJMFB`dhgl){@qZBvugFvpD`@aErAB=|p literal 0 HcmV?d00001 diff --git a/frontend/src/static/会场发票.png b/frontend/src/static/会场发票.png new file mode 100644 index 0000000000000000000000000000000000000000..b9d1ffdfe389b38285f7a57f48142c6b5e84d54d GIT binary patch literal 30772 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%0ASi<|WmVetPaR|P+puH5 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 TB&%MCWtuc?DcOG>-ZuOnxBI{T literal 0 HcmV?d00001 diff --git a/frontend/src/static/会场合同.png b/frontend/src/static/会场合同.png new file mode 100644 index 0000000000000000000000000000000000000000..10f22c9af02dd33b4182da7ca1e9b5e9688dc9d8 GIT binary patch literal 22382 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 af2s79g)fUBfG@bc#Q#EQT%)?FxZPL) literal 0 HcmV?d00001 diff --git a/frontend/src/static/会场小票.png b/frontend/src/static/会场小票.png new file mode 100644 index 0000000000000000000000000000000000000000..7ee5958c9a42f6bc1118c36877d974009a0373d5 GIT binary patch literal 37213 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
  • N`ghRlsG8L**8GNnmi;|G-||Ii%z~ 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 WeZL<4kGh@z@lO9g7u5fG=>Gvi6y99` literal 0 HcmV?d00001 diff --git a/frontend/src/static/会议串场.png b/frontend/src/static/会议串场.png new file mode 100644 index 0000000000000000000000000000000000000000..f76bc30689441ebaee4d14adf35c9506bb613062 GIT binary patch literal 34905 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 literal 0 HcmV?d00001 diff --git a/frontend/src/static/会议主持.png b/frontend/src/static/会议主持.png new file mode 100644 index 0000000000000000000000000000000000000000..29015b780c3b7ef6903d1077234539c44e479d0f GIT binary patch literal 11945 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 ZASGOH_w=E7N$BpVMsC4%?wxCc{{zMlpG5!w literal 0 HcmV?d00001 diff --git a/frontend/src/static/会议主题.png b/frontend/src/static/会议主题.png new file mode 100644 index 0000000000000000000000000000000000000000..cd9f2532f9c0e370cfbd8611ceae05457bb52b68 GIT binary patch literal 60389 zcma&O2_RHm|2TeUETMSFmaU{hi0mO5g_5P2Otvi5#Mt*C36nNiD=}Gy35{LJzP2b6 zlYNU&VzL(=$?ktH&+|U-`}=<1-~acy=G=4dJ@?#mKIgNab8LRv{0ecOH83=Q5C{lD zfCJk6hWKQ7>XbeD{5b67A?R@UM>zHYw(Kf8?c^@7j; z1wg%vJ#g(1)OqB;l=;8iX20s-ivtB_!SA3C_&8`RAIS6JxAW3){>pa#IGhjg^YQ~_ z&~V-dd;S#2Uj=z-{J-U|{97L98E zXQ|f^4;!lln+g*`0z&d2n0OGI@1Q+^)&Tt7RrriRGBJZEO5ET!62Xi_f&{?=A(_Av z9>v}Jbummzyq6`sPoBT|@aYxi1Ik6Sr`q2P&w+OY|3_tTZQy|j5Bw3PfBz5<0=Zjp zzwTx~w2KLRh=+*>(t&=)_Q>FVMG*hJJrEm3oFa~l30yOi|FhJOIna&1b8>ZY>K-PtLYL$nSAppzdkq1RyEvzSB5}s6DI=Nez0{P0qH|b zhbtIC7LL9&U*8AbuNr=Mb%Z)Am~?*?25&gNbB|PM`3f~fL_iP)V&f18uOVgC!k(E> zyZjo;M&$GIfAmNYv!R{W9I{CdcwqAXzXLOxy(tr|kK%_on%c>(5I5i}=IsN#sEp@+ zJCx$ed*hIz|7;d~#m5YBV|E;`(;x-*S1@8M?JYicC3v{piqTOB#`muhd|JQt-UwkH%C~kfTcbqV!Tv$V3bl+DE zEwY)bQ#hi~7#os9SNee@Qc|*SiSE3joC}2!krWQ%){BY>pB)Riu4kDnPGs1ZJ!~}( zBrFwvkISqtLro&n!-$Fc3fD5AZ1L@{{Pz@j7;z~ZDFd;nqWSOW{?&J8&`v<pYx%OeOD zd;o}`1VWD@uVa}5s20A%0Vm>c!XdtO5aC-5p`wjXcO=XCG_0(b-=mM{EE9@vRO07C zvuYM(^Vk14q2X|E-|W;~UyaWj%@m0|={ii{o<@ z4wdf-#ld5TKTC7@%vg2TAF&fgL#%S{DoILAGBR+pw_q>A>&mZ-eVR&sk!86WAARDf zWlfkGPsb+>OU_p*ZyN zptCm%3#3jlBJ3frcFy>BC07wEa8=WfT%c@FgtCRcE&Q_OAatcg)Lx_`ct40>j)zfu z9`IZ~Bh3PxNk<~Hkr0&3cDNMc#Xo>RscUCo&U-A)iTiakwr30bTOEVH{x?WV5W^@C zlg6;}Sel2>uy^ey^j-UK(EP0g9rkV*`T=ejp{vY&8*Nx33|jj5vg}8yw@{7@Id~P^^=s`n3EAJa<%hx9xifG7@r6 z=Ji4eT>y;vH|Qgd{g@E_{C3fm*BX(D=2N&bzw7?H=zpFAoh677L?rsG zL~#L3PU71wgTTEHJH=(sKukh*c{y)V?e<4?t%ps$BhdCHt{N-$UjM{~gmS@nP(Xd# zpJ3;MDC``NE{eDD8Fvo93X5eD8X=vDjgatxa>XSm5Q>izLPBUj%tU~2&XHeXxOZrn zm$wAV-%I?TBq^~X0NG^?Cce_)PPI>vMyM8QNw8niT0)oApiTZHP^1=qzVch>iA_rE~OqFZ>L2oMSNcZetakvnik*5JcRP zNL$PnMkF$+3z1w!eV3+x?!C!hzb~PsZ*Hc?_IK3S+{fycZu;_#fEU_Kdv3%jbh~Y- zO@!WGGIOi0|4f(xxfst4tXK4Xt1e1CaY%mU%GAjJixGE z;odqRG&i3~aD9WhxN~p++8#5O9`*KN> z1-sC2wni~fS4?M4EqbYB5t|mrf1vj@w+k~#u2{77h46kls1wB2)}Me*Ngr6 zde3n8Q0dZ~lddK{gL)mKH%@Es)K)>dTvuH|>s>&wzz!4wrHj(l2iDov0da4u44B{a zQ4oq7sD2a{Vy-mQD)OncHGSP<)kyQ#Ng(8h=(tbWaI_pd)0Qqlq+l>nbw`@%Upn`- z?qm#kGg}-Fu?n)HGQRMq)M;Fseos5DwFxb7H4J1m&Qw0G!3bm3(!-ItY(UikAW9QK z1`HCLL^y?wg95a-ILH1Xh>u4P3+-+UvXI+2;x$biiEZ?rA#uH=YXun3CWd-g(?@D= z4t(A-rZiUS#+i$hdFduBWE+t#!outLMz61k&m@_c?jeO;OkjVU$&S-K42W~>ow(~KzR+|u&3?~ue8b5WgLm}Ck0v^uvP^p>F--u)dzJ|%k zQb&sfH^7ok#vaQI6v9BRL*1%2u^ZTbz$nGQ2Oum2e~qUDPGqobE}rKCztZ{H4C!nl zjGHas02D$Aqlm*siF+k`4t0aE=%Lw|>c&bxm9y$M~Z=F-+t zC>>wczLm{bn*S|0$M_NYT9eB@Qo(oQk9yKd)*2Id;JHGY-GmsrV04w&EjBFX&IN4f z<~>g7nxYMyd#~L&hx-xsYh)vv0Dfta`~239ZC#SDZ9=*GBL6U2da(%^GZhAzR=kS+ zW8vJAJ?-9#)Ebkv*IHaRoX4;2i`}=-y2KhP_si^0+;{U8MexEVbZ4Bless^;rbH2qr96R9^_|HMshLT{Gwxl^6m!o-o~P{U=EbVCQz&KFG1?{2w1vkOS9*U z|0Pkl(O5p7J*{E^F~sMpzlBM@uBqDU3$&h}?^$1?9UE?ocDp&m|E1nx0c$FoUmasI zd_!KcBwM8^mqY5YwK(x_GUbRsW}J6J)FrOkoXgwn zQB!BxBGR|*XCzxTBxguBB64)Fdpr%&_*`8TM)QmnGwC^TTz#nODTM>}mZZaBrSb1T z`Ij6P2RtP%9S+1&LZem@sc~w<>WM@AAWP*%g20W?Ne|q?X-bMTT!{n`No*E~P;6HS z?wo5SRKDY}T=UiukSA(v$ep9bW})@~Dcl}QT}a|djS5yK(GKpoLa@x-A zDRswv);V6{h6$pDLL)%|ktgg{=nwp`iAHaU*%V}$JP`#q5uuCFRROjg$Wo-kWwsmz zX^HS`8KkR6;M&C%VDM)DalC1mFw$A5VO(MJBGGiMf^p~VkI)ZVvI#m;QLh+n`d9>t z4~v1T+mZ&Tt=zvQyPKPjF=DDM{C#P;93%48=1=H8lk+|5mnhOvebkP3?RRwl8aj9Y zNmyMmy@nsMmQBQ`*9@M_b42`0vB>~OiGbc=n9%DHq{fx($mk;{GiU#YH0v5pq@yG# z?D6JD22E_3&8;CO$Jd}%2mx`kJYb`ANGr#8M2+3pgX<18Jk&HXP7{)Rc*9Cn1z7Sx zbNprD!7uzLu)!7pI4Bphju%(jUg2~|N&CEyuC?O2FSf;GmOX}d@9zX6gyGQ#`W)`Y z?mK)^JZ5_(h(MNdh=)ZeNrxS3u)9KJglL^8m1<1TaR#nid4hBdZ00C)d(~TmKnpshvL)MM=%2ydP}BmH4P2qD~~Q3yF+f{aL`D9 zkrI8RswXRN6Ixu5wERfTXZ3EuYx#7>*o)C>epIa0DQ!Xz#q@^0ck$^1YhTSPfR^LjgDoi0l%8wn|BYC|*UV%eswfX zGdChznRMjPupSBpKL)^}51J0~gDQan_zMtM;)u+UQP9yIJn|>^4OtBh zsK?Tzf`%gA)yw^uuZnqTjCX)oK==q~o9))@o)>(0YG1zDzI;F$5Ikhuz=A&1$tQ7Rg{XFDD|5jY5{ie&CHrne( zb{Js2jkvHyI`CV6`KoYcYgiyt?2fci8g&zLRIjfCeC;i;HqFi=xra{S8ys`5f&zW$lOQ&Dk~O3 z17DaAMDZYm@WTT}_bHqbMe$#D#?L%I9HUjGUq4cx`?J1Ml1jNt%mwZ*67=$a10eB` z5StqU!j_p@(np^xlKY-8$b&NweY9*!plGQE;FNPDiWW{Wd4QUHq_4}BlE_uF4QWL-YbY@s}%~j{xRUW zFTpN|ZT#R%5S#%m-11$;;aY%%{!17G99=-+6k;5{L@@H}j=wsmn-k`sd5H%bGh zr1<47X(b8}Eii~v)bGr!tBl?0_VW~K$6Vl*7xJuU2l@fD>;HhQdt1^>9D2{lt7p$6 zvQf6EN*t6d(#^kmq)T+3?x)Q8IY?_b)_A>mcDV32N!Jg=MPQ%eZ};L~)iSeif07YV z2~3f+$mN!|tQ-{Q8|GUUhrO4|zergfBvTac(WJDeTmbTb#Rz2B z)-V&{x(?~LtX)v^40V?(g8uJuS z8vcg-tCu$tai0(S$-ZdO_*hZEmJU(CYcRkF0)N{8JQw8zUinZ4MO0qna*)@M&cWK} zF5`@}_X;-x7r1UkwaUpq=c?#?JV0qnM)Ut0)BIh-*4ca6tM}Tp-M5-g_WWfb#+p9s z^6Fj}5SV0^?3P>(@DUE=&J0x8gx{|5&Xf1+GpnPV^MmCNpk!nP6Z$^1Kln#-u>dEB z1+`Tvj92~l&d1I|QbglC$>x`BlFeb^$bTJfBJ6J+2>iUV-}VWJSp?h?s%x2dagkU8 zRyBt>tPRpZR0tela5#mvnJBVVYdZxNJ}8*fnT=lprPpA8FeM;ot+=+@V6I*?)X!W} z2p>Rlnut-}D=|H=2pk-U0}Q6*aJP0?)UR{+E$;;PFYlxgHP|ZL!S{U4HS@W6er~XM>oT3QPMiWEwf^c$&@L6&} z^cMutG5SUhm6702X@mLje*&9yh6YY4sfaG8^!7)9KeuoFD5=ag&ka9kT*0`XU}@_x z#8lCuVVvbcp&-L^x2SBpmh^vB#vy5z>r(SuB>K8l>)xDIeIJsyWp$#e{8xT~*%siy zbw#8T#IV9blhkpQDqJoZD|T?tWTWOwtjC~9(el$T^1XLo?S(!rJJe_@ha9)8p3SIe z5%hlcZs+S1UI)f#{%~hOhg~eN<4XRAI@#rpBgK*_+7l1o&p|Mq^+J#m+}HGgAX-XViFVjX=gLTUxe1-0TooT0EB5`Ic5ZrHe4=uCz;`^Y zIP(_u8{wRrANlC5;A^Yr8e_|@U+!d61^yLbWF|FneB!p?yxv-Id#51gQd+|GEXMkU z_H_2;Q0yA1T|yVUBTc{=L_-!wkt~dMu5m^pnB=%!{NDGx3Ucmt( z3xso*X!Ls)f%K@V=cmn241`!68j>g&{v+mRsizU^Rx~Yr7w05!iMTIcY)?PHbY11- z3r(l}HZ&7q%cDttDkqC>zbs2nmuAvvxY*=zv?Y7{J0NVzA%CpDSD%%&idW8W7jfIv z2WvBF&S>txM!Jt%oCbeakA9e+q_gUMU?3izxRkV zTEqfw;^Yw!jeLwi#%2ghZ-Ee+`(w`b8sxU>-Z5cR(K?5>6ibbTa;x(qW)p4v08T}(jUHb)} zCt#rrgu!ptOOKs}Q#f}`s0_s!vZ^Hcv1$A~{NCk8)vAKAQJ8M0D8pj!Eh|&`Hv`^l zW%dja+GxWv>$|>JpQMvhn_pwpIbUNmpZ02l*x0pI9mx}Ko~G6Wldr7;sXDhvXa%7k zo5i$8Ot^iI_9aMvqA^l)faA>>6Z$Bm^#j)}(r8_5V=xF3^pY~3E1<>|dr6s-^bURZ zloyFo=62DH@i0F|Y|l(i+6pyp`B1{r4#R-A6(4<4F7$+Si=EcSPjA@0Cy2fxt;8~> z#ZK!mW^_m|59E{|v3Jg!zEw6{!RQPotM~1&P4<~sC+ocAs#arku4bM4@qo|=o^9p) z1~fC$&ix=O4~kE8YiHS=_%wb`Py7prSk(j~vzV)>6f?unitU9CN&q$H(AuU4Jg7amoGzE{P?zQ6X z3+)`O+X!gWO#{+tv@VHFd0A;8#JE=FHt%yCqu#VU+tr zlUA}3^7+%r;@^@em3R&TC)0QUDoS5e&g#sVX*M%TrD`v zsu@bSZml2QYS=E!CZ(q-MGf2a@#<_9n%Cela|aPjZGy=P>N}Rc5`@1S)c2x|E1=X5 zuQ8}_$u1Vkfit>~cLQu=ZC+0Vaa}WAc8xHE_!JcRcm) zGaEN+7Dwy(A1V+e1g8c+SX_FNClyENU#j;Db*I4n~EM<@Q+ zgc>`wzbu>S@lr!Jq0tE%h;qqhpvcEo>=MVJiPWb#laQElm!|hGh%0dt@D_Dmb|(_pOl}DOe3|xVIMeuK!n z6ICpnoqilZRU)eXX&&Y8tT>#ZfFBzX_zZ9?3pT%yVSh~tNQ+ZIum>(64y9AJ#uGe- zi-HquS#6aUflM}?@l{RnOW(%VPqfv}^>H=DjRg=1 z?oN~li;Pj5{c?I*lx7#+VmZ~6@dQjyfHok*IkLeSG?Mi-Cly$7F97ZEnNZ)D6#JO; zbN&#+JfT$I?;)*n9liq4?psB&P$D6V%>!HFUvye%ZL9;6{vi| za4cSdigE?>WcJ3yAr0%EsXbmDGCz{DDai#5)+vCk0Al3W*s4ram?V;zIfViJY^@Ok z1OgfZ5)Yyt|Cnq6y80W-FS-7BoOC)Y(-g!-yDcl{uv+8%)=1KUV|5dq1V`b_|C zcR8xQ^mFCWa87M_6YPR-Nj+Hd!A0ZM!8nY0Ih~$cISeIRPId4Nud=W*dmE%*ywwC5w7lry>*e0X+P5~bmsB(3L zLBP((DPd7y8guP|$R41rbmV^N)+YPDl2r3HD-3zl<-3-2Z*-mMX}Qjav`4oJdp&d> zt=t=}yZOm`t-MR;$S=fa+#ZITZJQI`Z&2Uaa`MF#v7=Zn?*&#(I-PCHlOswetI6Ci zz9N@gF^WzpU*0_?_jM05|CWqTb}oxUB?Fw^9jO~YHT8q z<=rsjV@^@UXvDvWMY$>1W-=Totv_|PjfTVU6fA7q>Ah9}!RP^V2R z2(+VTCYB`#8&6oGe{IASu3Tm7wbh&w*PkpnH+Weuy7}Z}(`xGC5EDPdQ?Br15QiVIvG|5e>Xzsy9@d83fg6)gqha#U~K?f=N z#vS44m$K2M94?u>C*ITR=5666+*fwJXtDa8#EhCJ@3ly1NOF#ibxu88k1O9a8zQ5p zZt~#^#k>xj()QN;3T%{3$Z%OlS8T_LSM9wkY~vcgHez12oj&Ce9GX#h+pn+uL=~Ye zCRuH)*R+bK({Z3`qE|nr8otbhFIRH9^;B=Ylrh=i#Yd-w@xddQEJiytV29n1%Egn{3jtze9{#_WKuSh;q+@8<+k z=7Hy)U=TGT&A6ipxamn+zh6C-Sv|J+8u@4!8{rD!N>WrGQnfpR@U7*6UBRz+Q&C|A z*FrJhPRQWZ!G#dRi3bPlA6?gxg3oaaA?G@!d0sWHfNSD`(5-980CdC(sS0}afv2#F z(1VI5bQZp3b}b|m9vraso5~+&>uTv9yc~32*lyY*`KfTFC7GbHU23QBR8FrMN`ETr zg33+7T(U(!LNHGOZ*JGdR%_AM9h`)U><%Vdd}7voUYxrM-LI-`mXSMkR_0y%Gu09a zr;e$r0XH^4HHxK3$>_t9-Ip^={QA@kU9hI{6Oo-3)VKE)BM%Blzto>LEVe#-v#2oj zy->fkUAHu~6Mw`;i|@+IZX54OnHMpMBb|GsiNzu$4hi)HO-YgDv+*|x=C~JEFRGd# zcXk>@6K`D`k^9k1dv)cRE&+`}pwZ0G4&xJr?zq=Ul|Com?#hdiFIU-ETnm24THP%eS)6>JSSbm;NW}864y}DQoP>@(e{o%E|>KJ<$)%Pk+ zn2MJZj`mc)7YA(ZGs{DGjRqv-vup3%&vhx~Gf=gtzHZAlHmC)p0+-Xo6(YpLm#|q(Dr`1P9Y^xT|8Fgwa_a!k(KW7;)B;pP#KeX)kHBpTk zGCAxuJ!boI#BgauBPXfh*iW(I$K3&76&ux)b~vRj{;7hFZ|t8Wct)jq8=@w|IL zSfgd_&`T!a>>+c@rfgFSYTVwh-NTBH8uAAmjd+A=v&*pNNe%KDoVX@V7x{GalJKOc zPg5NYmcLyUz3_7RURYevO}|T_%Z=1)w5*eZq}*ALmELnpbTO)(pq6%OthwB< zWo&$W+0^WuFIj|}yHjpqV57X@-dB;zg|Vg!9Z+)bRL%F?IqY-ANUNZ%c%{j`vpzL1 z>-MNCnL#dfFcN8Y6(TKbIsC$j9hq-uC}4f@s#;r+iWdA0UTF zb?;p3Xwy#vvpudORaJ)Se!U|#GqtYPE&ht+C#^oe{m4ZQ3Et!=yzGgbSZFNm)7!?N z#Nb5dEN92WN)M+rGd88mZ?t+c@%vuxbW5pZ1XwR0KUz@%br3-uS<`UMlF$&!LpC0B zdYHeaq(sS9kL7qWn+vm}R<(wm9^Q}|jdbcXc#JoqmN*F+EI0{ue5gX3a1dogSkAhY zvW{`Y-Qk0Fr&HJiofX8JU77RlLH)o;k4W?)$?o!*j8NWHm#3kCEIMYLD)@HSHG8Sz zHo?bv4@ppwWdx`Er*q*)vFNGt^TEmam4>In8% zOQaLUv)N=g!YFbjlX;S*t8zS=FEwApD0$=jakj z*9bKSdwP1*Nj(8J`9+%K=>eaL0GsY+SFum+l24WT{d>$@=|Udru{%R{7^ykur4}aq zzH?f})p51_O;tDUY0}g+A7$sHigQQ0Qv9{3)Ms(-%2K{{ZE`VzzI`>HTm`vRWU7Yi zQ|%pRhodTaxm7sjl?k0x4O4n|aQH+g*ORg8nSkI(;g`Z)f#l~%O}{r9GKx$RjRTI5Nh(Q_ASx7!Wfjl=rUa9lVo$WetMinqk}~a zGD$9e&4sbDJiHhsa?)(#x^CmE*u9?}O?use49YER>P&H2G@V;IQhm>3r+8=Aj1@P` zpEuUqW+Ye;lqYeTE*E(&x?g`@?#adV=iNY@NK8;&uYY>sm5gi60S*o|!bw#>OXvzF z7qdSW+kQGFQqf%Q{_70c@)o&lXu6b~JpGX*n>`Ud5(*1}PN4+F&^VDP&7``Ib(v*v z>3TldJ(i!M%OW-OQ{IRv4LA16i^lDOoDJp%E?1^q?dqUgSQyE73@Sxg4RWYty5>G< z^z{F}=jNR4=ideM-s6EW12YW)KubQQ zCX);kRs~%A6>ebI6&~VeY6%dy?4~IV^@Kr zJnkA}xSF6ga<8vfYt77e(nGH}uiAPh>$p$TY;~iA>hoO=_u_}_p0vtpU3lMcXoBuh zG1m2MVlL3tx^tBAd=7uu-KN^W%IYod&BnpBcc*3PmWF|j^ToyP8=8|&-~1K2W9s@Z zCuA|-9!?2rb7>6pr6+8dwzyyU1_XdO#LVaqw((|sS-TL8(|JLvJ1b7PmHAbQLxKZo zC;D8z_nu6;Q%82x1Qru-E6glta1-7gf&L_U%W)NyV@C5p{JIJ<#Q6MUj^+0t3X+4Z z`NxxGd*@ggj#R9Ljw+sIG+g4Ey||=}VAyPE_(5j@DAoF^7#?v9g7)616x1oz*F!Vg z?U$VJ^g=;cG}y2K?9S=w;kk8kI|?|WEV>wBT`|?QL+3bgn^42YuiRK>h?lP6)6Gb! z6#4dn>8Y>-N+0EczuGb4aiRJ12+hoGx*=fXh5`OMo^;o%wy(jvXSPAP+Tu1X?)WK< z8k^s^(&5&NuE%Yzc*;x#>Cwc5zOdKDgqY^J@lZ1gYEugQHX%W|N<6PH4XoVEJMk@W+IN!`sg<@jBy(Ic60Ho`GM>Ss=4r*ZYEHtCxFzVyfZ6n-pA zN7%bgjKL=Kp4|856?4^W%S+Q`efo6r&*G$8fu=21>Vqj2X1sek_xvuHAgk2H$>w}2 zF6#*hQ;sfoPCJ5Ab=8=s9voU#kE$NTbM;ndR!=+UTyBsp98*m=zj!y)Orhj%=mi_! zhRJH9Y8$Z=@-9E*3oU<2j&^mbEiiI>)P3DDQUg6+IyBoQ7nDX{I$*2nLLD6A(&q2? zYLz5>xEQS|^Zl!OELTABd56o?^3QZllHd4m3!#D+Am%1zjMEC}s|lfuJpR%9eldsC z|NQ-8!G`+Mka-K&`k|^L8*)dVtQ&f)KAANx$KpqQ{g(GMc$7xtWHq8UboxJPJ znU?wE(xz zV35<5J>Rjv#lwF9FGp3{Ctz!4@$ku*Pg7uydrkjL0Xld3$w)i%!+ z%ylO|t}OVq`?6H1oJQ$$xiwP#MY+dsKDf$Vb?6lFdsCo1H|vl<%@CQ=$p`DyW=9m} zV|q3rUK8)~D(j1b37e3J-_l(5=PbF8nGNPj%SVP|t-cgKSu9m*F&aFfQ}}UqaMYlv z=_+G>e&VjSB7JmKXmUh8O?0MQ{f69xtA$?c-K_HCKj!g!T-Ght%oK7rq3~mNhDT~6 z=~=WO^`+0|{+;TaM_LSueyUqKD9uFf+jnHprsUCjM=iktDq1+KZO|u z*+>Q0&*50-SMr3%7-je)Y)Hz)G4|JBla8IJ?Q{MQ5I=}8p1PD%((fkR?+)DjvSj=v zIDpb)H(jXX#MSiB3b^ZNeMHjGam7H-y}(OH2q}Iig$2i-D1I-EQ+~{Be`s{~ zY~0;M+J~Jyx>hwM%{z>*nf;L)#=&$q^g7*MYAEwZ_AOh<(gEJvt`~n)wJFD_b&Ld9 zCr-UtmZJEc{|(2II4}PW@T&hQ){a78&=AY1vtjDdJ(~Na<`BTNhlKJ+2l4ysQt?_bgQVQK)XExj>WqCG)+|olrZ6=Q36zmAaM~80Q=i zJks;s%xLN4pkh$Dn>z0<-|QZX>LVo+$9$Rs3gZq-$vHIR6%~)p_)p09(Zrfd-QIN? zc$_zXQ9abxHC25kvX-__zPH4?25==ZuW+Bm@w(>WdLf_t;mDGSs3cdahH<`K?tL*; zP6N%Hu9K=y)l_fUmf(iGQcAVh9{rZO5pm_Az-)t!O3TS_IJ$NIh+nzNfVx1-;HQR3 zlI(d~+XpdmK|fj>=@!ys@q@<3?>tAozZeoQ3b5`e&GWQytcVG8_~NZZ&w29E!({Mz zBd|b!@yb<&J01xAL$$faXQDtgZ+`t%->cXO(U-Lrt}zyTQLWKmVlI~b%4RG(`<6<^ zfd#)(?lhh&G>`0B49&4Km*78n59`xcoYS2*RI2nyW7^f{VRh1QfM{cURO@ZOGWWpY zhQa)%#>TGuB-IdwVwIr^%c7n>4fzs=dky)5#A)M@%wi8)3xf{^8wQ8E``oj8t~9Jn z^t?W)-b3g?0tfj6_w5lqI`czV4Ce0Cr7n~b_xT_UK6v$R$9?R;|ZVnCB=?+cEp z*v87il=*?KOS?I72^r#rWMA7yj<5Bif1N5kdT@VG_uI*InJSw`68`9I7lyM^2eSmj z{{w}$6k$z2?3jo+3Yizt^{W~2Y(4TRmEQ);tabSx=X4LcsvR&HC>5RQ!|WGM{M5ay z9t$d9C!ox!!N09vNoDPqR2Clmd~Gdt;H{jeTv_tf^Xf_+7t2u5MuQy;m2Z;65I+jh zInrcIIKM_-J$iq)7H-}oz_;bcjLL2CVp+*_`B!JvPnfm@JW%Ed2rggNay52YQAMNp z4T@SCsX<}#KNdY!O_L`G!AOr)2;xU$^?1&SPu#YtP#mS-@$JDGf9ujk?J#_s$&6*; zm6uKNlwEUu3|5(#mMZ!UaP18eIYDA;(~xQ)|%Mtxpz-Zl_`j& zA$G5u`@~HdUog+GNI=ynZkUqpX)WsBcxL^W6|egI@CJ(X&{JH-?2T^c$Vv%~R(}b} zL~7JYlvqo<#OfK8voUiqr=%gU?t-AfV^(>&h2B%Bv5q7{hm+_%qeyjbutbgZ1$i*V zdj@RMLP6YEKFDBh1zRJpV@)C+mskQJX}qM8r=-tU*F6vc;@5+Q1JGw8i&(-6+x83h zm9VK6F`ogkV&PQDl$cV{S5pg1z~icf!%L^>t@e0p()Xi((+0+*=-r6iA^7g`S& zVp3d%zpA;?4jsf?}eFitA1}$M_bh% zMvkDx`9{eVtEE+jHGPuyzWFHa)cZq^hrS&knN+Id-LEuEH`Ym(bxh#xbjmeU;{r+s zt^}9ses)~VGS|{53ulm|?VJiOA+w3$^2$}JL?F=- z%^P<>p)7Ow_0qdoZ&WEVUO>!{UW)z9(N(!`#j$Q(e)Pw#lKP_ajP_O)fvh{T#6znJ zw-g}KgAdTtYpiwZr;lqCgi)CG9qK-}gFc*E_`1Bd)7VJ3KRvt}0#Rq7N@X9`j-sw) z%3#zdw@IQYuKifxN>*07R|GtT1cg!5rRH*m^FzYfhXvn)m18)0k$gMWBH|9fK!Aoo zc=-YX%oDUVg(Csc!P8GLa7atbNJk1g0DD%J>`|ou4k4_o`}YJWH+pMh3E1hX2V2l8 z*gi{(5>d? zzn*)sPFLcTzqgTlHIn;C3Bu9b|NhYYVIGI#BKPb}mPAY9k(?qnFs)eC#2s1vlHr1U zpJ@50F94GG^~D)DsCtnm{Y3pr>*Qi0li%PX{8{-)FuleGvE2&Y$K_FBqDyx1X?F3o z%54;OR3R2Ww)&t35yd5M9PJ4xK1O$ZzWq2J7lRa(0lMgI|xj!y=Z6kVQ#WW>YOu0>dZe7fBBMzRqr`Y8Ozne^LK4(A? zGwZj>Dl3I|UbTGpbi+4|yjCOb(o- zYOOF28CyU(>QYvPplE{97^M0P8e zVn7yPsT^3ozO^!#1x&lK@W3<$CT>Bbk(sYe+zF1{^SV;fRWobD(jzJP5iC4c#$^HP z@Uyg2vZI~a%neGMwyqx*i0!u$Zl~l&X6T}A#rC6JGbcDc^u#>KkM<)=gJOPn94rj( zWO^uTX^qU$519ufX!N+&E{!OU#G8H^!SoDMRa8@o9(z91L|1&MQW4~~i_`T6U!fp$ zQQ7jh&xVb6#6K($7I>+e*@hL&oY)x!S8Q_?d}k{Qf4$?-Rab+sm#W!+w@H|zNj(!Z zvdVmzIo}kQZ|pVS)-rLt*gC3PKO*i9%IX8|Lo^X7!z2_pj{mv|L09G%PX8f43nPIK zhz)?dTdnhUGq_(-YiW*i-;hL3Q>2%)1cf&tF{K1?L0TNSS2oo&RW|jkW^4Z8dp&&I zl!&p|C*@(mIbYe|e7*gZckR5wQYHd(X-_a&bZ%7>X(t#=t&}}+Ni}c08*H+hB&$;P zjMue9&b$y^0Xt^uJ|kixrze^M-I#|$=-q)G#4+y<-;1qxrTWEN&W{Y&oG8lQ$RB>s zO2hzb5`sk1u#qQnz)Er+G)J>@lDtNs$hgkFX}^l^1sghjZTH+Rd8UgUwfo2kW(`=b zx?=qJZ`KNPedPS4Z~AfE)9_TWWD@su3&}*-tXNp3=drA&iCTC(S=foIF|(7v^b$nd zZWU`ErcR@#lQgX(vK+6QJ%itO&i5W{r@lyQ+JVcc_8qw&(%x=4WZgP)ZzNi})!nC+ zU2-5lVELH$3s2S5$DP+5TgGdn^(XFv`fY{LIqB~&a_2kyIs5UeIQvNwE_#6sT%G*ylLrNGmLc|N0eof%CbP{TR*3meyr?{e<+_u}a-pAhr$qAEs zY2Oyz=ZJURxoKsvki?O6-Ft<$<3>o4x2h$LZWya#pupvk+Sj3)6fazaAPc+bx0RA5 zlL1#a71c_jWs&0@%(u?_o3QBmIdQwRG=!zAX6RbA4UiMtuJim%P>X`LQA zjzS}HUYqjk>7%5MC~G+J%RFO-PSp!(Uh{1{ZG7TI&b{v3Fp03rp+1E;`E%lNkM^En zKbGA!-}*p6;Vb@swJ_d-nwfxELsgQTf8z}Xby}Hlz5&W=l z@d!R+%Kky>vAwv2G+VQK5?gx2-FOh$#p&}$3wxZ>wd^R2Mr4?n^2rKFCg)IcGGWB+ z>Gn2bO5 zqwz*jKQc%4Upe;8cTlo(qww`yiL>9Mg{Q~x^M3fX2UhF?Ud0bBi>_X5dc$i_EguWL zd1lG}tmy16XTKudgSI@nDv2tIOgo=?A~PdN2oh2;<1CZeWJ7X_5^I9)B}KfiQ6Sn% zOPM;hE6mxi3X0jMprGfq9LU7AyJhCa4Q$QLXCOR1Ei0foq7&uYq}d{VH>J+L#NT^4 zh`7YEMImG@)?`m$zY6$r3#5l2;VpDEu z_(a>C4{PC+`^wz*jOnccksHmHAcs(i7U%l3j*Bc=dH>E_@q77jR3Ig_&$apL`SdQ` zE15vm>*YS)opFK1>dmthZeBx1=DfrZuZbNurv%ZI*9cNca+W@L@sC5y?a$B-mr$q9 zGHJSaMIM9ND7USN*oV#t8B_DjQGsm4o`Pajl-mt{WFw^R4iK#-|h#QEJkmivEC?YO}L3-&OD5;n(sV;;z!&#XwQtm=qj)aP-Na& zvgo70KV=yec20=rV=?d@ZmGi8vt_4VIG$d5OzNmz0K z5lYw(+_NY?I+0Gi3fZ$2-)0d<88tm)=;Gl##5OWNKO-7)>BI?{<#VwS392@0<*;IY-Ry*nG0tTCjpXf16weX2M@_e11pE`j`$&|L*mkCR#)RTsa=5{ zUbF|!m~9aw0`c%mYv31M74nUj)n7{r2GRo3gz$}?S?3yc+wk*;T5hE)_C6wj)t6wA zJgHkYn@Tk-1)-gc4eBcncgS)t^Wq{W9rtD3aKLsdi$ zPE!sblyn8WI!pziW|x=J();%mG$>)b80E)Lw72s^%=t_1N$l4Qo0iUrhsLQVJU>hl zzb1~7%^YqikA-rsouAXi^H|aD61S;^g}FwETS*N;Pl-Uja%PO<>gwb1qt6AJ|ew(g(58OfnE4oyT`TglX3UOAhGJ{xckO zV_) zU_3DLQcGDxjvHR8VDj)@N^+2k>weIIW21KTx5%=mdXhcAwM93EIh?nS21%e*Oo#Ub_gU$W(YbFuZ8YGfG|w&29jgtq`6beeVM{q*p$>=h8Dn*!+(W)5EN1f5JG z;R%A?O*{(hR|1`~lNp_<8A8#L8>cWTMu2_H%?AN$RlN;^bpk}J2WPrKl46Ps62X$V z+|9P!jUWzYWNpU-lLUJh0rkDHhJAE|`U=4gr+Lw1JmR><`;Jgo7_7Wo#+V;QB%}G; zDjYC;ujTGD+=EUR^#2G9*zf54dRkP_DkDN$#zEJ*oSXk22=Qg{$aIR6v_zfhPI>n> zInz(DO)-|Uf;|X1Mwr(|lOFs*cVJEy4|PC*V9O2NgXK1xNa4>d32r7(Xk?yC7y=O} zH5FBT0=6t(xaabWbqL#MK{G&@lQe|Bq(7?q$gFUP*H<@J47`0AT@(hxu$k}?Vqty> zo?gF}7eW)r4*VWnfK5vO6s3Q$3OH;oiLAF=2R6C&6zE^;3uOS_kV(3OyzPtMI9ktC z{0A!gy|B9CyQj4uX}tEYq7eZYJ!^P@<<$OpCh2gM<)m-I5E?gzh>y~}`nDSEpLAY= z5_ASDQKW`cSU;cawfKX9*ue=yM%7Z~q{0;7b!k?wZyq~w24+i&30F>mYDDGc=1A1v zsGz+)ufb20A@Bl!t}xI~vh)uWQnI|S5sq8mI<)Bw?$F}z3>2nEvvBi+lucH%<+5g& zH{xc(W1ymhRAL==hal;5ym5Vn@lR}1nMV+3L0dDcnamZoSK?7%(@ACkZ9D6HtJm-f z#95b$w|~wnB52KCBwOT0^pB%-F<32r4=w&bP6%yA&&>lvF}lAdAPb|Bz{SV+Qw+MQ zdae+F!-30#nzxjBjBT!1+?WfIbskg9*rqUf9}+t*)ZtTESwQ~5q@9uh3I~y{&%~=R z@vQ$qPlEku7I7@pZ(l8>?R zSWC&M{Rn>3EE>NrdUjZgL|!^^s68VY0v?rT!HaX#5-FhBcjQ=ER6y=hyRjP>(2Yl# z(K9|WMT7INqLvq&nyCWNNeKh4vwFgR{dztxgvMY{kfpl=-}35VaebG$N7As43j@m#{N7xcnCXkJi7d}`}1$Y0_in&*D*B~8#`>5g_+fIyrP1+f`3fLxkVk7l37$HoDy3 zird>0+O84CI?tl~zfd>t`s}VjaPS_0AW|U?u6ED{xK?d+1tfCODgUW+aJnnRr&r9F z>z$-QNXUSuzmg?w_{62{ln1-cv$MA8^kB)21*!(c@>!bzyaS~c+rk(7q?ZH&_CtZg zj39I#uS|LU<BjiibSn#4O+LFyGg7TDC3>VmcPUWc0Z?z^y?R;vfc=V{nKN%IE`@oEC<{ zoRneN7poZJ>h6P(6|Pt?QO`e!^V>V>cd+T+ux*RidyJ6Wzib7nfCLAj0P1wpGH~{H zZL7i~Em!2z*p}V`qKaWC0)m%d2srovi!lppJwNbQKxS<_bGsRQvEi73rv~o=FzEio z8zX+)*Sghw$0@mcj66K_PjyUzJ<>u1PItP1`!=sx(r;WjZt4#G8*UH@oX9soW&w%# z&&4RcXUvVbE9Kk}LJ>vp?wfDbMb9i=o91~F8LEr2(A5#l{cH9k*dm*ws`0EhcCky; zDy{bixzUkKpoZyP6Uj6_1UCAgj$UpQlU&v~`7>jL7BF(Js8lhf_;`9=W@+93uK3Nr z&z|xSv2pij8^h4^aO%IOmimaLD}3xQ=sIKj=+-$kN%|!LXX>wp!FM|ZQV+RxaLa$^ ze=E-Js471`_<57{AiRECe(Ss7K8J^B6^679Xza9&E-W7aHVSK#$#EcDCmz@LrjmgH zU^l!-T}DgsE;I~DV6es^pmgyu1`L`Wt)GB~0SP{Eyh$ln;J|T&0cxvUnX7iovgxMc zh5ub~ph3qW1}ug734#TMi$^n{Xqzzp5!fNyA}DuXvU$ucHa$>Zu{Hyd97?HcO*espD2o?x;K{1#OhRin1OVduZ3ccfhzl#;SG$Ee^^mATE{w8@2| zJu}Gdo>!iXmH|%)N*1ikea^t0-vDT=I7pYkJ*MGOLAjSDZQh{U6bsla13uA-BadB- zDtu!N@JCt#LT7&8ri=MvDsSNss1a?(1HUeAp${*NkB?^ta+w$J@o>W*Frw(lhWlt{ zxQw&JIzLNXJr61Cok=QeJAw;oIl+KWg|40pUT1-dRkiv|I> zI9itV1$-Ub+MLlA9e;|+O`ykMFd!!3;f0&=TqnRKi%t(ePOPHx`b*s^q1U~{=Icbx%w6a}#kwc#MTQcbnS6c!nZ`20!WXh61rEF>@!X#qUQ6cIF_9xO610f7() zEKggIgMoM-33dux`pR<_U@OPSMY+@wz(Ff?>1LyLGcyx$PzCrk3~hzc1PESd>v|7F zagZW7w%LsNtBv^y%KYxMP#olQn0WZjS_LN7h#ytWhLQV7>f2(s%%L;C|Lo^N9+Iqm zWwIT>)yG>%q#vHA-lfP;yR~#ie7SJ6y11t{_iJ~S_saK(>JgrIF$bgL&RgbNXrJ#U7K73)HIrMGL6yaV-7z1! zV;=B|zv2O_MI=b;f=EIa$INWPcxm%ykrbz{n|r7u6i39Rn?>_Qzrr)&^wRq7!J!d{ z?Q)&vx%AF{d}+`@9+srU8sV8jPAda==o_HdBYSy?JX5yV6Dgu~kG$>j1?0j(K=Tdg z_5A<;O8xJDEl3eXQTG*g@fuIYgI7o+F84EMU}WCv!@3mNEawBXomRE{)?wfjM5hFf z4b@my)!6^b+>Tiwq%rZ#s6Soz41g9n#sD-$=>KF3yucX(U9|tNu?=6s05OGAKC-7! zx|bku^nryQ1F-`FoK!Z|il{&Cr0u~HV;Sp28S9V$fFM9Z3oP~z;z7oNP!JeeU*tgo zUi?w1vja@KPrVt03>-CT{xo+YxOY@tyLEVtQGo(Jf$#Ftztk%p9vaf149<7j`2lQ; zC7wNOJKQz=8EHx2DM~jE7#lDRK#Tr2VuRH-!BsBgQf*uC){eA`-TwO3177;y3%0zkHTd^n!IU5gutC-ZxUK(q6CjSN%fqWk zC7|dfqs4jjEVwP(xT0Sh%<>?|h|C1Fr4FvMo5!eT+a1t7HMBJcli<$~QXB>UVMaup z4I=#e+*yb2rH0&fn9V_nx0vrc+aeGEoR2dW-iS-~9Y~%M}5C9*F1q6R);0_(b z5Wo#a-7`be0#}%+VxRM=gHA#qks4uZf}dq_QcMm&46)#NcmT;=3@qZ-Hc3W4LGCbNK?Pt$o8I^47jiMFNTjy?_g7_JGs?go!Z-!uZ@$W(waR4SUvS3TMS<}V+BCPxEq9MjwB$zfjuYMR`FkHZ{=7Kt z;h9RL`_8;slZ%lRL>Ket7h(bub0$!Gp)`x>0YI5hZ6ha_*@{!T=XRJ6i zw|K}5(d8Q%pR7O6=pz}m-2szC=37P$9Dj9Q4x-{^o9;Si*tJkz{~sH`$G53rx82w5 zqYLm?x1HrDW~<8!**itdgC_5UIa@FLbrzmL0TG96Abg2}V7@L@7_ZE7m<5EC0n7IQ z&in4rZG7pNt!{Z&N&t79eS;1(DS3P`4Sew+Q9qAc|32mT9e8?M&+y1ARFm zz9F_V>gC#QNW z{%W8dJRl0uY{avMF$!?^_J}GJp#qx6fGnQj@QJS&wB_zKElVaf2)Vf8HWiv)Q{g4ARxf zF2`^>AwcNNOBcagR;4HY9L05GjPEWzQx=%Z#7YBX}vAqn&V=T*rw7O!Qi;*qxQ(swwA&r_QvFXRL)W(sa&eS$FnCi>68E(XQ4@y)#Quj zl7%H`)W@<%o*no3)Y`*@0vj7T`SomzE0uUUKqk+WR8i;OYOT6WU41v=B6kjN^zqbm zHtx9mtlk!dyEkxiUiFSkm1gvn5*?s!dry!0CGD{<2sD7;F-$L@1=nj8l-Q~?-~h|Z z0X%>p$mf|H*1_QeY~-5jTB)`*!KTB!^fsES^fqXSHU-~eD}H;u*InxvImj}2+iMr? zn5OOJoNsk^cDnm*(hrvnzrNYhb*b2CI^f%BQ5z0|k~23yuf{*Lb=@A~i&go?ejNln zhNp8!m5D z0-hZtRM+>uBFNFBVE)Cie5=Ob+uOU%d)EcBs2#TJPsQ)%&%e8lj)F9koXF+eZJZsWfi8bC8$N`sb`AP7NIWS0Nu;tW84 z(xm0uahr`E>2)9qt`;vGLk%#%J@y}GUyET#y>4u*4l2uuN zNiai)5hRL2sb^f|Zd$oLDaXh1+n=4) zY269v{RcWH*ciHQ?7uD(|HGKk)xST_SMOhq97Uj!BT2Z0-8Q+rlz;DO3YqJ>32H^{ z$Zh3dbV!}mB;&TW^bVWLk1Dc~V_HDWb*1I>cB5MSbd-11H?oW~Rd>ulBHN$~$hsid zb3D2g+ucEM8wC*uJ`8MDEq}BO&T{C(-{Qzdt0J$0ri8X zcG1?^4WyJWAI&aCZk}+UHR6%SQ+6g)Y}uPbs%&0Pdzn`6VjBq+W0L*7lHi!i~UO0=T-nZ~1u;DglHIHmF@67(m|8$vaDmu!SY3d>H=i zsq0*{8yGRot*(u|yg|?=WCBn=wm`rDJ`chHYagEDeWeD?pYKunEAth}1rA!Tiq%WD!A1gZ`Iy19 zjQ_sE*ko$JpbWJkg!sfXSd8T!kZj1@`v!#O-?bHwrbUYW1D#>=@HD? zf8dmlHfx-(r>;PjM{Gpr==-m1K}Xs60B?YKGTz6)AL7S^zJKQDYfdc(3>;RM-Q@4F ztQ`a2@11O>l1l>GhW@uV6e$HVu7jz8xyR0!;l*N;MNJI=;0Dh^WwbpRCj98Gtr$FPlUClx45>Ca$mCnEux7ootpZC^!s9~ zwC4KkRELro1;n{pSWQuNs&MVM@i_>X%C1ub|kXVi_Fy|H|gi@?!pw#bm5>p%^ z8SSh^HSa83HrfFq+IkMjIhyJvjLNp%d&@E(4PF;DE|ETfH1ng_4rth3C(Wd?xy*kR zs7zsz%JARCmi`JNcZ)5%JQ7y?HTjotOIagty0Cg#qoM3pibf}C2RBkkkZ`mxY+{6X zGDi@9&XGm$no&8xqDO6jwz~nWPkNTAu&)vL1AuTY?;6+rL7eZP z%tY`_oQId8SDSQp66I&SB3Qr(&2|mkXGAR;3HLJ!e z_^IP&5|h_4GC}PWmvJ(#WYhc+S6l z)Gy~Po3~&hZmiB@{K9j(yXG^`9JJ5ad`Zdyr@svg}VOjHVpq1XmHiJBaw&`2VBHt=6y$0C3GKygs4p665A^_P4&{gjv zB=fCkF^AkrlpnpT+N2OaL1}u+7Nnb{2mO&Mf|9-}y=gJ5Yj8)5_vy*K3;#=6pm~O> zdY7P670Mb48)E5uKsnZjKoHqj?ti2|S%|~mPry&q`rI%_z8<4P0d@d5X&^TCuJg&C zqpwnf*?iOR8Fz7D0!2Rm zw<>ApWs26FELr}U>6Ych+CCd`sRm75=S6ZQZx_(FFM-p8B|Nq7ze@Veq`AG;St@Nb z8ptL?0V+}Ug>ghWe~VG*!udm|w$TSno^1+Lv~cXXzW9|y6&wrP!daUo9v`F6qCiJn zq8m{6)?5~pp;ut&n+orAj#MbXxG z7h7C@5TYTvxcCW2_e2L>Jz^i7`y2Ev7ajZOzb-S`X{8EU_q`F|Fa=s4pr4BWoj*$c zw@R<5`xFEh$0a*T+-b>G-gc=Oey7vCbuIa@tS80Nk+`!W;<340U3U41%p)NfA!gjA14EhRHV>1N1``BbfZzVbJ;#S0p1{7dn z9JJ9mj8lR1G^6a9S^RZLz&dgHqad)$0K=ntT_F@bFn(ecG)rwfMOf=bvh-7J+Hhx91s1!8HTz7a$(3K$I^G2F&jF2*yqYeq>-={;^6hK# zup52-bMbML24X-VMBMVFJNmj$DIll*r8;^6{BhZ|`ft-qT(aY;rnY~JtP>d0I@tmT zFaGsm^nxvp+v|Diu`w{Aok6`~imqYDOYBm^jX>69 z`>;D8DPsm&YWKoUj$S81wKixAmtdCl*pc%aZT>I(*(-%WmJvkNjBbuJ24Wp<5w`5# zBQ6VW&q*$kdzc8m0Ql3Ea2N^^at-rHUnftgK0ed1O)zPgag~EgguavaufiJqTav6q z(`4r8)a)m2Er2Z$Ni1q4r5J0^=xD@cdG}u`U|p8|F@my;6w!23*!!t89o>_`Cs>?0 zTT}GG51-+;_gQ6%b#e3R{7(<*3o3L;6#SjUwlY1fdp-SYE2L5FgcFXtC)6Dr3VLFn z{l?0r2ydRy2n`DafTT@~VjQb-%Cz$61>URk@Wihs?3i?tD}vBoZ1Kt|uoOsXAcD`+C4UcPL(X4|t|6(k zbfVVe_q8trT^hoBa=?Q%9V1Ut%ZjBB^BiDXo@UQ>z6e;&L;vB^!!e`M1|S0f1u#TyZ5%kt(Ubuos+uF z8pn;>w~2{M4f;JjeEj2$#@S<|ihj3C=-YZ5KjdajKlKUTsQe)q5VGPK&2K1LaF6#p#_0ecChrIx#)Bb82o` z8ZYS#>k8hDo@Wa*deeI?X8W6MTBza6sx&KuFxTKpRWI)6H6eHHC;3(T-c-AECkIWh zn`o0GGt#X10cj5d;>Y;MA@^A9C{TDWAE)wgYjPgS$Z?r$ONpQ zM#R>iw6t93nDzr`HmMt1#cBr3o4K_|`!_G7lr3fL1(I6g7rkriU(dB1fcD<28$IZd zJ^2m)-H$(RDBs6bUrOu0g@S>sgQhfx!XW7Q{by8DU2YV&$DIo`E^d7BhA60Vu^IQs z-9dM_*qrCg5aQfZ&JSdx9})MS=05|9G8ixN=grBbL;1Y5N!$%NHaF=ntT<+%8^va* zs^*X~^_Q~WWLnwoF|}JHk%Ri--;B2E+Fm;muVNIaGex^Sd%w5Yy$kr#wvPURR;%P! z7w$$IF8`5G`?r@xzjSTO6iLyj_Vm*MiRkyFO{IA|j(zzO|iaWZ2-DJb?FoT9&21G2-kZ-c>Y_$6@u4!^%hR$`X_BHF-1lI^MKv9UFP5`=Yc0IbiE*yv9=X63S6w zNehsw&r1<=;7@Yky1h+okr}zBG#F^nw2||mO;v!xRG=v<&^j|LyH(KO|CoFtWmT1O z(Rf$Zkcz+j?t+!^^+aRaQ7v0ST-Amymq!!4lmfFET(`w)?G5?!mF6ik!EV!(Jxr@2 zYTNwQ%C{(<`KKE_v8&u;X~s70%ISV&Dfi)?cNsA-wC$~a$d+BQ8qWS^h#y) zITw)l!##?cpV=JZI=@bEsIL0ji^sXk>3iM2=Swd9Ntvv%x3TjAYV(2YQ(p(x2GkZu zH9&CtideM>l$h@+P-4EZt`!zV4_8oz@q|krrN3-qpWg_wSC(Y`!k;;vVYxBG%@#5G z90{wtUI@xCG!d~TmY7eTJUM+Ea$-qNeOi3)j&EnqfEoC_tr z!R1x4nSXIM#+vW1d7=w?d6Pi=*^t(frcK*{4jG$<@_B1-@= z4g)+vJ|06W)P!j08a39lq$baNn$4hMRio*LiFYkeN{&dC?1APp*MQK4)ul?@4`UG< z@+gmBE+7&DG?4&@#G?|YS#sl#Ps^vrSlO@hZUk^vZz#BUSw}gry`CzBSYwjJaq`t5 z*|SjbhCgibMk3AqRAFX04+;xiUV~wTVqNWj5?kWaQRD3Rv5#k3?9r}8mYCI!dw#$5 zrbvQgs};;6?iEoE7KpggBv9{Ow;eaEZ3t$Pzpbzn2&Oyfjw@RT1^l+WHYhl5v)b{< z=AA&F!~3xmmJFZFDZ&Qx=eTWXPO;Wm`cSgp25KdtbPSnP=et;Aj8j}J?g(sj4!*uq>; z5FfXlPH`L30hS8u@$02p38~I+u1_1kEpt=me_5aVVl}QL&)kmd6X~~|_cWuzUhVkU z*TJ4-Yj6eyPrKyyA{Fxk_=H}pmD&W^k74W!y-<(5A5VBOmVCW7mj}eq;u*Ubw|-SP z|3C-swY8`nIM@ROOsM+B>WBQSJ3|36b8?21f$jK=jofU*bbi3MrkJKe5HCI8bkoKk zoZK!C1EeGv7Ezaq=0*wXl>r48ohr@x2EX1ppsX!p62Jd0$xfBme*VoKHhcEOZ``5%Y`P8om!RivC}CP4Fw zFG@jb1bB~V7^Ce?&=p?lPc;RFl0Th60zKwDIw>rvSL)s5pCt$i}Ft z?O-?ZkHOIur0662lbhwCoT)Heqj^w%t-T+m z^smSLbB!}o$J}=h1xW94J$=%19@cxQV?SW|1sd0?mAoq6pCK9UtjGKV&1s0&Rn&5X(uZp=Le0HG^~h~&s%rB#)R zM1O_xv^A62%F}Ih_h|@e(7{Ph2mM8!|Gkg*CHUhK)g#w(nzO zeicavVe9oFS(??9{8sdB8NY#VXPaxiU!4Tq$FiXPzYE+w;Jtvb2e~-Mzx(daYVuZG zO&xmX>@u`>InTxq7P|RiNuQLE(7a$@uEfCse(U!(jp;K~MrVl-Tld ze939#==<#}9RG4UICG6(MPc8ko66ltef z#b_D+dWO9$)G?m%_^0avo5L_pFt7GTd1Ml92xy)rnC4ntuVe#&Cyk4F%Du42@F9Mp z0%X>o>14zpzz!n-=4NzGQ6}p=D3k!qmv$i$vRbxSQ5o?X&4ymh26*Aa3Wx74(oC8P zYlCJUr#9}My!z%*c(jH^Eu=3!PM;34p|8rauXLbtM7+{KYdsg@Sl(*3pWU-S4=&8f z3T81Rblj#AN_h1eQ-NW`{QD|EZeKhKpbN+W9!VA?pp7;)N^U$Pn?H{qz>^5Vh@NR_0 zMn^^7tE{cLkoM8S$nrYW^4?Es^Qv)I3s8Y>@HSJN{Kfd==>XN;Qy9#DoPt%R8xbIA zLe;Mv+o+}*w1ItdsU~Nd{2|dVb8}3$ho0W*A_aTm|YxOPkXYK&JA4e2o@i=?EcjK(v1qygEZazWRok@$`0VJDq`dZ$c zs$K2x%a<(XRM9*kU<6~j0=;1q;64K3TBI1%RN+LBeF`aqiBNj?Xw&Bq$3~AO?o`6m zu_c-Po5fVq+q98%Xg+l2Q9{to-Bq6}XJ$9^YgWZ)>^h=t!|R0 zIgV-$?rrY>7F6}j!U+B&BKg^HapM&UtGXoWsN2mEsf;U)Pv8i;Mw;TaJ{N3ViZOIGtA`wu-Fz3|t{XT%sKqYOr1iFrKG=KxCvI_VrhITa1NQcTZx zr6Q&%p)P%ileR1A(ac<__5!9nO1EHvw_tvl_~U9Go&y*@ujUqJsOZ{+26nsWvs-d* za5I;^gt>GG*B#^hETzY5r8P>%V^j|BpNY=O($)HnsT?r=Y7E?>ac?7;OXfp4x6}NN zBPqYhKvuI~DT$b<5aED8=LY?f&D+W%)M% zvy{W;8LsQ=U^eJK(2HfsSeg11QU^`J3+6J(MN5Tv2Cf&BtmCdqT*&?QuChCLVe1PuU(KeK;IVy_LcXhpP>;B_ za?n|(;>G0l-Fw|&(oVj8PE6UqWAr+h;X{N+!2#zFL^MKoBc`I`Z*PS(8e;Y~UNjJ6|XZ_Ym zxL=O%8BKaIX@9YQcUP*%``!D%1J#Ow|3nCt@tC?=`nHGNG3?NIvee=i<%Lzt@ke*H9PMn$2GOuzaX@7Fc{3jlx#i#QhXrD zb7xR$v~+g{m1q}adI3Jc^M2)934*y)V@2a@~c?LVXzA$ric zvU1pB8&x|DdO^M&<=Aw|?{!A*L|h01JuRUA=RtP)2)awtg8cKEnKXTyU79JQ)Rtbw z#BcL!_1}Gp4qput*RZZ*KEyR=*H;}y)%O{H4ZiI*qqD#JxYSOBZX;r zY5}QyT5bAxf8ff|xy{9K{OW=lk!kU;AZ2%oNV>cMQb$}*w`t%w>daRnL2(pR+wu#h z-x4H}kZ3M_W-yun)ITByQf5$=(&HmCR0eAfj`&9wOj6{^ByP~H2BkTDVtmN0tK^0P zrp5+Z6XC$dAFAEr9FBRz_Atyec%b#@$&hL&_AqH>t4m<0HSV6^jdERtKeHR1dB16% zch#51bCNe0OPrTTm4X{i(S6F>{n$uP6`UCT*Cdn!ya?$Knr(CX zqWoyyvW&9)q?%;|-Gws2lDW;+N9u>I<}=tzr2D*1`=BRPHA$)NV%u7?^JqpE ze8PQ9dlje)-nkczjNgLLuy8{r?pT-*fdO=1AAk~`6g&Dua7-VGGxvIZt);{M{mARp5d_#n1yVzU7v6I=FR(9927GFIHb{UTg*hz_==qbFGj^ zpO@Q3@`jKO3lg*)cXx~#^r>5v42@YI3s8JBTQI_gtCg;OR+fJFlGACUyqj&Y3yDNR zw@!7-xY!<~0fDlPw#)7>w>(0|ucxI_B3@1v^E%PtQv_Bl7f9JfCbN~@l-xH7SrbCr;|D(xkQs9L@L9I}R5P&5q}K#R<+ zZ@?mtmyS3(eR3yMq%&Nya5`w`ci$Y19sJ4U-R6^?;UIFkh|=2q>r3?0=Nq3_cpcW; z<)s&-{L`|ZS9|5$FW>)bw-bNjdaw1)hr<>%Y%W0mjzs)jqSl*^HBR~3De%}m3Y7squRpt zL>P=3ImHkN)Nwj{m7CsBbgEyap3;Aqid-b;e;Ux43V;m<-BoD87$>SO9OJY8r4gn9 z{~9`tC9#>T=6)r}*Be(QEhy=3#=o|3tpE)MTq5Gp5uo8$3_1g$#b9Peb13`dv`-x#0mshc zGB@2l46%`U2JS^=VRufgSGN1j)s8Oa?Ov*>v%+x_hF?y}CV|OPB`O&N+r*i&oC ztMmKDYqFW?x2q`mccNy@-Ci=5_R1mRC$8X-;7-Id_fuaoGIm{0idRsfJ56B!8`RED z4>A=Z&fnNgt`-v`0&xt(sB@v$&k1no2Et6sU%zO@#6RA>r2qH11FsvE-DfJVvp?i* z2HbCEu?orbamk%LGkXSC=Mb;$JkgnQ%c6XRi0Of#@bC}~ubN*M&qrwDx0b%JfbzVy zi1Ax?)#I89>-qM%_nfct=Qd9A$ePU~j>)KkNwm%a3B)@9GD9 z?TIeQ&sJWqtC>1HY5L(?DV;fE2)NC{Ke?oXG+M$#%Bw*7WBX{S7As&^mSR{~pW<0G z^!*xjH5X-mBP_0B8>^h2VjerF{G>AOvv?1RlP2l@|9XEA-I}7l5?@?f%(tizq-xA^(Lg_8Dyr<|~!kwq);vKBXvPCrP6DjQ>IG5JOGls1{JFYX@uKZht z&P*5UQEO{>R1D~u^Hn{nAyZ(ixa|hpuQTpWtUs#_oz_ewFAfMWZX3>t)4F)*JJ=omNxv&c%9p9c&?A~RZoo6W6%*(TeY z1&@YW*2f$@ud@jqHj}Vfdnd7>iZAUt!-<=wjaLgSKXA#@#b$w$c&miU2X^<&+F1eHzK0?2Uul73aND{L=I@@tY8k zH{@SBeP_(?dnI&#|6)BTu*7mk zm{gwk(`lMem^j_o(?hovOig^g8hlA4_()@RkUgjQb$IwtptG#)jJ9Mvl04-q`z8<+ zOfk1mpiu@G4fgt|YMPtZ6!O|^deTqEk0?*F=;pM&9GF1VS{eb7(2EaE zt0f4h)qPCmR)n?_=j}ab_(WDK6WQE(#S0Oj89t5x9j8~-DJL6;&NS?@%;eA58THoB zcskfVsSPmqdyqhENib8$x_fO*-9^8aWY%vbT|$z%efyXEPSy>z5h;al{6;fC9p~wT z`8Brikk-=QyT08G7Ly{FfPKFSW0T8dRaGIF)bQ3E1!#;0??vu?R6mO$R@xoevNRE&$1lGAuC$DQ zB{$JR-f&?`k@i-#bQ5{MnbmKVrs>URVG6-+HojJOPCc`?;T>+?Z#{xEluruQWh}8& znGge>R}-b z<+RhVtmPlynbdP!1)}h-VRx+i{qicW=6-$g8|zm!QQIL9Kxq!)cOzC2m3gvkp9oby z{@LRi@FsAqd^l;dod4j-TKzMUrm(o)or(G)O96D=G@z%Ns=zlC@`DG!AamIeDV>q! zVjxPH55{v&@QgkS@9*^%{3fO0ZlEKfGIwP`_fYn{o$Z)TALYsB*z5g3*u$-NeLE&# z+OeKi+Ey=^g53S@W1$NC>c`11+mX+?()&V&D^O>K4NSS=!z>S9t{oyq9Ly_s`Ug9q z5zPAGS#ksn47Y>wE}JFBLnmcZ0fa2^$W)-NP7HKX)3{wMj5we*#MrZ{uH#2Bzx~C_ z)Z(lP1BG|~ebn<2H=siM;8OLi)v3Q6j+N+MwZ-=J2qY;KDuxMw;!K|Cb64cfQ9s>j zONslJ^^|76aT|H%ZXL-cF8|;Ys-@)aOlZpI>1|bMsrk(XXOgvztvyO6MHs`7Q7?d0 z#RgxjX3Z<0DJKzWsik#oaNX`XW2xQ7!l%|(pZB#EW`;K-w@`sF?z#YDfhKBMh;~NE zlS>NU;*G7kss=z?e-1D6ibOvVOh1vv^q}acS^72*tm9Q*crE4J$ADlh!?mD)Al4eY zx5xVS&Z}|w@PE#vY}B7%W`WbDei#$X-P%iD&^gYi4a^wi8RXIHm&5^Wrz^yCnqL4F z!&i?gm9ptXt~mu)d-611s0X+SzFRo5BrEu%z)AZWRrh!RO0n~AcJhsPJgoVAerI5o z?$q7N61%X8jKLxK^dng<`?`YHt8akf-vM8JvK8JWc7qTs2SuNg^Dlb@Hb~Vvdx6q7 zsqCkw{{}lgpSml)ZgvlE8HM`_YeYVeLO>9qwg7)DU%RQ6c!Dj%-Xe2)=b%7nu#HMi z?n_)lao7aTR^9q&Wt8J|vz4*zPMNpRk=l%R)Gc@v;|^o;`x%c5ZvtlxtiO8q2p|pd z_4F(`3}92xff+4eW(^EIb&f5SN8#6~%Qq!<=eweHMY~Dj-3A;seD-mKw=FR0;5Ynk zr_kCvMX*oWMHvN15f&#Yl2XcCT6Z6RAUQo6q-MBu@+6lPemhsGJ-1920em-wxlyyV z<^ks`HASZu*u?AWJaG$VKh$!V9E!Z}sNb6eb0m^WgzrdVwTg&9c=NJW@fzpE2hC=e z?Q7G%_)4ki$iBh&dGBeRMdTA9G-x70=vmK$9jQdN4E+1%t=GI1B{RH2*T~E=N$M98*y`*rW8ZOPk zHy_kpZ6Yd6ENXy>_8x%b1^NudarY|Ragg*!$>YWRMV4m^iYhsyqn)Q{b1 z8US(&*-22A(P9a#@5Yl{FmJNWX~%u91m7nUw%n8cy(~<+SKu&X?pzBoUOX-%%Ebl( zU6iaH^&~#ej{07@u>M84vd9>Z#bm_}6%C4VA|ps;F{N8N#k#YR))5g8_BKn=0f2UbP8+FNCnj#XQ^wPlWLW^-_Qyae0U+O-^=B(l;;fl;^`R{+&Eq zrqkaXX6towvwWm|ib4H~&XT#X@GIa{=Jl3AswsfW^j1kgiAW;_T`ix9_6sH&g!gog zUI^U>6Y1VQ=`%X-=Na5Qa5_JrPdVAff1xKbhcEs0I)b=^>#M~hChT5FY0Hgz-bk^^ zQ^%@qO_4|of`|1FtB?4f)v12$P>2m0KdhI}rn=xQ1O~s5)rSBGbwOKnM03!D1S%pN zw`iCnLmXig-@oGWw;7RqF~Hiwq6pQ#3V{$LIRI^T9SHdx@S<> zzSyO-u-6#{olqiGey-cyp-vkG`|T}ts$ARMxt8K`S(w+Y?ODWo@1MFJk=?C$^1NZ; za+_bl3;RVErt@uOMq&+ zmiiT(`RkC6D=6#Oklz!8)<+#w<4~Gwx)Cljz3aQevwdO zT#^C2(6lXeJILnXmWE&&c~DC`?e6yIU9Lgjz~*o48v8CVWi zTtKqpmv)+{=VsS({}i5S@DM=lvi*z)M%RYrH_Ct28tWx zb-SV(JF8RdnUke{B6beBKDSuT8u|oU;&9)$^4ui{)dR8+{C90~RYRTp+4L7;wPjOv zoTN>wuJRhc2I?|!Or;GW9pZol_cj-951jg8zZXJ58<>q%$d1w`EXA>i0qQ|L7~0N* zW)LGl@dWV}=R16cF73h2VYfM_*JoJCA>H~syoE^6Q2+)iJUDfYg574z9_b+U42pg!fSJau&WvCU2D zJD#;%sq+hqJoN7}UQiyHY}IN3QT4w1(>9SpH!)5>-!)f$7;s!#;4xj^mv5rFxUB5^ zt?&K(thqt}2S#UiWbDqzjZKFMI?(plV*mD7Md0}Kms1~Sg5_QBU3uTWGbGi&?L3NK zU2&%7kbW@%EqVK+b5)Uz$L8PuxC)jWjQWNnnI*9G z3AHw`r;vTk2b7o6!0K}ky{zeITk9_Nj36VmpLmPlPH$7c)27NLafHHTZJ^6B*LO7c zWkS|Gd-alk@7IwJ$}Qv6gk<$kKDWvSU5nETQaX(07S$g4wePRdl}dyfd9_QJ?+y7* z;JK{EjS;FZKQs+$hHwVw&gpIKRe!_ZTBnUnP7XbA0COK=ZOW&0CA6Pk^sX;Ij@B@l z1M9})&cX~1W9V3MErEY}w=KBb`NBu__wC!)HXd4CTl%|T)rdvVb;+*S_5b1OI{=!> zwzW?}KtSMv5D*ZDL4_FF(5o7}1f?1jkS>CB>0K-VEEqtL-i@IcLQ#5GEYwJmCPh%h zh$u}|?9RV}ckch*JI(|&?VPjsUVD{qeIKid$Il&FOHX5fvq~Fd)}}h{EY9BEJ<1ID zf5rSea^ysvUEfyF+Tw!e1rg4ChJ_WE%j7;U^&uA$cMsgA~MeZx|gz|$*=kXG7TH)Rhd0j331>+vT> zV4duK{*j(4ktGloKATexytun-kNa61^D}#0H=?_EF?XfQc2)0`ucZ2bty#_NJKd%r zy|*gOH&$BC>!0n<;ydT_>w59SMt=vhpwZPCssUkpU%h`(!3sB`0F&FaGw9mDegpdI zrKLHkKOA&W4co`#@#30-^Vs#f)G~XG|Wn$Vf5~H6cb9A)+Ani2g6axIO z+z6<&SBpU37ZG64<>eBd`hKfIM|$s08ObTe*+zNi>(_A->D*g|pyP|pi4udwvdcC3_ z&@Oyaa8dVmQRtGl;p(!)Fd9MIQvXsUI0pOmR)jqD;b-7b#av0SBM*CrpBA#{h>h>|Q&cTnq)h zC-0h0|MBqiW@oB@Qa%hgYrkcu0T$z=Y0RQr5#N?Tz+PyV;GvucbBD zKGZ*hcKFeVK8_T~Yv~|o7NV+?DXZL5Y%5tqt7d&mgH+7+j+uJ}W)JrILHjU6{JGRuL zaoJhE=vv-`FVxwA9;#BDip_kC4c7RFKCyU)By{AOHF-ujADFAiNec2z zbvU~SofHqn!+oY&vUB=b_l8f-I?ZTg9awtYWxNuTUf{Iq-mMTc|F7?6>#3isE~Ndz z*O+|Bv8~6fRR75Lz#n&hWK%yr{`{u7=fn8`(vR^klzmd}Uw6nxxlbK3`>S=<=}~A_ zNYb8iJ9dWBlHG^8d6+&R&!vx5OMI0rhXe$U4A{7~mbYf7+~)IIKaN{%yJ-j6cpFbV zAP9lCEvJplM#ALB6q%qwNU)ZikCRPOK+;-9MA~BN3T@WXdUQNMgF%6eZ1q9Le;jg@ z3mCbk-)eyhzANXIB4mFBcUrQbc|@yjW#IEE-&gMs_+Bl$rk7WG%u!3uBjnZg@u?xI zLc#TlNDk0Feo}&yuF?6C^X|)lmu!^785q7Zv)Ye6Pd(%}x-Wt_bmP!cxr=2~+1QOW zG>r`4Y-o{CL@+?y&Op*HA;E1e1CBM{NxK^FYi0h_?k;+9qwBfL8<1p!G%Mna>HM^icCPO4xP)1;vj@D#oem3PjAt# z&0zjvHovjOd~(N!pRM5|g^{PX7wj*ucv<~dEi+{2%$r@wgVWlpw{9$ak4piz5-LaI z{R`I$9QRAkw!)dhF9+T^27V|wzg{`C&$l^QdKhfD3?6}Pmz#F==BC)|!E((w zt>zNw0cnoF4mfGt?RcW#>hrLz&NHq&eB6)d>&l+5zYj1g zC=nDi^ENK_KHuB)0)khBE}Ua9Ek2SUyFt!MpUlE-_3__>Dsx?dR<ajUQs03CTO!+c$~#C%sR8kTfNaeCp%Q~S03IiDVSjXi||@8 zGuj-HnNR9_%VBH&)ViA|@n;nXlwc5^18GriW8sfjEp)#Og4p39+1&hH$H+hdq)@6B zDy;v_b7gH{``<{$>(cGdYThw_-nzA!E8$=aOxdL(jk4hg4RN@o7h%SL;K7` zZ>^Fn-+Qpi`tA5eRoQD?i3OE@AS0< zydff-#n z@!)Rjk7ldc0{MFiuBMkqa;dP&nc6>dnJISndG4cp_uISeA8}d8bQt{RyJhddj0)G( zi1ydh;)@4%v@vMaU#kB`J~;v~Y8auT?Ci<1kGQH?r7G4&Dfx^?^x;2wM~+=le>*Ht zw0%0t;nZu&SdIDYj^cZ=4s90O2lrRh&pq8y+RAa3s{;m*69o_3nS1&c2Y|!{2u(-X z1Qpb)SZRfj!6hgQNN1nBwed;6>F2GDkJM6qAr*MrwjsrTr-(i5_q5f52I?>{vzLe%gaEvg@|qg1UfY~kQk!kZ#UrtI zPzvVi+yhI_w|$F;B(o?Li(xMK#e%U+RN35NYTUn7>_pe&M|5_Dq2v^Iv*;X$?_f2Q zFJXcF00iJA#xx8H^Uo1S3&pG!sj(l&#)LQliH{*#D)Ys;sa8_UB0bA=W5pwn$Pe;; z4ej1%-;aNz(!Rl-hqnzkXQOXq2R9WbL;jbASY&q&fcrua^BL$My+(7> zX^^NPdxz4Ll6tb_4I*S5+aZ;bzwQktmQb(BGaBrt8&f86?VQy+>e9lx+S6kS4HPom zRgPowqBbH@nkuUPR@Q=SYDJWkexSu-^uSSbywdG!64{gerUf7Sn@>nTx6oo1bgi~8 z`ONy-bBOO=qEmU=_Y4q#EyeXmW^L;oZ>*4gXX!2%SULY3FsV6iF!lVNnHLGT2CWw{ZV(ihk~vzFUJInB}QTP^cz+8u9MZwNG%7m7PC``0JF z3(0VGsGqSKPnq#;xb-tmw4(dM6WP_p@-WTZCWqZKL4*6tznOh_TO41UMBpklH_IQD zI+N7A9L~QS-u2XFyeqtwEen2hM$!$><3DKy&OH&uCQ#?3laB1xPvP2c;db8cs}4E) zL&DI9fjtou8c8bOt-4dsrG&;xa(ENCsT|^6Bg%CBufPsiIod5{&p+R1-JMP)qjk@Q zF!LJrRa^RMR(de@5D9`S5mxGQA%ym%N29t1XNoj$o0${LOEwxF)ni8_o2qPQj@uDL ztPl(q73#x99s(bnXxgW1=6=QOBW1yIe10Eb$WpQW?hfxAqU|JZh*FQIY z%X`w*=Gx;=l0r8N$m1iYm!4TxmX3r>`E&expQ%#EwsjfCo_bxn(QUrW(HWwZmW>lf zL79I+tBqZHMd_M-9@R^efG@88JN;z1!+QeaZ_|C?d0r;L?SSAYe(mc}INF zO+WOlyB2C;D&LBTWjmq3%ivA`?H(bAt_|UxoKms4t3@XLeFV#db+zCLUB_bA*PST8 zdJHFqi?yVtRDQT9Dm$^-&2E+W#+2^R+zT0-VgsKx9xp% zyFBXR8D!v*M3dNCwf><9B;l3#SLW8rl>w&|bpE!10 zNGP7m_k5qERO<8f#Cl@oT3-V%Hx-yQUoto{Si7Q(IPLm`(*+S8t@QARMQD72tC-K zTg+klR#w++{!DomQ?E~;rNK}AhQNjS#^?hk*L0nqwC*|5c~82t_0SPuv))l?(C?6a z#PV zi!W+?{qXrumCQ(1lpC!;M0Dn>=Pxl@0g@r{ru>P)`2$L+0a<&K@{ga57(wY4ThPay z^|qal8DKrHR^E2V^gT0j#iHzuu#UgJ_f<(|(sToYg*cvl#v(+NA|7zC?BYCwg0?o{ zrjQkn$Q#^{OIZgIhThsO=Dg|;<;aPei^;qLJHR*Krpi#&(y(n4v|A~|+?G+M>ij>> z+AvOja$@Z9sH}jl32&m|FonB?Kh_(I+e4(It?~FUCoe&`dn6x??MWt-Q5$zFn~V&u zi?#w6lvvL%dpP_&?yIHYsQO^udS`O-zKynq_4&-1wZU`rBno+loRz63)#=-c>0OUh zryv0ae%d%qM#$fW$}5fXLG}nfv2vPskV29y7Tl}lk-x$jj4vC+LxtyvWvz!3F`8t zS;g)H$?r;E$`i=2wNa^P=qqs+|0=sSONA4G{Eq=gCMIW>~ zI#W!YEfW3gBPQsf@yJ3%ILtH1MUJ~3l`vH^9`bd!m4UBx>Ewz}oCBq+?3v{7?)r$O z9kr}1hbzYlx~&8;mN3gi0C0|we0Or}L_C%2e?1BQeqxzD@iq?#;4ocZtZZnt5&+*} z{}bnboKW<8=V<2e+`73tbZ1gD9$6{#)!QMQz&;O|4Fpqys!*CgzoWXPswS3`jOXQ6 zzQ~c^kmLORtgPdOJ4pdSDhtNRTf|%&=f4qOmcGE{ef0GbQTGzrA3eA?# z;hI2&mSj9a0TcwCJ;#8P)C%KMQzx$c=UI-BtS&Mb3i_ zfgo%m?S-;4V!+7?5yW;{?+V15i6WX(TXyiRb6hkd48Pm|YA z^R$S0>HYAlb#)Tv)xjRDgDv*`ZsED*+v;{d&j8B!sTMo+YI!xCWR>Q16W>4fK1rtV z_m#<}1A!>eIzB_JxK3X!9eyO9WxWADCB8a8CTguM z-{l9_LbsCLyDzU+J2WZ-!*=&98$=&-N;7Un-2~A`-pl=<;r!I!$zsF*xKa*Q{OwOk z=E#X9wDH-hI0CdWGxk!=A2kqbWJ|cte0r!i#Zn*W0B+{IHf8hQh7Bts0aOTNapv-c zI>)KhpuZHjSlgbV1f_QCfl$*Qj=M}z*rh)gKOZjnO85IoWiws;3U>igT&``$yI&{sj z6&OLzB-h8RvJMrcjf(kxA6iAF(XPXl@_Hn1yQw7+0*Vcri9e0i^i$-V^2VE`^9)v)p=|Xr^kIpQ;k&|XO;3%7*^I>+V(W`p>4Eck^|7@NA7^*Fj8kr}G16a$B zFN@5^%carKWX4U04Tu~|KJ0!xz8_Oi9{ucOIKPa29#_)MDej2SZgIU=kDga`$2hqc zK4nA)qNW+q&SlFTTh5m)y)B##`~CA7M(P_I1`&vQ&XBT%C+T5_5UFoBn_irj`pn1cMU$f^%LU$(p>hHc0G1)O zj84l%C~v-(@J?y0*~7VZB}OAi;z3Q*`a=scK)`?*96kHKTPNt=E$>;iLcOlIOQrkX zT6d>}R#duMT4$!43YGd$gs`JBhQV#YEW+1=x4mIUKW4toM!!BzF)pO4Y~}Pe7tS7 z(SB?Sw7owa@wD3DN|HJ@IkqpR=#bS$*b&#Or^?6n0ZR~{`}=RfHQhywXwwl=U`BK1 z{_sY=fC9Ps?w=-7kvG!Bf60G&{~`6`$t9x;a&LY#YdwkmIQNYh{G(avz@GKRiUX!? z27`=W6Yb$&&uCq3UfOv+|90Ga*5aGl{R3r7I}S*NJ@E0a9Q0VY!x1vsyo9NaVB;_e z%gaL65ORwkcx(FB{8>?LlaG;6O-s9ue%Fn7pwM6~8gQa%`SN-BLQQ8@ZhO;~Gr4Yj zKWsW?n^(fycw97QFBmDT)F(?Y07V;0#+9D(hD6c8*|CycB_{Su+|w9_!>|<(Mm>LL1a(({-m)s z?oGjkcWQE%{bvn<{wZ_X{yCQ=h+6GHJ?fqD#H0jpVJ77xpnMn?4u91CWuU%Z?Y2ST#cRTEBa9C{{3TEbUpH1scny9oo!99XQH1>LY z@z5;Vp-_SW&O#LR62>S8c-aYv380Q_w>AXd6|D{k#Gt}k-HATd?(PQkk!|&L)|&mj zh139`1;dHf)MG{Q2EWgreM#)AL7-*|@L?c+qQOrD?%}vbcIszhm>|&-u_mHvx5z$$ z;J$b|EeM=YU&USXq0pFBdRhv#8?S5LwQ;dB-!|&0WFF``HLevX2?|BN5}DGv%%-kL z-!_#Nf9D6FyF+G!`@&Y_D_)6GEHxGCz zdqai;i=Z`rX=FeBGGxpw4qktk42AvQ_7Jjg6b?mI&bjV&sEo9LfBDZ`Ma<94~x|6 zd5Pom0*#WMo_a|Mxz`Iy%A-~3TlLbdtQ4X9Tcb2^IeDYu^9UaF1nuM5qmHBSA}XGt zwYA}Dkz52Tf(Ymo*gTu+V&LdJ4cA>{)66BfY>pk?3hdwp2*H5UN9lx3c+Jv|C&OpM zM}I`g+|l~7Zjl&sy^0b#bjmr`=NsnOi6R1-T>$;^?+#eI{B})D%RF^}%;0fwh3qN* zzYhw&>ukOF`mV@w?K%n`refJaN>k;_N8On*uiFyR$E~rDCjv3+mX@$&{pFeGk`s~$ z?{#m)5`yA~7y7jKo!yA4?Y1{2fjrIfk(L8b}CB zJZMYE^t7D~xDx<4f#WeSv?``D5>OWE^bO1!LDV;(a4k{1whWcZMO<_Y@)*$0|ehN>YNc-=*HT)(C^C2Sj> zJ&3@;0Z-N1sAa5d)v?(X99Jqu`|5gXl&)B=G&y<4r#I^ANHRT7v&g=%NM~MQzGc-r zO!kfT&QprNtOE-+=+F5ae&n_3IQz)omQUuJzd#uvokq4XAmL#Ra`#j2s2)y?EgqUC z==^56AwH*MDu7trOGv?&@E7F3h0PZ4Y#+8=EIQci^UXO=vOW9l6u(>Q<3`)ARB)8t z=W?QRX3Tl>oQhZ42%34oo`R~(E6j01h2|FK4m(K8nVH_+I(y57Kt(u?R<4RC5sf(e zRO3j888E{C_XZ#`G)nai4OpwbqB)%;EC%pi=olTpt4!CXAGLOVS580}K%pWEc`XhT z`BhGIVMV8w$E8lMM&cr8E)Z6+R~^V$g0bN+xtp16qqja4*QI-Qg1vl0e&6BEMww$kRi_0lRe%=YozKkB-?WGX`CiLAm->s2`7V;@;! z@ijDB`nJ*xRCrAdmC?6^RTShjDn=iiAwu>ydMu?&oyW%fAX+U?IP(O~(*u(`mj z2KVZJAKY|25@#ypnmBef3N;>BG5_!|+uGh)Hf9- zLwIltaxKJ851Cq&4BJw=BEm9in~V93d%5Lp*9Uu zDoecC@4y}3@PZ;OW#b;jp7tQ&mTUK1Ke9u+cR zFppm*fITO`;3J57kU9AszWNG3jSltC&A1gFP|2AaL1AB`f~{m|F=i&UbtF1fLIaBo zdlyg0C=xKz>cjtbakoti^F|eu&=@W?mWt!AF8$5#=BC3Yv-#Wqe}H`uSO*hCJU&2& ze7shInd<~}H_F7K*=<;bf^8`OqAZOO<)T9Ujx&MFlGj4&{Ub0uv{Xwj#c_`!9KWwC+X7Et`Ut<@z=`vRT#Jd49=T~n`#LPRvvw~bxjMS|;*7Kl2>7!Yr3d?Co@4Fi`N`R$tjcUqtjtY~n8 z4royrGSd@MrAJwf+KbwpM5s1%vsGcl$oEKY3TjRkx2y5!PYV@c*5iZ)8pezh!@xCRqO8txOHqIryXMWov>88|t)-JL&q=}_N zVXr+3dGQLwOKe~Icf3SKQw6ebUXeGrPJ$qY2&0X^c?Ux}WER~8fsDbh{7wfVJK2dc z;DooMwvD-?XH+t#ZNftsGLNtlHkz%FUlWxX;5225YW8DRB778ExFT6PB`XBmGEq>% z;=J_YYQ`_=g??`pRsasJj+cGe$p6^^K3R^7`53;YQ`e|wL58{s34M~DTqNw_VK7Otj z2%4wFG@Zi7$|_#9aplp9QdoO>vG9q_!4v}6B!~z^Ff}Lmo~cw)b)F}WXl<1u2abJA zHcFk}$NNI}`8At>ZiDPw=7o@TgsDXU2p+6>ACf0UBYnLGUyOyN%!Ze49-Ev=@G46K zSNFb0%#>X~3}=Bj1+*E6i1rWkfG)kEE&t1k0efOpO>Vv9SI_ld6IPoQ42iDK0UNIu zFL5pK9;7QAuI6M{=wk6eE4T zAFGBtE_`^HuCO*Cp7Wu*`b==>~ z!K*SL!n`_h#KnR@kin_Tc35gsA#{O*8^;~mM^$?w-lQwBMgEdS?sD;v_S^f@#z9#f zRfiV?KiSp{A_;ipxtiG|qoy*ClE`596ymtyy#Nabr=1MHNrMelFl&SVP!fqC5n>lnHZl=hkFtUwh7tiqVADiq_Ry}u8=XO`+97A2)qeT>T+ma1 zgJ*&?$j(0hdiwnoe4yA)C;4>NaI8!Tio=B)O|Dw3b!aPXBj(@K;j9=hSLgP;&-Z1EhS9A+ zbA`@IsFGjW1_WU+-KbO?)5D?n?}l?2bpf;*C zfIi$J#~UND2EH%CJxqk6H{`}F(_DrI`r{5F&?e#pn9j{B@EYRKn$H+iP=LIxhJ4gX z)nCs(1+lD^eKexGARs4T;8pCz5_=bcg&|^PDJts>{~<|6ZX}8EmkMUL&-awLSJHT} zSOVf`pMU51MeFwYfsYpN0ov0SCjPpdBYVpY z0=B;I4O;6q|6u%RYFmu^atJPjiW-Re(Bq+Kx z2Fbm*|x4o2Ttq8(MajumCi&C4Sf zL(uUcqTr`hq$a zZFxJ@XE_60OgK2%tDOx?Ksc@A?+`sfw0U!^9Ufv^A97dDJy_hYH(4xRExFdMj{r0! zBV9tyr)E!V^Fp$@`}1Q8q4ViA3~BZ!X(WwJOYaM5Q_psOQY>sdJS4ww?-ME1j&G;e zTWhmtuU)r&<}YV@6l$OBS94RP|IfPTh+KOZ`5xt=w`%_~T+eWor1)OPJX$gB;#gZu zK#}tpryvw>g$Rb$Luw@?jx(lqR^b_Cj$cMBv3Ct8+v5%cDan72Of@%x&Ktn{(EA{V zf)K#-$J~tiZQS630bnR%Wldne9A&Wi2!&xoOL`J;83^w&i$RD!iXIv-F!>ibq5I7J z7*P~2WI!U)anfXDr_*1#*Mi100z>*%8Vg2)%`__H&NW6GcKOIg+^Nt#si)Hd@z zZ8`K|Dy74oq_2Z+aBZQZg&t%U3<>LGP77-&>ddnQqAb=-^a;>R}M+5yC zuWX04JHblC68a%|FL&6|O`#?ZGDcLM{8=T7r&lC3emibJR|nfNTrL4Yfx54dU2kbH zX4Aa4{WUW;K!ZL#zvN`5ms-YB_A%MOSCUON&z9Viv-uDxW~g_GwNfkIm8f+y{MB=7 z$$@Rxa?HQ1bY%$glQFEq6TD$oiu|p60^X^|to7(*CE4&pj`= zEVlR!o@nus()u2^Eu0-F|C;n=KY+`J*lF44d#fZl>?X7~n@@k!xciAPP8NZk41^R* z8~YXw)M*Y>&HmC2N^I}!F55vCs%?Qwr2nt)GA#&KI!bZHPxTe&V@l zPGKKJwu8e3K3js!H_@Loj#Df4=?bNvo*>t@TW7x$C}(Z18nzQjLx4RR?Rppyr^m{$Lc?8 zSCb4Jn!JDhdO?f$3DGuz>u<#9#pN{9=?T1SE~-Gn8Ko;xsUVM=_kp7X5`RqhRu@GL`{> z?HI80;%s9=aK*u04*P=-{fZh`~}CI zE~d0;A8@w6#MOjqfUw$MRmUXm;mptl0|w1cS#K5DP*iPGVXZb~rEx@|1cr z*c?!VNsKxz4oxxbi#lBI3vw4M<+gk^l&F@VQ@X4Y(v2UGWzh3NRnpt)bvy#c)hvzL zc**dk|2@1$MTAdO6D(emPcI?48o(Q)x|izF6*`;5QJC7bWQuWd5aiD@w5(35x?c~O zYZ@-kG)2Q#+`X(#wM<>d@EgF_!X%6O-IG9MDQ z2rM7o#*VxvzLK1cUI-3$YmFK88VZfl0xM5+V3{9+TD>4O8Wa*6iS4nnox2X~we892r+sOE5vWf!CKq zT82T3A8(Wi{pKrLT+kW3SYEz3t|2pejpOdEQvdD4#liKG%=y}c#{Hrx7?AvElgpHn z<6$&jk)LhMSnANaxa1zKFqeNnqdPr7&^zaILbQtVdc2;Mm4w+XkF#1C&)i&jY-n2( z?E}e+7Yp=gy+2M?y+yxaoAUp005FmLhX94&YF{lE*aDp?lD4teoGT?HZ z{+Q&imL<>E9AB>Ou|BpeZU9*2e&2a)9fLdo`BP4HBDwD~{tb`MC)HN2maNl%Bh?d< z;!l@W_?JAJpO1o2#5tw=hAR=A!p+Y_%zq{QiZP1##@1XJX;N2vw!+W%c_U(j{pjvE`Z?xGSL6vKmI^#Fv^br>$G%G2xyEG3 z>^rD-{YB3jms}YM6iiNyTJ- zT-^(|_4AqTc)X26ZBT*&x=O7tn2OSyh-(lkl~YuQdREV2YOZO58LNeFO{q~J9n>SJ zF;uqM{^ww$Y%tX(d^W-5X(C2=MIu6iz65sgZ9nYtjKeY#g^;jpXdx>9tH0&G|~(kT%T8$viajIF3WNNtQK0@2_4uO}4?ADG|G@zO2o zJSEpdYtTJyds@sLn++mbCr>?*O=Z2rH7qKvYb-Sqg0?rhw?dd}3)v=E(wL>DTYn?I z$+FqQX=zMFn7RT{!9->{*CW6YLNJ4dv3?kqyA_)~#6AaP;OuV4FJ@6&yr;bLW}*($ zI|q)0<*nQI7p!g=KijtQq1I&f0G63Y4IXYiayhEu_RB7g#LWIL>Weo7LLnUvtxWW0 z8Fm~95|yw}*dGZ)kB5SKERElFL^yMdy;Zudi(SVWS{oIabv0Vr7y+<{Fg|3_Vc!+e zkXV~`W#4B|S9R4$mL8o`ZO?VvZR4aeH(>sh;Hi0Bi3I78?e}YqR9|(;9uI4a#5CP9 zbrpzi%A9iVO+Hg}tPUmO$llhb_`7l_bOu}Z7}Hm`(Ej=jF5~DrH(T#eLu5$J<8mE` zOmE99O~CC;;=2uQrF$MRoTJViJ;NJZTotF_BrT16O1M;A8`Jh*c78y#YW{a+7Qf=w%RnZ|(pdwGI_=YMB$218n1nV3?OZ6`i-Q3nbhd z=HI2o|Ncm4R>LST=j|wT3j9`U-+fV}jmr_ox*=fwRB1=>D-JI@E)RkQ6K2>&OL|`? zI1X%vN+SDaAq1#IBZ!Zhh|$Fi*lawNEQ1Y*L?QZJ@uWz!M)T|D2vqC(5kAKnI@%l7 z5!ZWH^A=+|rM~ZTB`f+be%G-%m4d|EF0L`JjNTVyuKgR-kOr9*bA7>Ysd#6>5N1Ya z9@BQ6_EnUI8`rmq0G@M{o7sT1rG5TxF9T(-3iq>UH@$|(Ue=!dRv!pN15Sc9f@#ml zGpNv>HrBSpEKZU8N6kB#nFis0BGy6%cnv#5T^f(h$gM}^X)=q%uJ2f9Nifq%6oQ}? zN(6&!G4r3#vz>i01@SQsLBp0Wt#U)LdOebw(kB!}(Mhlb@RKD$5U!dCzRZ8dA0mlY zzlR^LdyB>qJSECsMaL(Yvbc@T-S1OtfS~9JC<7PJKj&i|70Y0=bP@^h8AoYDU=TLV zj!6QDNP;A{0$@^!Y$ykdG$~hOE~b1~w5MQEt|5K*llDEm3E^9Vr+8&>;6tM+(xoZt z5(i)dlXEAC2Io0Ok4jpKdmDhC#wCAxXqQ(pc`%uSu`Kf%*)@0nc^tAZ;bm8%)?dxwBt|-Qq9>0^hVbEWp zR-fW4EQe8ybt=i>;kL#|H^L~Rk4E2#%5QQnf55Fe%#nGv=!D#4hUjJ;1d5^qq%F1; z^U-YT0|d{DqmTzK*rXk(+@d||pvtPw#>{4W(rosI!R4J zD51x_Po~0G)V>otbR`y~>1_;h@o;apVl$~4yku4CRuS^1nH2dQq0?|%Ed!7qWb}m` zqQg3n@oRWSob{tzH4HB~_1Hf3;3P_3v=a7l9356Sj*T`Tz?U1Si+)3V1?&QLYUa8! z`09WykdC&=*%V%vp$ge~At*et45?`7=gcVGMS#36A`|JI;@e@tm4XoS>mIZ27&0@n zsSE3Y3;K>FNF?H(dMx^%$?Hcy;OoZ z(B)Hv2v)W{si1e?f#A966ovS4>1Uq!5%FLO6S5b@FSb6+i(2d8fAO$Xy77u0Gb*q{ zNwGHWj5-Kwf9&pwV?VJ2uUL>@a9coDsp=0;qy0Lw4PLbwhpErLb*^Q<=W7zmn`IC= zg;}m!}uscEb0cn&>jnMlBuw{Y)sw*3$fi8Z;UlMHqs~u zLGAcuVH@!3B&0FeW(h$Y9YXRO_#wd!vz0~;!Eq#GT|K8|I8L)9u3EX^D2zUKvWuG% zPX)v&0}pISifSn`wAe1FBf5btV+mQe*(SZDJdecyB+(AiOfEQl7bJucArT-kfEj_} zBpkhj5h8FxvlW81G{`_;3CkycXx#{GcCzp61%cq~#uE$|8kE?O!x2Pqq4^?Thbnc9wt!dJAlQ4_))(G11p?$`& z_SHkXum|BG`7IAERaZeH*K5Jd$#>rSueqXwc$~(SqW$8t<#rKyLQqku=EfI8i972~ z;&fRHtDU919KvK2vx@zlJa79oEo>|;COvbvt~jcJlB3AfZGxQBnw6)- z8^i4K>yi+3x><}R=w=FBkh?7TRsSz`7FQZ(0Ho#IgQ!zf83qoqfGwU#URXDBD-Ed+ zCO@-rvounTpK+y8D7-QZ(0qh>f?j)* z%H86G#1N4Q*!&UY4KjYK9(rECE_xtuC)3*A>oZcvD@*ecK zi42*em_-Z3?@2svbefBkj*q9Y?+Snl$dJv$a1DtF846l)LLC%F-j11>wqIJtPm{wy zOuS`RWT#a-4M6Z`F17b+Sxc!Dq!*-)+NiSjMq$NxTHbc?J4|W>4a&EqT@PYeKHZ5C zx5=+%Jz**x=RaaGa%C^`X4$2=!Q9yk$MvJO7=vYD@B(%wcg!6%V!uuK^Mrw%;*kxD z?=Sv^(U?f#Qtfl7tWR%Qdh}(febge!;!~d6h26|Y4JspH?Rcg0!dn^RN8g55d}RH~ zeb8FIedP$NZ@PZl=StUyk+CJ-xjb07dpwbhoD=f~gQ7Vg*Th?vb;(m189oa8Ysgof zL?f^b6bMr`Z82!LH&x8r!@32N1{g(b*ZL}}Tc?7YWVFlDD5sj9zFlS_o&lSlG&e+n zo5rEk5%1oi1~T9kMDl=RvE$dnlxO}bk_H4?ttCm2o41ULWyd)|c2htUf=1J*u*cv) zCHr^P5K;%R2*4-iZp^ZiqCZzR)cat%(QqiNm`h-6Oz|rW%n%L}Y1+D1yLL(0WDNm`6hEy001d7NjfW3xSz)-~q^3V=Gib4btf;Bv> zj8X-qJjUX3takRruIc=kvlcfYi*?93_uPH<{?50*eS>OHJE76kg)_e+FClw!=Wka$ z(R}{N3H_6SCu=5Ci%N4W+HYg_bis4kLP5pvN^kCrKljM^(g+Sk>~HOIYoq zMr~*Q<{6dK+?hm|+$}9`)CL}E59hfA=G@R<*PjTGN+9c^KfDDhI)LnVn2$%x5_|e<^ERsEgy@ni>lG+JB=^bEHrfgES-f zUC?lWu=-#}SO-oW2jlvc=rH}6L5RXx))6ry@tzQKZZ@RgpBF)<$5RawV7{^wN3DA-_d%@?!b1#oji0x?!D6 zD9gF9F9N7X^L^!RBYOwdI28F`p@$yyth(NCBh#Pt>@-I`FT`IO7MUU+&(G%B`pr*N zZo9fDRK*wdDnu^h+v{4s_ZM>?)~dz|{h(GRHjEEVs}eu-nZ_!Hf%QUZ=3l!%P*8IV z=y?-lUii98ot>$@)6BIok5;S6qpFG2>-V-rI2fn@Qb$`|sA2{rfTft4=&hp{m40q~ zc;R`h*g*9xB)r{wh+dX7w57k#>ATEFGf|^|8EZb)UzEM7Wz~1?Rz=0!zCgCfPa+>= zn}`AiXbg+xW-@8TrR_+FxNLL}(^=7KWP<+c$e$ePA=U6;32&o%{ zB1Z4zKCA#Hp?KmjYvcNtRbpnyH&@yixm>6w&=d(oT}!hp`Fk-RiZ{chKMG(KAOLj3SH_8Gs=F*UPo+Cxau_b zRDC5WW3^@5_a3OXvUH=RF1otSwr|m=Vls94ur6-OBkNF1=79NJxr1Z;qpXVU*^xf| z<$aNTGx=FEr{QsI6@6Jzlo01A)6$QfNo*fGJ<2x{z$W;=E&Sjf7mR)cLpEXUkQ(*5 z)1BL7J-VGap;1rwt1hGmlDjifqYOsk)TPFW<#Rtrk|x|7>xDO_vL|{wqK`1^=q)FH z8Hob2w+iQaPnAxac&=*ttSw82>6CO#7oYB@fx71uZbb-w7bZMy&S<)4Kk6$ zz?AOSsU|E~Zh*LLJLVHr#*EIoBsZCG}g2&;axaJ53=3(v{G>pJz02(a{F~A4lUz7lotXc^9 zWtw=2ST0}^pNTyPh5MG_5MzH9T^56E0aIZ;zQ$4|3wl*I_*COvoC`H3M~qw5DfwY7 zdT~$hK;KfJL85R70${Qjvp5JsXNJ6xm2c@u&}Q6TUa&}_(2xpbbHywg7_FRsrN1{g zViIIB4vyfOEDK$)=-eO{doRcxEp9*b>$(|nOZEr^rxHk8gGF!ksTa*K_D`r*$k%^- zx5)t4qPJPxiG>qJVK>sFGZMkkgaz2v<3`e>4 zJ9j(^HV%l__o!@(&AXmmspt>gCA=Z(Do>K$F5PoDIrV0nD|a;hXsP<>F8fYx(EIJG zgvxErwVt%vSv_u`_3T^UG|nmEHN~E+!MD3X<|Fy#>4CJWiMj)!9WfR^rm8a*$MbY( zbi`{5^_#E@q+d)eTBp~BwLJLEUjEo9ZPjebzYp$4Q?AvF^w`gYs?%H#T-JBbh!G=r z$IOI+4ULANk}9b${`)s3e7y9dZ7IT_h-1RZzNJS2moBE?0?kZ5j@*68r)%2QB>|i1 z8pM0D^k763^U1Y=QKD+dJ9 z>o(xBViRTp`nnFlGKX49JF5AmZQN&tlul=xu?m&lpq6gM5G&txHZtVE_ z!VlIJ3w9ywu_m2O#`=)%qQadB~Vq7qYyTwk{6tQPRD;pWM=SmHsU_{Pb z-Yzc5R8Ig%!nzA~C)X}G+;%xab3GirA$7_+C^V@|bkuc!!Jb)7y4qu7G>_s^Ub4a*4Ab{tw)IZ{ceX}r}0 z{yctphtZ|i-BX*?;xCntQUbFAqwJ`gK@dgM2&^Kh*iKNUUCCA|XvmN&{EwPmJZFCY z)#S4tZ4K{e8-$^Epp>4;KH#^*-i1ZQ9~>jVieUwT`o0t_!rgSEyq7Mv+3aX4GWmrs zqRi=(NMmsOkle?5JCYC~D~`uNYeq z^4Y43kBM>4rIZy@6?Y7L0*E{(Jeh&```>SPj%Z)SeT5_Wa_Uh6_B)ZWm3%g9>8&aD zR?OBi5YkS*DTBl!@U?m*wiZY7)FKg{S{(1g$57zRwjMRbI?^Ng?#B9zU_lQ`!yN1@ zkRdVAt#x^|OU@>Ja;%C%y?!P(2*@p(UyGEpP*IpnPG?+B_NJ<^I4ml-skfqZiV6gV{~ zf!Vdp?NisYS$ymZArZdZKL&{`4d>61E{z;MWLP5+_Bb+}G@B#Nl`o$y&A}>P7Kyut zr3~puDNB=BS6}TvfS`lzK6YM=1$$2GiS~@bM9Pz&;MQnNK_EY2mk^B00{U8{N8}fE6d55 zX`wU`%4!OKGX?+xau*1jjOIU7~+8!E#L{D07XFdYW(0m2yTS{ zAbth_u!Mi7S!Mu0Yd8ShTKsn!b3OnNhXFv#_`lQsO%rzuw8fw1us{s8wFQ8aG5{bn z1b`b80Dy1&Cl5sbOWzp4ELyN%Zs4CC;0)LR41hA=3RnTWU`!C;2Lu4o-|qoAKv_@} zF3O_}mx80zmGxX&d|a%pT~_kS!&-jt{r>%X@7E=W|G?$1f5rY=2sQ#yN1|#}991ABTR*wZB&=Y`Nb()!gg2>k?OIS-_Suu;xo8Ea+i|WW~ z^jYuY$LeP7%eVUVBXHA&Sbg^p&|2R;p$<6)Lc8ElsZ&8Mk(^6{!vUD#vyl;uNJh-a zAiO=NR?!Ae#Aj6wqtOC;+1a>Zyz)Ag&>YBU`+z+` z6isoLwNWU7l~P4BFo))8LbdG%?+ zH-4fM7PNUvw|UAByz~2s>-Q6EM`6kN2Jt|M)`m9EL2sfFM^Y(H2tdt|6oyKGsK6;y zWJB)bV{_nR%aNMW?2(#Qg!vgJqn8GB0DuD^hrodXH~?tLV*Z1*|6kENaFYL)f{g{( zx~`bgyoqq1Mxz6$deWm*vx=yiYZ8$w2K8}^jWM5Dcvd2eoMKa(T1*ci^l=&3rk#3+ z0CxPd4*`zJ^BXS0oed`MF*A`x;}2vRzyFl&Y+=I0-T0Mw_Ga zHs;h0btOVUAZyi2*8=wDsAI+4GDc_jy$`PB9w_{?c+Io~2~o10wHjJwCpOIC7)owk z*S)khE&%s633fCKA*Mx+*WrfL8fMa4aKqtUm@lIGzy?#{^|wCIXOwpDg=1SUdOaHGC+_0zI9 zbJY;_fSQE_avv1U$y-=BDgH6#1V<*%<076Xa^vWZI1ECKpe?FQ!J(olu<|$6LtH)( z$t}Lr1OsBrX_f|(2iPYJ5Z1-*iEX!xnOy}cj_UAYOfQ8()*jCZOyYVfC#+tN|XqcX{;2+XO!+eYdEIapY8gruYK3IInEs~PKj zLxn=3&sS>?uR2DT+r15nllE7ISsPA#_l;M49Kj5!!&$u)B;fb zJ;Zx*?5VHh3hh%M0qq^bu@zLkJUwP7=gNoG?H}`}*7RvBQy0b{?Q4n^7|7YBEHU;m zs|EJ?Ufm}Q2L4){;;Lhp37X@)#f%l@;(U^BMf||WUpE#818;c;Nbv)+(F`y>?g5z3 zm8j4&7O<%kv1z2_CfuQlF{qQY<+aL*hH3l6P@XbC6Cm&_I&!zb0edZ2{tQ44-cb$! zE!-G<@P`MKDUIQaup95%F@{vMb#U7cfcX%B9C|ekB>4ct6#^F;c>M$9Bmh3?qvnUu zko0ieu9#O8U21#4#&PCCsZEz^s0_4ot#0c=U%S{nX;s@t6C)R1 zxH{ZF6|-K#%Wr$h!_paB(oUPlmmp&8+Bz|uUUfc^KFEyoJhef>%Pd*ysPm@Ky7epu zd@e+u2p}7DXYD@&Y%tL2UMg#*bbB&cZxEok^{duBAz2*=rg8mr!1OO+D4 z4vr0qMiPj#;opS}vReivP6)RFIoYrcz;hk?7?AXBsBGj}*#g-Pn;c|FcK8)du^?cr z`}kKhx}xyE;13nw_hjj8?Ga$`Kr)g;u*o6p|15r^Lx=mHCkR3dWArOLS?X?SxlK%U ztNQRX7;SXUD!9vzNTAJ_(QkM{T29}|Nwl+$81GL&TKUXECGv;|f$I-D0Y`z6uc{UF zajgt27=WGKo7LtwaExzeC6R}HBC_!dD)Jjx#F<(>u}#RQYQKZt)521HFj`WC@GxBm^1Zq1Z-`O7;FXI^bVJ^^|gSIz4zf~|Ky|g-+`t7dO6p{?e>qXw>j{xUepO{8`6L*kc zTD;(Hyf99P5U;(#zgPkON2*y`s}Jkbm@__C7@o-h`q zTS)bwvs$Mvk@LH7cYzGOUp-@OdLPT9UR8)Y?t^RIpoS-5FUjKT*e; z<^Y&9j)v>zkEb@Uu~71(dtFV}GLDN8HScGbj9mwvp4a2E(*&}}D}Ui5M@8zl{i9T& z{UdSBt=ft6Ni{O*D5T9c7D-4Gq7%lGEP#j^NJDfQFfbGc3IY;_Rs3It9VoJlnKIfs z@L}@c5dKX57QFlmD5Of&uaHtcI}``SUk=~{21i)=votlz4Dn``KX3j9`1S%c@BN7} zO`vyA5LCjhAY|d- zvO&Vh!Wl6_sc88@dPL`~qs@Vw#WP7jVry%D;Ma~bBr|l%H z=cMu}gd*CDx*~`9&(0NAZNzgAnVtb0epEc1Q=O1nE z#@}Sw8!|P)k0(lC=n_)cE?V(yq|aQKd(6^+rrws@fTrXAv)*J$6ZpRMh^<7)c3hUB zbrs>@YsW659l_6Jn|7@4ms_B%D*=>3t~#FF(gQ@}R-)fB1F0A@@`UvE2K73%Aixsk zn;xZoY}5l_lS`vI#nGupwvZee0tZmkN_cb2DtfP2@&)DOp->D1I|BgFS)QItWJL_9 zbU$iNctucFC|D#oD#Xnw#A0m&y`p9~18vEUHTRDm;V4%c+R^pTFmo_fvU>pYBoSIP z3a-zD#xKJac=-T?N~tT11BxNX(*n-V`6eHo$l&JH|BC(%B<@Lgep3Np3}DTFz(3IK z@T-hsdQ0%Qr{BXTN%tuxlZ(Z%pi8j%70QwB`4ys(@;!iu0U*a>CujK29|tyoT_6h$ z0e^D0auH}L&4Dpx`=@o7=_Mou#4#FfO;Mwoa?P@PGMp0H8&R$ev1w!=G-zPNICm$% z9(|U!PPWPqK@^Em6j=;ifZH(pb|j9v;z(55PlA3gTr_!v4(EKhF*&M`Iud6iRzyQa zUR_n^MWTs^e)Q7YX_`l#St0~O(8m}6KbDC-Uw_-i_y#6!03(|KV6o?est<(^5^Az7 z(!hC$L%flbgfxUIS#413juL2O0Dx!D@-0bA=Q!JZZ3DL+^BL*4bf94c&=qXKExwU( zN>78tynaR1C;%W$iD3u%9)vR=X9J6Mof3_)Rb=?rUsPY-?%vH-o4>rR4c^@fD!LZ@ zsr#-U8#(c#nf0LGK!jO&*Z1E5?l>$8chca(%^?`?^YOc*AN1Fqp0-9e*{|YV|?czamziNkX>~3)|2WfXblz(g`33uf2a)#zE`Ir!{sf3EazCGVyccJ{Ciic`G#SBIcGre^}J#q`FJ5%-8NsG4K#c{nEeKl zgtvDh^rzy^M@IcNgY^&=A@Di_H{IQsQpMQA~EBI;DoGg{L&j05(aG0LbMGe z%b_&JI$fG!PMKOGm3gZbm_7P>L}nnL0oL@eywqZw9W?dc6Axg(Y147f%YC4hd4&?; zV8cLpIOv@G7j=+-*Rr=+UGV$e|(J>RQwVrwby;+5V z>6`Xv;ymQ9+4~6v(=j%U{P;KtZRQ?_kx#C)?EP6l;3P~Cq6`T-!Y%DA~iwk?8i4f3wokipzKf%%-S>|f&-uU?ANw{#9$7Dcmqj+8! zZ%lTQG#oasSSggUfX|9d$v4Od(m+i%)(-_+;0@eSk(9GS@Lyzs?$MUpiI69t9kYIA zzy4#yg6b54{3FdlqC)@fpRTr6g~lkHbTcU>~cQ<0b4nnv@uD z@P9Vmca*^4j>{I?Zs}oK(5Ki<7-^57g=Uivyb+=m)JJr zwFPJRx4Ehwv95E-*;S-fx=j?*d@B1I3|0fj)JZ3`@N+d}MKfb$g8CEN$aW+;+0E0| zrj62QeZ2!=J^hnnpNUyl;SRjy4m=-2tZBPw+V2^M#e%hzYB;|$zLgJjQsveqoeJ9q zD|>PK(+7X_NJ7}rHrJFIN=rz&n&H^7x0UyuKGY}{=5r`5oWy)1U99R4s&Hy$plc97GlA_J!X#;!>!dKimhsu{)^-Mrj&FMSuw9nF7 z>_~!oYpNh3ZFu=v@30e3Ang=9O7W3J+QrjvQL(bBGOOiBKh;C0RveONtj~n;_%~~J zj7n@*cI>vS(+aZn)5(lnN9;~TqN_AB6$QEcf8son!-g6DB(yt~NFm%kW%3z`&X?qI zx7lYlpS!)U?q#dQfy4)$lNcETQsm=gqsE~?QBXI0Y((67De4)J#{%F0BR`V%>_>jY z7g!B^9@Q4~PVp*hAK53#R#9vNAn=hNrICDGumqr-XRM5=a2_9ky}7xc&)=aAEqX(| zU;F%$O(4|(T;AzRvh!aWWq(7}_-FJakU0#1!P8;@@U1gr7gsyPwP4R*W3GcW*w3p2h8U-l153{Z4!a9kO}8{>H0`Z;vM4AGT%F z20yB2lMbYP`@FDcll?~{@~|!1U*UUf{YR5`88k7*M%0%MUn1Mq2H7gP?cyDHgO=Kf zj!fb{5Gm^xZl@PW+>7vbALNx|Zl`O|%%ox&y_x*=ajx$*+gW6cg5f(vz{?6xMQM$3 z36+h_${N)-GSza!kz4U{83&irD%F#WhX(1@g<|~;xt6(kg?Y;)FUda{A|fu%#bwFz zQVngcAwIes1X_f$R~~2AOYt&iRUoon_g$Pfoh&3zpBm(O-8DPCtq}QDZu>WI|BXzE zgoO*J-Xn<-x2=0(vRtL~wl1YLJih(vS*4%tDRV3C*}a|gla)>UN9^ZAcaHb_c4PPM zoSP_qFFFB@ST z9CtG+B{R6phS!dbJZoYWK9n?C(BM+X7-qu+;Ne3KJ7EdL?mC41RvA+?5I7J3GA#sj zr&7=z-&0-w4RG&ycK+n^{n1!3n4V#OwNr+rJN&#B_=rvWZ^+U=LW~D|fP=z<9FDqT zr$6vVTmx8vB+o4Yk?@^!L8t4ZhdMr24*ae5d|)_J{*yi{&fD?Vr)+-T7$if}{qL-( zKBK$eJNSkf)-J@-vk519y%nUq!mG9etOgDeot+>6>Q83OLW3FHKG2K2nN^^Ta0d#+1Q73?J8t!v|T0@JW5W|9_h0C`T5P0*$y^CDC9hsbDHN@*{d3}o2 z0J{8N9sS=!kN!0ui4E3+s-@Mk6I#KQitLqL!!GZJa|tOE_I-H<933VO`c4Q$0SoX< z+Y*r(Nrk!dJJN*I!Ewv3pHAE|D-A)e;-0?K|B*xFcdztC$JU<5_Kwk&@A7}6At-OL znf|Ls@ISG5OX@(H!$DPgj^eo&cOTmp_A_J|-`0N7{TtA@2m2e~rgM8~@7Sh`o$!3Q zo9F%HS9UP&Uks*ihaUJEJlgMH`-{>23U>`;avDhD$UI`SN8i#5P1CkS1CU}+(<;M( z!U+;`Y`E5a{1yPFr&a=hPL3;;kPr3+iyXiP^0DoS>Rc?8ZA%!$BODOJsgN;&70vli z3v`>R1%joih3mB+Tj}P9SJ(p~JD=>vj~%Q;UNq(gNre02-aQuyNbZPdRTtdz#ubUZ z`SwXH#oJiHJulqb712piF-v(^G2_i$WW((0cXc|-r+J^`)4SDnf44jCD8D?s*YfE4 zC0M$(nBJ}CpTwnqB?jEzxm>!7y7;xL@k{y{SW^rAX>%~uO5qE&&BPv2(4i+vJv{a% z=UiWgmn1Cva}nPA7pDSe{M&pXr4NYKwrTx_!_raJ_F_g?{lSqMh&y3YdqIQa*`GoC z!M@$=`GN3oL7o5ZV$n670^v)(1G9<<8|u{htIGX;?Sd6XE^PVJyb&S750yEXKj=?5 zq|3p#K5vwymYSZSt<+x7L54eGXQjA3tay^Ix@7r~P_*bJlS>Vg)6B}P5t}0c%WoM0 zGWU<-{El@qIK}B>%$THm$d~xDHx^U$^*m*68R}hkF}O(#<>VOG->;vQBMct+aVsp# zC`&lP%YM7irAT!L?z8>y9w$1c>iT-U=^spFeLdMGHe8w$jq^JqaVdX2 z&v-*11N{XC=sOa?c1wt#M*!iHKqyoOdWQvwAFXY8ALi{?eEM;NkL+#&u%!i-#?5Q_ z74;iPKa*yA*<5)X>JJ!sh0b=uhKdmyOSqMA6mjqQak@}s? zNL97Shfn7lLy=q(9nX(<6=uFva>_h7 zG)Xbfa#OXdOtCWKUSDu;=zkUXI&R7uPfSBvtA=@j%*hyWGz}Y$rqebq?9+!8>9>zs zTs+4(vM}9V8eOmS;(Bns$#{o!>aLiZD`Fu=rb&2}c~vXUCznN}v;D?GV|F9A{7G&3 z9>w@g!t%!P<$<$TO8q0_tjvp9gM4^IN4l0b%9}pcmeMn~9e8#5Y4BJIT;pv+Yv|VY zYsa9ub<-SML?St4b@eJwZD<2gpIKiBjrpb|>oQNi-BZ9$2uD%182=V08Tepiy za9Q*G*$(U_{PK|I+JdoZz4-#mnK9^eLBV;P!$G~Ct5*cq0qmsCV(M5pzz#Gx^8od> z&fu8=>p!~=P*k^C|HGs{hgH9Uf%uMNJCIGE{+mscuGm!E){#2ryWKwDH+nYKu{_O< zr2RbPiV!|e)f%VL{0FKR`74>UN6LQC!5L9?CXf_OVv1ut*M>~zHKNjY zU5Q0Ab5GYO6el~pu|^#?+gg6z^VC*xB}}Ee-Z>ifgMpOwOo>fxm&0V9K1Q_RYT|CsN1hiE(HVgzXGpa#lw?Mut_$1)oFbM3z>Awg#-i3* zbd+416&ZfH5%w>g6=?qtmm&s>wYVOqjt&yPyEl3FC75`_saMP$wl>*)NEKcGnkXpu1wu+`Eja9QyUsP=iKa z4(X~14<(=R$p@N*w^2y#Seba~@0=@wK~x{ZoXF#@Nmt;u6qMc6m=+Q%q1JON{@QXzFTN`?pw;NZ;b zG%Q9R!6IFmwj;FwSr}5+DPfyUiVOO%6;MjVZ|MH)Q*tA}|oStiy|>T(4Q70-aPAF1&4a zOWEXKDz(jtvrV<6-fk$lhTens^2SyyFB_}*Hz_LBFEiuqVtmjd$;pgMLWK*=%90A; zI3>n2t@Y#SzxUfQO)lz(hgW`;Oyw zQON1Y4aXe78Jk9Oy^p7gQ$l;ISLB8LnY!#X5v5Iz#hSaoZpAFmHR{*R!kEv zQJaIBQ~hXE`2JWq#StM>zv!1?Q`V~&jnxAHK9+Ecpo24oUQ9JJ)z#2IkhJLa&yHE| zmRd=itzEBBc>i8Y;OVEhCqk&?WS0ym=p7BrWPs$>d}6tBm6U8`Dr$1OULHaLqS zcy)+%a=y!$IqvvzxUv2~&KT3my%eU9r`sE`ij_2q77~)Pe4f!h=TfS$8)1)Fjyyz% z%aBbz0#9hfmfn#4pkoS~+gs5e$WnBXi_rJh+3W6lZTh6*ZDZK+=d3Bw_C;>HYy6wi zBDXj=*dF50l(1-I>|F*?el_~g)76>jraGPB)8A$@Bsj>Wyjn4GaC|aYVL#?wArHQ1 zj;5&HHpbU4rz;D`dwcMyqUYeG`4F91Ryt|`6+je`e{I6CZ}K{tvWf9#*eR-LhSlmk zO|GkE*9+QZVUt~g(Yj)qrMGcdSCuhIqnL<~lU8ilI5roIFdOU@^K zycON;P#dQ2m9rOH`tIhD|J_2T$C8RoOGW`VmV;s*$$XJewQRTbK_Ev9>Daq!MbS=4%ss6HQ(XKJ;h?J71&Prad-#ZrwrELqOVUT&` zB=P!Gl0&=Hj>5UKE-7jFo~`~hg$r-R8;-upW^l2baxva4S_|>PS4Xjy()+R^9b21E zPx|@j8BEkvZTaM`2dRg62hcao(Nml#65uU;dMvakz*FvS9CFZ^oJ*Fxyn5m#;S+Gg zWZE$}vpqag4xa7(2c-Re167=j_}5ydrL49`zGZLLzl-mqLJuAY24vzy_)OroK}#=a za@zxLMfx(Re8=-m688wCdzTYJB`eBqWp0L8HN;-+b#;Hpk`UXJk-b2cnt>^q+zR@w7@ThKx)Jrkc=+!qP(H2B>JFHUs7T{2#x3R3?Kv$`l^ecUaiE63 zob6#{zjw-{uYjEYP3T0xUI6_G0TgLPzaBVRl1#ne)o@8N#OjRoiXa~u}yr+R+1 z+jdn=l5c~{5{L^HI%Mzu1_oy~7ak4wuYQxFZTC+FaKO3z1Gw=`Mtv?d^dpHnwO=Kb zd(X?VBKnnO#b3PakZZghBX}}VR9sg=<6XF6KGCf)s5ZR>p$x}jY>Exc{zxIje-@$tIIOLTC(Tau;UyxSLLG4X8J^W)PtwFoQ{WAROqgv^ym@d=b)IZJWBL7VJ8+orqzoV7_3}6m$>&!_Px$*kzUGY*wit@ zN?JywkN@fuGY=aHlBK#`Vqqge(7eS%K`gbJ;6;4yt;Fv48?c6kgiH&g^BKZ8z~NC6 zb5EZ!z-pOMsWr}t^;L^a)HMmWdcHKWmhD<7)ZLjP<}7VxR3JevXqvIMM_OJPmmL?pAWv(R(YUN_c0;)vm)Q?-@x9N3=g%GyD=Vj__(LPiYSf@ zL}RlLO_NJmN%f>_CyN$t9dM5JH;#5tu*}2?gHUsF|Gu3jFVrrO2%;Lk*3J#v~c1xWk zSX%zx=73F2=Z>}xmuy_Z3B#RYElz3h3`^CWD^>>GpP*^n0%r#}W3voU4}oVc9L9QH zpZIL#4Tz(;3lcqH5{o`@i9@L^)tfdgE13}6>D26%ogWgfG}n?mewGu|;SI6f@Un3A zvLAUW%yW}nU&6hRI*+AufUfW(t8aI&I`dcQ3%71W;r@hT&1>vEbWD?IZcJfwl`L;l zQj-;F%aj?LxF#!4@B(Dc;Gy}a2}Qz=WwqsAuF{cLN*#*|mfwq~r_{#m5t4((3$@ri z`jHz8^bZk@em*cKVj*ww=7XLB)zRC@*HF}+&^~-raX?`TO4Zu)Z5(JVcQkOq&Y1q*sxLTXaG09@f{~V;^$^3pTGXn=w~&x$p@_eGB*IY_vBL2Gr1X_Ac9N9 zwj4a=Za~Hu@oXF11M11mwkz*eQaLtwa!2GuJgT{cBs<6R)NJH{htjQg^Mkq;)h=Ib8@p{b1;M zSXAwP=D_>5LM-d^M|b*-O}B@pXOWe!2brjh=NDza8SjZ~pY*5RX3DO+!!zq8IlU8Y zS`w-JB<(@!uR*gS@nItOFA7C_b14g)#tw3pdBPR+`{wJzeji5d(djmi8cauK)L5iX zuZs^6l~BgimdFPtJolnsBQ0Wqvq$7)ys^2ygA-N=QjHtsrf)=0P1rWwq=@!5!wyIj zbk9ujg)4KWy-b+d+WI2Wo)n_wL`2q;sXr$&e9>(pGhE2AI2k%i+-Ml#DpPmOh`@vGScM zfnn+wR8>Jr3p9c?$4zQ|LRtY;a5w&|GrS}Qb>)+SX}uxoWXJ|c(wq&v3LfVs>}4TT zrQ)QWlC1HJj%gb%H*h7XaKUpkcJjuH=0E^AR|{OKv5q38fZxv895fY)q+gJhhy2c-2z$_U5Hdi z;!15~r50RiYD*oy*Dq31gf*$PzALDbYv}oG zj^9PZgK<%@{V`5u5wgnl59=-2Tzi^;R@>Iz|3Qe*_rA6!M{Vb$oEW(?08tGY{(%4(uY^_&_SN;d6sZ`D?Ez)#r%hrW(pmT_57*k zy4YQGK*Qp8kcLC0Bx{nPUUf2lMQc~|oEhGb`q3{!7A2_1E|Yp+GlINk|HN^B?hvm8 zyK~NlH@GYBrs_oQ?e;|dVw)4ruc}+=H+z3%4j=r2XQX)P#0+023K+U}Dm?vRCi5=2 zj*Yp6wjpI%*3Cv0>i*svb4Pc}MJL)H*L(>%>ODN}c{j{!e`2{deCP+$1cu$OCH-zInQ;?yXZ+_Ov?>ZhB{}X1)=42aGsPwcp(N_z>*OV zVnN1PNC;Ef$Zu@h*#0<* zeKSt}iYH`Oh^aV#V?TRwCTdVNI*@TIofS(oHv&DJ%&G2*t0Yvow0)xhDWx(ccrIa_ zg4*)!F|3o6l29c6ar3SQX4N)V`Te${v%hWBdc|%xawK_UET`0{xJ6zMg^GOxlR`Dw z;Zh(r`M88PYS%a54(yhWKe(54nd1cJ99T@o*6_=DOJ*_Z$2y+9-KhA?t+-0TwC|Mu z{6%G17^=hl>RV-RK&$JW-a%A9{FjO32~l<_MRDZIA5Krt_D+YFJ=2v zo`?3I8RR(!GXT^ zoC5r);N8r4iXx>#<08eTs;SxAQBLlyT{gCtccx=>kt$pZw4eElBHh6A(tSRH$4d`1yT9?I zXs7%*+_#hX_KXDdXO+oQbH2e53LyCCtYYkq+?mwT{c>O_q!~oj_CkoWQD14BUFF<% zmq(prv0~ZXCZv}&HOKtp1FA&##bN!P@?~RgU9vnvLe#=bh8ngi7v2(eUDl^(RdNF4 zt$~yaLN`UTZY-;Ph?e)z)cH`L^UUmHq?L|taYVeGtXH{{}|Fe|T$4w)a>NXzH zqQj4Mo;oB&MH8{cn3^H;CJ0~0gmaIT&s}}(WOK$mXUbq6UtSOQ>Ygg`@a|HqB5p#c z_GAYf`q{H?*3s`r2Shk0ksrRul$567^lZccWdtLHn zaq))1yNuRPnKxdmNnGCK5X{hXsYjH@;K?=sd{6+J0e^x4Z-Sw&1`7K3@d4rClrr`G zo+y{Y#K>=}NOSLzZ^>d+5>+-sbB9s;89eW)K3UuHyLnbVHX)%xCU3=ZbFFA@)oSqF z&nB?CufQqZSjwCorvL-Lo?)Wdi1ju5#t@?)mDu(wuB1MLUA{EYf6`h)}5zajJYY0|dC@jDpKQQ1$t_+!GUZ zPe%I;ox-Z!S@a8P58U;=USy}_JV@10UQT>a_wiuLBrbwssW2-r3z`+?rgzHBDnG|*tq9T%~;cGJKiFGd{R#*8&)XDvS~m%Ce^4rzb4)3 z>v5k{ZW%+O`f@}sqj=umXl5yu_RcBJwFMW3t9v z0Sj9oO;5LRdY2JU&89GBVkv~^s(Oph*xwc@3Zw}}NG zsO^oL-gby-N-FhAqZOcN$}6YGzjKghW7eYXEv`Wpu~$Sx9l^aZXDB0X8aA1!)mr)@ z?Z_EhDW3Np5@{mPT(L~-Y-5$bX8dMIzxA| z^=u9YKHP$FvWg{y9jVr?g#$0Kfl`oQ+mj7FgJOVUc5=WWoegit&f!#B*XRtM z`5P=pqs3``QUkTjeoL4GV^yDcp`PRo%B+w%REzw`gH&N`=aHm<)G{j!6@bmrpe>r4 zLS2lbZc){Ppnsx9=mC|1lM^&@&%}ffXd;|PW~;W8_0)#FjJG%v4u9c@?|Z<2(ncBxq2{I0oWBw`UTFQ28j|2l!HkWQ-N z)3lr9(g%JCv{=jOiHPQq${2MHc6bsBwDp6qbxu;nHxPid7!g z`{_r!yXd}=tM68@lPkUb@&^Tdwc0I9sepTOqtOf7H`g_kR)`(u>gluT=$|#Sc=%wx z)d=sG=9SA_L=(Aj@4wM=?>!uUDAO*A`^>O?eT?3|$^5!~sj?T&u!QjD{tYgxS|&~2 zEcv3G($;qqZLY~KM$VA|)IVb(9oHA5Qv8_RCFLk;m~AZ{piX(S4WmaJ8#45o)?^*k zhMizO!a^HAt3cz&l=aL6z8xF3IM%umL9m(1rDVBTS>6#Ky_i-jGiCZ?kY_|?R+-*@ zLqxw)%6BqtEapHcrcnV&Zby+?rFraf7b=*!OwrcMpXZZnqu-k~>e5^v`D!e;7dfpC z)!wat5 zB6oWrx}#HZL$zU*r<0yG&|vC~UHh$Ea@%IOpLQd%YVKX7*xgZ<>YH0zGx-N9QSq8V zk(4P;q`?|>{b5bX$aGde%lDbJb`#lmB6s!fvpgZteS2AGv93sTvhr#S-I+q~d{Z-- zEif9_AkskJSxqfkrEzH9rPU2hY810%R1}${y09 z?uVgNWeG9npLdQd##Jb!wBlDQ$}eP%C9$RqhiG#o;6z#HJAjAjsTFh4{#!Liw(plC z!BhAc@Q9OWOwZyx!DkT)$8({c8N2j^n_RCAGjKjG$99GzZ8wQ*TjocrsN_?bO7>kQ zyF6PJLa3%2e--rXKR!@UY=&{^*b@v4mRdvT%Zp*j4(@_Gs|6k??E}vy1ue>AXNDf% zYo2Umal=;Pi|bxCkrjQ%#jk;r%+R@*6vD~X#!A$~Q|Tdf`#}C#i(&}ZhWF!=is7$x zX_=%t#T!81JyShp^-N1vD!WXS3piU8p^JiM1@+Q`~{aM;;7M0feaZ)=hYAP zmMNCU<*u9YRJwk}o#^R|V4Q9GNleL0)17+HZlwIJ73)*DOq;89;wio+B`OK|+K;K& zz2Vh#YAwRU<7H+TW0PW%C)#JZf%0^`sqf}3xuq}Tl;|(Y9jg4Y1=hlas~R?{Mvq%? zP*Q8sh-`+%vn{Xg!$Ibq}iqXgVrDke885(%o{=TYBU zE*KTBVRT4b1jX2dyKk@2$wB8s_$&luY^VESPi@K1+ zQ9M@0Te6>QT%3j5hW~8ofwQe&q(mdFhqySqEkEc~M z)gKzGxh=f^BwG3&U-!J$c}bDN#Af)Sl?)rw&T%4&2ylf^xyO8sx)6D@Mxcm8>Qfl| z`MUer9?Wwz?l;h5S60wZFQqz9gvEhZE}H=X|G!2A%RXOnB7QuQ`%6D*zxw2?va%7h zk!K>g#7=xz$Ors31Y*z3xHk)7uT+PI=v0?-MrGiXhl5@Y7iTOVoWyPL69pxMnQ>DuX1oZ7@(7Vn<)16W+hDoH5A z64eTI(I};Fcajk~%R*+L=-oU`E~!^K=@qF_Tc;C5*Kq^_rOu=pl$R3H^C?)eS8wFf z9V|?`Dwr}5cJ5-=e6hP<&y8;^WZ5~ldBGA5(;uMPGhVL)!~#f%A1`4us`BWxoLP^j zSBzGujxxUnzHHu?HKnukuq&e%iFlD4j4e+I$5oJ2bf%Tqcv%@QG~(0MpGKedA%TA0 zqMy*utxDMP)}|E}=*MNvthf>UXs@^oa_Y%dw(;LU zd+z^J$9YCIm2Cm|k|0e=0t84v2I<8h0wMH?2%&@$sx%8jOK4J~Smp&41wt|;h)M)r zAPIyLKmrIX&?tzJCL-#9fP*LqGAb74`DT3Y-JsKcy#3>zwa&fw$31(mbNAVMf4e6R zkElz}vMP-gVbav>ZA^;Zs89VXxgHjoHM)Zi1B`iGy{X(Etg+d|; zm`g)L7mbB$pOv3nd}`urU`SsZ2|<8`3!SzYT{5PA1%`OVP^EnNtaSDB_sulM&(E?Sz_DvTc<$L*oWFy)KhxYm zZqK1}%*nBef~5qPdx~xh+d-DnU_wWLQ5Cs@|(1b||nbEY#L z9@ZQ{tZhei-efh5DfyWLv;0 zX{YDMrY#Lw#9|!d^FLEJhnjPAqT}w=b`6zG?u>+&wT#A!=`_{HP1Y40b@0$PniN|_ zfu{Sq>6)Di$w4 z7D>qfKo}{d1Sp;%(IbBZw7>%PF9OEHYaOZ^f+h<=u7zBMAl!af{r2h-t)b#w+6L@` z`(;<1WAAT;qf*Pgx!XG%KdtR5S-*crKUK-$;k6EHch}6vg^j*7$h#APK@?f=h$E49 zDgtr%LIZ?za%l0?Zk)3)m~<=I!^%JiQ&J$B8$eD&zGC20j$o-qsPfCDY{~~w)!J$r zqD%oEV1M{!-9k5OO^%S!To9HKE?!&Ijnx+Dz4JC{#9HH{Q**jcnhy@POrW21gJ1&^ zDhuHqQBw`gLnODUiT3W+ZxiS{yTrN6^AE?Jy1{bQ`Oo|}M9}Tj)@}qNr5pu3QT`%@ zH#zPd8@ZZVaGU9EHa%_RH)D9t_?73#nTr1A3{w{0XSpW4&Yt(8qa90N@M=Z{ezBfC zc~IhWM&u1aZu*^!^Y)_e_-pcgmYe~2#2|aSr&n+u_s26-`PC+az?#C7kHpEg;FuMZ z?(fH|&PeP{eESxffkt-y$~0nZmUre}Xy>DDlsN^$M%n653ahxLyjY(XdE^htHf}0O zbm2r4d(s08mO5JetG3N%55*2{6cveZNxVa=Lhs(KsTs9^(K9=V`^3Z2^e!`89g&s> z0N9yfmN}rmV&r>)!f-IvVs3a(4_n(`)o+`D6Dr9EN_2v=52D>v!Y#dIU_dY+`=w+E zGz8pNYxpw2G{H3U?*ejbI6%3hJAwM=9ExTe`C2|~{C3rSo}~5k*(e`7PYWffY5|vh zTkvq*(Tx2drx- zX8yadPR6CfPCSMyR#rZq(!&)#FkDeWytairaP zC#p(%wt7ih?HBL4Wxl`Sw|0Eb8=*Wt5*?0_tTfmdQ6xw}u&UTI;P5=#^6+sM!?daH z6Dl$w+x6}x8+|qr^qW*8FEfHz&vVAKI0qjh%p1{;n!fv+;4EZ3I2Pu=&ne*t zlVS|O7ycs7W~8Y-^5Pk}y47k8ClpQT30|dvM3(`RyMHjsj-7LSp_QFS&>d^uxMX{9 zl5yiD3b}KE8fQ9g{vLA}t2?n2uMX53$5;2TSX{yL{tiF(cNV-l0Ur z$*{^~DpNH)Psm7kmf&IY{6F$70(vLf zu&=`MS(0xb$01eJMEjVmZCbtKZFt+)tL>3pk}91u^T%<{;1=Izy{ZrXqK8(crd(k_E_` zBlgrcXMa-{@!md)j7Hswl=yLd);VFvN}ErJ)04<>I;MRU>7opOFf zz@%ZKd1Hm0q4=eTDUtf6@CktYWpTIa3c*G*94QZ~+_Jw zSI9%5Wze2I$c1Hzus$`1otq-=zy~lnwg9yCD_lh9Hm`s|yRfQyMiq>LS`=W-%8 zWm3o{SG8s$icKdfea%Vm+ZFKFWYk{vEe4sF_&RTWWaT7vQ&tXwG!`Y4~X}PHvgdoN*H7xt QVKe4@sJ>CDVs;;W$aQyHy#HgvJp$5TV5Cj7U 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 CsDOy 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 literal 0 HcmV?d00001 diff --git a/frontend/src/static/会议日程 copy.png b/frontend/src/static/会议日程 copy.png new file mode 100644 index 0000000000000000000000000000000000000000..a0730b9f38d1603af504fc7f4ab3b269193d9c09 GIT binary patch literal 41183 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 literal 0 HcmV?d00001 diff --git a/frontend/src/static/会议日程.png b/frontend/src/static/会议日程.png new file mode 100644 index 0000000000000000000000000000000000000000..bc2a85dfd9943b3c5686b34ee0e4e08a01ff040e GIT binary patch literal 548005 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

    Y;T^Z>Cy4AxJ9x^m)^em#y5ZA*M8&m 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 W1%VcV97g~UDs~BIr?oWW|C<0R=@;z) literal 0 HcmV?d00001 diff --git a/frontend/src/static/会议讨论.png b/frontend/src/static/会议讨论.png new file mode 100644 index 0000000000000000000000000000000000000000..402910875869eaa6973ca09c7742693640e85db0 GIT binary patch literal 14921 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 literal 0 HcmV?d00001 diff --git a/frontend/src/static/劳务费发票.png b/frontend/src/static/劳务费发票.png new file mode 100644 index 0000000000000000000000000000000000000000..4b2b9c8be2c3c5bc040403857413df308d9301e9 GIT binary patch literal 38331 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�uv>kwQ{@q`{iACHzxSFi<{h6#JLL!k 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 S;{MC2FB15lBY~h-&HoQag=7E# literal 0 HcmV?d00001 diff --git a/frontend/src/static/大交通发票2.png b/frontend/src/static/大交通发票2.png new file mode 100644 index 0000000000000000000000000000000000000000..d781142bc4f65c9b7cb4111774ef0767770c0dcc GIT binary patch literal 22613 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 TWG5B-Pu_**ya|l#|8D{S$Or3( literal 0 HcmV?d00001 diff --git a/frontend/src/static/大交通明细2.png b/frontend/src/static/大交通明细2.png new file mode 100644 index 0000000000000000000000000000000000000000..83439b3f2201dc2362472b010aefcc9fc012bf54 GIT binary patch literal 45498 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

    TIFJij6eUfa3SE7ji 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 literal 0 HcmV?d00001 diff --git a/frontend/src/static/大会主持.png b/frontend/src/static/大会主持.png new file mode 100644 index 0000000000000000000000000000000000000000..fd8bd385b2de4bb4124d618f92cee43b73a4b22c GIT binary patch literal 8270 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 literal 0 HcmV?d00001 diff --git a/frontend/src/static/小交通明细.png b/frontend/src/static/小交通明细.png new file mode 100644 index 0000000000000000000000000000000000000000..f0b9e1847318056964565ea7faaf2b55e2bc4356 GIT binary patch literal 34439 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 B1)l%_ literal 0 HcmV?d00001 diff --git a/frontend/src/static/桌牌.png b/frontend/src/static/桌牌.png new file mode 100644 index 0000000000000000000000000000000000000000..24672768f8b3012c65895e7e9e960e4ea3813709 GIT binary patch literal 3533 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 W$BsFG%sKjw{WNty8hvlNHuOL6jd4T( literal 0 HcmV?d00001 diff --git a/frontend/src/static/物料2.png b/frontend/src/static/物料2.png new file mode 100644 index 0000000000000000000000000000000000000000..5fb599b6ef816f542ae7344742f93f6df153da29 GIT binary patch literal 34896 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 H>hXU8h9dPb literal 0 HcmV?d00001 diff --git a/frontend/src/static/物料3.png b/frontend/src/static/物料3.png new file mode 100644 index 0000000000000000000000000000000000000000..02cd9b023a3706dc643930167bfdae03c3948241 GIT binary patch literal 68822 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

    Xx=GLhRfGJiCNMI4kOD)%9&Mru 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ཱྀTqxGl9aef^n7$Hn4$v3m|##DkA(2Z_o|X-)AYw# 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 literal 0 HcmV?d00001 diff --git a/frontend/src/static/会议结算单.png b/frontend/src/static/会议结算单.png new file mode 100644 index 0000000000000000000000000000000000000000..b10d81a48d63aa3e03de9b3c700398accb079df1 GIT binary patch literal 50494 zcmb5W3p`YN_dmYJ)Tms(xulSeM>R!J)3`*rom>jz(r~6k9BMFfzeZ&aryjRbr;jzY6S-BQP!v%r^k1JnI_Esk@B92-zyBWgXWwV<*=w!OTJQB?RQib zeJ(r$?fcn1$RD-;WrFK|e1fiyVSM%fa?Jm2^U_0ygWTbO_wc_i5WXC4Ru-0JJ%q~} z(XyLxc_&&93-%9&V{FlKp#9!muzU!XH+uYS+3j!3?*4)3_(V8nr_YfP^j&B|cPxI` z*MSc068K+@1z~$JbL>a-{ICv_Up9tqpT;oa*}t~ArD9n5Neo-n{nxg291Q!PUoq@P z+h5!M`c3?Q4*VIv+#;BZFqs%OnTKH#`!P)B5r!>0fbWCZf4Mhh*tHt&%MU&t*b(e7 zri|^ud@*-S1D3X7TQLfz{jCSvg`IRd6mp1uGUP}|@|lyF_rE>8UtWIy+qZ9%@c;ib z!9@sXl9TTj!?{oYpU(JS4ywbXmMlI*$|Mq0FcB#NQHt=b9$N*n2GjRPMJ+*uxCr)a zSq`E}glUlok$}M;+9PErEwh*S56U*v@?{r-!gv;ON84PPLfL`0(D*H{L>F8lkY?>pG|f(koc zc_X?ox-|MRIruWV75v80Mzp}j^6?!DGm%)h=WKSo@XY`C0scG?z2xsJOVAtre);bk z7h1RpSYmI)U*h84H>aIoOdEXx=Z)LQ>ugAj z$);4yJgAttuT80%X`9t?m-&Vf9tHI!+RW;Beg1~A;XwX}Z`jJvvz{J-m(wo{D#-Wd zkt+iwHtCJDD2%j-6zUbUDCBIFP+=wB8BMHPb}8{vBD)}5V;Osy3VX>iWf9J@g{NAQ z56jBSmcTE$j16+k&flo~_aAW?TMgcJHhT&F!vEtZ`QKL;e(wKuC;AEyF=f~(9##Iu zC+XuijK)TX;;IAf63{aE>^HJB_PoPRPLxahk^L~GUcr6&V-c@J<7|qAGCAHqJ@`c_ zDRks^xwRspjdfYkzEYV|X7iv-KDjQaJMlr;XG@M`q|1eLmkX8H>$07LJ%wnYoUzvQ zSOylvcUfygKhKd29c7Az1WB@W=bpukAJfBL!;2Ag3=9)9i}GZgk11h6oi2+z8Kk