From 11eaa9b007cbc856032efd8e3d3d4fb192741830 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=94=A1=E6=97=BB=E5=B3=B0?= <1441170195@qq.com> Date: Mon, 21 Sep 2026 13:04:59 +0800 Subject: [PATCH] feat:Init project --- .gitignore | 37 + README.md | 2 + pom.xml | 236 + settings/config.ini | 0 .../sjmresorts/opsapp/MainApplication.java | 20 + .../controllers/AdminDevicesController.java | 94 + .../controllers/AdminLoginController.java | 158 + .../controllers/AdminMenusController.java | 48 + .../controllers/AdminRoleUsersController.java | 101 + .../controllers/AdminRolesController.java | 203 + .../controllers/AdminUserController.java | 137 + .../AdminDevicesInfoPageRequest.java | 17 + .../AdminDevicesInfoPageResponse.java | 18 + .../AdminGetClientInfosRequest.java | 19 + .../AdminGetClientInfosResponse.java | 17 + .../admin/dtos/appsetting/ClientInfosDto.java | 26 + .../dtos/appsetting/GetAppSettingRequest.java | 21 + .../admin/dtos/menus/AdminMenuItemDto.java | 20 + .../dtos/menus/AdminMenuStatusRequest.java | 16 + .../dtos/menus/AdminMenuStatusResponse.java | 18 + .../dtos/menus/AdminMenusListResponse.java | 16 + .../AdminAssignGroupPermissionRequest.java | 22 + .../AdminAssignGroupPermissionResponse.java | 27 + .../AdminListPermissionSettingRequest.java | 17 + .../AdminListPermissionSettingResponse.java | 27 + .../roleUsers/AdminGetRoleUsersRequest.java | 21 + .../roleUsers/AdminGetRoleUsersResponse.java | 30 + .../dtos/roleUsers/AdminRoleUsersDto.java | 13 + .../dtos/roles/AdminCreateRoleRequest.java | 17 + .../dtos/roles/AdminCreateRoleResponse.java | 27 + .../dtos/roles/AdminDeleteRoleRequest.java | 19 + .../dtos/roles/AdminDeleteRoleResponse.java | 27 + .../dtos/roles/AdminGetRoleByIdRequest.java | 17 + .../dtos/roles/AdminGetRoleByIdResponse.java | 27 + .../dtos/roles/AdminGetRolesRequest.java | 19 + .../dtos/roles/AdminGetRolesResponse.java | 27 + .../dtos/roles/AdminModifyRoleRequest.java | 19 + .../dtos/roles/AdminModifyRoleResponse.java | 27 + .../roles/AdminRoleAssignUsersRequest.java | 21 + .../roles/AdminRoleAssignUsersResponse.java | 27 + .../opsapp/admin/dtos/roles/AdminRoleDto.java | 11 + .../dtos/system/login/AdminLoginRequest.java | 25 + .../dtos/system/login/AdminLoginResponse.java | 28 + .../system/login/AdminLogoutResponse.java | 25 + .../admin/dtos/system/login/CmsLoginInfo.java | 25 + .../system/router/RouterPermissionDto.java | 16 + .../dtos/system/router/UserRouterDto.java | 24 + .../system/user/CmsAppUserPageRequest.java | 14 + .../admin/dtos/system/user/CmsUserDto.java | 19 + .../dtos/system/user/CmsUserInfoResponse.java | 17 + .../dtos/system/user/GetCmsUserResponse.java | 15 + .../admin/mappers/ClientInfosMapper.java | 17 + .../admin/mappers/CmsLoginRouterMapper.java | 41 + .../admin/mappers/RouterPermissionMapper.java | 15 + .../opsapp/admin/mappers/SysRouterMapper.java | 17 + .../admin/models/GroupPermissionObject.java | 17 + .../repositories/RoleUsersRepository.java | 18 + .../admin/repositories/RolesRepository.java | 23 + .../admin/services/AdminDevicesService.java | 108 + .../admin/services/AdminLoginService.java | 163 + .../admin/services/AdminMenusService.java | 113 + .../services/AdminPermissionService.java | 109 + .../admin/services/AdminRoleUsersService.java | 14 + .../admin/services/AdminRolesService.java | 174 + .../admin/services/AdminUserService.java | 180 + .../MobileAuthorizationController.java | 147 + .../dtos/AdAuthorizeResponse.java | 19 + .../authentication/dtos/AdLoginData.java | 42 + .../authentication/dtos/AdLoginRequest.java | 25 + .../authentication/dtos/AdLoginResponse.java | 47 + .../authentication/dtos/AdUserInfo.java | 20 + .../dtos/AppLogoutResponse.java | 25 + .../authentication/dtos/DeviceLoginData.java | 45 + .../dtos/DeviceLoginRequest.java | 21 + .../dtos/DeviceLoginResponse.java | 47 + .../authentication/dtos/GetAuthTokenData.java | 21 + .../dtos/GetAuthTokenRequest.java | 25 + .../dtos/GetAuthTokenResponse.java | 28 + .../authentication/dtos/LoginUserInfo.java | 21 + .../dtos/RefreshTokenResponse.java | 21 + .../dtos/RegisterTrackInfoData.java | 26 + .../dtos/RegisterTrackInfoRequest.java | 22 + .../dtos/RegisterTrackInfoResponse.java | 26 + .../authentication/filters/JwtAuthFilter.java | 111 + .../authentication/mappers/RoleMapper.java | 14 + .../authentication/mappers/UserMapper.java | 31 + .../authentication/models/ClientInfo.java | 50 + .../opsapp/authentication/models/Role.java | 21 + .../authentication/models/RolePermission.java | 24 + .../authentication/models/RoleUsers.java | 24 + .../models/RouterPermission.java | 25 + .../authentication/models/SimpleUser.java | 30 + .../authentication/models/SysRouter.java | 45 + .../opsapp/authentication/models/User.java | 81 + .../authentication/models/UserRole.java | 25 + .../authentication/models/UserToken.java | 40 + .../repositories/DeviceInfoRepository.java | 29 + .../RolePermissionRepository.java | 16 + .../RouterPermissionRepository.java | 21 + .../repositories/SysRouterRepository.java | 24 + .../repositories/UserRepository.java | 66 + .../repositories/UserRoleRepository.java | 15 + .../repositories/UserTokenRepository.java | 30 + .../services/AdAuthorizeService.java | 76 + .../authentication/services/LoginService.java | 174 + .../services/RolePermissionService.java | 50 + .../services/SysRouterService.java | 59 + .../services/UserTokenService.java | 63 + .../opsapp/common/config/AsyncConfig.java | 52 + .../common/config/GoogleApiProxyFactory.java | 56 + .../common/config/JvmProxyConfigurator.java | 40 + .../opsapp/common/config/LdapConfig.java | 39 + .../opsapp/common/config/OpsAppDbConfig.java | 96 + .../opsapp/common/config/SwaggerConfig.java | 19 + .../opsapp/common/config/WebFilterConfig.java | 142 + .../opsapp/common/constants/AppConstants.java | 16 + .../opsapp/common/db/DbDialect.java | 75 + .../opsapp/common/db/DbDialectConfig.java | 47 + .../sjmresorts/opsapp/common/db/DbType.java | 36 + .../opsapp/common/db/MySqlDbDialect.java | 138 + .../opsapp/common/db/PostgresDbDialect.java | 133 + .../opsapp/common/db/SqlServerDbDialect.java | 135 + .../opsapp/common/dtos/BaseApiResponse.java | 23 + .../opsapp/common/dtos/BasePageRequest.java | 17 + .../opsapp/common/dtos/DataTableResult.java | 24 + .../common/dtos/LDAP/ADAccountInfo.java | 45 + .../opsapp/common/dtos/SchemaEntry.java | 25 + .../common/dtos/UserCredentialsDto.java | 18 + .../dtos/httpclient/HttpRequestDefine.java | 24 + .../dtos/httpclient/HttpResponseResult.java | 39 + .../exception/GlobalExceptionHandler.java | 35 + .../exceptions/TokenExpiredException.java | 17 + .../common/filters/CorsHeaderFilter.java | 54 + .../opsapp/common/filters/JwtAuthFilter.java | 116 + .../opsapp/common/utils/AESUtil.java | 150 + .../opsapp/common/utils/DaoUtil.java | 92 + .../opsapp/common/utils/DateTimeUtil.java | 206 + .../opsapp/common/utils/HttpClientUtil.java | 284 + .../opsapp/common/utils/JwtTokenUtil.java | 268 + .../opsapp/common/utils/JwtTokenUtilTest.java | 103 + .../opsapp/common/utils/LDAPUtil.java | 108 + .../opsapp/common/utils/NetworkUtil.java | 276 + .../opsapp/common/utils/ResourceFileUtil.java | 83 + .../opsapp/common/utils/SimpleHttpClient.java | 289 + .../opsapp/common/utils/TemplateUtil.java | 42 + .../controllers/EsignAuditLogController.java | 65 + .../audit/dtos/EsignAuditLogResponse.java | 51 + .../esign/audit/models/EsignAuditLog.java | 73 + .../repositories/EsignAuditLogRepository.java | 16 + .../audit/services/EsignAuditLogService.java | 240 + .../support/ThirdPartyAuditDetailBuilder.java | 92 + .../controllers/AppDeepLinkController.java | 177 + .../controllers/EsignAppUserController.java | 45 + .../controllers/EsignHotelController.java | 115 + .../controllers/EsignLoginController.java | 92 + .../esign/auth/dtos/EsignAppUserResponse.java | 48 + .../auth/dtos/EsignAppUserSaveRequest.java | 23 + .../auth/dtos/EsignAppUserStatusRequest.java | 13 + .../dtos/EsignBindDeviceTokenRequest.java | 28 + .../opsapp/esign/auth/dtos/EsignHotelDto.java | 26 + .../auth/dtos/EsignHotelListResponse.java | 26 + .../auth/dtos/EsignHotelLogoRequest.java | 14 + .../auth/dtos/EsignHotelMutationResponse.java | 31 + .../auth/dtos/EsignHotelUpsertRequest.java | 26 + .../esign/auth/dtos/EsignLoginRequest.java | 31 + .../esign/auth/dtos/EsignLoginResponse.java | 61 + .../opsapp/esign/auth/models/EsignHotel.java | 58 + .../esign/auth/models/EsignHotelSetting.java | 31 + .../opsapp/esign/auth/models/EsignUser.java | 63 + .../repositories/EsignHotelRepository.java | 15 + .../EsignHotelSettingRepository.java | 6 + .../repositories/EsignUserRepository.java | 56 + .../auth/services/EsignAppUserService.java | 179 + .../auth/services/EsignHotelService.java | 419 + .../auth/services/EsignLoginService.java | 118 + .../services/EsignUserPresenceService.java | 178 + .../config/EsignCaptureSchemaMigrator.java | 153 + .../controllers/EsignCaptureController.java | 177 + .../controllers/EsignLogController.java | 88 + .../capture/dtos/CreateEsignLogRequest.java | 46 + .../capture/dtos/CreateEsignLogResponse.java | 44 + .../capture/dtos/EsignIosCaptureResponse.java | 49 + .../esign/capture/dtos/EsignLogDetailDto.java | 43 + .../esign/capture/dtos/EsignLogListDto.java | 37 + .../capture/dtos/EsignLogQueryRequest.java | 24 + .../esign/capture/mappers/EsignLogMapper.java | 21 + .../capture/models/EsignCaptureTask.java | 50 + .../capture/models/EsignCaptureTaskImage.java | 42 + .../opsapp/esign/capture/models/EsignLog.java | 51 + .../EsignCaptureTaskImageRepository.java | 16 + .../EsignCaptureTaskRepository.java | 23 + .../repositories/EsignLogRepository.java | 38 + .../capture/services/EsignCaptureService.java | 718 + .../capture/services/EsignLogService.java | 114 + .../opsapp/esign/common/EsignApiPaths.java | 26 + .../common/EsignLegacyApiProperties.java | 17 + .../common/config/EsignCmsSchemaMigrator.java | 743 + .../common/dtos/EsignSimpleResponse.java | 29 + .../dtos/OhipProfileFieldCatalogResponse.java | 38 + .../common/dtos/OhipProfileFieldDef.java | 26 + .../common/dtos/SignFieldOhipMappingItem.java | 23 + .../common/filters/EsignAccessLogFilter.java | 104 + .../filters/EsignCmsOperationAuditFilter.java | 104 + .../EsignLegacyApiDeprecationFilter.java | 70 + .../mappers/SignFieldOhipValueMapper.java | 207 + .../common/support/EsignLogMaskUtil.java | 103 + .../support/EsignReservationParamUtil.java | 24 + .../support/OhipProfileFieldCatalog.java | 135 + .../support/ProfileDetailsJsonReader.java | 188 + .../EsignAppPageLayoutController.java | 63 + .../EsignHubPromotionController.java | 175 + .../EsignHubSettingsController.java | 49 + .../hub/dtos/EsignAppPageLayoutResponse.java | 50 + .../dtos/EsignAppPageLayoutSaveRequest.java | 18 + .../hub/dtos/EsignHubPromotionResponse.java | 61 + .../dtos/EsignHubPromotionSaveRequest.java | 25 + .../hub/dtos/EsignHubSettingsResponse.java | 53 + .../hub/dtos/EsignHubSettingsSaveRequest.java | 21 + .../esign/hub/dtos/HubSettingsMenu.java | 21 + .../esign/hub/dtos/HubUserMenuStyle.java | 18 + .../esign/hub/models/EsignAppPageLayout.java | 50 + .../esign/hub/models/EsignHubPromotion.java | 65 + .../esign/hub/models/EsignHubSettings.java | 53 + .../EsignAppPageLayoutRepository.java | 11 + .../EsignHubPromotionRepository.java | 15 + .../EsignHubSettingsRepository.java | 13 + .../services/EsignAppPageLayoutService.java | 218 + .../EsignHubPromotionMediaService.java | 298 + .../services/EsignHubPromotionService.java | 398 + .../hub/services/EsignHubSettingsService.java | 325 + .../config/EsignPrinterSchemaMigrator.java | 79 + .../config/EsignPrinterWebSocketConfig.java | 25 + .../EsignPrinterDeviceLinkController.java | 91 + .../EsignPrinterPcRegistryController.java | 48 + .../EsignPrinterPdfTaskController.java | 488 + .../EsignPrinterPushController.java | 92 + .../EsignPrinterPushRuleController.java | 60 + .../EsignPrinterTabletRegistryController.java | 48 + ...EsignPrinterWebSocketStatusController.java | 21 + .../dtos/EsignClientTransportLogRequest.java | 21 + .../dtos/EsignPendingListResponse.java | 61 + .../dtos/EsignPrinterDashboardResponse.java | 77 + .../dtos/EsignPrinterDeviceLinkRequest.java | 17 + .../dtos/EsignPrinterDeviceLinkResponse.java | 52 + .../EsignPrinterDeviceLinkReviewRequest.java | 18 + .../dtos/EsignPrinterDevicesResponse.java | 103 + .../dtos/EsignPrinterEventLogResponse.java | 42 + .../dtos/EsignPrinterOhipStatusRow.java | 15 + .../EsignPrinterPcRegistryDeleteRequest.java | 13 + .../dtos/EsignPrinterPcRegistryResponse.java | 47 + .../EsignPrinterPcRegistrySaveRequest.java | 18 + .../printer/dtos/EsignPrinterPcSourceRow.java | 17 + .../dtos/EsignPrinterPdfArchiveListRow.java | 36 + ...signPrinterPdfArchiveOcrBatchResponse.java | 33 + .../dtos/EsignPrinterPdfArchiveOcrEvent.java | 4 + .../dtos/EsignPrinterPdfArchiveResponse.java | 69 + .../dtos/EsignPrinterPdfArchiveRow.java | 32 + .../dtos/EsignPrinterPdfArchiveSpecs.java | 116 + .../EsignPrinterPdfFieldExtractRequest.java | 37 + .../EsignPrinterPdfFieldExtractResponse.java | 44 + .../EsignPrinterPdfFieldPrefillRequest.java | 14 + .../EsignPrinterPdfFieldPrefillResponse.java | 47 + .../dtos/EsignPrinterPdfSignedOhipEvent.java | 11 + .../EsignPrinterPdfSignedSubmitRequest.java | 53 + .../EsignPrinterPdfTaskCompleteRequest.java | 17 + .../EsignPrinterPdfTaskDetailResponse.java | 35 + .../dtos/EsignPrinterPdfTaskListRow.java | 29 + .../EsignPrinterPdfTaskSubmitRequest.java | 32 + .../EsignPrinterPdfTaskUpdateRequest.java | 23 + ...EsignPrinterPdfUsbSignedSubmitRequest.java | 47 + .../dtos/EsignPrinterPushAssignRequest.java | 19 + .../EsignPrinterPushCandidatesResponse.java | 71 + .../EsignPrinterPushGroupListResponse.java | 44 + .../EsignPrinterPushGroupSaveRequest.java | 18 + .../dtos/EsignPrinterPushRuleResponse.java | 65 + .../dtos/EsignPrinterPushRuleSaveRequest.java | 23 + .../dtos/EsignPrinterQueueResponse.java | 71 + .../dtos/EsignPrinterQueueWatchResponse.java | 37 + ...ignPrinterTabletRegistryDeleteRequest.java | 13 + .../EsignPrinterTabletRegistryResponse.java | 49 + ...EsignPrinterTabletRegistrySaveRequest.java | 26 + .../printer/dtos/EsignSignTaskRequest.java | 20 + .../printer/dtos/EsignTaskDetailResponse.java | 34 + .../printer/dtos/LocalExeLaunchResult.java | 29 + .../printer/dtos/RegistrationCardMeta.java | 107 + .../dtos/RegistrationCardMetaExtractor.java | 732 + .../dtos/RegistrationCardResponseParser.java | 117 + .../printer/dtos/RegistrationCardResult.java | 55 + .../dtos/RegistrationCardSignFieldParser.java | 71 + .../dtos/SignedRegistrationPdfComposer.java | 336 + .../filters/EsignPrinterPdfArchiveFilter.java | 47 + .../models/EsignPrinterDeviceLink.java | 74 + .../printer/models/EsignPrinterEventLog.java | 49 + .../models/EsignPrinterPcRegistry.java | 65 + .../printer/models/EsignPrinterPdfTask.java | 132 + .../models/EsignPrinterPdfTaskPush.java | 53 + .../printer/models/EsignPrinterPushGroup.java | 53 + .../models/EsignPrinterPushGroupMember.java | 37 + .../models/EsignPrinterPushRuleConfig.java | 53 + .../models/EsignPrinterPushRuleMapping.java | 54 + .../models/EsignPrinterTabletRegistry.java | 69 + .../models/EsignPrinterTabletRegistryPc.java | 40 + .../RegistrationCardSignFieldDefaults.java | 17 + .../EsignPrinterDeviceLinkRepository.java | 24 + .../EsignPrinterEventLogRepository.java | 17 + .../EsignPrinterPcRegistryRepository.java | 20 + .../EsignPrinterPdfTaskPushRepository.java | 54 + .../EsignPrinterPdfTaskRepository.java | 300 + ...EsignPrinterPushGroupMemberRepository.java | 22 + .../EsignPrinterPushGroupRepository.java | 11 + .../EsignPrinterPushRuleConfigRepository.java | 7 + ...EsignPrinterPushRuleMappingRepository.java | 18 + ...signPrinterTabletRegistryPcRepository.java | 13 + .../EsignPrinterTabletRegistryRepository.java | 24 + .../EsignPrinterDeviceLinkService.java | 395 + .../services/EsignPrinterEventLogService.java | 105 + .../EsignPrinterPcRegistryService.java | 224 + ...EsignPrinterPdfArchiveOcrBatchService.java | 302 + .../EsignPrinterPdfArchiveOcrService.java | 191 + .../EsignPrinterPdfFieldPrefillService.java | 286 + .../EsignPrinterPdfOhipUploadService.java | 282 + .../services/EsignPrinterPdfTaskService.java | 2095 ++ .../services/EsignPrinterPushRuleService.java | 406 + .../services/EsignPrinterPushService.java | 688 + .../EsignPrinterTabletRegistryService.java | 404 + .../services/LocalExeLaunchService.java | 294 + .../RegistrationCardMetaExtractService.java | 88 + .../EsignOhipUploadRetryScheduler.java | 64 + .../support/EsignPrinterOhipStatus.java | 14 + .../EsignPrinterPdfArchiveListQuery.java | 130 + .../EsignPrinterPdfArchiveOcrBatchQuery.java | 74 + .../EsignPrinterPdfArchiveOcrBatchRunner.java | 19 + ...signPrinterPdfArchiveOcrEventListener.java | 35 + .../support/EsignPrinterPdfFileStorage.java | 283 + ...signPrinterPdfSignedOhipEventListener.java | 58 + ...nPrinterPdfSignedSubmitAsyncScheduler.java | 56 + .../support/EsignPrinterQueueNotifier.java | 47 + .../support/EsignPrinterQueueSseHub.java | 103 + .../support/PdfSignFieldTextExtractor.java | 366 + .../RegistrationConfirmationOcrExtractor.java | 628 + .../support/SignedPdfArchiveReader.java | 84 + .../support/SignedPdfComposeValidator.java | 50 + .../EsignPrinterWebSocketAuthInterceptor.java | 116 + .../EsignPrinterWebSocketHandler.java | 50 + .../websocket/EsignPrinterWebSocketHub.java | 227 + .../EsignReservationController.java | 204 + .../EsignSignFieldOhipMappingController.java | 64 + .../dtos/EsignReservationNotifyResponse.java | 121 + .../EsignReservationTaskCompleteRequest.java | 20 + .../EsignSignFieldOhipMappingResponse.java | 48 + .../EsignSignFieldOhipMappingSaveRequest.java | 16 + .../EsignSignFieldOhipPreviewRequest.java | 20 + .../EsignSignFieldOhipPreviewResponse.java | 48 + .../dtos/GuestProfileIdResponse.java | 50 + .../reservation/dtos/GuestProfileRef.java | 9 + .../dtos/HospitalityOAuthSettings.java | 32 + .../reservation/dtos/OAuthTokenResponse.java | 21 + .../dtos/RegistrationCardResponse.java | 99 + .../dtos/ReservationLookupHit.java | 5 + .../dtos/ReservationNotificationResult.java | 52 + .../dtos/ReservationThirdPartyResult.java | 45 + .../SignedRegistrationCardSubmitRequest.java | 44 + .../SignedRegistrationCardUploadResult.java | 72 + .../models/EsignReservationTask.java | 45 + .../models/EsignSignFieldOhipConfig.java | 34 + .../EsignReservationTaskRepository.java | 24 + .../EsignSignFieldOhipConfigRepository.java | 13 + .../EsignReservationNotificationService.java | 170 + .../services/EsignReservationService.java | 221 + .../services/EsignReservationTaskService.java | 207 + .../EsignSignFieldOhipMappingService.java | 194 + ...egistrationConfirmationExtractService.java | 96 + .../services/ReservationThirdPartyClient.java | 824 + .../SignedRegistrationCardOhipService.java | 258 + .../HospitalityOAuthTokenConfigurer.java | 91 + .../HospitalityOAuthTokenSingleton.java | 240 + .../support/OperaFlexLinkPageBuilder.java | 797 + .../support/ProfileDetailsJsonBuilder.java | 353 + .../RegistrationConfirmationExtractor.java | 94 + .../ReservationDetailResponseParser.java | 138 + .../ReservationLookupResponseParser.java | 103 + .../controllers/EsignSurveyController.java | 309 + .../EsignSurveyExternalWebhookRequest.java | 32 + .../dtos/EsignSurveyPushAssignRequest.java | 21 + .../survey/dtos/EsignSurveyResponse.java | 93 + .../survey/dtos/EsignSurveySaveRequest.java | 36 + .../survey/dtos/EsignSurveySubmitRequest.java | 22 + .../dtos/ExternalSurveyUrlParseResponse.java | 41 + .../survey/dtos/ExternalSurveyUrlParser.java | 123 + .../esign/survey/models/EsignSurvey.java | 89 + .../esign/survey/models/EsignSurveyPush.java | 52 + .../survey/models/EsignSurveySubmission.java | 51 + .../EsignSurveyPushRepository.java | 31 + .../repositories/EsignSurveyRepository.java | 15 + .../EsignSurveySubmissionRepository.java | 19 + .../services/EsignSurveyMediaService.java | 227 + .../services/EsignSurveyPushService.java | 487 + .../survey/services/EsignSurveyService.java | 668 + .../logging/controllers/ApiLogController.java | 73 + .../logging/controllers/AppLogController.java | 58 + .../controllers/AuditLogController.java | 74 + .../opsapp/logging/dtos/ApiLogDto.java | 31 + .../logging/dtos/ApiLogPageRequest.java | 20 + .../logging/dtos/ApiLogPageResponse.java | 17 + .../opsapp/logging/dtos/AuditLogDto.java | 25 + .../logging/dtos/AuditLogPageRequest.java | 20 + .../logging/dtos/AuditLogPageResponse.java | 15 + .../logging/dtos/CreateAppLogRequest.java | 36 + .../logging/dtos/CreateAppLogResponse.java | 23 + .../logging/dtos/GetApiLogResponse.java | 16 + .../logging/dtos/GetAuditLogResponse.java | 16 + .../dtos/GetNotificationLogResponse.java | 16 + .../logging/dtos/NotificationLogDto.java | 29 + .../dtos/NotificationLogPageRequest.java | 18 + .../dtos/NotificationLogPageResponse.java | 15 + .../logging/filters/ApiLoggingFilter.java | 232 + .../opsapp/logging/mappers/ApiLogMapper.java | 15 + .../opsapp/logging/mappers/AppLogMapper.java | 11 + .../logging/mappers/AuditLogMapper.java | 16 + .../mappers/NotificationLogMapper.java | 15 + .../opsapp/logging/models/ApiLog.java | 42 + .../opsapp/logging/models/AppLog.java | 32 + .../opsapp/logging/models/AuditLog.java | 36 + .../logging/models/NotificationLog.java | 38 + .../repositories/ApiLogRepository.java | 14 + .../repositories/AppLogRepository.java | 10 + .../repositories/AuditLogRepository.java | 14 + .../NotificationLogRepository.java | 14 + .../logging/services/ApiLogService.java | 107 + .../logging/services/AppLogService.java | 25 + .../logging/services/AuditLogService.java | 147 + .../services/NotificationLogService.java | 25 + .../notifications/config/FirebaseConfig.java | 105 + .../controllers/NotificationController.java | 63 + .../dtos/BatchFcmSendResult.java | 15 + .../notifications/dtos/FcmSendResult.java | 16 + .../dtos/NotificationFailureDto.java | 27 + .../dtos/NotificationPayloadDto.java | 17 + .../dtos/SendNotificationData.java | 46 + .../dtos/SendNotificationRequest.java | 22 + .../dtos/SendNotificationResponse.java | 17 + .../dtos/SendNotificationResultData.java | 29 + .../enums/NotificationFailureReason.java | 10 + .../enums/NotificationResultStatus.java | 7 + .../enums/NotificationTargetType.java | 10 + .../resolvers/RecipientResolver.java | 90 + .../services/FcmPushService.java | 16 + .../services/NotificationService.java | 9 + .../services/impl/FcmPushServiceImpl.java | 283 + .../impl/NotificationServiceImpl.java | 412 + src/main/resources/application-db-mysql.yml | 17 + .../resources/application-db-postgres.yml | 14 + .../resources/application-db-sqlserver.yml | 13 + src/main/resources/application-dev.yml | 167 + src/main/resources/application-local.yml | 180 + src/main/resources/application-prod.yml | 56 + src/main/resources/application.yml | 28 + src/main/resources/config/ssl/dev/CA.crt | 33 + .../resources/config/ssl/dev/keystore.p12 | Bin 0 -> 5842 bytes .../resources/config/ssl/dev/truststore.jks | Bin 0 -> 4327 bytes src/main/resources/config/ssl/qa/keystore.p12 | Bin 0 -> 6353 bytes .../resources/config/ssl/qa/truststore.jks | Bin 0 -> 4327 bytes src/main/resources/logback-spring.xml | 82 + .../mockup/dashboard_report_mockup.json | 18835 ++++++++++++++++ .../sql/create_detail_view_log_table.sql | 13 + .../resources/sql/create_query_log_table.sql | 16 + .../RegistrationCardMetaExtractorTest.java | 120 + 467 files changed, 58741 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 pom.xml create mode 100644 settings/config.ini create mode 100644 src/main/java/com/sjmresorts/opsapp/MainApplication.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminDevicesController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminLoginController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminMenusController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminRoleUsersController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminRolesController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminUserController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/AdminDevicesInfoPageRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/AdminDevicesInfoPageResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/AdminGetClientInfosRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/AdminGetClientInfosResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/ClientInfosDto.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/GetAppSettingRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/menus/AdminMenuItemDto.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/menus/AdminMenuStatusRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/menus/AdminMenuStatusResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/menus/AdminMenusListResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/permissions/AdminAssignGroupPermissionRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/permissions/AdminAssignGroupPermissionResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/permissions/AdminListPermissionSettingRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/permissions/AdminListPermissionSettingResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/roleUsers/AdminGetRoleUsersRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/roleUsers/AdminGetRoleUsersResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/roleUsers/AdminRoleUsersDto.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminCreateRoleRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminCreateRoleResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminDeleteRoleRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminDeleteRoleResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminGetRoleByIdRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminGetRoleByIdResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminGetRolesRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminGetRolesResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminModifyRoleRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminModifyRoleResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminRoleAssignUsersRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminRoleAssignUsersResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminRoleDto.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/system/login/AdminLoginRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/system/login/AdminLoginResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/system/login/AdminLogoutResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/system/login/CmsLoginInfo.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/system/router/RouterPermissionDto.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/system/router/UserRouterDto.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/system/user/CmsAppUserPageRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/system/user/CmsUserDto.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/system/user/CmsUserInfoResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/dtos/system/user/GetCmsUserResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/mappers/ClientInfosMapper.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/mappers/CmsLoginRouterMapper.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/mappers/RouterPermissionMapper.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/mappers/SysRouterMapper.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/models/GroupPermissionObject.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/repositories/RoleUsersRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/repositories/RolesRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/services/AdminDevicesService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/services/AdminLoginService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/services/AdminMenusService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/services/AdminPermissionService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/services/AdminRoleUsersService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/services/AdminRolesService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/admin/services/AdminUserService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/controllers/MobileAuthorizationController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdAuthorizeResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdLoginData.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdLoginRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdLoginResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdUserInfo.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/AppLogoutResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/DeviceLoginData.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/DeviceLoginRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/DeviceLoginResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/GetAuthTokenData.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/GetAuthTokenRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/GetAuthTokenResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/LoginUserInfo.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/RefreshTokenResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/RegisterTrackInfoData.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/RegisterTrackInfoRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/dtos/RegisterTrackInfoResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/filters/JwtAuthFilter.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/mappers/RoleMapper.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/mappers/UserMapper.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/models/ClientInfo.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/models/Role.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/models/RolePermission.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/models/RoleUsers.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/models/RouterPermission.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/models/SimpleUser.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/models/SysRouter.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/models/User.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/models/UserRole.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/models/UserToken.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/repositories/DeviceInfoRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/repositories/RolePermissionRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/repositories/RouterPermissionRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/repositories/SysRouterRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/repositories/UserRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/repositories/UserRoleRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/repositories/UserTokenRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/services/AdAuthorizeService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/services/LoginService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/services/RolePermissionService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/services/SysRouterService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/authentication/services/UserTokenService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/config/AsyncConfig.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/config/GoogleApiProxyFactory.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/config/JvmProxyConfigurator.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/config/LdapConfig.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/config/OpsAppDbConfig.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/config/SwaggerConfig.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/config/WebFilterConfig.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/constants/AppConstants.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/db/DbDialect.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/db/DbDialectConfig.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/db/DbType.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/db/MySqlDbDialect.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/db/PostgresDbDialect.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/db/SqlServerDbDialect.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/dtos/BaseApiResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/dtos/BasePageRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/dtos/DataTableResult.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/dtos/LDAP/ADAccountInfo.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/dtos/SchemaEntry.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/dtos/UserCredentialsDto.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/dtos/httpclient/HttpRequestDefine.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/dtos/httpclient/HttpResponseResult.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/exceptions/TokenExpiredException.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/filters/CorsHeaderFilter.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/filters/JwtAuthFilter.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/utils/AESUtil.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/utils/DaoUtil.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/utils/DateTimeUtil.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/utils/HttpClientUtil.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/utils/JwtTokenUtil.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/utils/JwtTokenUtilTest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/utils/LDAPUtil.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/utils/NetworkUtil.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/utils/ResourceFileUtil.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/utils/SimpleHttpClient.java create mode 100644 src/main/java/com/sjmresorts/opsapp/common/utils/TemplateUtil.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/audit/controllers/EsignAuditLogController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/audit/dtos/EsignAuditLogResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/audit/models/EsignAuditLog.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/audit/repositories/EsignAuditLogRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/audit/services/EsignAuditLogService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/audit/support/ThirdPartyAuditDetailBuilder.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/controllers/AppDeepLinkController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/controllers/EsignAppUserController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/controllers/EsignHotelController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/controllers/EsignLoginController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignAppUserResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignAppUserSaveRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignAppUserStatusRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignBindDeviceTokenRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelDto.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelListResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelLogoRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelMutationResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelUpsertRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignLoginRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignLoginResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/models/EsignHotel.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/models/EsignHotelSetting.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/models/EsignUser.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/repositories/EsignHotelRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/repositories/EsignHotelSettingRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/repositories/EsignUserRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/services/EsignAppUserService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/services/EsignHotelService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/services/EsignLoginService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/auth/services/EsignUserPresenceService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/config/EsignCaptureSchemaMigrator.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/controllers/EsignCaptureController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/controllers/EsignLogController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/CreateEsignLogRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/CreateEsignLogResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/EsignIosCaptureResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/EsignLogDetailDto.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/EsignLogListDto.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/EsignLogQueryRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/mappers/EsignLogMapper.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/models/EsignCaptureTask.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/models/EsignCaptureTaskImage.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/models/EsignLog.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/repositories/EsignCaptureTaskImageRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/repositories/EsignCaptureTaskRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/repositories/EsignLogRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/services/EsignCaptureService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/capture/services/EsignLogService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/common/EsignApiPaths.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/common/EsignLegacyApiProperties.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/common/config/EsignCmsSchemaMigrator.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/common/dtos/EsignSimpleResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/common/dtos/OhipProfileFieldCatalogResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/common/dtos/OhipProfileFieldDef.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/common/dtos/SignFieldOhipMappingItem.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/common/filters/EsignAccessLogFilter.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/common/filters/EsignCmsOperationAuditFilter.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/common/filters/EsignLegacyApiDeprecationFilter.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/common/mappers/SignFieldOhipValueMapper.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/common/support/EsignLogMaskUtil.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/common/support/EsignReservationParamUtil.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/common/support/OhipProfileFieldCatalog.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/common/support/ProfileDetailsJsonReader.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/controllers/EsignAppPageLayoutController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/controllers/EsignHubPromotionController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/controllers/EsignHubSettingsController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignAppPageLayoutResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignAppPageLayoutSaveRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignHubPromotionResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignHubPromotionSaveRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignHubSettingsResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignHubSettingsSaveRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/HubSettingsMenu.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/HubUserMenuStyle.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/models/EsignAppPageLayout.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/models/EsignHubPromotion.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/models/EsignHubSettings.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/repositories/EsignAppPageLayoutRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/repositories/EsignHubPromotionRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/repositories/EsignHubSettingsRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/services/EsignAppPageLayoutService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/services/EsignHubPromotionMediaService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/services/EsignHubPromotionService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/hub/services/EsignHubSettingsService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/config/EsignPrinterSchemaMigrator.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/config/EsignPrinterWebSocketConfig.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterDeviceLinkController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterPcRegistryController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterPdfTaskController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterPushController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterPushRuleController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterTabletRegistryController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterWebSocketStatusController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignClientTransportLogRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPendingListResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDashboardResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDeviceLinkRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDeviceLinkResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDeviceLinkReviewRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDevicesResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterEventLogResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterOhipStatusRow.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPcRegistryDeleteRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPcRegistryResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPcRegistrySaveRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPcSourceRow.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveListRow.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveOcrBatchResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveOcrEvent.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveRow.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveSpecs.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfFieldExtractRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfFieldExtractResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfFieldPrefillRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfFieldPrefillResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfSignedOhipEvent.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfSignedSubmitRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskCompleteRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskDetailResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskListRow.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskSubmitRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskUpdateRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfUsbSignedSubmitRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushAssignRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushCandidatesResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushGroupListResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushGroupSaveRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushRuleResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushRuleSaveRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterQueueResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterQueueWatchResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterTabletRegistryDeleteRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterTabletRegistryResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterTabletRegistrySaveRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignSignTaskRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignTaskDetailResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/LocalExeLaunchResult.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardMeta.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardMetaExtractor.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardResponseParser.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardResult.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardSignFieldParser.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/SignedRegistrationPdfComposer.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/filters/EsignPrinterPdfArchiveFilter.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterDeviceLink.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterEventLog.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPcRegistry.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPdfTask.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPdfTaskPush.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPushGroup.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPushGroupMember.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPushRuleConfig.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPushRuleMapping.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterTabletRegistry.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterTabletRegistryPc.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/models/RegistrationCardSignFieldDefaults.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterDeviceLinkRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterEventLogRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPcRegistryRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPdfTaskPushRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPdfTaskRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPushGroupMemberRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPushGroupRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPushRuleConfigRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPushRuleMappingRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterTabletRegistryPcRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterTabletRegistryRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterDeviceLinkService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterEventLogService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPcRegistryService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfArchiveOcrBatchService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfArchiveOcrService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfFieldPrefillService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfOhipUploadService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfTaskService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPushRuleService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPushService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterTabletRegistryService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/services/LocalExeLaunchService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/services/RegistrationCardMetaExtractService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignOhipUploadRetryScheduler.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterOhipStatus.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfArchiveListQuery.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfArchiveOcrBatchQuery.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfArchiveOcrBatchRunner.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfArchiveOcrEventListener.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfFileStorage.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfSignedOhipEventListener.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfSignedSubmitAsyncScheduler.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterQueueNotifier.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterQueueSseHub.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/support/PdfSignFieldTextExtractor.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/support/RegistrationConfirmationOcrExtractor.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/support/SignedPdfArchiveReader.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/support/SignedPdfComposeValidator.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/websocket/EsignPrinterWebSocketAuthInterceptor.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/websocket/EsignPrinterWebSocketHandler.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/printer/websocket/EsignPrinterWebSocketHub.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/controllers/EsignReservationController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/controllers/EsignSignFieldOhipMappingController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignReservationNotifyResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignReservationTaskCompleteRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignSignFieldOhipMappingResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignSignFieldOhipMappingSaveRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignSignFieldOhipPreviewRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignSignFieldOhipPreviewResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/GuestProfileIdResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/GuestProfileRef.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/HospitalityOAuthSettings.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/OAuthTokenResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/RegistrationCardResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/ReservationLookupHit.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/ReservationNotificationResult.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/ReservationThirdPartyResult.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/SignedRegistrationCardSubmitRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/SignedRegistrationCardUploadResult.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/models/EsignReservationTask.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/models/EsignSignFieldOhipConfig.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/repositories/EsignReservationTaskRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/repositories/EsignSignFieldOhipConfigRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/services/EsignReservationNotificationService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/services/EsignReservationService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/services/EsignReservationTaskService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/services/EsignSignFieldOhipMappingService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/services/RegistrationConfirmationExtractService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/services/ReservationThirdPartyClient.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/services/SignedRegistrationCardOhipService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/support/HospitalityOAuthTokenConfigurer.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/support/HospitalityOAuthTokenSingleton.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/support/OperaFlexLinkPageBuilder.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/support/ProfileDetailsJsonBuilder.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/support/RegistrationConfirmationExtractor.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/support/ReservationDetailResponseParser.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/reservation/support/ReservationLookupResponseParser.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/controllers/EsignSurveyController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveyExternalWebhookRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveyPushAssignRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveyResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveySaveRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveySubmitRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/ExternalSurveyUrlParseResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/ExternalSurveyUrlParser.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/models/EsignSurvey.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/models/EsignSurveyPush.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/models/EsignSurveySubmission.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/repositories/EsignSurveyPushRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/repositories/EsignSurveyRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/repositories/EsignSurveySubmissionRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/services/EsignSurveyMediaService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/services/EsignSurveyPushService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/esign/survey/services/EsignSurveyService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/controllers/ApiLogController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/controllers/AppLogController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/controllers/AuditLogController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/dtos/ApiLogDto.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/dtos/ApiLogPageRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/dtos/ApiLogPageResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/dtos/AuditLogDto.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/dtos/AuditLogPageRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/dtos/AuditLogPageResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/dtos/CreateAppLogRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/dtos/CreateAppLogResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/dtos/GetApiLogResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/dtos/GetAuditLogResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/dtos/GetNotificationLogResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/dtos/NotificationLogDto.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/dtos/NotificationLogPageRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/dtos/NotificationLogPageResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/filters/ApiLoggingFilter.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/mappers/ApiLogMapper.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/mappers/AppLogMapper.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/mappers/AuditLogMapper.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/mappers/NotificationLogMapper.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/models/ApiLog.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/models/AppLog.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/models/AuditLog.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/models/NotificationLog.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/repositories/ApiLogRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/repositories/AppLogRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/repositories/AuditLogRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/repositories/NotificationLogRepository.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/services/ApiLogService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/services/AppLogService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/services/AuditLogService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/logging/services/NotificationLogService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/config/FirebaseConfig.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/controllers/NotificationController.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/dtos/BatchFcmSendResult.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/dtos/FcmSendResult.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/dtos/NotificationFailureDto.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/dtos/NotificationPayloadDto.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/dtos/SendNotificationData.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/dtos/SendNotificationRequest.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/dtos/SendNotificationResponse.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/dtos/SendNotificationResultData.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/enums/NotificationFailureReason.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/enums/NotificationResultStatus.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/enums/NotificationTargetType.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/resolvers/RecipientResolver.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/services/FcmPushService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/services/NotificationService.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/services/impl/FcmPushServiceImpl.java create mode 100644 src/main/java/com/sjmresorts/opsapp/notifications/services/impl/NotificationServiceImpl.java create mode 100644 src/main/resources/application-db-mysql.yml create mode 100644 src/main/resources/application-db-postgres.yml create mode 100644 src/main/resources/application-db-sqlserver.yml create mode 100644 src/main/resources/application-dev.yml create mode 100644 src/main/resources/application-local.yml create mode 100644 src/main/resources/application-prod.yml create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/config/ssl/dev/CA.crt create mode 100644 src/main/resources/config/ssl/dev/keystore.p12 create mode 100644 src/main/resources/config/ssl/dev/truststore.jks create mode 100644 src/main/resources/config/ssl/qa/keystore.p12 create mode 100644 src/main/resources/config/ssl/qa/truststore.jks create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/main/resources/mockup/dashboard_report_mockup.json create mode 100644 src/main/resources/sql/create_detail_view_log_table.sql create mode 100644 src/main/resources/sql/create_query_log_table.sql create mode 100644 src/test/java/com/sjmresorts/opsapp/logging/support/RegistrationCardMetaExtractorTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0f97e48 --- /dev/null +++ b/.gitignore @@ -0,0 +1,37 @@ +# Maven +target/ +!.mvn/wrapper/maven-wrapper.jar +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties + +# IDE +.idea/ +.vscode/ +*.iml +*.iws +*.ipr +.DS_Store + +# Logs +logs/ +*.log +ops_runtime/logs/ + +# OS +Thumbs.db +desktop.ini + +# Application specific +serviceKey.json +**/config/service/serviceKey.json +*.class + +# SVN (if exists) +.svn/ +svn.url diff --git a/README.md b/README.md new file mode 100644 index 0000000..9537179 --- /dev/null +++ b/README.md @@ -0,0 +1,2 @@ +# will-product-esign-backend + diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..21e95dc --- /dev/null +++ b/pom.xml @@ -0,0 +1,236 @@ + + 4.0.0 + + com.sjmresorts + auto-check-in-backend + 1.0 + jar + + auto-check-in-backend + + + UTF-8 + 21 + 1.5.20 + ${java.version} + ${java.version} + + + + org.springframework.boot + spring-boot-starter-parent + 3.5.7 + + + + + org.springframework.boot + spring-boot-starter + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-aop + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + ch.qos.logback + logback-classic + ${logback.version} + + + ch.qos.logback + logback-core + ${logback.version} + + + org.springframework.retry + spring-retry + 2.0.12 + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.11 + + + org.projectlombok + lombok + + + org.mapstruct + mapstruct + 1.6.3 + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + org.mapstruct + mapstruct-processor + 1.6.3 + + + com.microsoft.sqlserver + mssql-jdbc + + + com.mysql + mysql-connector-j + runtime + + + org.postgresql + postgresql + runtime + + + com.alibaba.fastjson2 + fastjson2 + 2.0.58 + + + org.apache.commons + commons-lang3 + 3.18.0 + + + org.apache.httpcomponents.client5 + httpclient5 + 5.5 + + + org.apache.pdfbox + pdfbox + 3.0.5 + + + net.sourceforge.tess4j + tess4j + 5.11.0 + + + com.auth0 + java-jwt + 4.5.0 + + + junit + junit + test + + + + javax.xml.bind + jaxb-api + 2.3.1 + + + + org.glassfish.jaxb + jaxb-runtime + 4.0.5 + + + + com.google.firebase + firebase-admin + 9.5.0 + + + + org.springframework.boot + spring-boot-starter-data-ldap + + + + com.hierynomus + smbj + 0.13.0 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${java.version} + ${java.version} + true + + + org.mapstruct + mapstruct-processor + 1.6.3 + + + org.projectlombok + lombok + + + org.projectlombok + lombok-mapstruct-binding + 0.2.0 + + + + + + org.springframework.boot + spring-boot-maven-plugin + 3.3.0 + + -Xms512m -Xmx1536m -XX:MaxMetaspaceSize=256m -XX:ReservedCodeCacheSize=128m + + + + + repackage + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.3.0 + + + + true + com.sjmresorts.opsapp.MainApplication + + + + + + + diff --git a/settings/config.ini b/settings/config.ini new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/com/sjmresorts/opsapp/MainApplication.java b/src/main/java/com/sjmresorts/opsapp/MainApplication.java new file mode 100644 index 0000000..1e19b94 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/MainApplication.java @@ -0,0 +1,20 @@ +package com.sjmresorts.opsapp; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.servlet.ServletComponentScan; +import org.springframework.data.web.config.EnableSpringDataWebSupport; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@ServletComponentScan +@EnableScheduling +@EnableAsync +@EnableSpringDataWebSupport(pageSerializationMode = EnableSpringDataWebSupport.PageSerializationMode.VIA_DTO) +public class MainApplication{ + + public static void main( String[] args ) { + SpringApplication.run(MainApplication.class, args); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminDevicesController.java b/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminDevicesController.java new file mode 100644 index 0000000..93e5ec3 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminDevicesController.java @@ -0,0 +1,94 @@ +package com.sjmresorts.opsapp.admin.controllers; + +import com.sjmresorts.opsapp.admin.dtos.appsetting.AdminDevicesInfoPageRequest; +import com.sjmresorts.opsapp.admin.dtos.appsetting.AdminDevicesInfoPageResponse; +import com.sjmresorts.opsapp.admin.dtos.appsetting.AdminGetClientInfosResponse; +import com.sjmresorts.opsapp.admin.dtos.appsetting.ClientInfosDto; +import com.sjmresorts.opsapp.admin.services.AdminDevicesService; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import com.sjmresorts.opsapp.common.dtos.DataTableResult; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.NotNull; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "CMS", description = "APIs for Admin Devices functions") +@RestController +@RequestMapping("/api/v1/admin") +public class AdminDevicesController { + + @Resource + private AdminDevicesService adminDeviceService; + + @Operation( + summary = "Query CMS devices info page", + description = "API for query CMS devices info page" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "CMS devices info page queried successful"), + @ApiResponse(responseCode = "400", description = "Invalid request data") + }) + @PostMapping(value = "/devices/queryPage", produces = "application/json;charset=UTF-8") + public ResponseEntity getAppDevicesByPage(HttpServletRequest request, + + @RequestBody(required = true) AdminDevicesInfoPageRequest pageRequest) { + + DataTableResult devicesInfoList = adminDeviceService.getDevicesInfoListByPage(pageRequest); + AdminDevicesInfoPageResponse devicesInfoResponse = new AdminDevicesInfoPageResponse(); + devicesInfoResponse.setCode("200"); + devicesInfoResponse.setSuccess(true); + devicesInfoResponse.setMessage("Query Successfully"); + devicesInfoResponse.setData(devicesInfoList); + return ResponseEntity.status(HttpStatus.OK).body(devicesInfoResponse); + } + + @Operation( + summary = "Update CMS devices info", + description = "API for CMS devices info update" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "CMS devices info updated successful"), + @ApiResponse(responseCode = "400", description = "Invalid request data") + }) + @PostMapping(value = "/devices/update", produces = "application/json;charset=UTF-8") + public ResponseEntity updateDevice( + HttpServletRequest request, + @RequestBody ClientInfosDto requestData) { + LoginUserInfo loginUserInfo = JwtTokenUtil.parseUserFromRequest(request); + if (loginUserInfo == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + } else { + BaseApiResponse responseData = adminDeviceService.updateDevice(requestData, loginUserInfo); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } + } + + @Operation( + summary = "Get one CMS devices info", + description = "API for get one CMS devices info" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "CMS devices info got successful"), + @ApiResponse(responseCode = "400", description = "Invalid request data") + }) + @GetMapping(value = "/devices/{deviceId}", produces = "application/json;charset=UTF-8") + public ResponseEntity getAppDeviceById( + @PathVariable(name = "deviceId", required = true) @NotNull Long deviceId) { + + AdminGetClientInfosResponse responseData = new AdminGetClientInfosResponse(); + ClientInfosDto deviceDto = adminDeviceService.queryDeviceById(deviceId); + responseData.setCode("200"); + responseData.setSuccess(deviceDto != null); + responseData.setMessage(deviceDto != null ? "Query Successfully" : "Device does not exists"); + responseData.setData(deviceDto); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminLoginController.java b/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminLoginController.java new file mode 100644 index 0000000..3e3f26e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminLoginController.java @@ -0,0 +1,158 @@ +package com.sjmresorts.opsapp.admin.controllers; + +import com.sjmresorts.opsapp.admin.dtos.system.login.AdminLoginRequest; +import com.sjmresorts.opsapp.admin.dtos.system.login.AdminLoginResponse; +import com.sjmresorts.opsapp.admin.dtos.system.login.AdminLogoutResponse; +import com.sjmresorts.opsapp.admin.dtos.system.login.CmsLoginInfo; +import com.sjmresorts.opsapp.admin.services.AdminLoginService; +import com.sjmresorts.opsapp.common.constants.AppConstants; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.authentication.dtos.RefreshTokenResponse; +import com.sjmresorts.opsapp.authentication.models.UserToken; +import com.sjmresorts.opsapp.authentication.services.UserTokenService; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Base64; + +@Tag(name = "CMS", description = "APIs for CMS administration") +@RestController +@RequestMapping("/api/v1/admin") +@CrossOrigin(origins = "*") +public class AdminLoginController { + + @Resource + private AdminLoginService adminLoginService; + @Resource + private UserTokenService userTokenService; + + @Operation( + summary = "CMS Login", + description = "API for CMS login with input ad account and password" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Check ad login success") + }) + @PostMapping(value="/login",produces = "application/json;charset=UTF-8") + public ResponseEntity cmsLogin( + HttpServletRequest request, + @Parameter(description = "login request info", required = true) @Valid @RequestBody AdminLoginRequest requestData){ + AdminLoginResponse result = new AdminLoginResponse(); + String passwordVal = requestData.getPassword(); + if(StringUtils.isNotBlank(passwordVal)) { + passwordVal =new String(Base64.getDecoder().decode(passwordVal.getBytes())); + } + int isCheckLogin; + try{ + isCheckLogin = adminLoginService.checkLoginByLoginIdAndPassword(requestData.getUserCode(), passwordVal); + } catch (Exception e) { + result.setSuccess(false); + result.setCode("500"); + result.setMessage(e.getCause().toString()); + return ResponseEntity.status(HttpStatus.OK).body(result); + } + + if (isCheckLogin >= 1) { + CmsLoginInfo loginInfo; + try{ + loginInfo = adminLoginService.getLoginUserInfoByEmployeeId(requestData.getUserCode()); + } catch (Exception e) { + result.setSuccess(false); + result.setCode("500"); + result.setMessage(e.getCause().toString()); + return ResponseEntity.status(HttpStatus.OK).body(result); + } + if (loginInfo != null && loginInfo.getLoginUser()!=null) { + userTokenService.clearUserTokenByType(AppConstants.CMS_API_ACCESS_TOKEN_TYPE,requestData.getUserCode()); + userTokenService.clearUserTokenByType(AppConstants.CMS_REFRESH_TOKEN_TYPE,requestData.getUserCode()); + LoginUserInfo loginUser = loginInfo.getLoginUser(); + loginUser.setLoginSource("cms_login"); + UserToken accessToken = JwtTokenUtil.generateTokenByLoginUser(AppConstants.CMS_API_ACCESS_TOKEN_TYPE,loginUser); + userTokenService.saveUserToken(accessToken); + UserToken refreshToken = JwtTokenUtil.generateTokenByLoginUser(AppConstants.CMS_REFRESH_TOKEN_TYPE,loginUser); + userTokenService.saveUserToken(refreshToken); + loginInfo.setAccessToken(accessToken.getTokenValue()); + loginInfo.setRefreshToken(refreshToken.getTokenValue()); + result.setSuccess(true); + result.setCode("200"); + result.setMessage("User "+requestData.getUserCode() + " login CMS successful."); + result.setLoginInfo(loginInfo); + } else { + result.setSuccess(false); + result.setMessage("User "+requestData.getUserCode()+" not found."); + } + } else if (isCheckLogin == -2) { + result.setSuccess(false); + result.setMessage(requestData.getUserCode() + " has been locked."); + } else if (isCheckLogin == -3) { + result.setSuccess(false); + result.setMessage(requestData.getUserCode() + " has been disabled."); + } else { + result.setSuccess(false); + result.setMessage("Incorrect login ID or password."); + } + return ResponseEntity.status(HttpStatus.OK).body(result); + } + + @PostMapping(value="/verifyToken",produces = "application/json;charset=UTF-8") + public ResponseEntity verifyToken( + HttpServletRequest request, + HttpServletResponse response) { + return ResponseEntity.status(HttpStatus.OK).body("Token Verified"); + } + + @PostMapping(value="/logout",produces = "application/json;charset=UTF-8") + public ResponseEntity processLogout( + HttpServletRequest request, + HttpServletResponse response) { + AdminLogoutResponse result = new AdminLogoutResponse(); + LoginUserInfo loginUser = JwtTokenUtil.parseUserFromRequest(request); + if(loginUser!=null){ + adminLoginService.processLogout(loginUser); + result.setSuccess(true); + result.setCode("200"); + result.setMessage("Logout successful."); + }else{ + result.setSuccess(false); + result.setCode("401"); + result.setMessage("Unauthorized logout"); + } + return ResponseEntity.status(HttpStatus.OK).body(result); + } + + @Operation( + summary = "Renew API token by refresh token", + description = "API to renew access token" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Access Token refreshed successful") + }) + @PostMapping(value="/refreshToken",produces = "application/json;charset=UTF-8") + public ResponseEntity refreshToken( + HttpServletRequest request, + HttpServletResponse response) { + RefreshTokenResponse responseData = new RefreshTokenResponse(); + LoginUserInfo loginUser = JwtTokenUtil.parseUserFromRequest(request); + if(loginUser!=null){ + responseData = adminLoginService.refreshToken(loginUser); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + }else{ + responseData.setSuccess(false); + responseData.setCode("401"); + responseData.setMessage("Unauthorized operation"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(responseData); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminMenusController.java b/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminMenusController.java new file mode 100644 index 0000000..7b94ccf --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminMenusController.java @@ -0,0 +1,48 @@ +package com.sjmresorts.opsapp.admin.controllers; + +import com.sjmresorts.opsapp.admin.dtos.menus.AdminMenuStatusRequest; +import com.sjmresorts.opsapp.admin.dtos.menus.AdminMenuStatusResponse; +import com.sjmresorts.opsapp.admin.dtos.menus.AdminMenusListResponse; +import com.sjmresorts.opsapp.admin.services.AdminMenusService; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "CMS", description = "APIs for CMS sidebar menus") +@RestController +@RequestMapping("/api/v1/admin") +@CrossOrigin(origins = "*") +public class AdminMenusController { + + @Resource + private AdminMenusService adminMenusService; + + @Operation(summary = "List CMS sidebar menus with visibility status") + @PostMapping(value = "/menu/list", produces = "application/json;charset=UTF-8") + public ResponseEntity listMenus(HttpServletRequest request) { + LoginUserInfo loginUserInfo = JwtTokenUtil.parseUserFromRequest(request); + if (loginUserInfo == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + } + return ResponseEntity.ok(adminMenusService.listMenus()); + } + + @Operation(summary = "Update CMS sidebar menu visibility (sys_routers.status)") + @PostMapping(value = "/menu/update_status", produces = "application/json;charset=UTF-8") + public ResponseEntity updateStatus( + HttpServletRequest request, + @Valid @RequestBody AdminMenuStatusRequest body) { + LoginUserInfo loginUserInfo = JwtTokenUtil.parseUserFromRequest(request); + if (loginUserInfo == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + } + return ResponseEntity.ok(adminMenusService.updateStatus(body, loginUserInfo)); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminRoleUsersController.java b/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminRoleUsersController.java new file mode 100644 index 0000000..81cd5b9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminRoleUsersController.java @@ -0,0 +1,101 @@ +package com.sjmresorts.opsapp.admin.controllers; + +import com.sjmresorts.opsapp.admin.dtos.permissions.AdminAssignGroupPermissionRequest; +import com.sjmresorts.opsapp.admin.dtos.permissions.AdminAssignGroupPermissionResponse; +import com.sjmresorts.opsapp.admin.dtos.roleUsers.AdminGetRoleUsersRequest; +import com.sjmresorts.opsapp.admin.dtos.roleUsers.AdminGetRoleUsersResponse; +import com.sjmresorts.opsapp.admin.services.AdminPermissionService; +import com.sjmresorts.opsapp.admin.services.AdminUserService; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.authentication.models.User; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Tag(name = "CMS", description = "APIs for CMS RoleUser") +@RestController +@RequestMapping("/api/v1/admin") +@CrossOrigin(origins = "*") +public class AdminRoleUsersController { + + @Resource + private AdminUserService adminUserService; + + @Resource + private AdminPermissionService adminPermissionService; + + @Operation( + summary = "CMS Get Role And User Status", + description = "API for CMS Get Role And User Status" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Check CMS Get Role And User Status Success") + }) + @PostMapping(value = "/role/get_role_user_status", produces = "application/json;charset=UTF-8") + public ResponseEntity cmsGetRoleUserStatus( + HttpServletRequest request, + @RequestBody(required = true) AdminGetRoleUsersRequest pageRequest) { + AdminGetRoleUsersResponse Requestresult = new AdminGetRoleUsersResponse(); + Map> serviceResult = new HashMap<>(); + List assignUsers = adminUserService.listUser(pageRequest,"USER_ROLE"); + List unassignUsers = adminUserService.listUser(pageRequest,"UNASSIGN_USER"); + serviceResult.put("assignUser", assignUsers); + serviceResult.put("unassignUser", unassignUsers); + + if (serviceResult != null) { + Requestresult.setSuccess(true); + Requestresult.setMessage("CMS Get Roles Success"); + Requestresult.setData(serviceResult); + Requestresult.setCode("200"); + }else{ + Requestresult.setCode("401"); + Requestresult.setMessage("CMS Get Roles Failed"); + Requestresult.setSuccess(false); + } + return ResponseEntity.status(HttpStatus.OK).body(Requestresult); + } + + @Operation( + summary = "CMS Assign Role Permissions", + description = "API for CMS Assign Role Permissions" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "CMS Assign Role Permissions") + }) + @PostMapping(value = "/role/assign_permission", produces = "application/json;charset=UTF-8") + public ResponseEntity cmsAssignRolePermissions( + HttpServletRequest request, + @Parameter(description = "CMS Assign Role Permissions", required = true) @Valid @RequestBody AdminAssignGroupPermissionRequest requestData) { + LoginUserInfo loginUserInfo = JwtTokenUtil.parseUserFromRequest(request); + if(loginUserInfo==null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + }else{ + boolean success = adminPermissionService.assginPermission(requestData); + AdminAssignGroupPermissionResponse responseData = new AdminAssignGroupPermissionResponse(); + if(success) { + responseData.setCode("200"); + responseData.setSuccess(true); + responseData.setMessage("CMS Assign Role Permissions Success"); + }else{ + responseData.setSuccess(false); + responseData.setMessage("CMS Assign Role Permissions Failed"); + } + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } + } +} + + diff --git a/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminRolesController.java b/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminRolesController.java new file mode 100644 index 0000000..e4a6503 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminRolesController.java @@ -0,0 +1,203 @@ +package com.sjmresorts.opsapp.admin.controllers; + +import com.sjmresorts.opsapp.admin.dtos.permissions.AdminListPermissionSettingRequest; +import com.sjmresorts.opsapp.admin.dtos.permissions.AdminListPermissionSettingResponse; +import com.sjmresorts.opsapp.admin.dtos.roles.*; +import com.sjmresorts.opsapp.admin.services.AdminPermissionService; +import com.sjmresorts.opsapp.admin.services.AdminRolesService; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import com.sjmresorts.opsapp.common.dtos.DataTableResult; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "CMS", description = "APIs for CMS Roles") +@RestController +@RequestMapping("/api/v1/admin") +@CrossOrigin(origins = "*") +public class AdminRolesController { + @Resource + private AdminRolesService adminRolesService; + @Resource + private AdminPermissionService adminPermissionService; + + @Operation( + summary = "CMS Get Roles", + description = "API for CMS Get Roles" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Check CMS Get Roles success") + }) + @PostMapping(value = "/role/get_roles", produces = "application/json;charset=UTF-8") + public ResponseEntity cmsGetRoles( + HttpServletRequest request, + @RequestBody(required = true) AdminGetRolesRequest pageRequest) { + AdminGetRolesResponse Requestresult = new AdminGetRolesResponse(); + DataTableResult serviceResult = adminRolesService.getRoleInfoListByPage(pageRequest); + + if (serviceResult != null) { + Requestresult.setSuccess(true); + Requestresult.setMessage("CMS Get Roles Success"); + Requestresult.setData(serviceResult); + Requestresult.setCode("200"); + }else{ + Requestresult.setCode("401"); + Requestresult.setMessage("CMS Get Roles Failed"); + Requestresult.setSuccess(false); + } + return ResponseEntity.status(HttpStatus.OK).body(Requestresult); + } + + + @Operation( + summary = "CMS Create Role", + description = "API for CMS Create Role" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Check CMS Create Role success") + }) + @PostMapping(value = "/role/create_role", produces = "application/json;charset=UTF-8") + public ResponseEntity cmsCreateRole( + HttpServletRequest request, + @Parameter(description = "CMS Create Role info", required = true) @Valid @RequestBody AdminCreateRoleRequest requestData) { + LoginUserInfo loginUserInfo = JwtTokenUtil.parseUserFromRequest(request); + if(loginUserInfo==null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + }else{ + BaseApiResponse responseData = adminRolesService.createRole(requestData, loginUserInfo); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } + } + + + @Operation( + summary = "CMS Delete Role", + description = "API for CMS Delete Role" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Check CMS Delete Role success") + }) + @PostMapping(value = "/role/delete_role", produces = "application/json;charset=UTF-8") + public ResponseEntity cmsDeleteRole( + HttpServletRequest request, + @Parameter(description = "CMS Delete Role info", required = true) @Valid @RequestBody AdminDeleteRoleRequest requestData) { + LoginUserInfo loginUserInfo = JwtTokenUtil.parseUserFromRequest(request); + if(loginUserInfo==null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + }else{ + BaseApiResponse responseData = adminRolesService.deleteRole(requestData, loginUserInfo); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } + } + + + @Operation( + summary = "CMS Modify Role", + description = "API for CMS Modify Role" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Check CMS Modify Role success") + }) + @PostMapping(value = "/role/modify_role", produces = "application/json;charset=UTF-8") + public ResponseEntity cmsmodifyRole( + HttpServletRequest request, + @Parameter(description = "CMS modify Role info", required = true) @Valid @RequestBody AdminModifyRoleRequest requestData) { + LoginUserInfo loginUserInfo = JwtTokenUtil.parseUserFromRequest(request); + if(loginUserInfo==null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + }else{ + BaseApiResponse responseData = adminRolesService.modifyRole(requestData, loginUserInfo); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } + } + + @Operation( + summary = "CMS Get Role By Id", + description = "API for CMS Get Role By Id" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Check CMS Get Role success") + }) + @PostMapping(value = "/role/get_role_by_id", produces = "application/json;charset=UTF-8") + public ResponseEntity cmsGetRoleById( + HttpServletRequest request, + @Parameter(description = "CMS modify Role info", required = true) @Valid @RequestBody AdminGetRoleByIdRequest requestData) { + LoginUserInfo loginUserInfo = JwtTokenUtil.parseUserFromRequest(request); + if(loginUserInfo==null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + }else{ + AdminGetRoleByIdResponse responseData = adminRolesService.getRoleById(requestData); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } + } + + + @Operation( + summary = "CMS Set Assign Users", + description = "API for CMS Set Assign Users" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "CMS Set Assign Users") + }) + @PostMapping(value = "/role/assign_users", produces = "application/json;charset=UTF-8") + public ResponseEntity cmsSetAssignUsers( + HttpServletRequest request, + @Parameter(description = "CMS modify Role info", required = true) @Valid @RequestBody AdminRoleAssignUsersRequest requestData) { + LoginUserInfo loginUserInfo = JwtTokenUtil.parseUserFromRequest(request); + if(loginUserInfo==null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + }else{ + AdminRoleAssignUsersResponse responseData = new AdminRoleAssignUsersResponse(); + Boolean serviceResult = adminRolesService.updateGroupAssignUser(requestData,loginUserInfo); + if(serviceResult) { + responseData.setCode("200"); + responseData.setSuccess(true); + responseData.setMessage("CMS Set Assign Users Success"); + }else{ + responseData.setSuccess(false); + responseData.setMessage("CMS Set Assign Users Failed"); + } + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } + } + + @Operation( + summary = "CMS Get Permission", + description = "API for CMS Get Permission" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "CMS Get Permission") + }) + @PostMapping(value = "/role/list_permission_setting", produces = "application/json;charset=UTF-8") + public ResponseEntity cmsGetListPermission( + HttpServletRequest request, + @Parameter(description = "CMS Get Permission", required = true) @Valid @RequestBody AdminListPermissionSettingRequest requestData) { + LoginUserInfo loginUserInfo = JwtTokenUtil.parseUserFromRequest(request); + if(loginUserInfo==null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + }else{ + AdminListPermissionSettingResponse responseData = adminPermissionService.getPermissionById(requestData); + if(responseData!=null) { + responseData.setCode("200"); + responseData.setSuccess(true); + responseData.setMessage("CMS Get Permission Success"); + }else{ + responseData.setSuccess(false); + responseData.setMessage("CMS Get Permission Failed"); + } + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } + } +} + + diff --git a/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminUserController.java b/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminUserController.java new file mode 100644 index 0000000..3d16df5 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/controllers/AdminUserController.java @@ -0,0 +1,137 @@ +package com.sjmresorts.opsapp.admin.controllers; + +import com.sjmresorts.opsapp.admin.dtos.system.user.CmsAppUserPageRequest; +import com.sjmresorts.opsapp.admin.dtos.system.user.CmsUserDto; +import com.sjmresorts.opsapp.admin.dtos.system.user.CmsUserInfoResponse; +import com.sjmresorts.opsapp.admin.dtos.system.user.GetCmsUserResponse; +import com.sjmresorts.opsapp.admin.services.AdminUserService; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import com.sjmresorts.opsapp.common.dtos.DataTableResult; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.NotNull; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "CMS", description = "APIs for Admin User functions") +@RestController +@RequestMapping("/api/v1/admin") +public class AdminUserController { + + @Resource + private AdminUserService adminUserService; + + @Operation( + summary = "Query CMS user info page", + description = "API for query CMS user info page" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "CMS user info page queried successful"), + @ApiResponse(responseCode = "400", description = "Invalid request data") + }) + @PostMapping(value = "/user/queryPage", produces = "application/json;charset=UTF-8") + public ResponseEntity getAppUserByPage( + HttpServletRequest request, + @RequestBody(required = true) CmsAppUserPageRequest pageRequest) { + + DataTableResult userInfoList = adminUserService.getUserInfoListByPage(pageRequest); + CmsUserInfoResponse cmsUserInfoResponse = new CmsUserInfoResponse(); + cmsUserInfoResponse.setCode("200"); + cmsUserInfoResponse.setSuccess(true); + cmsUserInfoResponse.setMessage("Query Successfully"); + cmsUserInfoResponse.setData(userInfoList); + return ResponseEntity.status(HttpStatus.OK).body(cmsUserInfoResponse); + } + + @Operation( + summary = "Create CMS user", + description = "API for CMS user creation" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "CMS user created successful"), + @ApiResponse(responseCode = "400", description = "Invalid request data") + }) + @PostMapping(value = "/user/create", produces = "application/json;charset=UTF-8") + public ResponseEntity createUser( + HttpServletRequest request, + @RequestBody CmsUserDto requestData) { + LoginUserInfo loginUserInfo = JwtTokenUtil.parseUserFromRequest(request); + if (loginUserInfo == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + } else { + BaseApiResponse responseData = adminUserService.createUser(requestData, loginUserInfo); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } + } + + @Operation( + summary = "Update CMS user status", + description = "API for CMS user status update" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "CMS user status updated successful"), + @ApiResponse(responseCode = "400", description = "Invalid request data") + }) + @PostMapping(value = "/user/updateStatus", produces = "application/json;charset=UTF-8") + public ResponseEntity updateStatus( + HttpServletRequest request, + @RequestBody CmsUserDto requestData) { + + LoginUserInfo loginUserInfo = JwtTokenUtil.parseUserFromRequest(request); + if (loginUserInfo == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + } else { + BaseApiResponse responseData = adminUserService.updateStatus(requestData, loginUserInfo); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } + } + + @Operation( + summary = "Update CMS user info", + description = "API for CMS user info update" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "CMS user info updated successful"), + @ApiResponse(responseCode = "400", description = "Invalid request data") + }) + @PostMapping(value = "/user/update", produces = "application/json;charset=UTF-8") + public ResponseEntity updateUser( + HttpServletRequest request, + @RequestBody CmsUserDto requestData) { + LoginUserInfo loginUserInfo = JwtTokenUtil.parseUserFromRequest(request); + if (loginUserInfo == null) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + } else { + BaseApiResponse responseData = adminUserService.updateUser(requestData, loginUserInfo); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } + } + + @Operation( + summary = "Get one CMS user info", + description = "API for get one CMS user info" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "CMS user info got successful"), + @ApiResponse(responseCode = "400", description = "Invalid request data") + }) + @GetMapping(value = "/user/{userId}", produces = "application/json;charset=UTF-8") + public ResponseEntity getUserById( + @PathVariable(name = "userId", required = true) @NotNull Long userId) { + + GetCmsUserResponse responseData = new GetCmsUserResponse(); + CmsUserDto userDto = adminUserService.queryCmsUserById(userId); + responseData.setCode("200"); + responseData.setSuccess(userDto != null); + responseData.setMessage(userDto != null ? "Query Successfully" : "User does not exists"); + responseData.setData(userDto); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/AdminDevicesInfoPageRequest.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/AdminDevicesInfoPageRequest.java new file mode 100644 index 0000000..021764b --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/AdminDevicesInfoPageRequest.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.admin.dtos.appsetting; + +import com.sjmresorts.opsapp.common.dtos.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Get Admin Devices Info Page Request") +public class AdminDevicesInfoPageRequest extends BasePageRequest { + +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/AdminDevicesInfoPageResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/AdminDevicesInfoPageResponse.java new file mode 100644 index 0000000..c7d1b98 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/AdminDevicesInfoPageResponse.java @@ -0,0 +1,18 @@ +package com.sjmresorts.opsapp.admin.dtos.appsetting; + +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import com.sjmresorts.opsapp.common.dtos.DataTableResult; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Get Admin Devices Info Page Response") +public class AdminDevicesInfoPageResponse extends BaseApiResponse { + + @Schema(description = "Match Devices Info items") + private DataTableResult data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/AdminGetClientInfosRequest.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/AdminGetClientInfosRequest.java new file mode 100644 index 0000000..22f08dd --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/AdminGetClientInfosRequest.java @@ -0,0 +1,19 @@ +package com.sjmresorts.opsapp.admin.dtos.appsetting; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "CMS get device Request") +public class AdminGetClientInfosRequest { + @Schema(description = "Device ID of Clients Info") + @NotBlank(message = "Device Id must not be blank") + private String deviceId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/AdminGetClientInfosResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/AdminGetClientInfosResponse.java new file mode 100644 index 0000000..c2803a3 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/AdminGetClientInfosResponse.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.admin.dtos.appsetting; + +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "CMS get device Response") +public class AdminGetClientInfosResponse extends BaseApiResponse { + + @Schema(description = "Match Device items") + private ClientInfosDto data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/ClientInfosDto.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/ClientInfosDto.java new file mode 100644 index 0000000..107dd81 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/ClientInfosDto.java @@ -0,0 +1,26 @@ +package com.sjmresorts.opsapp.admin.dtos.appsetting; + +import lombok.Data; + +import java.time.Instant; + +@Data +public class ClientInfosDto { + + private Long id; + + private Boolean isLatest; + + private Boolean isActive; + + private String clientName; + + private String clientVersion; + + private String deviceFuid; + + private String casinoCode; + + private Instant updatedAt; +} + diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/GetAppSettingRequest.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/GetAppSettingRequest.java new file mode 100644 index 0000000..849cdee --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/appsetting/GetAppSettingRequest.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.admin.dtos.appsetting; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "CMS get App Setting Request") +public class GetAppSettingRequest { + @Schema(description = "App setting keys to retrieve") + @NotNull(message = "App setting keys must not be empty") + private List appSettingKeys; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/menus/AdminMenuItemDto.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/menus/AdminMenuItemDto.java new file mode 100644 index 0000000..6d851b8 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/menus/AdminMenuItemDto.java @@ -0,0 +1,20 @@ +package com.sjmresorts.opsapp.admin.dtos.menus; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class AdminMenuItemDto { + private String id; + private String parentId; + private String heading; + private String sectionTitle; + private String route; + private String fontIcon; + private Integer sortNo; + /** 1=显示,0=隐藏 */ + private Integer status; + /** 不可关闭(避免锁死后台) */ + private boolean locked; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/menus/AdminMenuStatusRequest.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/menus/AdminMenuStatusRequest.java new file mode 100644 index 0000000..8674569 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/menus/AdminMenuStatusRequest.java @@ -0,0 +1,16 @@ +package com.sjmresorts.opsapp.admin.dtos.menus; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class AdminMenuStatusRequest { + @NotBlank + private String routerId; + /** 1=显示,0=隐藏 */ + @NotNull + private Integer status; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/menus/AdminMenuStatusResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/menus/AdminMenuStatusResponse.java new file mode 100644 index 0000000..daa24b9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/menus/AdminMenuStatusResponse.java @@ -0,0 +1,18 @@ +package com.sjmresorts.opsapp.admin.dtos.menus; + +import com.sjmresorts.opsapp.admin.dtos.system.router.UserRouterDto; +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +public class AdminMenuStatusResponse extends BaseApiResponse { + /** 更新后当前登录用户侧栏菜单(可立刻刷新) */ + private List userRouters = new ArrayList<>(); +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/menus/AdminMenusListResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/menus/AdminMenusListResponse.java new file mode 100644 index 0000000..ebb4112 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/menus/AdminMenusListResponse.java @@ -0,0 +1,16 @@ +package com.sjmresorts.opsapp.admin.dtos.menus; + +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +public class AdminMenusListResponse extends BaseApiResponse { + private List data = new ArrayList<>(); +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/permissions/AdminAssignGroupPermissionRequest.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/permissions/AdminAssignGroupPermissionRequest.java new file mode 100644 index 0000000..cbbd5b9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/permissions/AdminAssignGroupPermissionRequest.java @@ -0,0 +1,22 @@ +package com.sjmresorts.opsapp.admin.dtos.permissions; + +import com.sjmresorts.opsapp.authentication.models.RouterPermission; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = " Assign Role Permission Request") +public class AdminAssignGroupPermissionRequest { + @Schema(description = "Role Id",example="200") + private Long roleId; + @Schema(description = "Router Operation",example="200") + private List assignRouterOperation; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/permissions/AdminAssignGroupPermissionResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/permissions/AdminAssignGroupPermissionResponse.java new file mode 100644 index 0000000..d3b23ed --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/permissions/AdminAssignGroupPermissionResponse.java @@ -0,0 +1,27 @@ +package com.sjmresorts.opsapp.admin.dtos.permissions; + +import com.sjmresorts.opsapp.admin.models.GroupPermissionObject; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of CMS Delete Role") +public class AdminAssignGroupPermissionResponse { + @Schema(description = "Indicate CMS delete roles success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result",example="Get roles successfully") + private String message; + + @Schema(description = "data of result") + private GroupPermissionObject data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/permissions/AdminListPermissionSettingRequest.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/permissions/AdminListPermissionSettingRequest.java new file mode 100644 index 0000000..e1d3f34 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/permissions/AdminListPermissionSettingRequest.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.admin.dtos.permissions; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = " Delete Role Request") +public class AdminListPermissionSettingRequest { + @Schema(description = "Role Id",example="200") + private Long roleId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/permissions/AdminListPermissionSettingResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/permissions/AdminListPermissionSettingResponse.java new file mode 100644 index 0000000..abf6d06 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/permissions/AdminListPermissionSettingResponse.java @@ -0,0 +1,27 @@ +package com.sjmresorts.opsapp.admin.dtos.permissions; + +import com.sjmresorts.opsapp.admin.models.GroupPermissionObject; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of CMS Delete Role") +public class AdminListPermissionSettingResponse { + @Schema(description = "Indicate CMS delete roles success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result",example="Get roles successfully") + private String message; + + @Schema(description = "data of result") + private GroupPermissionObject data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/roleUsers/AdminGetRoleUsersRequest.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roleUsers/AdminGetRoleUsersRequest.java new file mode 100644 index 0000000..add51a6 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roleUsers/AdminGetRoleUsersRequest.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.admin.dtos.roleUsers; + +import com.sjmresorts.opsapp.common.dtos.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "CMS Get Role Uers Status Request") +public class AdminGetRoleUsersRequest extends BasePageRequest { + + private Long searchRoleId; + + private String searchRoleName; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/roleUsers/AdminGetRoleUsersResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roleUsers/AdminGetRoleUsersResponse.java new file mode 100644 index 0000000..ee3441f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roleUsers/AdminGetRoleUsersResponse.java @@ -0,0 +1,30 @@ +package com.sjmresorts.opsapp.admin.dtos.roleUsers; + +import com.sjmresorts.opsapp.authentication.models.User; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; +import java.util.Map; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of CMS Role Users Status") +public class AdminGetRoleUsersResponse { + @Schema(description = "Indicate CMS get roles success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result",example="Get roles successfully") + private String message; + + @Schema(description = "data of result") + private Map> data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/roleUsers/AdminRoleUsersDto.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roleUsers/AdminRoleUsersDto.java new file mode 100644 index 0000000..69ea795 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roleUsers/AdminRoleUsersDto.java @@ -0,0 +1,13 @@ +package com.sjmresorts.opsapp.admin.dtos.roleUsers; + +import lombok.Data; +import org.mapstruct.Mapper; + +@Data +@Mapper(componentModel = "spring") +public class AdminRoleUsersDto { + + private Long id; + private Long roleId; + private Long userId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminCreateRoleRequest.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminCreateRoleRequest.java new file mode 100644 index 0000000..7ade959 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminCreateRoleRequest.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.admin.dtos.roles; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = " Create Role Request") +public class AdminCreateRoleRequest { + @Schema(description = "Role Name",example="200") + private String roleName; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminCreateRoleResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminCreateRoleResponse.java new file mode 100644 index 0000000..9fc9f8d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminCreateRoleResponse.java @@ -0,0 +1,27 @@ +package com.sjmresorts.opsapp.admin.dtos.roles; + +import com.sjmresorts.opsapp.authentication.models.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of CMS Create Role") +public class AdminCreateRoleResponse { + @Schema(description = "Indicate CMS create roles success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result",example="Get roles successfully") + private String message; + + @Schema(description = "data of result") + private Role data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminDeleteRoleRequest.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminDeleteRoleRequest.java new file mode 100644 index 0000000..b479efe --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminDeleteRoleRequest.java @@ -0,0 +1,19 @@ +package com.sjmresorts.opsapp.admin.dtos.roles; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = " Delete Role Request") +public class AdminDeleteRoleRequest { + @Schema(description = "Role Id",example="200") + private Long roleId; + @Schema(description = "Role Name",example="200") + private String roleName; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminDeleteRoleResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminDeleteRoleResponse.java new file mode 100644 index 0000000..3d16497 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminDeleteRoleResponse.java @@ -0,0 +1,27 @@ +package com.sjmresorts.opsapp.admin.dtos.roles; + +import com.sjmresorts.opsapp.authentication.models.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of CMS Delete Role") +public class AdminDeleteRoleResponse { + @Schema(description = "Indicate CMS delete roles success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result",example="Get roles successfully") + private String message; + + @Schema(description = "data of result") + private Role data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminGetRoleByIdRequest.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminGetRoleByIdRequest.java new file mode 100644 index 0000000..bd0667e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminGetRoleByIdRequest.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.admin.dtos.roles; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = " Modify Role Request") +public class AdminGetRoleByIdRequest { + @Schema(description = "Role Id",example="200") + private Long roleId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminGetRoleByIdResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminGetRoleByIdResponse.java new file mode 100644 index 0000000..c902a7a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminGetRoleByIdResponse.java @@ -0,0 +1,27 @@ +package com.sjmresorts.opsapp.admin.dtos.roles; + +import com.sjmresorts.opsapp.authentication.models.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of CMS Get Role By Id") +public class AdminGetRoleByIdResponse { + @Schema(description = "Indicate CMS get roles by id success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result",example="Get roles successfully") + private String message; + + @Schema(description = "data of result") + private Role data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminGetRolesRequest.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminGetRolesRequest.java new file mode 100644 index 0000000..920a26d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminGetRolesRequest.java @@ -0,0 +1,19 @@ +package com.sjmresorts.opsapp.admin.dtos.roles; + +import com.sjmresorts.opsapp.common.dtos.BasePageRequest; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "CMS Roles Request") +public class AdminGetRolesRequest extends BasePageRequest { + + private String searchRoleName; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminGetRolesResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminGetRolesResponse.java new file mode 100644 index 0000000..8f279e3 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminGetRolesResponse.java @@ -0,0 +1,27 @@ +package com.sjmresorts.opsapp.admin.dtos.roles; + +import com.sjmresorts.opsapp.common.dtos.DataTableResult; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of CMS Roles") +public class AdminGetRolesResponse { + @Schema(description = "Indicate CMS get roles success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result",example="Get roles successfully") + private String message; + + @Schema(description = "data of result") + private DataTableResult data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminModifyRoleRequest.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminModifyRoleRequest.java new file mode 100644 index 0000000..5a15131 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminModifyRoleRequest.java @@ -0,0 +1,19 @@ +package com.sjmresorts.opsapp.admin.dtos.roles; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = " Modify Role Request") +public class AdminModifyRoleRequest { + @Schema(description = "Role Id",example="200") + private Long roleId; + @Schema(description = "Role Name",example="200") + private String roleName; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminModifyRoleResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminModifyRoleResponse.java new file mode 100644 index 0000000..146427d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminModifyRoleResponse.java @@ -0,0 +1,27 @@ +package com.sjmresorts.opsapp.admin.dtos.roles; + +import com.sjmresorts.opsapp.authentication.models.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of CMS Modify Role") +public class AdminModifyRoleResponse { + @Schema(description = "Indicate CMS modify roles success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result",example="Get roles successfully") + private String message; + + @Schema(description = "data of result") + private Role data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminRoleAssignUsersRequest.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminRoleAssignUsersRequest.java new file mode 100644 index 0000000..c6360cc --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminRoleAssignUsersRequest.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.admin.dtos.roles; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = " Delete Role Request") +public class AdminRoleAssignUsersRequest { + @Schema(description = "Role Id",example="200") + private Long roleId; + @Schema(description = "Role Name",example="200") + private List employeeIds; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminRoleAssignUsersResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminRoleAssignUsersResponse.java new file mode 100644 index 0000000..cfa3719 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminRoleAssignUsersResponse.java @@ -0,0 +1,27 @@ +package com.sjmresorts.opsapp.admin.dtos.roles; + +import com.sjmresorts.opsapp.authentication.models.Role; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of CMS Delete Role") +public class AdminRoleAssignUsersResponse { + @Schema(description = "Indicate CMS delete roles success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result",example="Get roles successfully") + private String message; + + @Schema(description = "data of result") + private Role data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminRoleDto.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminRoleDto.java new file mode 100644 index 0000000..9dfb8e2 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/roles/AdminRoleDto.java @@ -0,0 +1,11 @@ +package com.sjmresorts.opsapp.admin.dtos.roles; + +import lombok.Data; +import org.mapstruct.Mapper; + +@Data +@Mapper(componentModel = "spring") +public class AdminRoleDto { + private Long roleId; + private String roleName; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/login/AdminLoginRequest.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/login/AdminLoginRequest.java new file mode 100644 index 0000000..8eb994f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/login/AdminLoginRequest.java @@ -0,0 +1,25 @@ +package com.sjmresorts.opsapp.admin.dtos.system.login; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "CMS LoginRequest") +public class AdminLoginRequest { + + @Schema(description = "AD Account of Employee") + @NotBlank(message = "Employee Id must not be blank") + private String userCode; + + @Schema(description = "Password of AD Account") + @NotBlank(message = "Password must not be blank") + private String password; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/login/AdminLoginResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/login/AdminLoginResponse.java new file mode 100644 index 0000000..cc8f9c1 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/login/AdminLoginResponse.java @@ -0,0 +1,28 @@ +package com.sjmresorts.opsapp.admin.dtos.system.login; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of CMS login with AD") +public class AdminLoginResponse { + + @Schema(description = "Indicate CMS login authorization success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result",example="Login successfully") + private String message; + + @Schema(description = "Login Info of response") + private CmsLoginInfo loginInfo; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/login/AdminLogoutResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/login/AdminLogoutResponse.java new file mode 100644 index 0000000..ca1add9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/login/AdminLogoutResponse.java @@ -0,0 +1,25 @@ +package com.sjmresorts.opsapp.admin.dtos.system.login; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of CMS logout") +public class AdminLogoutResponse { + + @Schema(description = "Indicate CMS logout success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result",example="Logout successfully") + private String message; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/login/CmsLoginInfo.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/login/CmsLoginInfo.java new file mode 100644 index 0000000..25df173 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/login/CmsLoginInfo.java @@ -0,0 +1,25 @@ +package com.sjmresorts.opsapp.admin.dtos.system.login; + +import com.sjmresorts.opsapp.admin.dtos.system.router.RouterPermissionDto; +import com.sjmresorts.opsapp.admin.dtos.system.router.UserRouterDto; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; +import java.util.Map; + +@Data +@Getter +@Setter +@NoArgsConstructor +public class CmsLoginInfo { + private String accessToken; + private String refreshToken; + private String defaultRouter; + private LoginUserInfo loginUser; + private List userRouters; + private Map> userPermissions; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/router/RouterPermissionDto.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/router/RouterPermissionDto.java new file mode 100644 index 0000000..cf3dba8 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/router/RouterPermissionDto.java @@ -0,0 +1,16 @@ +package com.sjmresorts.opsapp.admin.dtos.system.router; + +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +public class RouterPermissionDto { + private String id; + private String routerId; + private String permissionName; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/router/UserRouterDto.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/router/UserRouterDto.java new file mode 100644 index 0000000..8c2f0c5 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/router/UserRouterDto.java @@ -0,0 +1,24 @@ +package com.sjmresorts.opsapp.admin.dtos.system.router; + +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Data +@Getter +@Setter +@NoArgsConstructor +public class UserRouterDto { + private String parentId; + private String heading; + private String sectionTitle; + private String route; + private String svgIcon; + private String fontIcon; + private Integer sortNo; + private Integer status; + private List sub; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/user/CmsAppUserPageRequest.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/user/CmsAppUserPageRequest.java new file mode 100644 index 0000000..637fb1d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/user/CmsAppUserPageRequest.java @@ -0,0 +1,14 @@ +package com.sjmresorts.opsapp.admin.dtos.system.user; + +import com.sjmresorts.opsapp.common.dtos.BasePageRequest; +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +public class CmsAppUserPageRequest extends BasePageRequest { + + private String searchAccount; + private String searchUserName; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/user/CmsUserDto.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/user/CmsUserDto.java new file mode 100644 index 0000000..54d090f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/user/CmsUserDto.java @@ -0,0 +1,19 @@ +package com.sjmresorts.opsapp.admin.dtos.system.user; + +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@NoArgsConstructor +public class CmsUserDto { + + private Long userId; + private String userCode; + private String userName; + private String email; + private Integer status; + private Instant updatedAt; + private Instant createdAt; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/user/CmsUserInfoResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/user/CmsUserInfoResponse.java new file mode 100644 index 0000000..7d55719 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/user/CmsUserInfoResponse.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.admin.dtos.system.user; + +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import com.sjmresorts.opsapp.common.dtos.DataTableResult; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + + +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "User Info List") +public class CmsUserInfoResponse extends BaseApiResponse { + private DataTableResult data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/user/GetCmsUserResponse.java b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/user/GetCmsUserResponse.java new file mode 100644 index 0000000..ba55aa9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/dtos/system/user/GetCmsUserResponse.java @@ -0,0 +1,15 @@ +package com.sjmresorts.opsapp.admin.dtos.system.user; + +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of getting CMS User") +public class GetCmsUserResponse extends BaseApiResponse { + private CmsUserDto data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/mappers/ClientInfosMapper.java b/src/main/java/com/sjmresorts/opsapp/admin/mappers/ClientInfosMapper.java new file mode 100644 index 0000000..a6633b7 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/mappers/ClientInfosMapper.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.admin.mappers; + +import com.sjmresorts.opsapp.admin.dtos.appsetting.ClientInfosDto; +import com.sjmresorts.opsapp.authentication.models.ClientInfo; +import org.mapstruct.Mapper; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface ClientInfosMapper { + + ClientInfosDto toClientInfosDto(ClientInfo device); + + List toClientInfosDtoList(List content); + + +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/mappers/CmsLoginRouterMapper.java b/src/main/java/com/sjmresorts/opsapp/admin/mappers/CmsLoginRouterMapper.java new file mode 100644 index 0000000..92648ac --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/mappers/CmsLoginRouterMapper.java @@ -0,0 +1,41 @@ +package com.sjmresorts.opsapp.admin.mappers; + +import com.sjmresorts.opsapp.admin.dtos.system.router.RouterPermissionDto; +import com.sjmresorts.opsapp.admin.dtos.system.router.UserRouterDto; +import com.sjmresorts.opsapp.authentication.models.RouterPermission; +import com.sjmresorts.opsapp.authentication.models.SysRouter; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class CmsLoginRouterMapper { + private final SysRouterMapper routerMapper; + private final RouterPermissionMapper routerPermissionMapper; + + public List toRouterDtoListWithChildren(List sysRouters) { + List routerDtos = new ArrayList(); + routerMapper.toUserRouterDtoList(sysRouters); + for(SysRouter sysRouter:sysRouters){ + UserRouterDto routerDto = routerMapper.toUserRouterDto(sysRouter); + if(sysRouter.getSub()!=null && !sysRouter.getSub().isEmpty()){ + routerDto.setSub(toRouterDtoListWithChildren(sysRouter.getSub())); + } + routerDtos.add(routerDto); + } + return routerDtos; + } + + public Map> toRouterPermissionMap(Map> userPermissions) { + Map> permissionMap = new HashMap>(); + userPermissions.forEach((key, value) -> { + permissionMap.put(key, routerPermissionMapper.toRouterPermissionDtoList(value)); + }); + return permissionMap; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/mappers/RouterPermissionMapper.java b/src/main/java/com/sjmresorts/opsapp/admin/mappers/RouterPermissionMapper.java new file mode 100644 index 0000000..fc3df8c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/mappers/RouterPermissionMapper.java @@ -0,0 +1,15 @@ +package com.sjmresorts.opsapp.admin.mappers; + +import com.sjmresorts.opsapp.admin.dtos.system.router.RouterPermissionDto; +import com.sjmresorts.opsapp.authentication.models.RouterPermission; +import org.mapstruct.Mapper; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface RouterPermissionMapper { + + RouterPermissionDto toRouterPermissionDto(RouterPermission entity); + + List toRouterPermissionDtoList(List routerPermissions); +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/mappers/SysRouterMapper.java b/src/main/java/com/sjmresorts/opsapp/admin/mappers/SysRouterMapper.java new file mode 100644 index 0000000..2cede1e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/mappers/SysRouterMapper.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.admin.mappers; + +import com.sjmresorts.opsapp.admin.dtos.system.router.UserRouterDto; +import com.sjmresorts.opsapp.authentication.models.SysRouter; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface SysRouterMapper { + + @Mapping(target = "sub", ignore = true) + UserRouterDto toUserRouterDto(SysRouter sysRouter); + + List toUserRouterDtoList(List sysRouters); +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/models/GroupPermissionObject.java b/src/main/java/com/sjmresorts/opsapp/admin/models/GroupPermissionObject.java new file mode 100644 index 0000000..1f6d0fc --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/models/GroupPermissionObject.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.admin.models; + +import com.sjmresorts.opsapp.authentication.models.SysRouter; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +public class GroupPermissionObject { + private Long groupId; + private String groupName; + private List groupRouterSetting; +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/repositories/RoleUsersRepository.java b/src/main/java/com/sjmresorts/opsapp/admin/repositories/RoleUsersRepository.java new file mode 100644 index 0000000..dc39aee --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/repositories/RoleUsersRepository.java @@ -0,0 +1,18 @@ +package com.sjmresorts.opsapp.admin.repositories; + +import com.sjmresorts.opsapp.authentication.models.RoleUsers; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface RoleUsersRepository extends JpaRepository { + + + List findAllByRoleId(Long roleId); + + @Query(value = "select roleUser.roleId from RoleUsers roleUser where employeeId=?1 ") + List findRoleIdListByEmployeeId(String employeeId); +} \ No newline at end of file diff --git a/src/main/java/com/sjmresorts/opsapp/admin/repositories/RolesRepository.java b/src/main/java/com/sjmresorts/opsapp/admin/repositories/RolesRepository.java new file mode 100644 index 0000000..14e4a71 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/repositories/RolesRepository.java @@ -0,0 +1,23 @@ +package com.sjmresorts.opsapp.admin.repositories; + +import com.sjmresorts.opsapp.authentication.models.Role; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface RolesRepository extends JpaRepository { + + Page findAll(Specification listSpec, Pageable pageable); + + Role getRoleByRoleId(Long roleId); + + @Query(value="select r.roleId from Role r where r.roleName = ?1") + List queryRoleIdByRoleName(String roleName); + +} \ No newline at end of file diff --git a/src/main/java/com/sjmresorts/opsapp/admin/services/AdminDevicesService.java b/src/main/java/com/sjmresorts/opsapp/admin/services/AdminDevicesService.java new file mode 100644 index 0000000..02c7719 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/services/AdminDevicesService.java @@ -0,0 +1,108 @@ +package com.sjmresorts.opsapp.admin.services; + +import com.sjmresorts.opsapp.admin.dtos.appsetting.AdminDevicesInfoPageRequest; +import com.sjmresorts.opsapp.admin.dtos.appsetting.ClientInfosDto; +import com.sjmresorts.opsapp.admin.mappers.ClientInfosMapper; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.authentication.models.ClientInfo; +import com.sjmresorts.opsapp.authentication.repositories.DeviceInfoRepository; +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import com.sjmresorts.opsapp.common.dtos.BasePageRequest; +import com.sjmresorts.opsapp.common.dtos.DataTableResult; +import com.sjmresorts.opsapp.common.utils.DaoUtil; +import com.sjmresorts.opsapp.logging.models.AuditLog; +import com.sjmresorts.opsapp.logging.services.AuditLogService; +import jakarta.persistence.criteria.Predicate; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminDevicesService { + + private final DeviceInfoRepository deviceInfoRepository; + private final AuditLogService auditLogService; + private final ClientInfosMapper clientInfosMapper; + private final DeviceInfoRepository deviceRepository; + + public DataTableResult getDevicesInfoListByPage(AdminDevicesInfoPageRequest pageRequest) { + + Specification listSpec = buildSpecificationByPageRequest(pageRequest); + int pageSize = pageRequest.getPageSize(); + int pageStart = pageRequest.getStartRow() / pageSize; + Sort sort = DaoUtil.buildSortByString(pageRequest.getSortOrder(), "id"); + Pageable pageable = PageRequest.of(pageStart, pageSize, sort); + Page resultPage = deviceInfoRepository.findAll(listSpec, pageable); + List resultList = clientInfosMapper.toClientInfosDtoList(resultPage.getContent()); + DataTableResult dataResult = new DataTableResult<>(); + dataResult.setTotalRecords(resultPage.getTotalElements()); + dataResult.setPageSize(pageSize); + if (resultList == null) { + resultList = new ArrayList<>(); + } + dataResult.setRowData(resultList); + return dataResult; + } + + private Specification buildSpecificationByPageRequest(BasePageRequest pageRequest) { + + return ((root, criteriaQuery, cb) -> { + List list = new ArrayList<>(); +// if (StringUtils.isNotBlank(pageRequest.getSearchUserName())) { +// list.add(cb.like(cb.upper(root.get("userName")), +// "%" + pageRequest.getSearchUserName().toUpperCase() + "%")); +// } +// if (StringUtils.isNotBlank(pageRequest.getSearchAccount())) { +// list.add(cb.like(cb.upper(root.get("employeeId")), +// "%" + pageRequest.getSearchAccount().toUpperCase() + "%")); +// } + Predicate[] predicates = new Predicate[list.size()]; + criteriaQuery.where(list.toArray(predicates)); + return cb.and(list.toArray(predicates)); + }); + } + + public BaseApiResponse updateDevice(ClientInfosDto deviceDto, LoginUserInfo loginUserInfo) { + BaseApiResponse result = new BaseApiResponse(); + AuditLog auditLog = auditLogService.createAuditLog("Update Device", "Update Device from AdminDevicesService.updateDevice()", loginUserInfo); + ClientInfo device = deviceRepository.findById(deviceDto.getId()).orElse(null); + Instant currentTime = Instant.now(); + if (device != null) { + auditLogService.setLogOldValue(auditLog, device); + device.setCasinoCode(deviceDto.getCasinoCode()); + device.setUpdatedAt(currentTime); + deviceRepository.save(device); + auditLogService.setLogNewValue(auditLog, device); + auditLogService.saveAuditLog(auditLog); + result.setSuccess(true); + result.setCode("200"); + result.setMessage("Updated Successfully"); + } else { + result.setSuccess(false); + result.setCode("400"); + result.setMessage("User does not exists"); + } + return result; + } + + + public ClientInfosDto queryDeviceById(@NotBlank long deviceId) { + ClientInfosDto deviceDto = null; + ClientInfo device = deviceRepository.findById(deviceId).orElse(null); + + if (device != null) { + deviceDto = clientInfosMapper.toClientInfosDto(device); + } + return deviceDto; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/services/AdminLoginService.java b/src/main/java/com/sjmresorts/opsapp/admin/services/AdminLoginService.java new file mode 100644 index 0000000..98199d9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/services/AdminLoginService.java @@ -0,0 +1,163 @@ +package com.sjmresorts.opsapp.admin.services; + +import com.sjmresorts.opsapp.admin.dtos.system.login.CmsLoginInfo; +import com.sjmresorts.opsapp.admin.mappers.CmsLoginRouterMapper; +import com.sjmresorts.opsapp.admin.repositories.RoleUsersRepository; +import com.sjmresorts.opsapp.admin.repositories.RolesRepository; +import com.sjmresorts.opsapp.common.constants.AppConstants; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.authentication.dtos.RefreshTokenResponse; +import com.sjmresorts.opsapp.authentication.mappers.UserMapper; +import com.sjmresorts.opsapp.authentication.models.RouterPermission; +import com.sjmresorts.opsapp.authentication.models.SysRouter; +import com.sjmresorts.opsapp.authentication.models.User; +import com.sjmresorts.opsapp.authentication.models.UserToken; +import com.sjmresorts.opsapp.authentication.repositories.RouterPermissionRepository; +import com.sjmresorts.opsapp.authentication.repositories.UserRepository; +import com.sjmresorts.opsapp.authentication.services.RolePermissionService; +import com.sjmresorts.opsapp.authentication.services.SysRouterService; +import com.sjmresorts.opsapp.authentication.services.UserTokenService; +import com.sjmresorts.opsapp.common.dtos.LDAP.ADAccountInfo; +import com.sjmresorts.opsapp.common.utils.AESUtil; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import com.sjmresorts.opsapp.common.utils.LDAPUtil; +import jakarta.transaction.Transactional; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class AdminLoginService { + + private final UserRepository userRepository; + private final RoleUsersRepository roleUsersRepository; + private final UserMapper userMapper; + private final CmsLoginRouterMapper cmsLoginRouterMapper; + private final SysRouterService sysRouterService; + private final RolePermissionService rolePermissionService; + private final UserTokenService userTokenService; + private final LDAPUtil ldapUtil; + private final RolesRepository rolesRepository; + private final RouterPermissionRepository routerPermissionRepository; + + // -3=disabled, -2=locked, -1 or 0=incorrect loginId or password, 1=success + public int checkLoginByLoginIdAndPassword(String employeeId, String password) { + int checkPassed = -1; + if(AppConstants.SUPER_ADMIN_ID.equalsIgnoreCase(employeeId)) { + User user = userRepository.findByEmployeeId(employeeId).orElse(null); + if (user != null) { + if(user.getStatus()==null || user.getStatus()==0){ + return -3; // user disabled by app + }else if(user.getStatus()==2) { + return -2; // user locked + } + String encryptPassword = AESUtil.sha256(password); + checkPassed = userRepository.countByEmployeeIdAndPassword(employeeId.toUpperCase(), encryptPassword); + } + }else{ + //Check AD + boolean adResult = ldapUtil.authenticate(employeeId, password); + if(adResult) checkPassed = 1; + } + return checkPassed; + } + + @Retryable(retryFor = OptimisticLockingFailureException.class, + maxAttempts = 3, + backoff = @Backoff(delay = 100)) + @Transactional + public CmsLoginInfo getLoginUserInfoByEmployeeId(@NotBlank(message = "Employee Id must not be blank") String employeeId) { + CmsLoginInfo result = null; + if(AppConstants.SUPER_ADMIN_ID.equalsIgnoreCase(employeeId)) { + User user = userRepository.findByEmployeeId(employeeId).orElse(null); + if(user!=null){ + user.setCmsLastLogin(Instant.now()); + userRepository.save(user); + result = new CmsLoginInfo(); + LoginUserInfo loginUser = userMapper.toLoginUser(user); + result.setLoginUser(loginUser); + Map> userPermissions = new HashMap>(); + userPermissions = rolePermissionService.getFullRouterPermissions(); + List routers = sysRouterService.getRoutersByPermission("0",userPermissions); + result.setUserRouters(cmsLoginRouterMapper.toRouterDtoListWithChildren(routers)); + result.setUserPermissions(cmsLoginRouterMapper.toRouterPermissionMap(userPermissions)); + } + }else{ + ADAccountInfo adResult = ldapUtil.getAccountInfo(employeeId); + if(adResult!=null){ + result = new CmsLoginInfo(); + LoginUserInfo loginUser = new LoginUserInfo(); + loginUser.setUserName(adResult.getDisplayName()); + loginUser.setEmployeeId(employeeId); + loginUser.setEmail(adResult.getMail()); + loginUser.setUserCode(employeeId); + result.setLoginUser(loginUser); + Map> userPermissions = new HashMap>(); + List userRoleIds = getUserRoleIds(adResult.getDirectGroups()); + userPermissions = rolePermissionService.getRouterPermissionsByGroupIds(userRoleIds); + List routers = sysRouterService.getRoutersByPermission("0",userPermissions); + result.setUserRouters(cmsLoginRouterMapper.toRouterDtoListWithChildren(routers)); + result.setUserPermissions(cmsLoginRouterMapper.toRouterPermissionMap(userPermissions)); + } + } + + return result; + } + + public void processLogout(LoginUserInfo loginUser) { + if(loginUser!=null){ + userTokenService.clearUserTokenByType(AppConstants.CMS_API_ACCESS_TOKEN_TYPE,loginUser.getEmployeeId()); + userTokenService.clearUserTokenByType(AppConstants.CMS_REFRESH_TOKEN_TYPE,loginUser.getEmployeeId()); + } + } + + public RefreshTokenResponse refreshToken(LoginUserInfo loginUser) { + RefreshTokenResponse result = new RefreshTokenResponse(); + UserToken accessToken = null; + UserToken refreshToken = null; + if(loginUser!=null){ + accessToken= JwtTokenUtil.generateTokenByLoginUser(AppConstants.CMS_API_ACCESS_TOKEN_TYPE,loginUser); + userTokenService.saveUserToken(accessToken); + refreshToken=JwtTokenUtil.generateTokenByLoginUser(AppConstants.CMS_REFRESH_TOKEN_TYPE,loginUser); + userTokenService.saveUserToken(refreshToken); + if(StringUtils.isNotBlank(loginUser.getTokenUuid())){ + userTokenService.removeTokenByUniqueId(loginUser.getTokenUuid()); + } + } + + if(accessToken!=null){ + result.setSuccess(true); + result.setCode("200"); + result.setAccessToken(accessToken.getTokenValue()); + result.setRefreshToken(refreshToken.getTokenValue()); + result.setMessage("Token renew successful."); + }else{ + result.setSuccess(false); + result.setCode("200"); + result.setMessage("Token failed."); + } + return result; + } + + private List getUserRoleIds(List groupName) { + List roleIdList = new ArrayList<>(); + if(groupName!=null&&!groupName.isEmpty()){ + for(String roleName : groupName){ + List roleIds = rolesRepository.queryRoleIdByRoleName(roleName); + roleIdList.addAll(roleIds); + } + } + return roleIdList; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/services/AdminMenusService.java b/src/main/java/com/sjmresorts/opsapp/admin/services/AdminMenusService.java new file mode 100644 index 0000000..7498e7c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/services/AdminMenusService.java @@ -0,0 +1,113 @@ +package com.sjmresorts.opsapp.admin.services; + +import com.sjmresorts.opsapp.admin.dtos.menus.AdminMenuItemDto; +import com.sjmresorts.opsapp.admin.dtos.menus.AdminMenuStatusRequest; +import com.sjmresorts.opsapp.admin.dtos.menus.AdminMenuStatusResponse; +import com.sjmresorts.opsapp.admin.dtos.menus.AdminMenusListResponse; +import com.sjmresorts.opsapp.admin.dtos.system.router.UserRouterDto; +import com.sjmresorts.opsapp.admin.mappers.CmsLoginRouterMapper; +import com.sjmresorts.opsapp.admin.repositories.RoleUsersRepository; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.authentication.models.RouterPermission; +import com.sjmresorts.opsapp.authentication.models.SysRouter; +import com.sjmresorts.opsapp.authentication.repositories.SysRouterRepository; +import com.sjmresorts.opsapp.authentication.services.RolePermissionService; +import com.sjmresorts.opsapp.authentication.services.SysRouterService; +import com.sjmresorts.opsapp.common.constants.AppConstants; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class AdminMenusService { + + private static final Set LOCKED_ROUTER_IDS = Set.of( + "ESIGN_DASHBOARD", + "ESIGN_SYSTEM", + "ESIGN_SYS_USERS", + "ESIGN_SYS_ROLES", + "ESIGN_SYS_MENUS"); + + private final SysRouterRepository sysRouterRepository; + private final SysRouterService sysRouterService; + private final RolePermissionService rolePermissionService; + private final RoleUsersRepository roleUsersRepository; + private final CmsLoginRouterMapper cmsLoginRouterMapper; + + public AdminMenusListResponse listMenus() { + AdminMenusListResponse response = new AdminMenusListResponse(); + List routers = sysRouterRepository.queryAllRoutersOrderBySort(); + for (SysRouter router : routers) { + AdminMenuItemDto item = new AdminMenuItemDto(); + item.setId(router.getId()); + item.setParentId(router.getParentId()); + item.setHeading(router.getHeading()); + item.setSectionTitle(router.getSectionTitle()); + item.setRoute(router.getRoute()); + item.setFontIcon(router.getFontIcon()); + item.setSortNo(router.getSortNo()); + item.setStatus(router.getStatus() == null ? 1 : router.getStatus()); + item.setLocked(LOCKED_ROUTER_IDS.contains(router.getId())); + response.getData().add(item); + } + response.setSuccess(true); + response.setCode("200"); + response.setMessage("ok"); + return response; + } + + @Transactional + public AdminMenuStatusResponse updateStatus(AdminMenuStatusRequest request, LoginUserInfo loginUser) { + AdminMenuStatusResponse response = new AdminMenuStatusResponse(); + if (request == null || StringUtils.isBlank(request.getRouterId()) || request.getStatus() == null) { + response.setSuccess(false); + response.setCode("400"); + response.setMessage("routerId and status required"); + return response; + } + int status = request.getStatus() == 0 ? 0 : 1; + if (LOCKED_ROUTER_IDS.contains(request.getRouterId()) && status == 0) { + response.setSuccess(false); + response.setCode("400"); + response.setMessage("This menu cannot be hidden"); + return response; + } + SysRouter router = sysRouterRepository.findById(request.getRouterId()).orElse(null); + if (router == null) { + response.setSuccess(false); + response.setCode("404"); + response.setMessage("menu not found"); + return response; + } + router.setStatus(status); + sysRouterRepository.save(router); + + response.setUserRouters(buildUserRouters(loginUser)); + response.setSuccess(true); + response.setCode("200"); + response.setMessage("saved"); + return response; + } + + private List buildUserRouters(LoginUserInfo loginUser) { + if (loginUser == null || StringUtils.isBlank(loginUser.getEmployeeId())) { + return List.of(); + } + Map> userPermissions; + if (AppConstants.SUPER_ADMIN_ID.equalsIgnoreCase(loginUser.getEmployeeId())) { + userPermissions = rolePermissionService.getFullRouterPermissions(); + } else { + List roleIds = roleUsersRepository.findRoleIdListByEmployeeId(loginUser.getEmployeeId()); + userPermissions = rolePermissionService.getRouterPermissionsByGroupIds( + roleIds != null ? roleIds : List.of()); + } + List routers = sysRouterService.getRoutersByPermission("0", userPermissions); + return cmsLoginRouterMapper.toRouterDtoListWithChildren(routers); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/services/AdminPermissionService.java b/src/main/java/com/sjmresorts/opsapp/admin/services/AdminPermissionService.java new file mode 100644 index 0000000..cca62d0 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/services/AdminPermissionService.java @@ -0,0 +1,109 @@ +package com.sjmresorts.opsapp.admin.services; + + +import com.sjmresorts.opsapp.admin.dtos.permissions.AdminAssignGroupPermissionRequest; +import com.sjmresorts.opsapp.admin.dtos.permissions.AdminAssignGroupPermissionResponse; +import com.sjmresorts.opsapp.admin.dtos.permissions.AdminListPermissionSettingRequest; +import com.sjmresorts.opsapp.admin.dtos.permissions.AdminListPermissionSettingResponse; +import com.sjmresorts.opsapp.admin.models.GroupPermissionObject; +import com.sjmresorts.opsapp.authentication.models.*; +import com.sjmresorts.opsapp.authentication.repositories.RolePermissionRepository; +import com.sjmresorts.opsapp.authentication.repositories.RouterPermissionRepository; +import com.sjmresorts.opsapp.authentication.repositories.SysRouterRepository; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + + +@Service +@RequiredArgsConstructor +public class AdminPermissionService { + private final SysRouterRepository sysRouterRepository; + private final RouterPermissionRepository routerPermissionRepository; + private final RolePermissionRepository rolePermissionRepository; + + + public AdminListPermissionSettingResponse getPermissionById(AdminListPermissionSettingRequest request) { + AdminListPermissionSettingResponse result = new AdminListPermissionSettingResponse(); + GroupPermissionObject newObject = new GroupPermissionObject(); + +// 获取路由信息,路由权限,权限组权限 + List listRouter = sysRouterRepository.getAllChildrenRoutes(); + List listPermission = routerPermissionRepository.findAll(); + List listRolePermission = rolePermissionRepository.getAllPermissionByRoleId(request.getRoleId()); +// 判断权限组是否有权限,并将selectAble属性加入返回数据 + if (listRolePermission.size() > 0) { + for(int i=0; i permissionToDelete = rolePermissionRepository.getAllPermissionByRoleId(request.getRoleId()); + if(request.getAssignRouterOperation()!=null &&request.getAssignRouterOperation().size()>0){ + List assignRouterOperation = request.getAssignRouterOperation(); + ListsaveData = new ArrayList<>(); + for(int i =0;i getAllRoles() { + return rolesRepository.findAll(); + } + + public DataTableResult getRoleInfoListByPage(AdminGetRolesRequest pageRequest) { + + Specification listSpec = buildSpecificationByPageRequest(pageRequest); + int pageSize = pageRequest.getPageSize(); + int pageStart = pageRequest.getStartRow() / pageSize; + Sort sort = DaoUtil.buildSortByString(pageRequest.getSortOrder(), "roleId"); + Pageable pageable = PageRequest.of(pageStart, pageSize, sort); + Page resultPage = rolesRepository.findAll(listSpec,pageable); + List resultList = roleMapper.toAdminRoleDto(resultPage.getContent()); + DataTableResult dataResult = new DataTableResult<>(); + dataResult.setTotalRecords(resultPage.getTotalElements()); + dataResult.setPageSize(pageSize); + if (resultList == null) { + resultList = new ArrayList<>(); + } + dataResult.setRowData(resultList); + return dataResult; + } + + + public BaseApiResponse createRole(AdminCreateRoleRequest request, LoginUserInfo loginUserInfo) { + BaseApiResponse result = new BaseApiResponse(); + AuditLog auditLog = auditLogService.createAuditLog("Create Role", "Create a new Role from AdminRolesService.createRole()", loginUserInfo); + Role newRole = new Role(); + newRole.setRoleName(request.getRoleName()); + auditLogService.setLogOldValue(auditLog,null); + rolesRepository.save(newRole); + auditLogService.setLogNewValue(auditLog,newRole); + auditLogService.saveAuditLog(auditLog); + result.setSuccess(true); + result.setCode("200"); + result.setMessage("Created Successfully"); + return result; + } + + public BaseApiResponse deleteRole(AdminDeleteRoleRequest request, LoginUserInfo loginUserInfo) { + BaseApiResponse result = new BaseApiResponse(); + AuditLog auditLog = auditLogService.createAuditLog("Delete Role", "Delete a Role from AdminRolesService.deleteRole()", loginUserInfo); + auditLogService.setLogOldValue(auditLog,request.getRoleName()); + rolesRepository.deleteById(request.getRoleId()); + auditLogService.setLogNewValue(auditLog,""); + auditLogService.saveAuditLog(auditLog); + result.setSuccess(true); + result.setCode("200"); + result.setMessage("Created Successfully"); + return result; + } + + public BaseApiResponse modifyRole(AdminModifyRoleRequest request, LoginUserInfo loginUserInfo) { + BaseApiResponse result = new BaseApiResponse(); + AuditLog auditLog = auditLogService.createAuditLog("Modify Role", "Modify a Role from AdminRolesService.deleteRole()", loginUserInfo); + auditLogService.setLogOldValue(auditLog,request.getRoleName()); + Role oRole = rolesRepository.getRoleByRoleId((request.getRoleId())); + oRole.setRoleName(request.getRoleName()); + rolesRepository.save(oRole); + auditLogService.setLogNewValue(auditLog,""); + auditLogService.saveAuditLog(auditLog); + result.setSuccess(true); + result.setCode("200"); + result.setMessage("Modify Successfully"); + return result; + } + + public AdminGetRoleByIdResponse getRoleById(AdminGetRoleByIdRequest request) { + AdminGetRoleByIdResponse result = new AdminGetRoleByIdResponse(); + Role getRole = rolesRepository.findById(request.getRoleId()).orElse(null); + result.setData(getRole); + result.setSuccess(true); + result.setCode("200"); + result.setMessage("Get Role By Id Successfully"); + return result; + } + + + private Specification buildSpecificationByPageRequest(AdminGetRolesRequest pageRequest) { + + return ((root, criteriaQuery, cb) -> { + List list = new ArrayList<>(); + if (StringUtils.isNotBlank(pageRequest.getSearchRoleName())) { + list.add(cb.like(cb.upper(root.get("roleName")), + "%" + pageRequest.getSearchRoleName().toUpperCase() + "%")); + } + Predicate[] predicates = new Predicate[list.size()]; + criteriaQuery.where(list.toArray(predicates)); + return cb.and(list.toArray(predicates)); + }); + } + + public boolean updateGroupAssignUser(AdminRoleAssignUsersRequest request, LoginUserInfo loginUserInfo) { + Long roleId = request.getRoleId(); + List employeeIdList = request.getEmployeeIds(); + List dataToDelete =roleUsersRepository.findAllByRoleId(roleId); + AuditLog auditLog = auditLogService.createAuditLog("Update Group Assign Users", "Update Group Assign Users from AdminRolesService.updateGroupAssignUser()", loginUserInfo); + auditLogService.setLogOldValue(auditLog,dataToDelete); + if(employeeIdList!=null) { + List saveData = new ArrayList<>(); + for(int i=0; i0) { + roleUsersRepository.saveAll(saveData); + } + } + if(dataToDelete.size()>0) { + roleUsersRepository.deleteAll(dataToDelete); + } + auditLogService.setLogNewValue(auditLog,request); + auditLogService.saveAuditLog(auditLog); + return true; + } + +} diff --git a/src/main/java/com/sjmresorts/opsapp/admin/services/AdminUserService.java b/src/main/java/com/sjmresorts/opsapp/admin/services/AdminUserService.java new file mode 100644 index 0000000..3fa96f9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/admin/services/AdminUserService.java @@ -0,0 +1,180 @@ +package com.sjmresorts.opsapp.admin.services; + +import com.sjmresorts.opsapp.admin.dtos.roleUsers.AdminGetRoleUsersRequest; +import com.sjmresorts.opsapp.admin.dtos.system.user.CmsAppUserPageRequest; +import com.sjmresorts.opsapp.admin.dtos.system.user.CmsUserDto; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.authentication.mappers.UserMapper; +import com.sjmresorts.opsapp.authentication.models.RoleUsers; +import com.sjmresorts.opsapp.authentication.models.User; +import com.sjmresorts.opsapp.authentication.repositories.UserRepository; +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import com.sjmresorts.opsapp.common.dtos.DataTableResult; +import com.sjmresorts.opsapp.common.utils.DaoUtil; +import com.sjmresorts.opsapp.logging.models.AuditLog; +import com.sjmresorts.opsapp.logging.services.AuditLogService; +import jakarta.persistence.criteria.*; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminUserService { + + private final UserRepository userRepository; + private final AuditLogService auditLogService; + private final UserMapper userMapper; + + public DataTableResult getUserInfoListByPage(CmsAppUserPageRequest pageRequest) { + + Specification listSpec = buildSpecificationByPageRequest(pageRequest); + int pageSize = pageRequest.getPageSize(); + int pageStart = pageRequest.getStartRow() / pageSize; + Sort sort = DaoUtil.buildSortByString(pageRequest.getSortOrder(), "userId"); + Pageable pageable = PageRequest.of(pageStart, pageSize, sort); + Page resultPage = userRepository.findAll(listSpec, pageable); + List resultList = userMapper.toCmsUserDtoList(resultPage.getContent()); + DataTableResult dataResult = new DataTableResult<>(); + dataResult.setTotalRecords(resultPage.getTotalElements()); + dataResult.setPageSize(pageSize); + if (resultList == null) { + resultList = new ArrayList<>(); + } + dataResult.setRowData(resultList); + return dataResult; + } + + private Specification buildSpecificationByPageRequest(CmsAppUserPageRequest pageRequest) { + + return ((root, criteriaQuery, cb) -> { + List list = new ArrayList<>(); + if (StringUtils.isNotBlank(pageRequest.getSearchUserName())) { + list.add(cb.like(cb.upper(root.get("userName")), + "%" + pageRequest.getSearchUserName().toUpperCase() + "%")); + } + if (StringUtils.isNotBlank(pageRequest.getSearchAccount())) { + list.add(cb.like(cb.upper(root.get("employeeId")), + "%" + pageRequest.getSearchAccount().toUpperCase() + "%")); + } + Predicate[] predicates = new Predicate[list.size()]; + criteriaQuery.where(list.toArray(predicates)); + return cb.and(list.toArray(predicates)); + }); + } + + public List listUser(AdminGetRoleUsersRequest request, String searchType) { + Specification listSpec = buildSpecificationByRequest(request,searchType); + Sort sort = DaoUtil.buildSortByString(request.getSortOrder(), "employeeId"); + List resultList = userRepository.findAll(listSpec,sort); + return resultList; + } + + private Specification buildSpecificationByRequest(AdminGetRoleUsersRequest request, String searchType) { + Specification SysUserpec = new Specification() { + @Override + public Predicate toPredicate(Root root, CriteriaQuery criteriaQuery, CriteriaBuilder cb) { + List list = new ArrayList<>(); + if (StringUtils.equals(searchType, "USER_ROLE") && request.getSearchRoleId() != null) { + Subquery groupSubQuery = criteriaQuery.subquery(String.class); + Root subQueryRoot = groupSubQuery.from(RoleUsers.class); + groupSubQuery.select(subQueryRoot.get("employeeId")); + List subQueryPredicates = new ArrayList<>(); + subQueryPredicates.add(cb.equal(subQueryRoot.get("roleId"), request.getSearchRoleId())); + groupSubQuery.where(subQueryPredicates.toArray(new Predicate[] {})); + list.add(cb.in(root.get("employeeId")).value(groupSubQuery)); + } else if (StringUtils.equals(searchType, "UNASSIGN_USER")) { + Subquery groupSubQuery = criteriaQuery.subquery(Long.class); + Root subQueryRoot = groupSubQuery.from(RoleUsers.class); + groupSubQuery.select(cb.literal(1L)); + List subQueryPredicates = new ArrayList<>(); + subQueryPredicates.add(cb.equal(subQueryRoot.get("employeeId"), root.get("employeeId"))); + groupSubQuery.where(subQueryPredicates.toArray(new Predicate[] {})); + list.add(cb.not(cb.exists(groupSubQuery))); + } + Predicate[] predicates = new Predicate[list.size()]; + criteriaQuery.where(list.toArray(predicates)); + return cb.and(list.toArray(predicates)); + } + }; + return SysUserpec; + } + + + + public BaseApiResponse createUser(CmsUserDto userDto, LoginUserInfo loginUserInfo) { + BaseApiResponse result = new BaseApiResponse(); + AuditLog auditLog = auditLogService.createAuditLog("Create User", "Create a new User from AdminUserService.createUser()", loginUserInfo); + User user = userMapper.toUserEntity(userDto); + user.setCasinoCode(""); + auditLogService.setLogOldValue(auditLog, null); + userRepository.save(user); + auditLogService.setLogNewValue(auditLog, user); + auditLogService.saveAuditLog(auditLog); + result.setSuccess(true); + result.setCode("200"); + result.setMessage("Created Successfully"); + return result; + } + + public BaseApiResponse updateUser(CmsUserDto userDto, LoginUserInfo loginUserInfo) { + BaseApiResponse result = new BaseApiResponse(); + AuditLog auditLog = auditLogService.createAuditLog("Update User", "Update User from AdminUserService.updateUser()", loginUserInfo); + User user = userRepository.findById(userDto.getUserId()).orElse(null); + if (user != null) { + auditLogService.setLogOldValue(auditLog, user); + userMapper.updateUserFromCmsUserDto(userDto, user); + userRepository.save(user); + auditLogService.setLogNewValue(auditLog, user); + auditLogService.saveAuditLog(auditLog); + result.setSuccess(true); + result.setCode("200"); + result.setMessage("Updated Successfully"); + } else { + result.setSuccess(false); + result.setCode("400"); + result.setMessage("User does not exists"); + } + return result; + } + + public BaseApiResponse updateStatus(CmsUserDto userDto, LoginUserInfo loginUserInfo) { + BaseApiResponse result = new BaseApiResponse(); + AuditLog auditLog = auditLogService.createAuditLog("Update User Status", "Update User status from AdminUserService.updateStatus()", loginUserInfo); + User user = userRepository.findById(userDto.getUserId()).orElse(null); + if (user != null) { + auditLogService.setLogOldValue(auditLog, user); + user.setStatus(userDto.getStatus()); + userRepository.save(user); + auditLogService.setLogNewValue(auditLog, user); + auditLogService.saveAuditLog(auditLog); + result.setSuccess(true); + result.setCode("200"); + result.setMessage("Status Updated Successfully"); + } else { + result.setSuccess(false); + result.setCode("400"); + result.setMessage("User does not exists"); + } + return result; + } + + public CmsUserDto queryCmsUserById(@NotBlank long userId) { + CmsUserDto userDto = null; + User user = userRepository.findById(userId).orElse(null); + + if (user != null) { + userDto = userMapper.toCmsUserDto(user); + } + return userDto; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/controllers/MobileAuthorizationController.java b/src/main/java/com/sjmresorts/opsapp/authentication/controllers/MobileAuthorizationController.java new file mode 100644 index 0000000..b73b420 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/controllers/MobileAuthorizationController.java @@ -0,0 +1,147 @@ +package com.sjmresorts.opsapp.authentication.controllers; + +import com.sjmresorts.opsapp.authentication.dtos.*; +import com.sjmresorts.opsapp.authentication.services.AdAuthorizeService; +import com.sjmresorts.opsapp.authentication.services.LoginService; +import com.sjmresorts.opsapp.common.constants.AppConstants; +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Authorization", description = "APIs relate with ad login authorization") +@RestController +@RequestMapping("/api/v1/authorization") +@CrossOrigin(origins = "*") +public class MobileAuthorizationController { + @Resource + private LoginService loginService; + @Resource + private AdAuthorizeService adAuthService; + + @Operation( + summary = "appLogout", + description = "API for app logout process" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "App logout successful") + }) + @PostMapping(value="/logout",produces = "application/json;charset=UTF-8") + public ResponseEntity deviceLogin( + HttpServletRequest request) { + + AppLogoutResponse result = new AppLogoutResponse(); + LoginUserInfo loginUser = JwtTokenUtil.parseUserFromRequest(request); + if(loginUser!=null){ + loginService.processLogout(loginUser); + result.setSuccess(true); + result.setCode("200"); + result.setMessage("Logout successful."); + return ResponseEntity.status(HttpStatus.OK).body(result); + }else{ + result.setSuccess(false); + result.setCode("401"); + result.setMessage("Unauthorized logout"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(result); + } + } + + + @Operation( + summary = "Register Pits info for tracking", + description = "API for location tracking registration" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Register Pits successful"), + @ApiResponse(responseCode = "400", description = "Invalid request data") + }) + @PostMapping(value="/register_track_pits",produces = "application/json;charset=UTF-8") + public ResponseEntity registerTrackScope( + HttpServletRequest request, + @Parameter(description = "Register request for pits tracking", required = true) @Valid @RequestBody RegisterTrackInfoRequest requestData) { + RegisterTrackInfoResponse responseData = loginService.registerTrackPits(requestData.getData()); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } + + @Operation( + summary = "Third Party Authorization", + description = "API to retrieve access token for third party caller, such as Service Button" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Access Token generated successful"), + @ApiResponse(responseCode = "401", description = "Authorization key not math") + }) + @PostMapping(value="/getAccessToken",produces = "application/json;charset=UTF-8") + public ResponseEntity getApiAccessToken( + HttpServletRequest request, + @Parameter(description = "Third party requestor info", required = true) @Valid @RequestBody GetAuthTokenRequest requestData) { + String authorizationKey = request.getHeader("Authorization"); + if(authorizationKey!=null && authorizationKey.equals(JwtTokenUtil.getThirdPartySecretKey())){ + GetAuthTokenResponse responseData = loginService.assignAccessToken(AppConstants.THIRD_PARTY_TOKEN_TYPE,requestData.getData()); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + }else{ + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + } + } + + @Operation( + summary = "Renew API token by non-expired token", + description = "API to renew access token" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Access Token refreshed successful") + }) + @PostMapping(value="/refreshToken",produces = "application/json;charset=UTF-8") + public ResponseEntity refreshToken( + HttpServletRequest request, + HttpServletResponse response) { + RefreshTokenResponse responseData = new RefreshTokenResponse(); + LoginUserInfo loginUser = JwtTokenUtil.parseUserFromRequest(request); + boolean isRenewRefreshToken = (!"false".equalsIgnoreCase(request.getParameter("isRenewRefreshToken"))); + if(loginUser!=null){ + responseData = loginService.refreshToken(loginUser,isRenewRefreshToken); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + }else{ + responseData.setSuccess(false); + responseData.setCode("401"); + responseData.setMessage("Unauthorized operation"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(responseData); + } + } + + @Operation( + summary = "Heart-beat API", + description = "API to register last heart-beat time for login user" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Access Token refreshed successful") + }) + @GetMapping(value="/appHeartBeat") + public ResponseEntity sendHeartBeat( + HttpServletRequest request, + HttpServletResponse response) { + LoginUserInfo loginUser = JwtTokenUtil.parseUserFromRequest(request); + BaseApiResponse responseData = new BaseApiResponse(); + if(loginUser!=null){ + boolean registerSuccess = loginService.registerHeartBeat(loginUser); + responseData.setSuccess(registerSuccess); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + }else{ + responseData.setSuccess(false); + responseData.setCode("401"); + responseData.setMessage("Unauthorized operation"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(responseData); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdAuthorizeResponse.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdAuthorizeResponse.java new file mode 100644 index 0000000..4614268 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdAuthorizeResponse.java @@ -0,0 +1,19 @@ +package com.sjmresorts.opsapp.authentication.dtos; + + +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +public class AdAuthorizeResponse { + + private boolean success=false; + private String code; + private String message; + private AdUserInfo userInfo; +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdLoginData.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdLoginData.java new file mode 100644 index 0000000..cd6e0c1 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdLoginData.java @@ -0,0 +1,42 @@ +package com.sjmresorts.opsapp.authentication.dtos; + + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Data field of AdLoginRequest") +public class AdLoginData { + @Schema(description = "AD Account of Employee", example = "XXX") + @NotBlank(message = "Employee Id must not be blank") + private String employeeId; + + @Schema(description = "Password of AD Account", nullable = false) + @NotBlank(message = "Password must not be blank") + private String password; + + @Schema(description = "UUID of user login device") + private String deviceId; + + @Schema(description = "FCM token of current login device") + private String deviceToken; + + @Schema(description = "Device name of user login") + private String deviceName; + + @Schema(description = "Device version of user login") + private String deviceVersion; + + @Schema(description = "App version of current using") + private String appVersion; + + @Schema(description = "Is Mockup Login") + private Boolean isMock; +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdLoginRequest.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdLoginRequest.java new file mode 100644 index 0000000..0db4425 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdLoginRequest.java @@ -0,0 +1,25 @@ +package com.sjmresorts.opsapp.authentication.dtos; + +import com.sjmresorts.opsapp.common.dtos.SchemaEntry; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Request info of AD login with password") +public class AdLoginRequest { + @Schema(description = "Schema field of request") + @NotNull(message = "Schema attribute must not be null") + private SchemaEntry schema; + + @Schema(description = "Data field of request") + @NotNull(message = "Data attribute must not be null") + private AdLoginData data; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdLoginResponse.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdLoginResponse.java new file mode 100644 index 0000000..d2f9bfa --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdLoginResponse.java @@ -0,0 +1,47 @@ +package com.sjmresorts.opsapp.authentication.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Set; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of AD login with password") +public class AdLoginResponse { + + @Schema(description = "Indicate AD authorization success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result",example="Login successfully") + private String message; + + @Schema(description = "JWT token assign for login user") + private String accessToken; + + @Schema(description = "JWT refresh token assign for login user") + private String refreshToken; + + @Schema(description = "AD account of login user") + private String employeeId; + + @Schema(description = "User name of login user") + private String userName; + + @Schema(description = "User Id of login user") + private Long userId; + + @Schema(description = "Available access property of login user") + private String casinoCode; + + @Schema(description = "Permission Group of login user") + private Set permissionGroup; +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdUserInfo.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdUserInfo.java new file mode 100644 index 0000000..e93d4a9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AdUserInfo.java @@ -0,0 +1,20 @@ +package com.sjmresorts.opsapp.authentication.dtos; + + +import lombok.Data; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Data +@Getter +@Setter +public class AdUserInfo { + + private String employeeId; + private String userName; + private List adGroup; + private String role; + private String email; +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AppLogoutResponse.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AppLogoutResponse.java new file mode 100644 index 0000000..da30f2d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/AppLogoutResponse.java @@ -0,0 +1,25 @@ +package com.sjmresorts.opsapp.authentication.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of App logout") +public class AppLogoutResponse { + + @Schema(description = "Indicate app logout success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result",example="Device logout successfully") + private String message; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/DeviceLoginData.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/DeviceLoginData.java new file mode 100644 index 0000000..f9864d4 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/DeviceLoginData.java @@ -0,0 +1,45 @@ +package com.sjmresorts.opsapp.authentication.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Data field of DeviceLoginRequest") +public class DeviceLoginData { + @Schema(description = "Security content provide by SJM launcher") + private String securityContent; + + @Schema(description = "UUID of user login device") + private String deviceId; + + @Schema(description = "FCM token of current login device") + private String deviceToken; + + @Schema(description = "Device name of user login") + private String deviceName; + + @Schema(description = "Device version of user login") + private String deviceVersion; + + @Schema(description = "Employee ID from Launcher", example = "000001") + @NotBlank(message = "Employee Id must not be blank") + private String employeeId; + + @Schema(description = "User Name from Launcher", example = "XXX") + @NotBlank(message = "User Name must not be blank") + private String userName; + + @Schema(description = "Property code from Launcher", example = "GLC") + private String casinoCode; + + @Schema(description = "App version of current using") + private String appVersion; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/DeviceLoginRequest.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/DeviceLoginRequest.java new file mode 100644 index 0000000..07ceefd --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/DeviceLoginRequest.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.authentication.dtos; + +import com.sjmresorts.opsapp.common.dtos.SchemaEntry; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Login request info of SJM launcher") +public class DeviceLoginRequest { + @Schema(description = "Schema field of request") + private SchemaEntry schema; + + @Schema(description = "Data field of request") + private DeviceLoginData data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/DeviceLoginResponse.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/DeviceLoginResponse.java new file mode 100644 index 0000000..650676d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/DeviceLoginResponse.java @@ -0,0 +1,47 @@ +package com.sjmresorts.opsapp.authentication.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Set; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of SJM launcher login with AD content") +public class DeviceLoginResponse { + + @Schema(description = "Indicate AD authorization success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result",example="Device login successfully") + private String message; + + @Schema(description = "JWT API token assign for this login") + private String accessToken; + + @Schema(description = "JWT Refresh token assign for this login") + private String refreshToken; + + @Schema(description = "AD account of login user") + private String employeeId; + + @Schema(description = "User name of login user") + private String userName; + + @Schema(description = "User Id of login user") + private Long userId; + + @Schema(description = "Available access property of login user") + private String casinoCode; + + @Schema(description = "Permission Group of login user") + private Set permissionGroup; +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/GetAuthTokenData.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/GetAuthTokenData.java new file mode 100644 index 0000000..708b1d2 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/GetAuthTokenData.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.authentication.dtos; + + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Data field of GetAuthToken request") +public class GetAuthTokenData { + @Schema(description = "Requester ID of notification API caller", example = "ELO_XXXX") + @NotBlank(message = "Requester Id must not be blank") + private String requesterId; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/GetAuthTokenRequest.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/GetAuthTokenRequest.java new file mode 100644 index 0000000..864500a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/GetAuthTokenRequest.java @@ -0,0 +1,25 @@ +package com.sjmresorts.opsapp.authentication.dtos; + +import com.sjmresorts.opsapp.common.dtos.SchemaEntry; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Request to get notification API access token") +public class GetAuthTokenRequest { + @Schema(description = "Schema field of request") + @NotNull(message = "Schema attribute must not be null") + private SchemaEntry schema; + + @Schema(description = "Data field of request") + @NotNull(message = "Data attribute must not be null") + private GetAuthTokenData data; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/GetAuthTokenResponse.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/GetAuthTokenResponse.java new file mode 100644 index 0000000..ce4dd11 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/GetAuthTokenResponse.java @@ -0,0 +1,28 @@ +package com.sjmresorts.opsapp.authentication.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of notification API access token") +public class GetAuthTokenResponse { + + @Schema(description = "Indicate access token generated success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result",example="Access token generated successfully") + private String message; + + @Schema(description = "JWT token assign for service button") + private String accessToken; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/LoginUserInfo.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/LoginUserInfo.java new file mode 100644 index 0000000..659714c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/LoginUserInfo.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.authentication.dtos; + +import lombok.Data; +import lombok.Getter; +import lombok.Setter; + +@Data +@Getter +@Setter +public class LoginUserInfo { + private Long userId; + private String employeeId; + private String userCode; + private String userName; + private String adGroup; + private String role; + private String email; + private String loginSource; + private String tokenUuid; + private String appVersion; +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/RefreshTokenResponse.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/RefreshTokenResponse.java new file mode 100644 index 0000000..1240abd --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/RefreshTokenResponse.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.authentication.dtos; + +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of refreshing App access token") +public class RefreshTokenResponse extends BaseApiResponse { + + @Schema(description = "New API JWT token generated") + private String accessToken; + + @Schema(description = "New Refresh JWT token generated") + private String refreshToken; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/RegisterTrackInfoData.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/RegisterTrackInfoData.java new file mode 100644 index 0000000..6356cbb --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/RegisterTrackInfoData.java @@ -0,0 +1,26 @@ +package com.sjmresorts.opsapp.authentication.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Data field of RegisterTrackInfoRequest") +public class RegisterTrackInfoData { + @Schema(description = "Employee ID of current user") + private String employeeId; + + @Schema(description = "Casino code of user selected") + private String casinoCode; + + @Schema(description = "Pits code of user selected") + private List pitCodes; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/RegisterTrackInfoRequest.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/RegisterTrackInfoRequest.java new file mode 100644 index 0000000..508ec63 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/RegisterTrackInfoRequest.java @@ -0,0 +1,22 @@ +package com.sjmresorts.opsapp.authentication.dtos; + +import com.sjmresorts.opsapp.common.dtos.SchemaEntry; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Request info of Track pits registeration") +public class RegisterTrackInfoRequest { + @Schema(description = "Schema field of request") + private SchemaEntry schema; + + @Schema(description = "Data field of request") + private RegisterTrackInfoData data; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/dtos/RegisterTrackInfoResponse.java b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/RegisterTrackInfoResponse.java new file mode 100644 index 0000000..9c94f70 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/dtos/RegisterTrackInfoResponse.java @@ -0,0 +1,26 @@ +package com.sjmresorts.opsapp.authentication.dtos; + + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of Track pits registeration") +public class RegisterTrackInfoResponse { + + @Schema(description = "Indicate success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result",example="Pits are registered successfully") + private String message; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/filters/JwtAuthFilter.java b/src/main/java/com/sjmresorts/opsapp/authentication/filters/JwtAuthFilter.java new file mode 100644 index 0000000..b710b61 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/filters/JwtAuthFilter.java @@ -0,0 +1,111 @@ +package com.sjmresorts.opsapp.authentication.filters; + +import com.auth0.jwt.JWTVerifier; +import com.auth0.jwt.exceptions.TokenExpiredException; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.authentication.services.UserTokenService; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import com.sjmresorts.opsapp.common.utils.NetworkUtil; +import jakarta.annotation.Resource; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.Setter; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +public class JwtAuthFilter extends OncePerRequestFilter { + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + private final Logger logger = LoggerFactory.getLogger(JwtAuthFilter.class); + @Setter + private List excludePatterns; + + @Setter + private List verifyAppVersionList; + + @Setter + @Resource + private UserTokenService userTokenService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException{ + + NetworkUtil.checkAndSetAllowOrigin(request,response); + NetworkUtil.setSecurityHeaders(request,response); + String token = request.getHeader("Authorization"); + String uri = request.getRequestURI(); + if(StringUtils.isNotBlank(token) && token.startsWith("Bearer ")){ + token = token.substring(7); + } + if(StringUtils.isNotBlank(token)) { + JWTVerifier jwtVerifier = JwtTokenUtil.getJwtVerifier(); + try { + jwtVerifier.verify(token); + }catch (TokenExpiredException e) { + sendErrorResponse(response,HttpServletResponse.SC_UNAUTHORIZED,"Token Expired"); + request.setAttribute("AUTH_EXCEPTION","Token Expired"); + return; + }catch (Exception e) { + sendErrorResponse(response,HttpServletResponse.SC_UNAUTHORIZED,"Unauthorized"); + request.setAttribute("AUTH_EXCEPTION","Unauthorized"); + return; + } + LoginUserInfo loginInfo = JwtTokenUtil.parseUserFromToken(token); + if(loginInfo==null){ + sendErrorResponse(response,HttpServletResponse.SC_UNAUTHORIZED,"Unauthorized"); + request.setAttribute("AUTH_EXCEPTION","Invalid token"); + return; + }else{ + if(!userTokenService.checkUserTokenValid(loginInfo.getTokenUuid())){ + sendErrorResponse(response,HttpServletResponse.SC_UNAUTHORIZED,"Not Existing Token"); + request.setAttribute("AUTH_EXCEPTION","Not Existing Token"); + return; + } + } + + }else if(!"OPTIONS".equals(request.getMethod())) { + sendErrorResponse(response,HttpServletResponse.SC_UNAUTHORIZED,"Unauthorized"); + request.setAttribute("AUTH_EXCEPTION","Unauthorized"); + return; + } + filterChain.doFilter(request, response); + } + + + @Override + protected boolean shouldNotFilter(@NonNull HttpServletRequest request) { + if (excludePatterns == null) { + return false; + } + return excludePatterns.stream() + .anyMatch(pattern -> pathMatcher.match(pattern, request.getRequestURI())); + } + + private boolean needCheckAppVersion(@NonNull HttpServletRequest request){ + if(verifyAppVersionList==null || verifyAppVersionList.isEmpty()){ + return false; + } + return verifyAppVersionList.stream() + .anyMatch(pattern -> pathMatcher.match(pattern, request.getRequestURI())); + } + + private void sendErrorResponse(HttpServletResponse response, int errorCode, String errorMessage) throws IOException { + response.setStatus(errorCode); + response.setContentType("application/text"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(errorMessage); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/mappers/RoleMapper.java b/src/main/java/com/sjmresorts/opsapp/authentication/mappers/RoleMapper.java new file mode 100644 index 0000000..cf714fb --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/mappers/RoleMapper.java @@ -0,0 +1,14 @@ +package com.sjmresorts.opsapp.authentication.mappers; + +import com.sjmresorts.opsapp.admin.dtos.roles.AdminRoleDto; +import com.sjmresorts.opsapp.authentication.models.Role; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface RoleMapper { + @Mapping(source = "roleId", target = "roleName") + List toAdminRoleDto(List roles); +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/mappers/UserMapper.java b/src/main/java/com/sjmresorts/opsapp/authentication/mappers/UserMapper.java new file mode 100644 index 0000000..7bfdb5a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/mappers/UserMapper.java @@ -0,0 +1,31 @@ +package com.sjmresorts.opsapp.authentication.mappers; + +import com.sjmresorts.opsapp.admin.dtos.system.user.CmsUserDto; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.authentication.models.User; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.MappingTarget; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface UserMapper { + + @Mapping(source = "ldapGroup", target = "adGroup") + @Mapping(source = "employeeId", target = "userCode") + LoginUserInfo toLoginUser(User user); + + @Mapping(source = "employeeId", target = "userCode") + CmsUserDto toCmsUserDto(User user); + + @Mapping(source = "employeeId", target = "userCode") + List toCmsUserDtoList(List users); + + @Mapping(source = "userCode", target = "employeeId") + User toUserEntity(CmsUserDto userDto); + + @Mapping(target = "userId", ignore = true) + @Mapping(source = "userCode", target = "employeeId") + void updateUserFromCmsUserDto(CmsUserDto dto, @MappingTarget User entity); +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/models/ClientInfo.java b/src/main/java/com/sjmresorts/opsapp/authentication/models/ClientInfo.java new file mode 100644 index 0000000..2af9752 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/models/ClientInfo.java @@ -0,0 +1,50 @@ +package com.sjmresorts.opsapp.authentication.models; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name="client_infos") +public class ClientInfo { + + @Id + @Column(name = "id",nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "is_latest") + private Boolean isLatest; + + @Column(name = "is_active") + private Boolean isActive; + + @Column(name = "current_login_user") + private String currentLoginUser; + + @Column(name = "client_version") + private String clientVersion; + + @Column(name = "device_fuid") + private String deviceFuid; + + @Column(name = "client_name") + private String clientName; + + @Column(name = "casino_code") + private String casinoCode; + + @Column(name = "created_at") + private Instant createdAt; + + @Column(name = "updated_at") + private Instant updatedAt; + + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/models/Role.java b/src/main/java/com/sjmresorts/opsapp/authentication/models/Role.java new file mode 100644 index 0000000..a80b25e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/models/Role.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.authentication.models; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name="roles") +public class Role { + @Id + @Column(name = "role_id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long roleId; + + @Column(name = "role_name") + private String roleName; +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/models/RolePermission.java b/src/main/java/com/sjmresorts/opsapp/authentication/models/RolePermission.java new file mode 100644 index 0000000..fde2091 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/models/RolePermission.java @@ -0,0 +1,24 @@ +package com.sjmresorts.opsapp.authentication.models; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name="role_permissions") +public class RolePermission { + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "role_id") + private Long roleId; + + @Column(name = "permission_id") + private Long permissionId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/models/RoleUsers.java b/src/main/java/com/sjmresorts/opsapp/authentication/models/RoleUsers.java new file mode 100644 index 0000000..072480f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/models/RoleUsers.java @@ -0,0 +1,24 @@ +package com.sjmresorts.opsapp.authentication.models; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name="role_users") +public class RoleUsers { + @Id + @Column(name = "id") + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "role_id") + private Long roleId; + + @Column(name = "employee_id") + private String employeeId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/models/RouterPermission.java b/src/main/java/com/sjmresorts/opsapp/authentication/models/RouterPermission.java new file mode 100644 index 0000000..f177d26 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/models/RouterPermission.java @@ -0,0 +1,25 @@ +package com.sjmresorts.opsapp.authentication.models; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name="permissions") +public class RouterPermission { + @Id + @Column(name = "permission_id") + private Long id; + @Column(name = "router_id") + private String routerId; + @Column(name = "permission_name") + private String permissionName; + @Transient + private boolean selected=false; + @Transient + private String dataPermissionTag; +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/models/SimpleUser.java b/src/main/java/com/sjmresorts/opsapp/authentication/models/SimpleUser.java new file mode 100644 index 0000000..195a0cd --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/models/SimpleUser.java @@ -0,0 +1,30 @@ +package com.sjmresorts.opsapp.authentication.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name="users") +public class SimpleUser { + @Id + @Column(name = "user_id") + private Long userId; + + @Column(name = "employee_id") + private String employeeId; + + @Column(name = "username") + private String userName; + + @Column(name = "email") + private String email; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/models/SysRouter.java b/src/main/java/com/sjmresorts/opsapp/authentication/models/SysRouter.java new file mode 100644 index 0000000..e5855a3 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/models/SysRouter.java @@ -0,0 +1,45 @@ +package com.sjmresorts.opsapp.authentication.models; + + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name="sys_routers") +public class SysRouter { + @Id + @Column(name = "id") + private String id; + @Column(name="parent_id") + private String parentId; + @Column(name="heading") + private String heading; + @Column(name="section_title") + private String sectionTitle; + @Column(name="route") + private String route; + @Column(name="svg_icon") + private String svgIcon; + @Column(name="font_icon") + private String fontIcon; + @Column(name="sort_no") + private Integer sortNo; + @Column(name="status") + private Integer status; + + @Transient + private List sub; + + @Transient + private List routePermissions=new ArrayList(); + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/models/User.java b/src/main/java/com/sjmresorts/opsapp/authentication/models/User.java new file mode 100644 index 0000000..fff1c1f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/models/User.java @@ -0,0 +1,81 @@ +package com.sjmresorts.opsapp.authentication.models; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name="users") +@SQLDelete(sql = "UPDATE users SET deleted_at = CURRENT_TIMESTAMP WHERE user_id = ?") +@SQLRestriction("deleted_at IS NULL") +public class User { + + @Id + @Column(name = "user_id",nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userId; + + @Column(name = "employee_id",nullable = false) + private String employeeId; + + @Column(name = "username") + private String userName; + + @Column(name = "ldap_group") + private String ldapGroup; + + @Column(name = "role") + private String role; + + @Column(name = "casino_code",nullable = false) + private String casinoCode; + + @Column(name = "pits") + private String pitCodes; + + @Column(name = "device_id") + private String deviceId; + + @Column(name = "device_token") + private String deviceToken; + + @Column(name = "online_status") + private Boolean onlineStatus; + + @Column(name = "last_heartbeat") + private Instant lastHeartbeat; + + @Column(name = "email") + private String email; + + @Column(name = "password") + private String password; + + @Column(name = "status") + private Integer status; + + @Column(name = "last_login") + private Instant lastLogin; + + @Column(name = "cms_last_login") + private Instant cmsLastLogin; + + @Column(name = "is_deleted") + private Boolean deleted; + + @Column(name = "deleted_at") + private Instant deletedAt; + + @Version + @Column(name="version") + private Long version; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/models/UserRole.java b/src/main/java/com/sjmresorts/opsapp/authentication/models/UserRole.java new file mode 100644 index 0000000..1f359b1 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/models/UserRole.java @@ -0,0 +1,25 @@ +package com.sjmresorts.opsapp.authentication.models; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name="user_roles") +public class UserRole { + + @Id + @Column(name = "id",nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "role_id") + private Long roleId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/models/UserToken.java b/src/main/java/com/sjmresorts/opsapp/authentication/models/UserToken.java new file mode 100644 index 0000000..9d25c63 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/models/UserToken.java @@ -0,0 +1,40 @@ +package com.sjmresorts.opsapp.authentication.models; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; +import java.util.Date; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name="user_tokens") +public class UserToken { + @Id + @Column(name = "token_id",nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long tokenId; + + @Column(name = "user_code",nullable = false) + private String userCode; + + @Column(name = "token_type",nullable = false) + private String tokenType; + + @Column(name = "token_value",nullable = false) + private String tokenValue; + + @Column(name = "token_uuid") + private String tokenUniqueId; + + @Column(name = "created_at") + private Instant createdAt; + + @Column(name = "expiry_time") + private Date expiryTime; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/repositories/DeviceInfoRepository.java b/src/main/java/com/sjmresorts/opsapp/authentication/repositories/DeviceInfoRepository.java new file mode 100644 index 0000000..73e9009 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/repositories/DeviceInfoRepository.java @@ -0,0 +1,29 @@ +package com.sjmresorts.opsapp.authentication.repositories; + +import com.sjmresorts.opsapp.authentication.models.ClientInfo; +import jakarta.transaction.Transactional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; + +@Repository +public interface DeviceInfoRepository extends JpaRepository { + + List findAllByDeviceFuid(String deviceId); + + Page findAll(Specification listSpec, Pageable pageable); + + @Modifying + @Transactional + @Query(value="update ClientInfo set currentLoginUser=null, updatedAt=?2 " + + "where currentLoginUser=?1 ") + void clearLogoutDevice(String employeeId, Instant updateTime); + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/repositories/RolePermissionRepository.java b/src/main/java/com/sjmresorts/opsapp/authentication/repositories/RolePermissionRepository.java new file mode 100644 index 0000000..0e98e98 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/repositories/RolePermissionRepository.java @@ -0,0 +1,16 @@ +package com.sjmresorts.opsapp.authentication.repositories; + +import com.sjmresorts.opsapp.authentication.models.RolePermission; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface RolePermissionRepository extends JpaRepository { + + @Query("SELECT rp FROM RolePermission rp WHERE rp.roleId = ?1") + List getAllPermissionByRoleId(Long roleId); + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/repositories/RouterPermissionRepository.java b/src/main/java/com/sjmresorts/opsapp/authentication/repositories/RouterPermissionRepository.java new file mode 100644 index 0000000..58322bf --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/repositories/RouterPermissionRepository.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.authentication.repositories; + +import com.sjmresorts.opsapp.authentication.models.RouterPermission; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface RouterPermissionRepository extends JpaRepository { + + @Query(value="select rp from RouterPermission rp where rp.id in " + + " (select rop.permissionId from RolePermission rop where rop.roleId in ?1) ") + List queryPermissionByRoleIds(List roleIds); + + @Query(value="select rp from RouterPermission rp where rp.id in " + + " (select rop.permissionId from RolePermission rop where rop.roleId in ?1) and rp.routerId in ?2 ") + List queryPermissionByRoleAndRouterIds(List roleIds,List routerIds); + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/repositories/SysRouterRepository.java b/src/main/java/com/sjmresorts/opsapp/authentication/repositories/SysRouterRepository.java new file mode 100644 index 0000000..be58ca4 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/repositories/SysRouterRepository.java @@ -0,0 +1,24 @@ +package com.sjmresorts.opsapp.authentication.repositories; + +import com.sjmresorts.opsapp.authentication.models.SysRouter; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface SysRouterRepository extends JpaRepository { + + @Query(value="select rte from SysRouter rte where rte.status=1 order by rte.sortNo") + List queryAllActiveRouter(); + + @Query(value="select rte from SysRouter rte order by rte.sortNo") + List queryAllRoutersOrderBySort(); + + @Query(value="select rte from SysRouter rte where rte.heading is not null and rte.status=1 order by rte.sortNo") + List getAllChildrenRoutes(); + + @Query(value="select rte.id from SysRouter rte where heading=?1") + List queryIdsByHeading(String appRouterName); +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/repositories/UserRepository.java b/src/main/java/com/sjmresorts/opsapp/authentication/repositories/UserRepository.java new file mode 100644 index 0000000..9677afc --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/repositories/UserRepository.java @@ -0,0 +1,66 @@ +package com.sjmresorts.opsapp.authentication.repositories; + +import com.sjmresorts.opsapp.authentication.models.User; +import jakarta.transaction.Transactional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + Optional findByEmployeeId(String employeeId); + + Optional findByDeviceId(String deviceId); + + List findByEmployeeIdIn(Collection employeeIds); + + Optional findByDeviceToken(String deviceToken); + + Page findAll(Specification listSpec, Pageable pageable); + + List findAll(Specification spec, Sort sort); + + @Query(value="select usr from User usr where casinoCode=?1 and pitCodes like %?2% and lastHeartbeat >= ?3") + List queryOnlineUserForPit(String casinoCode, String pitCode, Instant heartbeatAfter); + + int countByEmployeeIdAndPassword(String upperCase, String encryptPassword); + + @Modifying + @Transactional + @Query(value="update User set casinoCode='', pitCodes='', deviceId=null, deviceToken=null, onlineStatus=false, lastHeartbeat=null " + + " where employeeId=?1 ") + void clearAppLoginInfoByUser(String employeeId); + + @Modifying + @Transactional + @Query(value="update User set lastHeartbeat=?2 where employeeId=?1 ") + int registerHeartBeat(String employeeId, Instant now); + + @Query(value = "select u.employeeId from User u where u.lastHeartbeat < ?1 order by u.lastHeartbeat asc") + List findExpiredSessionEmployeeIds(Instant checkExpiryTime, Pageable pageable); + + @Modifying + @Transactional + @Query(value="update User set deviceId=null, deviceToken=null where employeeId<>?1 and deviceId=?2") + void clearOtherUserDeviceInfo(String employeeId, String deviceId); + + @Modifying + @Transactional + @Query("update User u set u.deviceToken = null where u.deviceToken = ?1") + int clearDeviceTokenByToken(String deviceToken); + + @Modifying + @Transactional + @Query("update User u set u.deviceToken = null where u.employeeId = ?1") + int clearDeviceTokenByEmployeeId(String employeeId); +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/repositories/UserRoleRepository.java b/src/main/java/com/sjmresorts/opsapp/authentication/repositories/UserRoleRepository.java new file mode 100644 index 0000000..d083704 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/repositories/UserRoleRepository.java @@ -0,0 +1,15 @@ +package com.sjmresorts.opsapp.authentication.repositories; + +import com.sjmresorts.opsapp.authentication.models.UserRole; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface UserRoleRepository extends JpaRepository { + + @Query(value="select usrRole.id from UserRole usrRole where userId=?1 ") + List findRoleIdListByUserId(Long userId); +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/repositories/UserTokenRepository.java b/src/main/java/com/sjmresorts/opsapp/authentication/repositories/UserTokenRepository.java new file mode 100644 index 0000000..b97d14a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/repositories/UserTokenRepository.java @@ -0,0 +1,30 @@ +package com.sjmresorts.opsapp.authentication.repositories; + +import com.sjmresorts.opsapp.authentication.models.UserToken; +import jakarta.transaction.Transactional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Date; + +@Repository +public interface UserTokenRepository extends JpaRepository { + + int countByTokenUniqueId(String tokenUuid); + int countByTokenTypeAndTokenValue(String tokenType, String tokenValue); + + @Modifying + @Transactional + void deleteAllByTokenTypeAndUserCode(String tokenType, String userCode); + + @Modifying + @Transactional + void deleteAllByTokenUniqueId(String tokenUuid); + + @Modifying + @Transactional + @Query(value="delete from UserToken where expiryTime selectPitCodes=data.getPitCodes(); + for(String pitCode:selectPitCodes) { + pits +="["+pitCode+"]"; + } + } + user.setPitCodes(pits); + user.setLastHeartbeat(Instant.now()); + userRepository.save(user); + result.setSuccess(true); + result.setMessage("Pits registered successfully"); + } + return result; + } + + private void clearLastLoginInfo(String employeeId){ + userTokenService.clearUserTokenByType(AppConstants.APP_API_ACCESS_TOKEN_TYPE,employeeId); + userTokenService.clearUserTokenByType(AppConstants.APP_REFRESH_TOKEN_TYPE,employeeId); + deviceInfoRepository.clearLogoutDevice(employeeId,Instant.now()); + userRepository.clearAppLoginInfoByUser(employeeId); + } + public GetAuthTokenResponse assignAccessToken(String requestSource, @NotNull(message = "Data attribute must not be null") GetAuthTokenData data) { + GetAuthTokenResponse result = new GetAuthTokenResponse(); + LoginUserInfo thirdPartyUser = new LoginUserInfo(); + thirdPartyUser.setEmployeeId(data.getRequesterId()); + thirdPartyUser.setUserCode(data.getRequesterId()); + thirdPartyUser.setUserName(data.getRequesterId()); + thirdPartyUser.setLoginSource(requestSource); + UserToken accessToken = JwtTokenUtil.generateTokenByLoginUser(AppConstants.THIRD_PARTY_TOKEN_TYPE, thirdPartyUser); + userTokenService.saveUserToken(accessToken); + result.setAccessToken(accessToken.getTokenValue()); + result.setCode("200"); + result.setSuccess(true); + return result; + } + + @Retryable(retryFor = OptimisticLockingFailureException.class, + maxAttempts = 3, + backoff = @Backoff(delay = 100)) + @Transactional + public void processLogout(LoginUserInfo loginUser) { + if(loginUser!=null){ + clearLastLoginInfo(loginUser.getEmployeeId()); + } + } + + public RefreshTokenResponse refreshToken(LoginUserInfo loginUser, boolean isRenewRefreshToken) { + RefreshTokenResponse result = new RefreshTokenResponse(); + UserToken accessToken = null; + UserToken refreshToken = null; + if(loginUser!=null){ + accessToken=JwtTokenUtil.generateTokenByLoginUser(AppConstants.APP_API_ACCESS_TOKEN_TYPE,loginUser); + userTokenService.saveUserToken(accessToken); + result.setAccessToken(accessToken.getTokenValue()); + if(isRenewRefreshToken){ + refreshToken=JwtTokenUtil.generateTokenByLoginUser(AppConstants.APP_REFRESH_TOKEN_TYPE,loginUser); + userTokenService.saveUserToken(refreshToken); + result.setRefreshToken(refreshToken.getTokenValue()); + if(StringUtils.isNotBlank(loginUser.getTokenUuid())){ + userTokenService.removeTokenByUniqueId(loginUser.getTokenUuid()); + } + } + } + + if(accessToken!=null){ + result.setSuccess(true); + result.setCode("200"); + result.setMessage("Token renew successful."); + }else{ + result.setSuccess(false); + result.setCode("200"); + result.setMessage("Token failed."); + } + return result; + } + + public boolean registerHeartBeat(LoginUserInfo loginUser) { + boolean result = false; + if(loginUser!=null && StringUtils.isNotBlank(loginUser.getEmployeeId())){ + int updateCnt = userRepository.registerHeartBeat(loginUser.getEmployeeId(), Instant.now()); + result = updateCnt>0; + } + return result; + } + + public boolean logoutFirstIdleSessionByTime(Instant checkExpiryTime) { + boolean hasRecord =false; + List idleUsers = userRepository.findExpiredSessionEmployeeIds( + checkExpiryTime, PageRequest.of(0, 1)); + String idleUserCode = idleUsers == null || idleUsers.isEmpty() ? null : idleUsers.get(0); + if(StringUtils.isNotBlank(idleUserCode)) { + hasRecord=true; + userRepository.clearAppLoginInfoByUser(idleUserCode); + deviceInfoRepository.clearLogoutDevice(idleUserCode,Instant.now()); + } + return hasRecord; + } + + private Set getUserPermissionGroup(List groupName) { + Set permissionGroup = new HashSet<>(); + if(groupName!=null&&!groupName.isEmpty()){ + for(String roleName : groupName){ + List roleIds = rolesRepository.queryRoleIdByRoleName(roleName); + List appRouterIds = sysRouterRepository.queryIdsByHeading(AppConstants.APP_ROUTER_NAME); + if(!roleIds.isEmpty() && !appRouterIds.isEmpty()){ + List permissions = routerPermissionRepository.queryPermissionByRoleAndRouterIds(roleIds,appRouterIds); + if(!permissions.isEmpty()){ + Set names = permissions.stream() + .map(RouterPermission::getPermissionName) + .collect(Collectors.toSet()); + permissionGroup.addAll(names); + } + } + } + } + return permissionGroup; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/services/RolePermissionService.java b/src/main/java/com/sjmresorts/opsapp/authentication/services/RolePermissionService.java new file mode 100644 index 0000000..1a3310b --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/services/RolePermissionService.java @@ -0,0 +1,50 @@ +package com.sjmresorts.opsapp.authentication.services; + +import com.sjmresorts.opsapp.authentication.models.RouterPermission; +import com.sjmresorts.opsapp.authentication.repositories.RouterPermissionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class RolePermissionService { + private final RouterPermissionRepository routerPermissionRepository; + + public Map> getFullRouterPermissions(){ + List fullPermission = routerPermissionRepository.findAll(); + return constructPermissionMap(fullPermission); + } + + public Map> getRouterPermissionsByGroupIds(List userRoleIds) { + List rolePermissions = null; + if(userRoleIds.isEmpty()){ + rolePermissions = new ArrayList(); + }else{ + rolePermissions = routerPermissionRepository.queryPermissionByRoleIds(userRoleIds); + } + return constructPermissionMap(rolePermissions); + } + + private Map> constructPermissionMap(List permissions){ + Map> result= new HashMap>(); + if(permissions!=null) { + for(RouterPermission rpItem: permissions) { + if(result.containsKey(rpItem.getRouterId())) { + result.get(rpItem.getRouterId()).add(rpItem); + }else { + List permissionList = new ArrayList(); + permissionList.add(rpItem); + result.put(rpItem.getRouterId(), permissionList); + } + } + } + return result; + } + + +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/services/SysRouterService.java b/src/main/java/com/sjmresorts/opsapp/authentication/services/SysRouterService.java new file mode 100644 index 0000000..028a46b --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/services/SysRouterService.java @@ -0,0 +1,59 @@ +package com.sjmresorts.opsapp.authentication.services; + +import com.sjmresorts.opsapp.authentication.models.RouterPermission; +import com.sjmresorts.opsapp.authentication.models.SysRouter; +import com.sjmresorts.opsapp.authentication.repositories.SysRouterRepository; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.BeanUtils; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class SysRouterService { + + private final SysRouterRepository sysRouterRepository; + + public List getRoutersByPermission(String rootId, + Map> userPermissions) { + List srcRouters = sysRouterRepository.queryAllActiveRouter(); + if(StringUtils.isBlank(rootId)) { + rootId = "0"; + } + return this.constructRouterByParent(srcRouters,rootId,userPermissions); + } + + private List constructRouterByParent(List srcRouters, String parentId,Map> userPermissions) { + List resultList = new ArrayList(); + // get root routers + for(int i=0;i subList = constructRouterByParent(srcRouters,rItem.getId(),userPermissions); + if(!subList.isEmpty()) { + SysRouter router = buildRouter(rItem,new String[] { "sortNo","status","heading" }); + router.setSub(subList); + resultList.add(router); + } + } + } + } + return resultList; + } + + public SysRouter buildRouter(SysRouter router,String[] filter) { + SysRouter returnRouter = new SysRouter(); + BeanUtils.copyProperties(router, returnRouter, filter); + return returnRouter; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/authentication/services/UserTokenService.java b/src/main/java/com/sjmresorts/opsapp/authentication/services/UserTokenService.java new file mode 100644 index 0000000..94cd7d3 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/authentication/services/UserTokenService.java @@ -0,0 +1,63 @@ +package com.sjmresorts.opsapp.authentication.services; + +import com.sjmresorts.opsapp.authentication.models.UserToken; +import com.sjmresorts.opsapp.authentication.repositories.UserTokenRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.dao.OptimisticLockingFailureException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Date; + +@Service +@RequiredArgsConstructor +public class UserTokenService { + private final Logger logger = LoggerFactory.getLogger(UserTokenService.class); + private final UserTokenRepository userTokenRepository; + + @Retryable(retryFor = OptimisticLockingFailureException.class, + maxAttempts = 3, + backoff = @Backoff(delay = 100)) + @Transactional + public void saveUserToken(UserToken userToken){ + if(userToken!=null){ + if(userToken.getCreatedAt()==null){ + userToken.setCreatedAt(Instant.now()); + } + userTokenRepository.save(userToken); + } + } + + public boolean checkUserTokenValid(String tokenUuid){ + return userTokenRepository.countByTokenUniqueId(tokenUuid)>0; + } + + public void clearUserTokenByType(String tokenType, String userCode){ + try { + userTokenRepository.deleteAllByTokenTypeAndUserCode(tokenType, userCode); + }catch(Exception err){ + logger.warn("Encountered ignorable error when remove user token:{}",err.getMessage()); + } + } + + public void removeTokenByUniqueId(String tokenUuid) { + try { + userTokenRepository.deleteAllByTokenUniqueId(tokenUuid); + }catch(Exception err){ + logger.warn("Encountered ignorable error when delete user token:{}",err.getMessage()); + } + } + + public void expireUserTokenByTime(Date expiryTime) { + try{ + userTokenRepository.deleteAllExpiredBeforeTime(expiryTime); + }catch(Exception err){ + logger.warn("Encountered ignorable error when delete expired user token:{}",err.getMessage()); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/config/AsyncConfig.java b/src/main/java/com/sjmresorts/opsapp/common/config/AsyncConfig.java new file mode 100644 index 0000000..5dc1987 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/config/AsyncConfig.java @@ -0,0 +1,52 @@ +package com.sjmresorts.opsapp.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * 限制 @Async 并发,避免无界线程打满 Hikari 连接池。 + * OPERA/OHIP 上传使用独立池,避免被归档 OCR / 日志等任务挤占。 + */ +@Configuration +@EnableAsync +public class AsyncConfig implements AsyncConfigurer { + + /** 默认 @Async 池(OCR、审计日志等)。 */ + public static final String TASK_EXECUTOR = "taskExecutor"; + + /** 签名归档后推 OPERA 专用池。 */ + public static final String OHIP_UPLOAD_EXECUTOR = "ohipUploadExecutor"; + + @Override + @Bean(name = TASK_EXECUTOR) + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(5); + executor.setQueueCapacity(500); + executor.setThreadNamePrefix("ops-async-"); + executor.initialize(); + return executor; + } + + @Bean(name = OHIP_UPLOAD_EXECUTOR) + public Executor ohipUploadExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(200); + executor.setThreadNamePrefix("ohip-upload-"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.setAwaitTerminationSeconds(60); + // 队列满时回退到调用线程,避免静默丢弃导致 ohipStatus 永久 PENDING + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/config/GoogleApiProxyFactory.java b/src/main/java/com/sjmresorts/opsapp/common/config/GoogleApiProxyFactory.java new file mode 100644 index 0000000..ab1bfa6 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/config/GoogleApiProxyFactory.java @@ -0,0 +1,56 @@ +package com.sjmresorts.opsapp.common.config; + +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.auth.http.HttpTransportFactory; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.util.Optional; + +/** + * Firebase Admin / Google OAuth 默认不走 JVM {@code https.proxyHost},需显式配置 HttpTransport。 + */ +@Slf4j +@Component +public class GoogleApiProxyFactory { + + @Value("${proxy.enabled:false}") + private boolean proxyEnabled; + + @Value("${proxy.host:}") + private String proxyHost; + + @Value("${proxy.port:}") + private String proxyPort; + + public Optional resolveProxy() { + if (!proxyEnabled || StringUtils.isBlank(proxyHost) || StringUtils.isBlank(proxyPort)) { + return Optional.empty(); + } + try { + int port = Integer.parseInt(proxyPort.trim()); + Proxy proxy = new Proxy(Proxy.Type.HTTP, new InetSocketAddress(proxyHost.trim(), port)); + log.info("Google API HTTP proxy: {}:{}", proxyHost.trim(), port); + return Optional.of(proxy); + } catch (NumberFormatException e) { + log.warn("Invalid proxy.port={}, Google API proxy disabled", proxyPort); + return Optional.empty(); + } + } + + public HttpTransport createHttpTransport() { + NetHttpTransport.Builder builder = new NetHttpTransport.Builder(); + resolveProxy().ifPresent(builder::setProxy); + return builder.build(); + } + + public HttpTransportFactory createTransportFactory() { + HttpTransport transport = createHttpTransport(); + return () -> transport; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/config/JvmProxyConfigurator.java b/src/main/java/com/sjmresorts/opsapp/common/config/JvmProxyConfigurator.java new file mode 100644 index 0000000..0a10711 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/config/JvmProxyConfigurator.java @@ -0,0 +1,40 @@ +package com.sjmresorts.opsapp.common.config; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; + +/** + * Must run before Firebase Admin SDK initializes (Google OAuth needs outbound HTTPS). + */ +@Slf4j +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class JvmProxyConfigurator { + + @Value("${proxy.enabled:false}") + private boolean proxyEnabled; + + @Value("${proxy.host:}") + private String proxyHost; + + @Value("${proxy.port:}") + private String proxyPort; + + @PostConstruct + public void applyJvmProxy() { + if (!proxyEnabled || StringUtils.isBlank(proxyHost) || StringUtils.isBlank(proxyPort)) { + return; + } + System.setProperty("https.proxyHost", proxyHost.trim()); + System.setProperty("https.proxyPort", proxyPort.trim()); + System.setProperty("http.proxyHost", proxyHost.trim()); + System.setProperty("http.proxyPort", proxyPort.trim()); + log.info("JVM HTTPS proxy enabled: {}:{}", proxyHost, proxyPort); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/config/LdapConfig.java b/src/main/java/com/sjmresorts/opsapp/common/config/LdapConfig.java new file mode 100644 index 0000000..542ad17 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/config/LdapConfig.java @@ -0,0 +1,39 @@ +package com.sjmresorts.opsapp.common.config; + + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.ldap.core.LdapTemplate; +import org.springframework.ldap.core.support.LdapContextSource; + +@Configuration +public class LdapConfig { + @Value("${spring.ldap.urls}") + private String ldapUrl; + + @Value("${spring.ldap.base}") + private String baseDn; + + @Value("${spring.ldap.username}") + private String managerDn; + + @Value("${spring.ldap.password}") + private String managerPassword; + + @Bean + public LdapContextSource contextSource() { + LdapContextSource source = new LdapContextSource(); + source.setUrl(ldapUrl); + source.setBase(baseDn); + source.setUserDn(managerDn); + source.setPassword(managerPassword); + source.setReferral("follow"); + return source; + } + + @Bean + public LdapTemplate ldapTemplate() { + return new LdapTemplate(contextSource()); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/config/OpsAppDbConfig.java b/src/main/java/com/sjmresorts/opsapp/common/config/OpsAppDbConfig.java new file mode 100644 index 0000000..0105adf --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/config/OpsAppDbConfig.java @@ -0,0 +1,96 @@ +package com.sjmresorts.opsapp.common.config; + +import com.sjmresorts.opsapp.common.db.DbDialect; +import com.zaxxer.hikari.HikariDataSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.orm.jpa.JpaTransactionManager; +import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; +import org.springframework.retry.annotation.EnableRetry; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.EnableTransactionManagement; + +import jakarta.persistence.EntityManagerFactory; + +import java.util.HashMap; +import java.util.Map; + +@EnableRetry +@Configuration +@EnableTransactionManagement +@EnableJpaRepositories( + basePackages = { + "com.sjmresorts.opsapp.authentication", + "com.sjmresorts.opsapp.notifications", + "com.sjmresorts.opsapp.logging", + "com.sjmresorts.opsapp.esign", + "com.sjmresorts.opsapp.appsetting", + "com.sjmresorts.opsapp.admin", + "com.sjmresorts.opsapp.profile.repositories" + }, + entityManagerFactoryRef = "opsAppEntityManagerFactory", + transactionManagerRef = "opsAppTransactionManager" +) +public class OpsAppDbConfig { + + private static final Logger log = LoggerFactory.getLogger(OpsAppDbConfig.class); + + @Bean + @Primary + @ConfigurationProperties("spring.datasource.ops-app-db") + public HikariDataSource opsAppDataSource() { + return DataSourceBuilder.create() + .type(HikariDataSource.class) + .build(); + } + + @Primary + @Bean(name = "opsAppEntityManagerFactory") + @org.springframework.context.annotation.DependsOn({"esignCaptureSchemaMigrator", "esignCmsSchemaMigrator"}) + public LocalContainerEntityManagerFactoryBean opsAppEntityManagerFactory( + EntityManagerFactoryBuilder builder, + DbDialect dbDialect) { + Map jpaProps = new HashMap<>(); + jpaProps.put("hibernate.dialect", dbDialect.hibernateDialectClass()); + return builder + .dataSource(opsAppDataSource()) + .packages( + "com.sjmresorts.opsapp.authentication", + "com.sjmresorts.opsapp.notifications", + "com.sjmresorts.opsapp.logging", + "com.sjmresorts.opsapp.esign", + "com.sjmresorts.opsapp.appsetting", + "com.sjmresorts.opsapp.admin", + "com.sjmresorts.opsapp.profile.models" + ).persistenceUnit("opsAppDb") + .properties(jpaProps) + .build(); + } + + @Primary + @Bean(name = "opsAppTransactionManager") + public PlatformTransactionManager opsAppTransactionManager( + @Qualifier("opsAppEntityManagerFactory") EntityManagerFactory entityManagerFactory) { + return new JpaTransactionManager(entityManagerFactory); + } + + @Bean + ApplicationRunner logOpsAppDbPoolSize( + @Qualifier("opsAppDataSource") HikariDataSource dataSource, DbDialect dbDialect) { + return args -> log.info( + "ops-app-db Hikari pool: name={}, maximumPoolSize={}, minimumIdle={}, dbType={}", + dataSource.getPoolName(), + dataSource.getMaximumPoolSize(), + dataSource.getMinimumIdle(), + dbDialect.type()); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/config/SwaggerConfig.java b/src/main/java/com/sjmresorts/opsapp/common/config/SwaggerConfig.java new file mode 100644 index 0000000..22e1323 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/config/SwaggerConfig.java @@ -0,0 +1,19 @@ +package com.sjmresorts.opsapp.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("API Document for ops application") + .version("1.0") + .description("API document for SJM ops application")); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/config/WebFilterConfig.java b/src/main/java/com/sjmresorts/opsapp/common/config/WebFilterConfig.java new file mode 100644 index 0000000..0857f9c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/config/WebFilterConfig.java @@ -0,0 +1,142 @@ +package com.sjmresorts.opsapp.common.config; + +import com.sjmresorts.opsapp.common.filters.CorsHeaderFilter; +import com.sjmresorts.opsapp.common.filters.JwtAuthFilter; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; + +import java.util.Arrays; +import java.util.List; + +@Configuration +public class WebFilterConfig { + + @Value("${ops-app.security.jwt.secret_key}") + private String secretKey; + + @Value("${ops-app.security.jwt.app_authorization_code}") + private String appAuthorizationCode; + + @Value("${ops-app.security.jwt.api_token_expiration}") + private long apiTokenExpiration; + + @Value("${ops-app.security.jwt.cms_session_expiration}") + private long cmsSessionExpiration; + + @Value("${ops-app.security.jwt.app_session_expiration}") + private long appSessionExpiration; + + @Value("${ops-app.security.notification.authorization_code}") + private String thirdPartyAuthorization; + + @Value("${ops-app.security.notification.expiration}") + private long thirdPartyExpiration; + + @Value("${ops-app.api-logging-level}") + private String apiLogLevel; + + + + @Bean + public FilterRegistrationBean corsHeaderFilter() { + FilterRegistrationBean cors = new FilterRegistrationBean<>(); + cors.setFilter(new CorsHeaderFilter()); + cors.addUrlPatterns("/api/*"); + cors.setOrder(1); + cors.setName("CorsHeaderFilter"); + return cors; + } + + @Bean + public FilterRegistrationBean jwtAuthFilter() { + JwtTokenUtil.setSecretKey(secretKey); + JwtTokenUtil.setAccessTokenExpiration(apiTokenExpiration); + JwtTokenUtil.setAppSessionExpiration(appSessionExpiration); + JwtTokenUtil.setCmsSessionExpiration(cmsSessionExpiration); + JwtTokenUtil.setThirdPartySecretKey(thirdPartyAuthorization); + JwtTokenUtil.setThirdPartyExpiration(thirdPartyExpiration); + JwtTokenUtil.setAppLoginAuthorizationCode(appAuthorizationCode); + List excludePatterns = Arrays.asList( + "/api/v1/authorization/ad_login", + "/api/v1/authorization/device_login", + "/api/v1/authorization/getAccessToken", + "/api/v1/admin/login", + "/api/v1/logging/createAppLog", + "/api/v1/logging/auditLog/**", + "/api/v1/logging/apiLog/**", + "/api/v1/logs/**", + "/api/v1/esign/capture-logs/**", + "/api/v1/esign/auth/**", + "/api/v1/esign/**", + "/esign-api/api/v1/esign/**", + "**/api/v1/esign/**", + "/api/v1/sinosecu/auth/**", + "/api/v1/sinosecu/**", + "/esign-api/api/v1/sinosecu/**", + "**/api/v1/sinosecu/**", + "/api/v1/esign/ios-capture-submit", + "/api/v1/esign/ios-pending-tasks", + "/api/v1/esign/ios-task-list", + "/api/v1/esign/ios-task-sign", + "/api/v1/esign/ios-task/*", + "/api/v1/sinosecu/ios-capture-submit", + "/api/v1/sinosecu/ios-pending-tasks", + "/api/v1/sinosecu/ios-task-list", + "/api/v1/sinosecu/ios-task-sign", + "/api/v1/sinosecu/ios-task/*", + "/api/v1/config/pit_list/*", + "/api/v1/profile/*", + "/api/v1/profile/getPatronImage/*", + "/api/v1/profile/*", + "/api/v1/profile/**", + "/swagger-ui.html", + "/swagger-ui", + "/swagger-ui/**", + "/swagger*/*", + "/v3/**", + "/api/v1/notifications/**" + ); + List verifyAppVersionList = Arrays.asList( + "/api/v1/Pit/*" + ); + FilterRegistrationBean registrationBean = new FilterRegistrationBean<>(); + JwtAuthFilter filter = new JwtAuthFilter(); + filter.setExcludePatterns(excludePatterns); + filter.setVerifyAppVersionList(verifyAppVersionList); + registrationBean.setFilter(filter); + registrationBean.addUrlPatterns("/*"); + registrationBean.setOrder(2); + registrationBean.setName("RequestJwtFilter"); + + return registrationBean; + } + +// @Bean +// public FilterRegistrationBean apiLoggingFilterRegistration( +// ApiLoggingFilter apiLoggingFilter) { +// if(apiLoggingFilter!=null){ +// if("ERROR".equalsIgnoreCase(apiLogLevel)){ +// apiLoggingFilter.setDebugLevel("ERROR"); +// apiLoggingFilter.setDebugLevelCode(500); +// }else if("WARN".equalsIgnoreCase(apiLogLevel)){ +// apiLoggingFilter.setDebugLevel("WARN"); +// apiLoggingFilter.setDebugLevelCode(400); +// }else{ +// apiLoggingFilter.setDebugLevel("INFO"); +// apiLoggingFilter.setDebugLevelCode(0); +// } +// } +// FilterRegistrationBean registration = new FilterRegistrationBean<>(); +// registration.setFilter(apiLoggingFilter); +// registration.addUrlPatterns("/api/*"); +// registration.setOrder(Ordered.HIGHEST_PRECEDENCE); +// registration.setName("apiLoggingFilter"); +// return registration; +// } + + +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/constants/AppConstants.java b/src/main/java/com/sjmresorts/opsapp/common/constants/AppConstants.java new file mode 100644 index 0000000..65e9c48 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/constants/AppConstants.java @@ -0,0 +1,16 @@ +package com.sjmresorts.opsapp.common.constants; + +public final class AppConstants { + public static final String DEFAULT_CASINO_CODE="GLC"; + public static final String SUPER_ADMIN_ID="SYSADMIN"; + public static final String CMS_REFRESH_TOKEN_TYPE="CMS_REFRESH_TOKEN"; + public static final String APP_REFRESH_TOKEN_TYPE="APP_REFRESH_TOKEN"; + public static final String THIRD_PARTY_TOKEN_TYPE="THIRD_PARTY_TOKEN"; + public static final String CMS_API_ACCESS_TOKEN_TYPE="CMS_API_ACCESS_TOKEN"; + public static final String APP_API_ACCESS_TOKEN_TYPE="APP_API_ACCESS_TOKEN"; + public static final String EMAIL_NOTIFICATION="EMAIL"; + public static final String FIREBASE_NOTIFICATION="FCM"; + public static final int TASK_DEFAULT_EXPIRY=24; + public static final int MAX_SESSION_IDLE_TIME=4; + public static final String APP_ROUTER_NAME="Mobile App Permission"; +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/db/DbDialect.java b/src/main/java/com/sjmresorts/opsapp/common/db/DbDialect.java new file mode 100644 index 0000000..007de5b --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/db/DbDialect.java @@ -0,0 +1,75 @@ +package com.sjmresorts.opsapp.common.db; + +import org.springframework.jdbc.core.JdbcTemplate; + +/** + * 多库 DDL / 原生 SQL 方言。业务层继续用 JPA;启动迁移与少量原生 SQL 走此接口。 + */ +public interface DbDialect { + + DbType type(); + + /** Hibernate dialect FQCN */ + String hibernateDialectClass(); + + String varchar(int length); + + /** 大文本(JSON / Base64 引用等) */ + String text(); + + String datetime(); + + String boolType(); + + String intType(); + + String bigintType(); + + /** 如:{@code id BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY} */ + String bigintIdentityPrimaryKey(String columnName); + + /** {@code ALTER TABLE t ADD [COLUMN] c type} */ + String alterAddColumn(String table, String column, String typeAndNullability); + + /** + * 带默认值的 NOT NULL 列。 + * @param defaultLiteral 不含引号的字面量,如 {@code CARD} + */ + String alterAddNotNullVarcharWithDefault( + String table, String column, int length, String constraintName, String defaultLiteral); + + /** + * CREATE TABLE 列尾:NOT NULL + 默认值。 + * @param constraintName 仅 SQL Server 使用的约束名 + * @param defaultExpression 已含引号或数字的表达式,如 {@code 'CARD'}、{@code 0}、{@code '[]'} + */ + String notNullWithDefault(String constraintName, String defaultExpression); + + boolean tableExists(JdbcTemplate jdbc, String table); + + boolean columnExists(JdbcTemplate jdbc, String columnTable, String column); + + /** 索引/约束等对象是否存在(跨库尽力而为) */ + boolean objectExists(JdbcTemplate jdbc, String objectName); + + void renameTable(JdbcTemplate jdbc, String from, String to); + + /** SQL Server 约束/索引重命名;其它库尽力而为或跳过 */ + void renameObject(JdbcTemplate jdbc, String from, String to); + + /** 在已有 SELECT ... ORDER BY 上包一层「只取 1 行」 */ + String limitOne(String selectSql); + + /** UTC 时间函数,用于 INSERT 默认值表达式 */ + String utcTimestampFunction(); + + /** 软删除等 UPDATE 用的「当前时间」函数 */ + String nowFunction(); + + default void addColumnIfMissing( + JdbcTemplate jdbc, String table, String column, String typeAndNullability) { + if (!columnExists(jdbc, table, column)) { + jdbc.execute(alterAddColumn(table, column, typeAndNullability)); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/db/DbDialectConfig.java b/src/main/java/com/sjmresorts/opsapp/common/db/DbDialectConfig.java new file mode 100644 index 0000000..559d440 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/db/DbDialectConfig.java @@ -0,0 +1,47 @@ +package com.sjmresorts.opsapp.common.db; + +import com.zaxxer.hikari.HikariDataSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import javax.sql.DataSource; + +@Configuration +public class DbDialectConfig { + + private static final Logger log = LoggerFactory.getLogger(DbDialectConfig.class); + + @Bean + public DbDialect dbDialect( + @Value("${ops-app.db.type:auto}") String configuredType, + @Qualifier("opsAppDataSource") DataSource dataSource) { + DbType explicit = DbType.fromToken(configuredType); + DbType resolved = explicit != null ? explicit : DbType.fromJdbcUrl(resolveJdbcUrl(dataSource)); + DbDialect dialect = switch (resolved) { + case MYSQL -> new MySqlDbDialect(); + case POSTGRES -> new PostgresDbDialect(); + case SQLSERVER -> new SqlServerDbDialect(); + }; + log.info( + "ops-app db dialect: type={}, hibernate={}, configuredType={}", + dialect.type(), + dialect.hibernateDialectClass(), + configuredType); + return dialect; + } + + private static String resolveJdbcUrl(DataSource dataSource) { + if (dataSource instanceof HikariDataSource hikari) { + return hikari.getJdbcUrl(); + } + try { + return dataSource.getConnection().getMetaData().getURL(); + } catch (Exception e) { + return ""; + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/db/DbType.java b/src/main/java/com/sjmresorts/opsapp/common/db/DbType.java new file mode 100644 index 0000000..bd69001 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/db/DbType.java @@ -0,0 +1,36 @@ +package com.sjmresorts.opsapp.common.db; + +/** 业务库类型(可由配置或 JDBC URL 推断)。 */ +public enum DbType { + SQLSERVER, + MYSQL, + POSTGRES; + + public static DbType fromToken(String raw) { + if (raw == null || raw.isBlank() || "auto".equalsIgnoreCase(raw.trim())) { + return null; + } + String t = raw.trim().toLowerCase(); + return switch (t) { + case "mysql", "mariadb" -> MYSQL; + case "postgres", "postgresql", "pg" -> POSTGRES; + case "sqlserver", "mssql", "sql-server" -> SQLSERVER; + default -> throw new IllegalArgumentException( + "Unsupported ops-app.db.type='" + raw + "' (use sqlserver|mysql|postgres|auto)"); + }; + } + + public static DbType fromJdbcUrl(String jdbcUrl) { + if (jdbcUrl == null) { + return SQLSERVER; + } + String u = jdbcUrl.toLowerCase(); + if (u.startsWith("jdbc:mysql:") || u.startsWith("jdbc:mariadb:")) { + return MYSQL; + } + if (u.startsWith("jdbc:postgresql:") || u.startsWith("jdbc:postgres:")) { + return POSTGRES; + } + return SQLSERVER; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/db/MySqlDbDialect.java b/src/main/java/com/sjmresorts/opsapp/common/db/MySqlDbDialect.java new file mode 100644 index 0000000..901fdf0 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/db/MySqlDbDialect.java @@ -0,0 +1,138 @@ +package com.sjmresorts.opsapp.common.db; + +import org.springframework.jdbc.core.JdbcTemplate; + +public final class MySqlDbDialect implements DbDialect { + + @Override + public DbType type() { + return DbType.MYSQL; + } + + @Override + public String hibernateDialectClass() { + return "org.hibernate.dialect.MySQLDialect"; + } + + @Override + public String varchar(int length) { + return "VARCHAR(" + length + ")"; + } + + @Override + public String text() { + return "LONGTEXT"; + } + + @Override + public String datetime() { + return "DATETIME(6)"; + } + + @Override + public String boolType() { + return "TINYINT(1)"; + } + + @Override + public String intType() { + return "INT"; + } + + @Override + public String bigintType() { + return "BIGINT"; + } + + @Override + public String bigintIdentityPrimaryKey(String columnName) { + return columnName + " BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY"; + } + + @Override + public String alterAddColumn(String table, String column, String typeAndNullability) { + return "ALTER TABLE " + table + " ADD COLUMN " + column + " " + typeAndNullability; + } + + @Override + public String alterAddNotNullVarcharWithDefault( + String table, String column, int length, String constraintName, String defaultLiteral) { + return "ALTER TABLE " + + table + + " ADD COLUMN " + + column + + " " + + varchar(length) + + notNullWithDefault(constraintName, "'" + defaultLiteral + "'"); + } + + @Override + public String notNullWithDefault(String constraintName, String defaultExpression) { + // MySQL:列级 DEFAULT;TEXT/BLOB 需表达式形式 DEFAULT (...) + return " NOT NULL DEFAULT (" + defaultExpression + ")"; + } + + @Override + public boolean tableExists(JdbcTemplate jdbc, String table) { + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?", + Integer.class, + table); + return count != null && count > 0; + } + + @Override + public boolean columnExists(JdbcTemplate jdbc, String table, String column) { + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?", + Integer.class, + table, + column); + return count != null && count > 0; + } + + @Override + public boolean objectExists(JdbcTemplate jdbc, String objectName) { + Integer c1 = jdbc.queryForObject( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND CONSTRAINT_NAME = ?", + Integer.class, + objectName); + if (c1 != null && c1 > 0) { + return true; + } + Integer c2 = jdbc.queryForObject( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND INDEX_NAME = ?", + Integer.class, + objectName); + return c2 != null && c2 > 0; + } + + @Override + public void renameTable(JdbcTemplate jdbc, String from, String to) { + jdbc.execute("RENAME TABLE `" + from + "` TO `" + to + "`"); + } + + @Override + public void renameObject(JdbcTemplate jdbc, String from, String to) { + // MySQL 约束/索引重命名语法因版本而异;遗留 sinosecu 迁移主要针对 SQL Server + } + + @Override + public String limitOne(String selectSql) { + String sql = selectSql.trim(); + if (sql.toLowerCase().contains(" limit ")) { + return sql; + } + return sql + " LIMIT 1"; + } + + @Override + public String utcTimestampFunction() { + return "UTC_TIMESTAMP(6)"; + } + + @Override + public String nowFunction() { + return "CURRENT_TIMESTAMP(6)"; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/db/PostgresDbDialect.java b/src/main/java/com/sjmresorts/opsapp/common/db/PostgresDbDialect.java new file mode 100644 index 0000000..9bc2f2d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/db/PostgresDbDialect.java @@ -0,0 +1,133 @@ +package com.sjmresorts.opsapp.common.db; + +import org.springframework.jdbc.core.JdbcTemplate; + +public final class PostgresDbDialect implements DbDialect { + + @Override + public DbType type() { + return DbType.POSTGRES; + } + + @Override + public String hibernateDialectClass() { + return "org.hibernate.dialect.PostgreSQLDialect"; + } + + @Override + public String varchar(int length) { + return "VARCHAR(" + length + ")"; + } + + @Override + public String text() { + return "TEXT"; + } + + @Override + public String datetime() { + return "TIMESTAMP(6)"; + } + + @Override + public String boolType() { + return "BOOLEAN"; + } + + @Override + public String intType() { + return "INT"; + } + + @Override + public String bigintType() { + return "BIGINT"; + } + + @Override + public String bigintIdentityPrimaryKey(String columnName) { + return columnName + " BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY"; + } + + @Override + public String alterAddColumn(String table, String column, String typeAndNullability) { + return "ALTER TABLE " + table + " ADD COLUMN " + column + " " + typeAndNullability; + } + + @Override + public String alterAddNotNullVarcharWithDefault( + String table, String column, int length, String constraintName, String defaultLiteral) { + return "ALTER TABLE " + + table + + " ADD COLUMN " + + column + + " " + + varchar(length) + + notNullWithDefault(constraintName, "'" + defaultLiteral + "'"); + } + + @Override + public String notNullWithDefault(String constraintName, String defaultExpression) { + return " NOT NULL DEFAULT " + defaultExpression; + } + + @Override + public boolean tableExists(JdbcTemplate jdbc, String table) { + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'public' AND TABLE_NAME = ?", + Integer.class, + table); + return count != null && count > 0; + } + + @Override + public boolean columnExists(JdbcTemplate jdbc, String table, String column) { + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = 'public' AND TABLE_NAME = ? AND COLUMN_NAME = ?", + Integer.class, + table, + column); + return count != null && count > 0; + } + + @Override + public boolean objectExists(JdbcTemplate jdbc, String objectName) { + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM pg_catalog.pg_class c JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace " + + "WHERE n.nspname = 'public' AND c.relname = ?", + Integer.class, + objectName); + return count != null && count > 0; + } + + @Override + public void renameTable(JdbcTemplate jdbc, String from, String to) { + jdbc.execute("ALTER TABLE " + from + " RENAME TO " + to); + } + + @Override + public void renameObject(JdbcTemplate jdbc, String from, String to) { + if (objectExists(jdbc, from) && !objectExists(jdbc, to)) { + jdbc.execute("ALTER INDEX IF EXISTS " + from + " RENAME TO " + to); + } + } + + @Override + public String limitOne(String selectSql) { + String sql = selectSql.trim(); + if (sql.toLowerCase().contains(" limit ")) { + return sql; + } + return sql + " LIMIT 1"; + } + + @Override + public String utcTimestampFunction() { + return "(NOW() AT TIME ZONE 'UTC')"; + } + + @Override + public String nowFunction() { + return "CURRENT_TIMESTAMP"; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/db/SqlServerDbDialect.java b/src/main/java/com/sjmresorts/opsapp/common/db/SqlServerDbDialect.java new file mode 100644 index 0000000..9c6a106 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/db/SqlServerDbDialect.java @@ -0,0 +1,135 @@ +package com.sjmresorts.opsapp.common.db; + +import org.springframework.jdbc.core.JdbcTemplate; + +public final class SqlServerDbDialect implements DbDialect { + + @Override + public DbType type() { + return DbType.SQLSERVER; + } + + @Override + public String hibernateDialectClass() { + return "org.hibernate.dialect.SQLServerDialect"; + } + + @Override + public String varchar(int length) { + return "NVARCHAR(" + length + ")"; + } + + @Override + public String text() { + return "NVARCHAR(MAX)"; + } + + @Override + public String datetime() { + return "DATETIME2(7)"; + } + + @Override + public String boolType() { + return "BIT"; + } + + @Override + public String intType() { + return "INT"; + } + + @Override + public String bigintType() { + return "BIGINT"; + } + + @Override + public String bigintIdentityPrimaryKey(String columnName) { + return columnName + " BIGINT IDENTITY(1,1) NOT NULL PRIMARY KEY"; + } + + @Override + public String alterAddColumn(String table, String column, String typeAndNullability) { + return "ALTER TABLE " + table + " ADD " + column + " " + typeAndNullability; + } + + @Override + public String alterAddNotNullVarcharWithDefault( + String table, String column, int length, String constraintName, String defaultLiteral) { + return "ALTER TABLE " + + table + + " ADD " + + column + + " " + + varchar(length) + + notNullWithDefault(constraintName, "'" + defaultLiteral + "'"); + } + + @Override + public String notNullWithDefault(String constraintName, String defaultExpression) { + return " NOT NULL CONSTRAINT " + constraintName + " DEFAULT (" + defaultExpression + ")"; + } + + @Override + public boolean tableExists(JdbcTemplate jdbc, String table) { + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = ?", + Integer.class, + table); + return count != null && count > 0; + } + + @Override + public boolean columnExists(JdbcTemplate jdbc, String table, String column) { + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = ?", + Integer.class, + table, + column); + return count != null && count > 0; + } + + @Override + public boolean objectExists(JdbcTemplate jdbc, String objectName) { + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM sys.objects WHERE name = ?", Integer.class, objectName); + return count != null && count > 0; + } + + @Override + public void renameTable(JdbcTemplate jdbc, String from, String to) { + jdbc.execute("EXEC sp_rename '" + from + "', '" + to + "'"); + } + + @Override + public void renameObject(JdbcTemplate jdbc, String from, String to) { + jdbc.execute("EXEC sp_rename '" + from + "', '" + to + "', 'OBJECT'"); + } + + @Override + public String limitOne(String selectSql) { + String sql = selectSql.trim(); + String lower = sql.toLowerCase(); + if (lower.startsWith("select top")) { + return sql; + } + if (lower.startsWith("select distinct ")) { + return "SELECT DISTINCT TOP 1 " + sql.substring("select distinct ".length()); + } + if (lower.startsWith("select ")) { + return "SELECT TOP 1 " + sql.substring("select ".length()); + } + return sql; + } + + @Override + public String utcTimestampFunction() { + return "SYSUTCDATETIME()"; + } + + @Override + public String nowFunction() { + return "CURRENT_TIMESTAMP"; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/dtos/BaseApiResponse.java b/src/main/java/com/sjmresorts/opsapp/common/dtos/BaseApiResponse.java new file mode 100644 index 0000000..2033ef8 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/dtos/BaseApiResponse.java @@ -0,0 +1,23 @@ +package com.sjmresorts.opsapp.common.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class BaseApiResponse { + + @Schema(description = "Indicate API success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result") + private String message; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/dtos/BasePageRequest.java b/src/main/java/com/sjmresorts/opsapp/common/dtos/BasePageRequest.java new file mode 100644 index 0000000..eec5141 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/dtos/BasePageRequest.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.common.dtos; + + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class BasePageRequest { + private int startRow = 0; + private int endRow = 20; + private int pageSize = 20; + private String sortOrder; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/dtos/DataTableResult.java b/src/main/java/com/sjmresorts/opsapp/common/dtos/DataTableResult.java new file mode 100644 index 0000000..2a24ed7 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/dtos/DataTableResult.java @@ -0,0 +1,24 @@ +package com.sjmresorts.opsapp.common.dtos; + +import lombok.Getter; +import lombok.Setter; + +import java.util.ArrayList; +import java.util.List; + +@Setter +@Getter +public class DataTableResult { + + private List rowData; + + private int pageSize = 10; + + private long totalRecords = 0; + + private long startRow = 0; + + public DataTableResult() { + this.rowData = new ArrayList<>(); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/dtos/LDAP/ADAccountInfo.java b/src/main/java/com/sjmresorts/opsapp/common/dtos/LDAP/ADAccountInfo.java new file mode 100644 index 0000000..d234e69 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/dtos/LDAP/ADAccountInfo.java @@ -0,0 +1,45 @@ +package com.sjmresorts.opsapp.common.dtos.LDAP; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +public class ADAccountInfo { + + @Schema(description = "LDAP User sn") + private String sn; + + @Schema(description = "LDAP User Account Code") + private String adAccountCode; + + @Schema(description = "LDAP User Given Name") + private String givenName; + + @Schema(description = "LDAP User Display Name") + private String displayName; + + @Schema(description = "LDAP User Title") + private String title; + + @Schema(description = "LDAP User Department") + private String department; + + @Schema(description = "LDAP User Mail") + private String mail; + + @Schema(description = "LDAP User Company") + private String company; + + @Schema(description = "LDAP User Account Control") + private String userAccountControl; + + @Schema(description = "LDAP User Belong Direct Groups") + private List directGroups; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/dtos/SchemaEntry.java b/src/main/java/com/sjmresorts/opsapp/common/dtos/SchemaEntry.java new file mode 100644 index 0000000..ee132db --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/dtos/SchemaEntry.java @@ -0,0 +1,25 @@ +package com.sjmresorts.opsapp.common.dtos; + + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Schema info of request") +public class SchemaEntry { + + @Schema(description = "Unique code of the ELO terminal",example="ELO-001") + @NotBlank(message = "Terminal code must not be blank") + private String terminalCode; + + @Schema(description = "Unique ID for the transaction",example="txn-12345") + @NotBlank(message = "Transaction ID must not be blank") + private String transactionId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/dtos/UserCredentialsDto.java b/src/main/java/com/sjmresorts/opsapp/common/dtos/UserCredentialsDto.java new file mode 100644 index 0000000..7587b1d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/dtos/UserCredentialsDto.java @@ -0,0 +1,18 @@ +package com.sjmresorts.opsapp.common.dtos; + +import lombok.Data; + +import java.util.List; + +@Data +public class UserCredentialsDto { + private String username; + private String password; + private List adGroup; + + public UserCredentialsDto(String username, String password, List adGroup) { + this.username = username; + this.password = password; + this.adGroup = adGroup; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/dtos/httpclient/HttpRequestDefine.java b/src/main/java/com/sjmresorts/opsapp/common/dtos/httpclient/HttpRequestDefine.java new file mode 100644 index 0000000..d7afac8 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/dtos/httpclient/HttpRequestDefine.java @@ -0,0 +1,24 @@ +package com.sjmresorts.opsapp.common.dtos.httpclient; + +import java.util.Map; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +@Getter +@Setter +@NoArgsConstructor +public class HttpRequestDefine { + private String requestUrl; + private String requestMethod="GET"; + private int timeout=0; + private String entityContent=""; + private String requestCharSet ="UTF-8"; + private String responseCharSet = "UTF-8"; + private boolean isUpload =false; + private MultipartFile uploadFile = null; + private String fileParaName; + private Map headers; +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/dtos/httpclient/HttpResponseResult.java b/src/main/java/com/sjmresorts/opsapp/common/dtos/httpclient/HttpResponseResult.java new file mode 100644 index 0000000..dc0f012 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/dtos/httpclient/HttpResponseResult.java @@ -0,0 +1,39 @@ +package com.sjmresorts.opsapp.common.dtos.httpclient; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.Date; + +@Getter +@Setter +@NoArgsConstructor +public class HttpResponseResult { + + private int statusCode; + private String responseContent; + private String errorMessage; + private String requestUrl; + private String requestContent; + private String requestTime; + private String responseTime; + private Date requestTimeVal; + private Date responseTimeVal; + + public boolean isSuccessful() { + return statusCode >= 200 && statusCode < 300; + } + + public boolean isTransportError() { + return statusCode < 0; + } + + public String bodyOrEmpty() { + return responseContent != null ? responseContent : ""; + } + + public String errorOrEmpty() { + return errorMessage != null ? errorMessage : ""; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/exception/GlobalExceptionHandler.java b/src/main/java/com/sjmresorts/opsapp/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..95c2209 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,35 @@ +package com.sjmresorts.opsapp.common.exception; + +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import com.sjmresorts.opsapp.common.exceptions.TokenExpiredException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +/** + * 全局异常处理器 + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** + * 处理Token过期异常 + * 返回401状态码 + */ + @ExceptionHandler(TokenExpiredException.class) + public ResponseEntity handleTokenExpiredException(TokenExpiredException e) { + logger.warn("Token expired: {}", e.getMessage()); + + BaseApiResponse response = new BaseApiResponse(); + response.setSuccess(false); + response.setCode("401"); + response.setMessage("Token has expired"); + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); + } +} + diff --git a/src/main/java/com/sjmresorts/opsapp/common/exceptions/TokenExpiredException.java b/src/main/java/com/sjmresorts/opsapp/common/exceptions/TokenExpiredException.java new file mode 100644 index 0000000..aa65443 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/exceptions/TokenExpiredException.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.common.exceptions; + +/** + * Token过期异常 + * 当JWT token过期时抛出此异常 + */ +public class TokenExpiredException extends RuntimeException { + + public TokenExpiredException(String message) { + super(message); + } + + public TokenExpiredException(String message, Throwable cause) { + super(message, cause); + } +} + diff --git a/src/main/java/com/sjmresorts/opsapp/common/filters/CorsHeaderFilter.java b/src/main/java/com/sjmresorts/opsapp/common/filters/CorsHeaderFilter.java new file mode 100644 index 0000000..9da8381 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/filters/CorsHeaderFilter.java @@ -0,0 +1,54 @@ +package com.sjmresorts.opsapp.common.filters; + +import com.sjmresorts.opsapp.common.utils.NetworkUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * 所有 /api 请求统一加 CORS(含 OPTIONS 预检)。 + * Esign 等 JWT 白名单路径会跳过 JwtAuthFilter,仍需本过滤器处理跨域。 + */ +public class CorsHeaderFilter extends OncePerRequestFilter { + + private static boolean isPrinterWebSocketPath(HttpServletRequest request) { + String uri = request.getRequestURI(); + return uri != null && (uri.contains("/esign/ws/printer-tasks") || uri.contains("/sinosecu/ws/printer-tasks")); + } + + private static boolean isWebSocketUpgrade(HttpServletRequest request) { + String upgrade = request.getHeader("Upgrade"); + return upgrade != null && upgrade.equalsIgnoreCase("websocket"); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + // WebSocket 升级请求不能提前写 CORS 响应头,否则会破坏 101 握手 + return isPrinterWebSocketPath(request) && isWebSocketUpgrade(request); + } + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + NetworkUtil.checkAndSetAllowOrigin(request, response); + response.addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, HEAD"); + response.addHeader("Access-Control-Allow-Headers", + "Content-Type, Authorization, clientId, X-CMS-Operator, X-Client-Type, X-Requested-With, Accept, Origin"); + response.addHeader("Access-Control-Allow-Credentials", "true"); + response.addHeader("Access-Control-Max-Age", "3600"); + + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + return; + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/filters/JwtAuthFilter.java b/src/main/java/com/sjmresorts/opsapp/common/filters/JwtAuthFilter.java new file mode 100644 index 0000000..c1c603f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/filters/JwtAuthFilter.java @@ -0,0 +1,116 @@ +package com.sjmresorts.opsapp.common.filters; + +import com.auth0.jwt.JWTVerifier; +import com.auth0.jwt.exceptions.TokenExpiredException; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import com.sjmresorts.opsapp.common.utils.NetworkUtil; +import jakarta.annotation.Resource; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.Setter; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; + +public class JwtAuthFilter extends OncePerRequestFilter { + + private final AntPathMatcher pathMatcher = new AntPathMatcher(); + private final Logger logger = LoggerFactory.getLogger(JwtAuthFilter.class); + @Setter + private List excludePatterns; + + @Setter + private List verifyAppVersionList; + + + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException{ + + NetworkUtil.checkAndSetAllowOrigin(request,response); + NetworkUtil.setSecurityHeaders(request,response); + // 仅解析 Bearer;Nginx auth_basic 等会带 Authorization: Basic,不能当作 JWT 校验 + String token = extractBearerToken(request.getHeader("Authorization")); + if(StringUtils.isNotBlank(token)) { + JWTVerifier jwtVerifier = JwtTokenUtil.getJwtVerifier(); + try { + jwtVerifier.verify(token); + }catch (TokenExpiredException e) { + sendErrorResponse(response,HttpServletResponse.SC_UNAUTHORIZED,"Token Expired"); + request.setAttribute("AUTH_EXCEPTION","Token Expired"); + return; + }catch (Exception e) { + sendErrorResponse(response,HttpServletResponse.SC_UNAUTHORIZED,"Unauthorized"); + request.setAttribute("AUTH_EXCEPTION","Unauthorized"); + return; + } + }else if(!"OPTIONS".equals(request.getMethod())) { + sendErrorResponse(response,HttpServletResponse.SC_UNAUTHORIZED,"Unauthorized"); + request.setAttribute("AUTH_EXCEPTION","Unauthorized"); + return; + } + filterChain.doFilter(request, response); + } + + + @Override + protected boolean shouldNotFilter(@NonNull HttpServletRequest request) { + if (excludePatterns == null) { + return false; + } + String path = normalizeRequestPath(request); + return excludePatterns.stream() + .anyMatch(pattern -> pathMatcher.match(pattern, path)); + } + + /** 仅 Bearer JWT;Basic/Digest 等忽略,避免经 Nginx 鉴权后误报 401。 */ + static String extractBearerToken(String authorizationHeader) { + if (StringUtils.isBlank(authorizationHeader)) { + return null; + } + String h = authorizationHeader.trim(); + if (h.regionMatches(true, 0, "Bearer ", 0, 7)) { + String t = h.substring(7).trim(); + return StringUtils.isNotBlank(t) ? t : null; + } + return null; + } + + private static String normalizeRequestPath(HttpServletRequest request) { + String uri = request.getRequestURI(); + if (uri == null || uri.isEmpty()) { + return "/"; + } + while (uri.contains("//")) { + uri = uri.replace("//", "/"); + } + return uri; + } + + private boolean needCheckAppVersion(@NonNull HttpServletRequest request){ + if(verifyAppVersionList==null || verifyAppVersionList.isEmpty()){ + return false; + } + return verifyAppVersionList.stream() + .anyMatch(pattern -> pathMatcher.match(pattern, request.getRequestURI())); + } + + private void sendErrorResponse(HttpServletResponse response, int errorCode, String errorMessage) throws IOException { + response.setStatus(errorCode); + response.setContentType("application/text"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(errorMessage); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/utils/AESUtil.java b/src/main/java/com/sjmresorts/opsapp/common/utils/AESUtil.java new file mode 100644 index 0000000..428d8a1 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/utils/AESUtil.java @@ -0,0 +1,150 @@ +package com.sjmresorts.opsapp.common.utils; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.Permission; +import java.security.PermissionCollection; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.Map; + +public class AESUtil { + + private static final String AES = "AES"; + private static final int GCM_IV_LENGTH = 12; // GCM推荐使用12字节的IV + private static final int GCM_TAG_LENGTH = 16; // GCM认证标签长度(128位) + + private static final Base64.Encoder base64Encoder = Base64.getEncoder(); + private static final Base64.Decoder base64Decoder = Base64.getDecoder(); + +// static { +// java.security.Security.setProperty("crypto.policy", "unlimited"); +// removeCryptographyRestrictions(); +// } + + public static void removeCryptographyRestrictions() { + try { + /* + * Do the following, but with reflection to bypass access checks: + * JceSecurity.isRestricted = false; + * JceSecurity.defaultPolicy.perms.clear(); + * JceSecurity.defaultPolicy.add(CryptoAllPermission.INSTANCE); + */ + final Class jceSecurity = Class.forName("javax.crypto.JceSecurity"); + final Class cryptoPermissions = Class.forName("javax.crypto.CryptoPermissions"); + final Class cryptoAllPermission = Class.forName("javax.crypto.CryptoAllPermission"); + + final Field isRestrictedField = jceSecurity.getDeclaredField("isRestricted"); + isRestrictedField.setAccessible(true); + // 去除isRestricted的final限制 + final Field modifiersField = Field.class.getDeclaredField("modifiers"); + modifiersField.setAccessible(true); + modifiersField.setInt(isRestrictedField, isRestrictedField.getModifiers() & ~Modifier.FINAL); + isRestrictedField.set(null, false); + + final Field defaultPolicyField = jceSecurity.getDeclaredField("defaultPolicy"); + defaultPolicyField.setAccessible(true); + final PermissionCollection defaultPolicy = (PermissionCollection) defaultPolicyField.get(null); + + final Field perms = cryptoPermissions.getDeclaredField("perms"); + perms.setAccessible(true); + ((Map) perms.get(defaultPolicy)).clear(); + + final Field instance = cryptoAllPermission.getDeclaredField("INSTANCE"); + instance.setAccessible(true); + defaultPolicy.add((Permission) instance.get(null)); + + } catch (final Exception e) { + e.printStackTrace(); + } + } + + public static SecretKey generateKey() throws Exception { + KeyGenerator keyGen = KeyGenerator.getInstance("AES"); + keyGen.init(256); + return keyGen.generateKey(); + } + public static SecretKey stringToSecretKey(String keyString) { + byte[] keyBytes = base64Decoder.decode(keyString); + return new SecretKeySpec(keyBytes, AES); + } + public static String secretKeyToString(SecretKey secretKey) { + byte[] keyBytes = secretKey.getEncoded(); + return base64Encoder.encodeToString(keyBytes); + } + /** + * AES加密 + */ + public static String AES256Encrypt(String key, String content) { + try { + SecretKey secretKey = stringToSecretKey(key); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + byte[] iv = new byte[GCM_IV_LENGTH]; + SecureRandom secureRandom = new SecureRandom(); + secureRandom.nextBytes(iv); // 生成随机的IV + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec); + + byte[] ciphertext = cipher.doFinal(content.getBytes()); + byte[] encryptedData = new byte[iv.length + ciphertext.length]; + System.arraycopy(iv, 0, encryptedData, 0, iv.length); + System.arraycopy(ciphertext, 0, encryptedData, iv.length, ciphertext.length); + return base64Encoder.encodeToString(encryptedData); + + } catch (Exception e) { + //e.printStackTrace(); + } + return null; + } + /** + * AES解密 + */ + public static String AES256Decrypt(String key, String content) { + try { + SecretKey secretKey = stringToSecretKey(key); + byte[] encryptedData = base64Decoder.decode(content); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + // 从加密数据中提取IV + byte[] iv = new byte[GCM_IV_LENGTH]; + System.arraycopy(encryptedData, 0, iv, 0, iv.length); + + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH * 8, iv); + cipher.init(Cipher.DECRYPT_MODE, secretKey, spec); + + // 解密数据 + byte[] ciphertext = new byte[encryptedData.length - GCM_IV_LENGTH]; + System.arraycopy(encryptedData, GCM_IV_LENGTH, ciphertext, 0, ciphertext.length); + byte[] byteDecode = cipher.doFinal(ciphertext); + return new String(byteDecode, StandardCharsets.UTF_8); + } catch (Exception e) { + //e.printStackTrace(); + } + return null; + } + + public static String sha256(String plainString) { + String cipherString = null; + try { + // 获取实例(SHA-512同理) + MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); + // 计算摘要 + byte[] cipherBytes = messageDigest.digest(plainString.getBytes(StandardCharsets.UTF_8)); + // 输出为16进制字符串 + StringBuilder sb = new StringBuilder(); + for (byte b : cipherBytes) { + sb.append(String.format("%02x", b)); + } + cipherString = sb.toString(); + } catch (Exception e) { + e.printStackTrace(); + } + return cipherString; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/utils/DaoUtil.java b/src/main/java/com/sjmresorts/opsapp/common/utils/DaoUtil.java new file mode 100644 index 0000000..5b0ca5e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/utils/DaoUtil.java @@ -0,0 +1,92 @@ +package com.sjmresorts.opsapp.common.utils; + +import java.lang.reflect.Constructor; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import jakarta.persistence.EntityManager; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.Strings; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Direction; + + +public class DaoUtil { + + public static int TYPE_STRING = 0; + public static int TYPE_DATE = 1; + public static int TYPE_INT = 2; + public static int TYPE_DOUBLE = 3; + public static int TYPE_FLOAT = 4; + private static EntityManager entityManager; + public static final int BATCH_SIZE = 100; + + public static EntityManager getEntityManager() { + return entityManager; + } + + public static void setEntityManager(EntityManager srcEntityManager) { + entityManager = srcEntityManager; + } + + public static Sort buildSortByString(String sortProperties, String defaultSortProperty) { + Sort sort = null; + String sortColumn; + String sortInfo = sortProperties; + Direction defaultSortDir = Direction.ASC; + if (StringUtils.isBlank(sortInfo)) { + sortInfo = defaultSortProperty; + } + String[] listSortProperty = sortInfo.split(";"); + for (String s : listSortProperty) { + String[] propertyAttr = s.split("-"); + Direction currentDir = defaultSortDir; + if (propertyAttr.length > 1) { + sortColumn = propertyAttr[0]; + currentDir = Strings.CI.equals("DESC", propertyAttr[1]) ? Direction.DESC : Direction.ASC; + } else { + sortColumn = s; + } + if (sort == null) { + sort = Sort.by(currentDir, sortColumn); + } else { + sort = sort.and(Sort.by(currentDir, sortColumn)); + } + } + return sort; + } + + public static List parseMultipleFilter(String filerValues) { + List returnVals = new ArrayList<>(); + if (StringUtils.isNotBlank(filerValues)) { + String[] searchIds = filerValues.split(";"); + Collections.addAll(returnVals, searchIds); + } + return returnVals; + } + + public static List parseMultipleFilter(String filerValues, Class valueType) { + List returnVals = new ArrayList<>(); + if (StringUtils.isNotBlank(filerValues)) { + Constructor classConstructor = null; + try { + classConstructor = valueType.getConstructor(String.class); + } catch (Exception ignore) { + } + if (classConstructor != null) { + String[] listSplitValues = filerValues.split(";"); + for (String listSplitValue : listSplitValues) { + try { + T val = classConstructor.newInstance(listSplitValue); + returnVals.add(val); + } catch (Exception ignore) { + + } + } + } + } + return returnVals; + } + +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/utils/DateTimeUtil.java b/src/main/java/com/sjmresorts/opsapp/common/utils/DateTimeUtil.java new file mode 100644 index 0000000..561c753 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/utils/DateTimeUtil.java @@ -0,0 +1,206 @@ +package com.sjmresorts.opsapp.common.utils; + + +import org.apache.commons.lang3.StringUtils; + +import java.text.SimpleDateFormat; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Calendar; +import java.util.Date; + +public class DateTimeUtil { + + /** + * 根据String型时间,获取long型时间,单位毫秒 + * @param inVal 时间字符串 + * @return long型时间 + */ + public static long fromDateStringToLong(String inVal) { + Date date = null; + SimpleDateFormat inputFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss:SSS"); + try { + date = inputFormat.parse(inVal); + } catch (Exception e) { + e.printStackTrace(); + } + return date.getTime(); + } + public static long getTimeSpanSSS(Date startDate,Date endDate) { + SimpleDateFormat timeFormat = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss:SSS"); + String startStr = timeFormat.format(startDate); + String endStr = timeFormat.format(endDate); + + long startMS = fromDateStringToLong(startStr); + long endMS = fromDateStringToLong(endStr); + long returnMS = endMS-startMS; + + return returnMS; + } + public static Date getDateFromString(String dateTimeValue,String dateFormat) { + SimpleDateFormat dtFormat = new SimpleDateFormat(dateFormat); + Date returnDate = null; + if (dateTimeValue != null && StringUtils.isNotBlank(dateTimeValue)) { + try { + returnDate = dtFormat.parse(dateTimeValue); + } catch (Exception err) { + + } + } + return returnDate; + } + + public static String getDateByFormat(Date dtDateTime, String strFormat) { + + String returnDate = null; + + if (dtDateTime != null) { + try { + SimpleDateFormat formatter; + formatter = new SimpleDateFormat(strFormat); + returnDate = formatter.format(dtDateTime); + } catch (Exception err) { + + } + } + return returnDate; + } + + + public static String getCurrentDate(String strFormat) { + + return getDateByFormat(new Date(), strFormat); + } + + public static Date getCurrentDateTime(String strFormat) { + SimpleDateFormat dtFormat = new SimpleDateFormat(strFormat); + String dateTimeValue = getDateByFormat(new Date(), strFormat); + + Date returnDate = null; + try { + returnDate = dtFormat.parse(dateTimeValue); + } catch (Exception err) { + + } + + return returnDate; + } + public static Date addSecond(Date nowTime,Integer seconds) { + long getTime = nowTime.getTime(); + long diffTime = seconds * 1000L; + long newTime = getTime+diffTime; + String strTime = getDateByFormatUseTimeStamp(newTime,"yyyy-MM-dd HH:mm:ss"); + return getDateFromString(strTime,"yyyy-MM-dd HH:mm:ss"); + } + public static Date addMin(Date nowTime,Integer mins) { + long getTime = nowTime.getTime(); + long diffTime = mins * 60000L; + long newTime = getTime+diffTime; + String strTime = getDateByFormatUseTimeStamp(newTime,"yyyy-MM-dd HH:mm:ss"); + return getDateFromString(strTime,"yyyy-MM-dd HH:mm:ss"); + } + public static Date addDay(Date currdate,Integer days) { + Calendar ca = Calendar.getInstance(); + ca.setTime(currdate); + ca.add(Calendar.DATE, days); + Date newTime = ca.getTime(); + return newTime; + } + public static Date addMonth(Date currdate,Integer months) { + Calendar ca = Calendar.getInstance(); + ca.setTime(currdate); + ca.add(Calendar.MONTH, months); + Date newTime = ca.getTime(); + return newTime; + } + public static Date addYear(Date currdate,Integer years) { + Calendar ca = Calendar.getInstance(); + ca.setTime(currdate); + ca.add(Calendar.YEAR, years); + Date newTime = ca.getTime(); + return newTime; + } + public static String getDateByFormatUseTimeStamp(Long timeStamp, String strFormat) { + + String returnDate = null; + + if (timeStamp != null) { + try { + SimpleDateFormat formatter; + formatter = new SimpleDateFormat(strFormat); + returnDate = formatter.format(timeStamp); + } catch (Exception err) { + + } + } + return returnDate; + } + + public static String getDiffDate(Date DateStart, Date DateEnd, String dateType) { + String returnDate = null; + try { + + Instant instant1 = DateStart.toInstant(); + Instant instant2 = DateEnd.toInstant(); + + Duration duration = Duration.between(instant1, instant2); + + //long diffTime = DateEnd.getTime() - DateStart.getTime(); + if(dateType == "day") { + //returnDate = Long.toString(diffTime / 1000 / (60 * 60 * 24)); + long days = duration.toDays(); + returnDate = Long.toString(days); + }else if(dateType == "hour") { + //returnDate = Long.toString(diffTime / 1000 / (60 * 60)); + long hours = duration.toHours(); + returnDate = Long.toString(hours); + }else { //minute + long minutes = duration.toMinutes(); + returnDate = Long.toString(minutes); + } + } catch (Exception err) { + + } + return returnDate; + } + public static boolean isValidDate(String dateStr) { + try { + dateStr=dateStr.split(" ")[0]; + String[] parts = dateStr.split("-"); + + int year = Integer.parseInt(parts[0]); + int month = Integer.parseInt(parts[1]); + int day = Integer.parseInt(parts[2]); + + + if (month < 1 || month > 12) { + return false; + } + + YearMonth yearMonth = YearMonth.of(year, month); + int maxDay = yearMonth.lengthOfMonth(); + + boolean isLeapYear = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + if (month == 2 && isLeapYear) { + maxDay = 29; + } else if (month == 2) { + maxDay = 28; + } + + if (day < 1 || day > maxDay) { + return false; + } + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + LocalDate.parse(dateStr, formatter); + return true; + } catch (DateTimeParseException e) { + return false; + } + } + + +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/utils/HttpClientUtil.java b/src/main/java/com/sjmresorts/opsapp/common/utils/HttpClientUtil.java new file mode 100644 index 0000000..b6a0f6e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/utils/HttpClientUtil.java @@ -0,0 +1,284 @@ +package com.sjmresorts.opsapp.common.utils; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.security.cert.X509Certificate; +import java.util.Map; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.security.cert.CertificateException; + +import com.sjmresorts.opsapp.common.dtos.httpclient.HttpRequestDefine; +import com.sjmresorts.opsapp.common.dtos.httpclient.HttpResponseResult; +import jakarta.servlet.http.HttpServletRequest; + +import org.apache.commons.lang3.StringUtils; +import org.apache.hc.client5.http.classic.methods.HttpDelete; +import org.apache.hc.client5.http.classic.methods.HttpGet; +import org.apache.hc.client5.http.classic.methods.HttpPost; +import org.apache.hc.client5.http.classic.methods.HttpPut; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder; +import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; +import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse; +import org.apache.hc.client5.http.impl.classic.HttpClientBuilder; +import org.apache.hc.client5.http.impl.classic.HttpClients; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManager; +import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; +import org.apache.hc.client5.http.socket.ConnectionSocketFactory; +import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy; +import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; +import org.apache.hc.client5.http.ssl.SSLConnectionSocketFactory; +import org.apache.hc.client5.http.impl.routing.SystemDefaultRoutePlanner; +import org.apache.hc.client5.http.ssl.TrustAllStrategy; +import org.apache.hc.core5.http.ClassicHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.HttpEntity; +import org.apache.hc.core5.http.config.Registry; +import org.apache.hc.core5.http.config.RegistryBuilder; +import org.apache.hc.core5.http.io.HttpClientResponseHandler; +import org.apache.hc.core5.http.io.entity.EntityUtils; +import org.apache.hc.core5.http.io.entity.StringEntity; +import org.apache.hc.core5.ssl.SSLContextBuilder; +import org.apache.hc.core5.util.Timeout; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.web.multipart.MultipartFile; + +import java.net.ProxySelector; + + +public class HttpClientUtil { + private static final Logger logger = LoggerFactory.getLogger(HttpClientUtil.class); + //----------- + public static HttpResponseResult processHttpRequest(HttpRequestDefine request) { + HttpResponseResult responseResult = new HttpResponseResult(); + CloseableHttpClient httpclient=null; + try { + //-- + RequestConfig.Builder configBuilder = RequestConfig.custom(); + if(request.getTimeout()>0) { + int timeout = request.getTimeout(); + configBuilder.setConnectionRequestTimeout(Timeout.ofMicroseconds(timeout)); + } + RequestConfig requestConfig = configBuilder.build(); + //-- + String requestUrl =request.getRequestUrl(); + HttpClientBuilder clientBuilder = HttpClients.custom() + .setRoutePlanner(new SystemDefaultRoutePlanner(ProxySelector.getDefault())); + if(requestUrl!=null && requestUrl.startsWith("https://")) { + SSLContext sslContext = null; + try { + sslContext = SSLContextBuilder.create().loadTrustMaterial(TrustAllStrategy.INSTANCE).build(); + } catch (Exception e) { + logger.error("Create Http client SSL context failed:{}",e.getMessage()); + } + if(sslContext==null){ + responseResult.setStatusCode(-1); + responseResult.setErrorMessage("Init http client SSL failed"); + return responseResult; + }else{ + NoopHostnameVerifier hostnameVerifier = NoopHostnameVerifier.INSTANCE; + DefaultClientTlsStrategy tlsStrategy = new DefaultClientTlsStrategy(sslContext, hostnameVerifier); + PoolingHttpClientConnectionManager connectionManager = + PoolingHttpClientConnectionManagerBuilder.create() + .setTlsSocketStrategy(tlsStrategy) + .setMaxConnTotal(100) + .setMaxConnPerRoute(10) + .build(); + HttpClientBuilder httpClientBuilder = HttpClients.custom() + .setConnectionManager(connectionManager) + .setDefaultRequestConfig(requestConfig) + .setRoutePlanner(new SystemDefaultRoutePlanner(ProxySelector.getDefault())); + httpclient = httpClientBuilder.build(); + } + }else { + httpclient = clientBuilder.build(); + } + + HttpClientResponseHandler responseHandler = (ClassicHttpResponse responseObject) -> { + try { + responseResult.setStatusCode(responseObject.getCode()); + HttpEntity responseEntity = responseObject.getEntity(); + if (responseEntity != null) { + String responseBody = EntityUtils.toString(responseEntity, Charset.forName(request.getResponseCharSet())); + EntityUtils.consume(responseEntity); + responseResult.setResponseContent(responseBody); + } + }catch(Exception err){ + responseResult.setStatusCode(-1); + responseResult.setErrorMessage(err.getMessage()); + } + return responseResult; + }; + //-- + responseResult.setRequestUrl(requestUrl); + if("POST".equalsIgnoreCase(request.getRequestMethod())) { + HttpPost httppost = new HttpPost(requestUrl); + httppost.setConfig(requestConfig); + + if(!request.isUpload()) { + if(request.getHeaders()!=null) { + Map headermap = request.getHeaders(); + for (Map.Entry entry : headermap.entrySet()) { + if(!"Content-Length".equalsIgnoreCase(entry.getKey())){ + httppost.addHeader(entry.getKey(), entry.getValue()); + } + } + } + httppost.setEntity(new StringEntity( + request.getEntityContent(), + resolveEntityContentType(request.getHeaders()))); + responseResult.setRequestContent(request.getEntityContent()); + httpclient.execute(httppost,responseHandler); + }else { + if(request.getHeaders()!=null) { + Map headermap = request.getHeaders(); + for (Map.Entry entry : headermap.entrySet()) { + httppost.addHeader(entry.getKey(), entry.getValue()); + } + } + MultipartFile processFile = request.getUploadFile(); + MultipartEntityBuilder builder = MultipartEntityBuilder.create(); + builder.addBinaryBody(request.getFileParaName(), processFile.getInputStream(), ContentType.MULTIPART_FORM_DATA, processFile.getName()); + HttpEntity entity = builder.build(); + httppost.setEntity(entity); + httpclient.execute(httppost,responseHandler); + } + + }else if("PUT".equalsIgnoreCase(request.getRequestMethod())){ + HttpPut httpput = new HttpPut(requestUrl); + httpput.setConfig(requestConfig); + if(request.getHeaders()!=null) { + Map headermap = request.getHeaders(); + for (Map.Entry entry : headermap.entrySet()) { + if(!"Content-Length".equalsIgnoreCase(entry.getKey())){ + httpput.addHeader(entry.getKey(), entry.getValue()); + } + } + } + httpput.setEntity(new StringEntity( + request.getEntityContent(), + resolveEntityContentType(request.getHeaders()))); + responseResult.setRequestContent(request.getEntityContent()); + httpclient.execute(httpput,responseHandler); + }else if("DELETE".equalsIgnoreCase(request.getRequestMethod())){ + HttpDelete httpDelete = new HttpDelete(requestUrl); + httpDelete.setConfig(requestConfig); + httpclient.execute(httpDelete,responseHandler); + }else { + //-- Get + HttpGet httpget = new HttpGet(requestUrl); + httpget.setConfig(requestConfig); + if(request.getHeaders()!=null) { + Map headermap = request.getHeaders(); + for (Map.Entry entry : headermap.entrySet()) { + if(!"Content-Length".equalsIgnoreCase(entry.getKey())){ + httpget.addHeader(entry.getKey(), entry.getValue()); + } + } + } + httpclient.execute(httpget,responseHandler); + } + //-- + }catch(Exception err) { + responseResult.setStatusCode(-1); + responseResult.setErrorMessage(err.getMessage()); + logger.error("Request HTTP[URL:{}][Method:{}] error:{}",request.getRequestUrl(),request.getRequestMethod(),err.getMessage()); + }finally { + try { + if(httpclient!=null) { + httpclient.close(); + } + }catch(Exception err) { + + } + } + return responseResult; + } + //----------- + public static SSLContext getSSLContext() { + try { + // 这里可以填两种值 TLS和LLS , 具体差别可以自行搜索 + SSLContext sc = SSLContext.getInstance("TLS"); + // 构建新对象 + X509TrustManager manager = new X509TrustManager() { + @Override + public void checkServerTrusted(X509Certificate[] chain, String arg1) throws CertificateException { + if (chain == null || chain.length == 0) { + throw new IllegalArgumentException("No server certificates provided"); + } + for (X509Certificate cert : chain) { + cert.checkValidity(); + } + } + + @Override + public void checkClientTrusted(X509Certificate[] chain, String arg1) throws CertificateException { + if (chain == null || chain.length == 0) { + throw new CertificateException("No client certificates provided"); + } + for (X509Certificate cert : chain) { + cert.checkValidity(); // 检查证书是否在有效期内 + } + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[] {}; + } + + }; + sc.init(null, new TrustManager[]{manager}, null); + return sc; + } catch (Exception err) { + logger.error("Init httpclient SSL error:{}",err.getMessage()); + } + return null; + } + + + public static String readRequestBody(HttpServletRequest request) { + BufferedReader br = null; + StringBuilder sb = new StringBuilder(""); + try { + br = request.getReader(); + String str; + while ((str = br.readLine()) != null) { + sb.append(str); + } + br.close(); + } catch (IOException err) { + logger.error("Read httpclient body error:{}",err.getMessage()); + } finally { + if (null != br) { + try { + br.close(); + } catch (IOException ignored) { + } + } + } + return sb.toString(); + } + + /** 与请求头 Content-Type 一致,避免 StringEntity 默认 text/plain 导致 OHIP 415。 */ + private static ContentType resolveEntityContentType(Map headers) { + if (headers != null) { + for (Map.Entry entry : headers.entrySet()) { + if ("Content-Type".equalsIgnoreCase(entry.getKey()) + && StringUtils.isNotBlank(entry.getValue())) { + try { + return ContentType.parse(entry.getValue().trim()); + } catch (Exception ignored) { + // fall through + } + } + } + } + return ContentType.TEXT_PLAIN.withCharset(StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/utils/JwtTokenUtil.java b/src/main/java/com/sjmresorts/opsapp/common/utils/JwtTokenUtil.java new file mode 100644 index 0000000..b808a9f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/utils/JwtTokenUtil.java @@ -0,0 +1,268 @@ +package com.sjmresorts.opsapp.common.utils; + +import com.alibaba.fastjson2.JSON; +import com.auth0.jwt.JWT; +import com.auth0.jwt.JWTVerifier; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.authentication.models.UserToken; +import com.sjmresorts.opsapp.common.constants.AppConstants; +import com.sjmresorts.opsapp.common.dtos.UserCredentialsDto; +import jakarta.servlet.http.HttpServletRequest; +import lombok.Getter; +import lombok.Setter; +import org.apache.commons.lang3.StringUtils; + +import java.time.Instant; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +public class JwtTokenUtil { + + @Setter + @Getter + private static String secretKey; + + @Setter + @Getter + private static String appLoginAuthorizationCode; + + @Setter + @Getter + private static long accessTokenExpiration=5; + + @Setter + @Getter + private static long appSessionExpiration=30; + + @Setter + @Getter + private static long cmsSessionExpiration=30; + + @Setter + @Getter + private static String thirdPartySecretKey; + + @Setter + @Getter + private static long thirdPartyExpiration=30; + + + public static JWTVerifier getJwtVerifier(){ + return JWT.require(Algorithm.HMAC256(secretKey)).build(); + } + + public static UserToken generateTokenByLoginUser(String tokenType, LoginUserInfo user) { + UserToken userToken = new UserToken(); + long expiryVal = accessTokenExpiration; + String issueSource = user.getLoginSource(); + if(issueSource==null){ + issueSource="app_login"; + } + if(AppConstants.CMS_REFRESH_TOKEN_TYPE.equalsIgnoreCase(tokenType)){ + expiryVal = cmsSessionExpiration; + }else if(AppConstants.APP_REFRESH_TOKEN_TYPE.equalsIgnoreCase(tokenType)){ + expiryVal = appSessionExpiration; + }else if(AppConstants.THIRD_PARTY_TOKEN_TYPE.equalsIgnoreCase(tokenType)){ + expiryVal = thirdPartyExpiration; + } + expiryVal = expiryVal*60*1000; + Date now = new Date(); + Date expDate = new Date(now.getTime() + expiryVal); + String uuid = UUID.randomUUID().toString(); + + String tokenValue = JWT.create() + .withIssuer(issueSource) + .withIssuedAt(now).withNotBefore(now).withExpiresAt(expDate).withJWTId(uuid) + .withClaim("employeeId", user.getEmployeeId()) + .withClaim("group", user.getAdGroup()==null?"":user.getAdGroup()) + .withClaim("userName", user.getUserName()==null?"":user.getUserName()) + .withClaim("appVersion", user.getAppVersion()==null?"":user.getAppVersion()) + .withSubject(user.getEmployeeId()) + .sign(Algorithm.HMAC256(secretKey)); + + userToken.setTokenType(tokenType); + userToken.setTokenUniqueId(uuid); + userToken.setUserCode(user.getUserCode()); + userToken.setTokenValue(tokenValue); + userToken.setCreatedAt(Instant.now()); + userToken.setExpiryTime(expDate); + + return userToken; + } + + /** + * JWT加密用户名和密码 + * 将用户名和密码加密为JWT token + * + * @param username 用户名 + * @param password 密码 + * @param expirationMinutes 过期时间(分钟),默认30分钟 + * @return JWT加密后的token字符串 + */ + public static String encryptCredentials(String username, String password, long expirationMinutes) { + if (StringUtils.isBlank(username) || StringUtils.isBlank(password)) { + return null; + } + try { + Date now = new Date(); + long expirationMillis = expirationMinutes * 60 * 1000; + Date expDate = new Date(now.getTime() + expirationMillis); + + String token = JWT.create() + .withIssuer("credentials_encryption") + .withIssuedAt(now) + .withNotBefore(now) + .withExpiresAt(expDate) + .withClaim("username", username) + .withClaim("password", password) + .withSubject("encrypted_credentials") + .sign(Algorithm.HMAC256(secretKey)); + + return token; + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + public static LoginUserInfo parseUserFromRequest(HttpServletRequest request) { + LoginUserInfo loginUser = null; + String token = request.getHeader("Authorization"); + if(StringUtils.isNotBlank(token) && token.startsWith("Bearer ")){ + token = token.substring(7); + } + if(StringUtils.isNotBlank(token)) { + loginUser = parseUserFromToken(token); + } + return loginUser; + } + + public static LoginUserInfo parseUserFromToken(String tokenContent) { + LoginUserInfo loginUser = null; + if(StringUtils.isNotBlank(tokenContent)) { + try { + DecodedJWT decodeData = JWT.decode(tokenContent); + loginUser = new LoginUserInfo(); + loginUser.setEmployeeId(decodeData.getClaim("employeeId").asString()); + loginUser.setAdGroup(decodeData.getClaim("group").asString()); + loginUser.setUserName(decodeData.getClaim("userName").asString()); + loginUser.setAppVersion(decodeData.getClaim("appVersion").asString()); + loginUser.setLoginSource(decodeData.getIssuer()); + loginUser.setTokenUuid(decodeData.getId()); + loginUser.setUserCode(loginUser.getEmployeeId()); + }catch(Exception err) { + //System.out.println("Parse Token failed:"+err.getMessage()); + loginUser =null; + } + } + return loginUser; + } + + /** + * JWT加密用户名和密码(使用默认过期时间30分钟) + * + * @param username 用户名 + * @param password 密码 + * @return JWT加密后的token字符串 + */ + public static String encryptCredentials(String username, String password) { + return encryptCredentials(username, password, 30); + } + + /** + * JWT解密用户名和密码 + * 从JWT token中解密出用户名和密码 + * + * @param encryptedToken JWT加密的token字符串 + * @return 解密后的用户凭证对象,如果解密失败返回null + * 注意:此方法不验证token的过期时间,只解码token内容 + */ + public static UserCredentialsDto decryptCredentials(String encryptedToken) { + if (StringUtils.isBlank(encryptedToken)) { + return null; + } + // 解码JWT token(不验证过期时间) + DecodedJWT decodedJWT = JWT.decode(encryptedToken); + // 提取用户名和密码 + String username = decodedJWT.getClaim("username").asString(); + String password = null;//decodedJWT.getClaim("password").asString(); + String adGroup = decodedJWT.getClaim("ad-group").asString(); + // 解析JSON数组,格式如:["APP ADMIN,CMS ADMIN"] + List adGroupList = JSON.parseArray(adGroup, String.class); + // 将逗号分隔的字符串拆分成独立的列表项 + List adGroups = adGroupList.stream() + .flatMap(group -> Arrays.stream(group.split(","))) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + + if (adGroups == null || adGroups.isEmpty()) { + return null; + } + return new UserCredentialsDto(username, password, adGroups); + } + + /** + * 刷新token + * 从现有token中提取信息,生成新的token(包含ad-group信息) + * + * @param token 现有的token(可以是已过期或未过期的) + * @param expirationMinutes 新token的过期时间(分钟) + * @return 新的token字符串,如果刷新失败返回null + */ + public static String refreshToken(String token, long expirationMinutes) { + if (StringUtils.isBlank(token)) { + return null; + } + + try { + // 尝试解码token(不验证过期时间) + DecodedJWT decodedJWT = JWT.decode(token); + + // 提取token中的信息 + String username = decodedJWT.getClaim("username").asString(); + String password = decodedJWT.getClaim("password").asString(); + String adGroup = decodedJWT.getClaim("ad-group").asString(); + + if (StringUtils.isBlank(adGroup)) { + return null; + } + + // 生成新的token + Date now = new Date(); + long expirationMillis = expirationMinutes * 60 * 1000; + Date expDate = new Date(now.getTime() + expirationMillis); + + String newToken = JWT.create() + .withIssuer("credentials_encryption") + .withIssuedAt(now) + .withNotBefore(now) + .withExpiresAt(expDate) + .withClaim("username", username) + .withClaim("password", password != null ? password : "") + .withClaim("ad-group", adGroup) + .withSubject("encrypted_credentials") + .sign(Algorithm.HMAC256(secretKey)); + + return newToken; + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + + /** + * 刷新token(使用默认过期时间30分钟) + * + * @param token 现有的token(可以是已过期或未过期的) + * @return 新的token字符串,如果刷新失败返回null + */ + public static String refreshToken(String token) { + return refreshToken(token, 30); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/utils/JwtTokenUtilTest.java b/src/main/java/com/sjmresorts/opsapp/common/utils/JwtTokenUtilTest.java new file mode 100644 index 0000000..01a0c39 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/utils/JwtTokenUtilTest.java @@ -0,0 +1,103 @@ +package com.sjmresorts.opsapp.common.utils; + +import com.sjmresorts.opsapp.common.dtos.UserCredentialsDto; + +/** + * JWT加密解密测试类 + * 用于测试用户名和密码的JWT加密和解密功能 + */ +public class JwtTokenUtilTest { + + public static void main(String[] args) { + System.out.println("========== JWT加密解密测试 =========="); + + // 设置测试用的secretKey(与配置文件中的保持一致) + String testSecretKey = "SecretKeyWhichShouldBeLongEnoughToBeSecure67848563"; + JwtTokenUtil.setSecretKey(testSecretKey); + + // 测试数据 + String testUsername = "testuser"; + String testPassword = "testpassword123"; + + System.out.println("\n【测试1:基本加密解密】"); + System.out.println("原始用户名: " + testUsername); + System.out.println("原始密码: " + testPassword); + + // 测试加密(使用默认过期时间30分钟) + String encryptedToken = JwtTokenUtil.encryptCredentials(testUsername, testPassword); + if (encryptedToken != null) { + System.out.println("加密后的Token: " + encryptedToken); + System.out.println("Token长度: " + encryptedToken.length()); + + // 测试解密 + UserCredentialsDto decryptedCredentials = JwtTokenUtil.decryptCredentials(encryptedToken); + if (decryptedCredentials != null) { + System.out.println("解密后的用户名: " + decryptedCredentials.getUsername()); + System.out.println("解密后的密码: " + decryptedCredentials.getPassword()); + + // 验证结果 + boolean usernameMatch = testUsername.equals(decryptedCredentials.getUsername()); + boolean passwordMatch = testPassword.equals(decryptedCredentials.getPassword()); + + if (usernameMatch && passwordMatch) { + System.out.println("✓ 测试通过:加密和解密成功,数据一致!"); + } else { + System.out.println("✗ 测试失败:解密后的数据与原始数据不一致!"); + System.out.println(" 用户名匹配: " + usernameMatch); + System.out.println(" 密码匹配: " + passwordMatch); + } + } else { + System.out.println("✗ 解密失败!"); + } + } else { + System.out.println("✗ 加密失败!"); + } + + System.out.println("\n【测试2:指定过期时间的加密】"); + String testUsername2 = "admin"; + String testPassword2 = "admin@123"; + long expirationMinutes = 60; // 60分钟过期 + + String encryptedToken2 = JwtTokenUtil.encryptCredentials(testUsername2, testPassword2, expirationMinutes); + if (encryptedToken2 != null) { + System.out.println("加密后的Token: " + encryptedToken2); + UserCredentialsDto decryptedCredentials2 = JwtTokenUtil.decryptCredentials(encryptedToken2); + if (decryptedCredentials2 != null) { + System.out.println("解密后的用户名: " + decryptedCredentials2.getUsername()); + System.out.println("解密后的密码: " + decryptedCredentials2.getPassword()); + System.out.println("✓ 测试通过:指定过期时间的加密解密成功!"); + } else { + System.out.println("✗ 解密失败!"); + } + } else { + System.out.println("✗ 加密失败!"); + } + + System.out.println("\n【测试3:空值处理】"); + String emptyToken = JwtTokenUtil.encryptCredentials("", ""); + if (emptyToken == null) { + System.out.println("✓ 测试通过:空值加密返回null(符合预期)"); + } else { + System.out.println("✗ 测试失败:空值加密应该返回null"); + } + + UserCredentialsDto emptyDecrypt = JwtTokenUtil.decryptCredentials(""); + if (emptyDecrypt == null) { + System.out.println("✓ 测试通过:空值解密返回null(符合预期)"); + } else { + System.out.println("✗ 测试失败:空值解密应该返回null"); + } + + System.out.println("\n【测试4:无效Token处理】"); + String invalidToken = "invalid.jwt.token.here"; + UserCredentialsDto invalidDecrypt = JwtTokenUtil.decryptCredentials(invalidToken); + if (invalidDecrypt == null) { + System.out.println("✓ 测试通过:无效Token解密返回null(符合预期)"); + } else { + System.out.println("✗ 测试失败:无效Token解密应该返回null"); + } + + System.out.println("\n========== 测试完成 =========="); + } +} + diff --git a/src/main/java/com/sjmresorts/opsapp/common/utils/LDAPUtil.java b/src/main/java/com/sjmresorts/opsapp/common/utils/LDAPUtil.java new file mode 100644 index 0000000..3adcc5d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/utils/LDAPUtil.java @@ -0,0 +1,108 @@ +package com.sjmresorts.opsapp.common.utils; + +import java.util.List; + +import javax.naming.directory.SearchControls; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.ldap.core.AttributesMapper; +import org.springframework.ldap.core.DirContextOperations; +import org.springframework.ldap.core.LdapTemplate; +import org.springframework.ldap.filter.AndFilter; +import org.springframework.ldap.filter.EqualsFilter; +import org.springframework.ldap.query.LdapQuery; +import org.springframework.ldap.query.LdapQueryBuilder; +import org.springframework.stereotype.Component; + +import com.sjmresorts.opsapp.common.dtos.LDAP.ADAccountInfo; + + +@Component +@Slf4j +public class LDAPUtil { + private static final int ACCOUNT_DISABLED_FLAG = 0x0002; + + @Autowired + private LdapTemplate ldapTemplate; + + public boolean authenticate(String username, String password) { + log.info("===TEST:LDAP===Starting user authentication: {}", username); + AndFilter filter = new AndFilter() + .and(new EqualsFilter("sAMAccountName", username)); // AD域属性 + LdapQuery query = LdapQueryBuilder.query() + .where("sAMAccountName").is(username); + + try { + // 1. 查询账户状态属性 + DirContextOperations ctx = ldapTemplate.searchForContext(query); + + // 2. 检查账户禁用状态 + int userAccountControl = Integer.parseInt( + ctx.getStringAttribute("userAccountControl")); + log.info("===TEST:LDAP===User: {} account status: {}", username, userAccountControl); + if (isAccountDisabled(userAccountControl)) { + log.info("===TEST:LDAP===User: {} account is disabled", username); + return false; + } + + // 3. 执行标准认证 + return ldapTemplate.authenticate("", filter.encode(), password); + + } catch (Exception e) { + log.error("===TEST:LDAP===User: {} authentication failed: {} exception: ", username, e.getMessage(), e); + return false; + } + } + + public ADAccountInfo getAccountInfo(String username) { + AndFilter filter = new AndFilter() + .and(new EqualsFilter("sAMAccountName", username)); + + List users = ldapTemplate.search( + "", + filter.encode(), + (AttributesMapper) attrs -> { + ADAccountInfo account = new ADAccountInfo(); + if(attrs.get("cn") != null) + account.setDisplayName(attrs.get("cn").get().toString()); + if(attrs.get("mail") != null) + account.setMail(attrs.get("mail").get().toString()); + if(attrs.get("department") != null) + account.setDepartment(attrs.get("department").get().toString()); + if(attrs.get("userAccountControl") != null) + account.setUserAccountControl(attrs.get("userAccountControl").get().toString()); + + account.setDirectGroups(getUserGroups(account.getDisplayName())); + return account; + }); + + return users.isEmpty() ? null : users.getFirst(); + } + + public List getUserGroups(String username) { + // 显式指定ContextMapper返回类型避免歧义 + SearchControls controls = new SearchControls(); + controls.setReturningAttributes(new String[]{"cn"}); + controls.setSearchScope(SearchControls.SUBTREE_SCOPE); + + Object userDn = ldapTemplate.searchForObject( + LdapQueryBuilder.query() + .where("sAMAccountName").is(username) + .and("objectclass").is("person"), + ctx -> ((DirContextOperations) ctx).getNameInNamespace()); + + // 精确匹配member属性(兼容AD的memberOf语法) + return ldapTemplate.search( + LdapQueryBuilder.query() + .base("") // 明确组查询基准 + .where("member").is(userDn.toString()), // 显式toString() + (AttributesMapper) attrs -> attrs.get("cn").get().toString() + ); + } + + + private boolean isAccountDisabled(int userAccountControl) { + return (userAccountControl & ACCOUNT_DISABLED_FLAG) != 0; + } +} \ No newline at end of file diff --git a/src/main/java/com/sjmresorts/opsapp/common/utils/NetworkUtil.java b/src/main/java/com/sjmresorts/opsapp/common/utils/NetworkUtil.java new file mode 100644 index 0000000..c7ff3f3 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/utils/NetworkUtil.java @@ -0,0 +1,276 @@ +package com.sjmresorts.opsapp.common.utils; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Enumeration; +import java.util.List; +import java.util.regex.Pattern; + + +public class NetworkUtil { + private static final Logger logger = LoggerFactory.getLogger(NetworkUtil.class); + public static List validProxyIps = new ArrayList(); + public static List allowOriginSource = new ArrayList(); + private static final Pattern IPV4_PATTERN = + Pattern.compile("^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$"); + private static final List INTERNAL_IP_PREFIXES = Arrays.asList( + "10.", "192.168.", "172.16.", "172.17.", "172.18.", "172.19.", + "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.", + "172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31." + ); + + public static String getClientIpByRequest(HttpServletRequest request) { + String ip = getIpFromHeaders(request); + if(ip==null){ + ip = request.getRemoteAddr(); + } + return "0:0:0:0:0:0:0:1".equals(ip) ? "127.0.0.1" : ip; + } + + private static String getIpFromHeaders(HttpServletRequest request) { + String[] headerNames = { + "X-Real-IP", + "X-Forwarded-For", + "Proxy-Client-IP", + "WL-Proxy-Client-IP", + "HTTP_CLIENT_IP", + "HTTP_X_FORWARDED_FOR" + }; + boolean isContainForwardHeader=false; + for (String headerName : headerNames) { + String headerValue = request.getHeader(headerName); + if (headerValue != null && !headerValue.isEmpty()) { + isContainForwardHeader=true; + //logger.info("Check header[{}] of value[{}]",headerName,headerValue); + String ip = extractValidIp(headerValue); + if (ip != null) { + return ip; + }else{ + logger.info("Header is not empty but not valid IP"); + } + } + } + return (isContainForwardHeader?"INVALID_IP":null); + } + + private static String extractValidIp(String headerValue) { + if (headerValue.contains(",")) { + String[] ips = headerValue.split(","); + for(int i=ips.length-1;i>=0;i--){ + String ip = ips[i].trim(); + if (isValidPublicIp(ip)) { + return ip; + } + } + return isValidIp(ips[ips.length - 1].trim())?ips[ips.length - 1].trim():null; + } else { + String ip = headerValue.trim(); + return isValidIp(ip) ? ip : null; + } + } + + private static boolean isValidPublicIp(String ip) { + return isValidIp(ip) && !isInternalIp(ip); + } + + private static boolean isValidIp(String ip) { + return ip != null && !ip.isEmpty() && + !"unknown".equalsIgnoreCase(ip) && + IPV4_PATTERN.matcher(ip).matches(); + } + + private static boolean isInternalIp(String ip) { + if (ip == null) return false; + if (ip.equals("127.0.0.1") || ip.equals("0:0:0:0:0:0:0:1")) { + return true; + } + for (String prefix : INTERNAL_IP_PREFIXES) { + if (ip.startsWith(prefix)) { + return true; + } + } + return false; + } + + public static void checkAndSetAllowOrigin(HttpServletRequest request, HttpServletResponse response){ + String origin = request.getHeader("Origin"); + if(origin==null){ + response.setHeader("Access-Control-Allow-Origin",""); + }else{ + if(isAllowOriginSource(origin)){ + response.setHeader("Access-Control-Allow-Origin",origin); + }else{ + logger.warn("Origin of [{}] is not allowed",origin); + } + } + } + + public static void setSecurityHeaders(HttpServletRequest request, HttpServletResponse response){ + response.addHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, HEAD"); + response.addHeader("Access-Control-Allow-Headers", + "Content-Type, Authorization, clientId, X-CMS-Operator, X-Client-Type, X-Requested-With, Accept, Origin"); + response.addHeader("Access-Control-Allow-Credentials", "true"); + response.addHeader("Access-Control-Max-Age", "3600"); + + if (request.isSecure()) { + response.setHeader("Strict-Transport-Security", + "max-age=86400; includeSubDomains; preload"); + } + response.setHeader("X-Frame-Options", "SAMEORIGIN"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; " + + "style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; " + + "connect-src 'self'; media-src 'self'; object-src 'none'; frame-src 'self'"); + response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + response.setHeader("X-XSS-Protection", "1; mode=block"); + } + + public static List getRealIps() { + List ips = new ArrayList(); + String localip = null; + String netip = null; + Enumeration netInterfaces; + try { + netInterfaces = NetworkInterface.getNetworkInterfaces(); + } catch (SocketException e) { + return null; + } + + InetAddress ip = null; + boolean finded = false; + while (netInterfaces.hasMoreElements() && !finded) { + NetworkInterface ni = netInterfaces.nextElement(); + Enumeration address = ni.getInetAddresses(); + while (address.hasMoreElements()) { + ip = address.nextElement(); + if (!ip.isSiteLocalAddress() && !ip.isLoopbackAddress() && !ip.getHostAddress().contains(":")) {// 外网IP + netip = ip.getHostAddress(); + ips.add(netip); + finded = true; + break; + } else if (ip.isSiteLocalAddress() && !ip.isLoopbackAddress() + && !ip.getHostAddress().contains(":")) { + localip = ip.getHostAddress(); + ips.add(localip); + } + } + } + return ips; + } + + public static String getRealIp() { + List realIps =getRealIps(); + return realIps!=null && !realIps.isEmpty() ? realIps.getFirst():""; + } + + + public static String getInternalIp() { + String localip = null; + String netip = null; + try { + Enumeration netInterfaces = NetworkInterface.getNetworkInterfaces(); + InetAddress ip = null; + // 是否找到外网IP + boolean finded = false; + while (netInterfaces.hasMoreElements() && !finded) { + NetworkInterface ni = (NetworkInterface) netInterfaces.nextElement(); + Enumeration address = ni.getInetAddresses(); + while (address.hasMoreElements()) { + ip = (InetAddress) address.nextElement(); + if (!ip.isSiteLocalAddress() && !ip.isLoopbackAddress() && !ip.getHostAddress().contains(":")) { + // 外网IP + netip = ip.getHostAddress(); + finded = true; + break; + } else if (ip.isSiteLocalAddress() && !ip.isLoopbackAddress() + && !ip.getHostAddress().contains(":")) { + // 内网IP + localip = ip.getHostAddress(); + } + } + } + } catch (SocketException e) { + logger.error("Get Ip address with error:{}", e.getMessage()); + } + + if (StringUtils.isNotBlank(netip)) { + return netip; + } else { + return localip; + } + } + + public static String getServerName() { + String serverName=""; + try { + InetAddress localhost = InetAddress.getLocalHost(); + serverName = localhost.getHostName(); + } catch (Exception e) { + logger.error("Get server name with error:{}", e.getMessage()); + } + return serverName; + } + + public static boolean isRequestFromTrustedProxy(HttpServletRequest request) { + if(StringUtils.isNotBlank(request.getHeader("x-forwarded-for")) + ||StringUtils.isNotBlank(request.getHeader("Proxy-Client-IP")) + ||StringUtils.isNotBlank(request.getHeader("WL-Proxy-Client-IP")) + ||StringUtils.isNotBlank(request.getHeader("HTTP_CLIENT_IP")) + ||StringUtils.isNotBlank(request.getHeader("HTTP_X_FORWARDED_FOR"))){ + + String remoteAddress = request.getRemoteAddr(); + if(isValidProxyIp(remoteAddress)) { + return true; + }else { + logger.warn("IP of [{}] is not allowed proxy IP",remoteAddress); + return false; + } + }else{ + return true; + } + } + + public static boolean isValidProxyIp(String ip) { + boolean isAllow = false; + if(StringUtils.isNotBlank(ip)) { + if(validProxyIps.isEmpty()){ + isAllow=true; + }else{ + for(String allowIp:validProxyIps) { + if(ip.startsWith(allowIp)) { + isAllow=true; + break; + } + } + } + } + return isAllow; + } + + public static boolean isAllowOriginSource(String srcServer) { + boolean isAllow = false; + if(StringUtils.isNotBlank(srcServer)) { + if(allowOriginSource.isEmpty()){ + isAllow = true; + }else{ + for(String allowItem:allowOriginSource) { + if(srcServer.startsWith(allowItem)) { + isAllow=true; + break; + } + } + } + } + return isAllow; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/utils/ResourceFileUtil.java b/src/main/java/com/sjmresorts/opsapp/common/utils/ResourceFileUtil.java new file mode 100644 index 0000000..b088730 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/utils/ResourceFileUtil.java @@ -0,0 +1,83 @@ +package com.sjmresorts.opsapp.common.utils; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.boot.system.ApplicationHome; +import org.springframework.core.io.ClassPathResource; +import org.springframework.core.io.Resource; + +public class ResourceFileUtil { + + public static String runtime_path = ""; + public static String runtimeName=""; + public static final int CLASS_PATH=1; + public static final int APP_RUNTIME_PATH=2; + public static final int URI_PATH=3; + + public static String getSystemPath() { + ApplicationHome h = new ApplicationHome(ResourceFileUtil.class); + File f = h.getSource(); + return f.getParentFile().toString(); + } + + public static void initRuntimePath() { + runtime_path = getSystemPath(); + } + + //-- Path Type 1= Class path 2=Application path 3=URI Path + public static String loadResFileContentByPath(String filePath, int pathType) { + String fileContent = ""; + InputStream ifs = null; + BufferedReader bufferedReader = null; + InputStreamReader fileReader = null; + try { + if(pathType==2) { + if(StringUtils.isBlank(runtime_path)) { + runtime_path=getSystemPath(); + } + String applicationPath = runtime_path+"/"+runtimeName; + File dataFile = new File(applicationPath+filePath); + ifs=new FileInputStream(dataFile); + }else { + Resource resource = new ClassPathResource(filePath); + ifs = resource.getInputStream(); + } + fileReader = new InputStreamReader(ifs, "UTF-8"); + bufferedReader = new BufferedReader(fileReader); + String lineData = null; + int lineCnt =0; + StringBuffer contentBuffer = new StringBuffer(); + while ((lineData = bufferedReader.readLine()) != null) { + if(lineCnt>0) { + contentBuffer.append("\n"); + } + contentBuffer.append(lineData); + lineCnt++; + } + bufferedReader.close(); + fileContent = contentBuffer.toString(); + } catch (Exception e) { + //e.printStackTrace(); + //LogUtil.error(SysCommon.class, "Get Data File failed:"+e.getMessage()); + } finally { + try { + if (bufferedReader != null) { + bufferedReader.close(); + } + if(fileReader!=null) { + fileReader.close(); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + //-- + return fileContent; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/utils/SimpleHttpClient.java b/src/main/java/com/sjmresorts/opsapp/common/utils/SimpleHttpClient.java new file mode 100644 index 0000000..f00e493 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/utils/SimpleHttpClient.java @@ -0,0 +1,289 @@ +package com.sjmresorts.opsapp.common.utils; + +import com.sjmresorts.opsapp.common.dtos.httpclient.HttpRequestDefine; +import com.sjmresorts.opsapp.common.dtos.httpclient.HttpResponseResult; +import org.apache.commons.lang3.StringUtils; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * 轻量 HTTP 客户端封装,基于 {@link HttpClientUtil}。 + * + *
{@code
+ * HttpResponseResult resp = SimpleHttpClient.get("https://api.example.com/items")
+ *     .queryParam("id", "1")
+ *     .bearerToken("xxx")
+ *     .timeoutMs(30000)
+ *     .execute();
+ * }
+ */ +public final class SimpleHttpClient { + + private SimpleHttpClient() { + } + + public static RequestBuilder get(String url) { + return new RequestBuilder("GET", url); + } + + public static RequestBuilder post(String url) { + return new RequestBuilder("POST", url); + } + + public static RequestBuilder put(String url) { + return new RequestBuilder("PUT", url); + } + + public static RequestBuilder delete(String url) { + return new RequestBuilder("DELETE", url); + } + + /** 拼接 baseUrl 与 path,自动处理多余斜杠。 */ + public static String joinUrl(String baseUrl, String path) { + if (StringUtils.isBlank(baseUrl)) { + return StringUtils.defaultString(path); + } + if (StringUtils.isBlank(path)) { + return baseUrl.trim(); + } + String base = trimTrailingSlash(baseUrl.trim()); + String normalizedPath = path.trim(); + if (!normalizedPath.startsWith("/")) { + normalizedPath = "/" + normalizedPath; + } + return base + normalizedPath; + } + + /** 日志输出用:Authorization 等敏感头脱敏。 */ + public static Map maskHeadersForLog(Map headers) { + if (headers == null || headers.isEmpty()) { + return Collections.emptyMap(); + } + Map masked = new LinkedHashMap<>(); + for (Map.Entry entry : headers.entrySet()) { + String name = entry.getKey(); + String value = entry.getValue(); + if (StringUtils.isBlank(name)) { + continue; + } + if ("Authorization".equalsIgnoreCase(name)) { + masked.put(name, maskAuthorizationHeader(value)); + } else { + masked.put(name, value); + } + } + return masked; + } + + private static String maskAuthorizationHeader(String value) { + if (StringUtils.isBlank(value)) { + return "(empty)"; + } + String trimmed = value.trim(); + if (trimmed.regionMatches(true, 0, "Basic ", 0, 6)) { + return "Basic " + maskMiddle(trimmed.substring(6)); + } + if (trimmed.regionMatches(true, 0, "Bearer ", 0, 7)) { + return "Bearer " + maskMiddle(trimmed.substring(7)); + } + return maskMiddle(trimmed); + } + + private static String maskMiddle(String value) { + if (StringUtils.isBlank(value)) { + return "****"; + } + if (value.length() <= 8) { + return "****"; + } + return value.substring(0, 4) + "****" + value.substring(value.length() - 4); + } + + /** 与 Postman Basic Auth 一致:Base64(username:password) */ + public static String buildBasicAuthBase64(String username, String password) { + if (StringUtils.isBlank(username)) { + return ""; + } + String cred = username + ":" + StringUtils.defaultString(password); + return Base64.getEncoder().encodeToString(cred.getBytes(StandardCharsets.UTF_8)); + } + + public static final class RequestBuilder { + + private final HttpRequestDefine request = new HttpRequestDefine(); + private final Map queryParams = new LinkedHashMap<>(); + + private RequestBuilder(String method, String url) { + request.setRequestMethod(method); + request.setRequestUrl(url); + } + + public RequestBuilder header(String name, String value) { + if (StringUtils.isNotBlank(name) && value != null) { + headers().put(name, value); + } + return this; + } + + public RequestBuilder headers(Map headers) { + if (headers != null && !headers.isEmpty()) { + headers().putAll(headers); + } + return this; + } + + public RequestBuilder queryParam(String name, String value) { + if (StringUtils.isNotBlank(name) && value != null) { + queryParams.put(name, value); + } + return this; + } + + public RequestBuilder queryParams(Map params) { + if (params != null) { + params.forEach((key, value) -> { + if (StringUtils.isNotBlank(key) && value != null) { + queryParams.put(key, String.valueOf(value)); + } + }); + } + return this; + } + + public RequestBuilder jsonBody(String body) { + request.setEntityContent(StringUtils.defaultString(body)); + header("Content-Type", "application/json"); + header("Accept", "application/json"); + return this; + } + + public RequestBuilder formBody(Map params) { + if (params != null && !params.isEmpty()) { + StringBuilder sb = new StringBuilder(); + char separator = 0; + for (Map.Entry entry : params.entrySet()) { + if (StringUtils.isBlank(entry.getKey()) || entry.getValue() == null) { + continue; + } + if (separator != 0) { + sb.append(separator); + } + sb.append(encode(entry.getKey())).append('=').append(encode(entry.getValue())); + separator = '&'; + } + request.setEntityContent(sb.toString()); + } else { + request.setEntityContent(""); + } + header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8"); + return this; + } + + public RequestBuilder body(String body) { + request.setEntityContent(StringUtils.defaultString(body)); + return this; + } + + public RequestBuilder timeoutMs(int timeoutMs) { + if (timeoutMs > 0) { + // HttpClientUtil 内部以微秒读取 timeout 字段 + request.setTimeout(timeoutMs * 1000); + } + return this; + } + + public RequestBuilder authorization(String value) { + if (StringUtils.isNotBlank(value)) { + header("Authorization", value.trim()); + } + return this; + } + + public RequestBuilder bearerToken(String token) { + if (StringUtils.isNotBlank(token)) { + String trimmed = token.trim(); + if (trimmed.regionMatches(true, 0, "Bearer ", 0, 7)) { + authorization(trimmed); + } else { + authorization("Bearer " + trimmed); + } + } + return this; + } + + public RequestBuilder basicAuth(String username, String password) { + if (StringUtils.isNotBlank(username)) { + String cred = username + ":" + StringUtils.defaultString(password); + authorization("Basic " + Base64.getEncoder().encodeToString(cred.getBytes(StandardCharsets.UTF_8))); + } + return this; + } + + public HttpResponseResult execute() { + request.setRequestUrl(buildUrlWithQuery()); + return HttpClientUtil.processHttpRequest(request); + } + + public String getMethod() { + return request.getRequestMethod(); + } + + public String getRequestUrlWithQuery() { + return buildUrlWithQuery(); + } + + public Map getHeaders() { + Map headers = request.getHeaders(); + if (headers == null || headers.isEmpty()) { + return Collections.emptyMap(); + } + return Collections.unmodifiableMap(new LinkedHashMap<>(headers)); + } + + public String getBodyContent() { + return StringUtils.defaultString(request.getEntityContent()); + } + + private Map headers() { + Map headers = request.getHeaders(); + if (headers == null) { + headers = new LinkedHashMap<>(); + request.setHeaders(headers); + } + return headers; + } + + private String buildUrlWithQuery() { + String url = request.getRequestUrl(); + if (queryParams.isEmpty()) { + return url; + } + StringBuilder sb = new StringBuilder(url); + char separator = url.contains("?") ? '&' : '?'; + for (Map.Entry entry : queryParams.entrySet()) { + sb.append(separator) + .append(encode(entry.getKey())) + .append('=') + .append(encode(entry.getValue())); + separator = '&'; + } + return sb.toString(); + } + } + + private static String encode(String value) { + return URLEncoder.encode(StringUtils.defaultString(value), StandardCharsets.UTF_8); + } + + private static String trimTrailingSlash(String value) { + while (value.endsWith("/")) { + value = value.substring(0, value.length() - 1); + } + return value; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/common/utils/TemplateUtil.java b/src/main/java/com/sjmresorts/opsapp/common/utils/TemplateUtil.java new file mode 100644 index 0000000..657299d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/common/utils/TemplateUtil.java @@ -0,0 +1,42 @@ +package com.sjmresorts.opsapp.common.utils; + + +import org.springframework.beans.BeanWrapper; +import org.springframework.beans.BeanWrapperImpl; + +import java.beans.PropertyDescriptor; +import java.util.HashMap; +import java.util.Map; + +public class TemplateUtil { + + public static String fillInTemplateWithDataMap(String template, Map dataMap){ + String result = template; + if(dataMap!=null){ + for(Map.Entry dataItem : dataMap.entrySet()){ + result = result.replace("{"+dataItem.getKey().trim()+"}", dataItem.getValue()); + } + } + return result; + } + + public static Map convertToDataMap(Map sourceMap , Object obj) { + if(sourceMap==null){ + sourceMap = new HashMap(); + } + BeanWrapper wrapper = new BeanWrapperImpl(obj); + for (PropertyDescriptor pd : wrapper.getPropertyDescriptors()) { + String propertyName = pd.getName(); + if (!"class".equalsIgnoreCase(propertyName)) { + Object value = wrapper.getPropertyValue(propertyName); + try { + sourceMap.put(propertyName, value == null ? "" : value.toString()); + }catch(Exception ignored){ + + } + } + } + return sourceMap; + } + +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/audit/controllers/EsignAuditLogController.java b/src/main/java/com/sjmresorts/opsapp/esign/audit/controllers/EsignAuditLogController.java new file mode 100644 index 0000000..09c01d6 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/audit/controllers/EsignAuditLogController.java @@ -0,0 +1,65 @@ +package com.sjmresorts.opsapp.esign.audit.controllers; + +import com.sjmresorts.opsapp.esign.audit.dtos.EsignAuditLogResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignClientTransportLogRequest; +import com.sjmresorts.opsapp.esign.common.dtos.EsignSimpleResponse; +import com.sjmresorts.opsapp.esign.audit.services.EsignAuditLogService; +import com.sjmresorts.opsapp.esign.auth.services.EsignUserPresenceService; +import com.sjmresorts.opsapp.common.utils.NetworkUtil; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@Tag(name = "Esign Audit Logs") +@RestController +@RequestMapping({"/api/v1/esign", "/api/v1/sinosecu"}) +public class EsignAuditLogController { + + @Resource(name = "esignAuditLogService") + private EsignAuditLogService auditLogService; + + @Resource + private EsignUserPresenceService userPresenceService; + + @Operation(summary = "審計日誌", description = "免 JWT。category=OPERATION|THIRD_PARTY|CONNECTION") + @GetMapping(value = "/audit-logs", produces = "application/json;charset=UTF-8") + public ResponseEntity listAuditLogs( + @RequestParam(name = "category", defaultValue = "OPERATION") String category, + @RequestParam(name = "eventType", required = false) String eventType, + @RequestParam(name = "page", defaultValue = "0") int page, + @RequestParam(name = "size", defaultValue = "50") int size) { + return ResponseEntity.ok(auditLogService.listLogs(category, page, size, eventType)); + } + + @Operation(summary = "App 上報連接/輪詢事件", description = "免 JWT。WebSocket 斷開、輪詢失敗等。") + @PostMapping(value = "/client-transport-log", consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") + public ResponseEntity clientTransportLog( + @RequestBody EsignClientTransportLogRequest request, + HttpServletRequest httpRequest) { + if (request == null || StringUtils.isBlank(request.getEventType())) { + return ResponseEntity.badRequest() + .body(EsignSimpleResponse.fail("400", "eventType required")); + } + String clientIp = EsignUserPresenceService.normalizeClientIp(NetworkUtil.getClientIpByRequest(httpRequest)); + Long userId = request.getUserId(); + String eventType = request.getEventType().trim().toUpperCase(); + String message = StringUtils.defaultIfBlank(request.getMessage(), eventType); + auditLogService.appendConnection( + eventType, + userId, + message, + request.getTransport(), + clientIp, + null); + return ResponseEntity.ok(EsignSimpleResponse.ok("logged")); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/audit/dtos/EsignAuditLogResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/audit/dtos/EsignAuditLogResponse.java new file mode 100644 index 0000000..be7d900 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/audit/dtos/EsignAuditLogResponse.java @@ -0,0 +1,51 @@ +package com.sjmresorts.opsapp.esign.audit.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "e-sign 審計日誌列表") +public class EsignAuditLogResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignAuditLogData data; + + @Data + @NoArgsConstructor + public static class EsignAuditLogData { + private long total; + private int page; + private int size; + private List logs; + } + + @Data + @NoArgsConstructor + public static class EsignAuditLogItem { + private String logId; + private String logCategory; + private String eventType; + private String message; + private String operatorName; + private Long userId; + private String taskId; + private String fileName; + private String agentSource; + private String hotelId; + private String httpMethod; + private String requestUrl; + private Integer responseStatus; + private Long durationMs; + private String clientIp; + private String detailJson; + private String createdAt; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/audit/models/EsignAuditLog.java b/src/main/java/com/sjmresorts/opsapp/esign/audit/models/EsignAuditLog.java new file mode 100644 index 0000000..ef4bc90 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/audit/models/EsignAuditLog.java @@ -0,0 +1,73 @@ +package com.sjmresorts.opsapp.esign.audit.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_audit_logs") +public class EsignAuditLog { + + @Id + @Column(name = "log_id", nullable = false, length = 36) + private String logId; + + @Column(name = "log_category", nullable = false, length = 32) + private String logCategory; + + @Column(name = "event_type", nullable = false, length = 64) + private String eventType; + + @Column(name = "message", length = 512) + private String message; + + @Column(name = "operator_name", length = 128) + private String operatorName; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "task_id", length = 36) + private String taskId; + + @Column(name = "file_name", length = 512) + private String fileName; + + @Column(name = "agent_source", length = 256) + private String agentSource; + + @Column(name = "hotel_id", length = 64) + private String hotelId; + + @Column(name = "http_method", length = 16) + private String httpMethod; + + @Column(name = "request_url", length = 1024) + private String requestUrl; + + @Column(name = "response_status") + private Integer responseStatus; + + @Column(name = "duration_ms") + private Long durationMs; + + @Column(name = "client_ip", length = 64) + private String clientIp; + + @Lob + @Column(name = "detail_json") + private String detailJson; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/audit/repositories/EsignAuditLogRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/audit/repositories/EsignAuditLogRepository.java new file mode 100644 index 0000000..8a111e9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/audit/repositories/EsignAuditLogRepository.java @@ -0,0 +1,16 @@ +package com.sjmresorts.opsapp.esign.audit.repositories; + +import com.sjmresorts.opsapp.esign.audit.models.EsignAuditLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface EsignAuditLogRepository extends JpaRepository { + + Page findByLogCategoryOrderByCreatedAtDesc(String logCategory, Pageable pageable); + + Page findByLogCategoryAndEventTypeOrderByCreatedAtDesc( + String logCategory, String eventType, Pageable pageable); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/audit/services/EsignAuditLogService.java b/src/main/java/com/sjmresorts/opsapp/esign/audit/services/EsignAuditLogService.java new file mode 100644 index 0000000..1441f95 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/audit/services/EsignAuditLogService.java @@ -0,0 +1,240 @@ +package com.sjmresorts.opsapp.esign.audit.services; + +import com.sjmresorts.opsapp.esign.audit.dtos.EsignAuditLogResponse; +import com.sjmresorts.opsapp.esign.audit.models.EsignAuditLog; +import com.sjmresorts.opsapp.esign.audit.repositories.EsignAuditLogRepository; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service("esignAuditLogService") +public class EsignAuditLogService { + + public static final String CATEGORY_OPERATION = "OPERATION"; + public static final String CATEGORY_THIRD_PARTY = "THIRD_PARTY"; + public static final String CATEGORY_CONNECTION = "CONNECTION"; + + public static final String EVT_WS_CONNECTED = "WS_CONNECTED"; + public static final String EVT_WS_DISCONNECTED = "WS_DISCONNECTED"; + public static final String EVT_POLL_FAILED = "POLL_FAILED"; + public static final String EVT_POLL_RECOVERED = "POLL_RECOVERED"; + public static final String EVT_WS_ERROR = "WS_ERROR"; + + /** USB 直传(PC Agent ↔ App,PDF 不经后端;仅审计元数据) */ + public static final String EVT_USB_LINK_UP = "USB_LINK_UP"; + public static final String EVT_USB_LINK_DOWN = "USB_LINK_DOWN"; + public static final String EVT_USB_PUSH_QUEUED = "USB_PUSH_QUEUED"; + public static final String EVT_USB_PUSH_SENT = "USB_PUSH_SENT"; + public static final String EVT_USB_PUSH_ACK = "USB_PUSH_ACK"; + public static final String EVT_USB_PUSH_RECEIVED = "USB_PUSH_RECEIVED"; + public static final String EVT_USB_PUSH_OPENED = "USB_PUSH_OPENED"; + public static final String EVT_USB_PUSH_FAILED = "USB_PUSH_FAILED"; + + public static final String EVT_OHIP_GET_REGISTRATION = "OHIP_GET_REGISTRATION"; + public static final String EVT_OHIP_GET_RESERVATIONS = "OHIP_GET_RESERVATIONS"; + public static final String EVT_OHIP_POST_ATTACHMENT = "OHIP_POST_ATTACHMENT"; + public static final String EVT_OHIP_POST_PROFILE_FIELDS = "OHIP_POST_PROFILE_FIELDS"; + public static final String EVT_OHIP_PUT_PROFILE = "OHIP_PUT_PROFILE"; + + @Resource(name = "esignAuditLogRepository") + private EsignAuditLogRepository esignAuditLogRepository; + + @Transactional + public void appendOperation(String eventType, String message, String operatorName, String detailJson) { + append(CATEGORY_OPERATION, eventType, message, operatorName, null, null, null, null, null, + null, null, null, null, null, detailJson); + } + + @Transactional + public void appendCmsOperation( + String httpMethod, + String requestUri, + int responseStatus, + long durationMs, + String operatorName, + String clientIp) { + String eventType = resolveOperationEventType(httpMethod, requestUri); + String message = String.format("%s %s → HTTP %d (%dms)", httpMethod, requestUri, responseStatus, durationMs); + append(CATEGORY_OPERATION, eventType, message, operatorName, null, null, null, null, null, + httpMethod, requestUri, responseStatus, durationMs, clientIp, null); + } + + @Transactional + public void appendThirdParty( + String eventType, + String httpMethod, + String requestUrl, + Integer responseStatus, + long durationMs, + String hotelId, + String message, + String detailJson) { + append(CATEGORY_THIRD_PARTY, eventType, message, null, null, null, null, null, hotelId, + httpMethod, requestUrl, responseStatus, durationMs, null, detailJson); + } + + @Transactional + public void appendConnection( + String eventType, + Long userId, + String message, + String transport, + String clientIp, + String detailJson) { + String detail = detailJson; + if (StringUtils.isNotBlank(transport) && StringUtils.isBlank(detailJson)) { + detail = "{\"transport\":\"" + escapeJson(transport) + "\"}"; + } + append(CATEGORY_CONNECTION, eventType, message, null, userId, null, null, null, null, + null, null, null, null, clientIp, detail); + } + + private void append( + String category, + String eventType, + String message, + String operatorName, + Long userId, + String taskId, + String fileName, + String agentSource, + String hotelId, + String httpMethod, + String requestUrl, + Integer responseStatus, + Long durationMs, + String clientIp, + String detailJson) { + if (StringUtils.isBlank(category) || StringUtils.isBlank(eventType)) { + return; + } + EsignAuditLog row = new EsignAuditLog(); + row.setLogId(UUID.randomUUID().toString()); + row.setLogCategory(category.trim()); + row.setEventType(eventType.trim()); + row.setMessage(trim(message, 512)); + row.setOperatorName(trim(operatorName, 128)); + row.setUserId(userId); + row.setTaskId(trim(taskId, 36)); + row.setFileName(trim(fileName, 512)); + row.setAgentSource(trim(agentSource, 256)); + row.setHotelId(trim(hotelId, 64)); + row.setHttpMethod(trim(httpMethod, 16)); + row.setRequestUrl(trim(requestUrl, 1024)); + row.setResponseStatus(responseStatus); + row.setDurationMs(durationMs); + row.setClientIp(trim(clientIp, 64)); + row.setDetailJson(detailJson); + row.setCreatedAt(Instant.now()); + esignAuditLogRepository.save(row); + } + + @Transactional(readOnly = true) + public EsignAuditLogResponse listLogs(String category, int page, int size, String eventType) { + int safePage = Math.max(page, 0); + int safeSize = size > 0 ? Math.min(size, 200) : 50; + PageRequest pageable = PageRequest.of(safePage, safeSize); + Page result; + String cat = StringUtils.trimToEmpty(category); + if (StringUtils.isBlank(cat)) { + cat = CATEGORY_OPERATION; + } + if (StringUtils.isNotBlank(eventType)) { + result = esignAuditLogRepository.findByLogCategoryAndEventTypeOrderByCreatedAtDesc( + cat, eventType.trim(), pageable); + } else { + result = esignAuditLogRepository.findByLogCategoryOrderByCreatedAtDesc(cat, pageable); + } + + List logs = new ArrayList<>(); + for (EsignAuditLog row : result.getContent()) { + EsignAuditLogResponse.EsignAuditLogItem item = + new EsignAuditLogResponse.EsignAuditLogItem(); + item.setLogId(row.getLogId()); + item.setLogCategory(row.getLogCategory()); + item.setEventType(row.getEventType()); + item.setMessage(row.getMessage()); + item.setOperatorName(row.getOperatorName()); + item.setUserId(row.getUserId()); + item.setTaskId(row.getTaskId()); + item.setFileName(row.getFileName()); + item.setAgentSource(row.getAgentSource()); + item.setHotelId(row.getHotelId()); + item.setHttpMethod(row.getHttpMethod()); + item.setRequestUrl(row.getRequestUrl()); + item.setResponseStatus(row.getResponseStatus()); + item.setDurationMs(row.getDurationMs()); + item.setClientIp(row.getClientIp()); + item.setDetailJson(row.getDetailJson()); + item.setCreatedAt(row.getCreatedAt() != null ? row.getCreatedAt().toString() : null); + logs.add(item); + } + + EsignAuditLogResponse resp = new EsignAuditLogResponse(); + EsignAuditLogResponse.EsignAuditLogData data = + new EsignAuditLogResponse.EsignAuditLogData(); + data.setTotal(result.getTotalElements()); + data.setPage(safePage); + data.setSize(safeSize); + data.setLogs(logs); + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("ok"); + resp.setData(data); + return resp; + } + + private static String resolveOperationEventType(String method, String uri) { + if (uri == null) { + return "CMS_MUTATION"; + } + if (uri.contains("/printer-device-link/review")) { + return "CMS_DEVICE_LINK_REVIEW"; + } + if (uri.contains("/printer-push-rules")) { + return "CMS_PUSH_RULES"; + } + if (uri.contains("/printer-pdf-task-push") || uri.contains("/printer-pdf-task-quick-push")) { + return "CMS_PDF_PUSH"; + } + if (uri.contains("/ios-printer-pdf-task-update")) { + return "CMS_PDF_UPDATE"; + } + if (uri.contains("/hub-promotions")) { + return "CMS_HUB_PROMOTION"; + } + if (uri.contains("/hub-settings")) { + return "CMS_HUB_SETTINGS"; + } + if (uri.contains("/surveys")) { + return "CMS_SURVEY"; + } + if (uri.contains("/printer-push-groups")) { + return "CMS_PUSH_GROUP"; + } + return method + " " + uri; + } + + private static String escapeJson(String s) { + return StringUtils.defaultString(s).replace("\\", "\\\\").replace("\"", "\\\""); + } + + private static String trim(String s, int max) { + if (s == null) { + return null; + } + String t = s.trim(); + if (t.isEmpty()) { + return null; + } + return t.length() > max ? t.substring(0, max) : t; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/audit/support/ThirdPartyAuditDetailBuilder.java b/src/main/java/com/sjmresorts/opsapp/esign/audit/support/ThirdPartyAuditDetailBuilder.java new file mode 100644 index 0000000..485dd34 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/audit/support/ThirdPartyAuditDetailBuilder.java @@ -0,0 +1,92 @@ +package com.sjmresorts.opsapp.esign.audit.support; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.sjmresorts.opsapp.common.dtos.httpclient.HttpResponseResult; +import com.sjmresorts.opsapp.common.utils.SimpleHttpClient; +import org.apache.commons.lang3.StringUtils; + +/** + * 第三方审计 detail_json:结构化 request / response,便于 CMS 查看。 + */ +public final class ThirdPartyAuditDetailBuilder { + + private static final int MAX_TEXT_CHARS = 16_000; + + private ThirdPartyAuditDetailBuilder() {} + + public static String build(SimpleHttpClient.RequestBuilder request, HttpResponseResult response) { + JSONObject root = new JSONObject(); + if (request != null) { + root.put("request", buildRequest(request)); + } + root.put("response", buildResponse(response)); + return root.toJSONString(); + } + + private static JSONObject buildRequest(SimpleHttpClient.RequestBuilder request) { + JSONObject obj = new JSONObject(); + obj.put("method", request.getMethod()); + obj.put("url", request.getRequestUrlWithQuery()); + obj.put("headers", SimpleHttpClient.maskHeadersForLog(request.getHeaders())); + String body = request.getBodyContent(); + if (StringUtils.isNotBlank(body)) { + obj.put("body", sanitizeRequestBody(body)); + } + return obj; + } + + private static JSONObject buildResponse(HttpResponseResult response) { + JSONObject obj = new JSONObject(); + if (response == null) { + return obj; + } + obj.put("status", response.getStatusCode()); + if (response.isTransportError()) { + obj.put("transportError", StringUtils.defaultIfBlank(response.getErrorMessage(), "transport error")); + } + String body = response.getResponseContent(); + if (StringUtils.isNotBlank(body)) { + obj.put("body", parseJsonOrText(truncate(body))); + } + return obj; + } + + /** 请求体中的 PDF base64 不落库全文,只保留长度。 */ + private static Object sanitizeRequestBody(String body) { + try { + JSONObject json = JSON.parseObject(body); + if (json.containsKey("fileAttachment")) { + String att = json.getString("fileAttachment"); + JSONObject redacted = new JSONObject(); + redacted.put("_redacted", "base64"); + redacted.put("length", att != null ? att.length() : 0); + json.put("fileAttachment", redacted); + } + return json; + } catch (Exception e) { + return parseJsonOrText(truncate(body)); + } + } + + private static Object parseJsonOrText(String text) { + if (StringUtils.isBlank(text)) { + return null; + } + try { + return JSON.parse(text); + } catch (Exception e) { + return text; + } + } + + private static String truncate(String value) { + if (value == null) { + return null; + } + if (value.length() <= MAX_TEXT_CHARS) { + return value; + } + return value.substring(0, MAX_TEXT_CHARS) + "...(truncated)"; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/controllers/AppDeepLinkController.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/controllers/AppDeepLinkController.java new file mode 100644 index 0000000..42d8781 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/controllers/AppDeepLinkController.java @@ -0,0 +1,177 @@ +package com.sjmresorts.opsapp.esign.auth.controllers; + +import com.sjmresorts.opsapp.esign.printer.dtos.LocalExeLaunchResult; +import com.sjmresorts.opsapp.esign.printer.services.LocalExeLaunchService; +import com.sjmresorts.opsapp.esign.reservation.support.OperaFlexLinkPageBuilder; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.net.URI; +import java.util.Map; + +@Tag(name = "App Deep Link", description = "唤起桌面 App / exe(免 JWT)") +@RestController +@RequestMapping({"/api/v1/esign/app", "/api/v1/sinosecu/app"}) +@CrossOrigin(origins = "*") +@RequiredArgsConstructor +public class AppDeepLinkController { + + private final LocalExeLaunchService localExeLaunchService; + + @Value("${ops-app.deeplink.url:myapp://open}") + private String deepLinkUrl; + + @Value("${ops-app.deeplink.opera.close-popup:true}") + private boolean operaClosePopup; + + @Value("${ops-app.deeplink.opera.parent-origin:}") + private String operaParentOrigin; + + @Value("${ops-app.deeplink.opera.ext-app-id:0}") + private long operaExtAppId; + + @Value("${ops-app.deeplink.opera.close-delay-ms:100}") + private int operaCloseDelayMs; + + @Value("${ops-app.deeplink.opera.open-delay-ms:1200}") + private int operaOpenDelayMs; + + @Value("${ops-app.deeplink.opera.debug-page:true}") + private boolean operaDebugPage; + + @Value("${ops-app.deeplink.opera.popup-width:100%}") + private String operaPopupWidth; + + @Value("${ops-app.deeplink.opera.popup-height:800px}") + private String operaPopupHeight; + + @Value("${ops-app.deeplink.opera.launch-mode:new-window}") + private String operaLaunchMode; + + @Value("${ops-app.deeplink.opera.close-modal-message:true}") + private boolean operaCloseModalMessage; + + @Value("${ops-app.deeplink.opera.flex-link-origin:http://127.0.0.1:8066}") + private String operaFlexLinkOrigin; + + @Operation( + summary = "HTML 页唤起 exe", + description = "免 JWT。Opera iframe 内只发 CLOSE_SHELL 信号;外层代理脚本负责销毁 Pop-up。独立标签走 myapp:// + window.close()。" + ) + @ApiResponse(responseCode = "200", description = "HTML 页面") + @GetMapping(value = "/launch", produces = "text/html;charset=UTF-8") + public ResponseEntity launchApp() { + return htmlPage(); + } + + @Operation(summary = "本机启动 exe(JSON,便于调试)") + @GetMapping("/launch/trigger") + public ResponseEntity> launchTrigger() { + LocalExeLaunchResult result = localExeLaunchService.launchIfEnabled(); + return ResponseEntity.ok(Map.of( + "launched", result.isLaunched(), + "skipped", result.isSkipped(), + "message", result.getMessage())); + } + + @Operation(summary = "302 重定向到自定义协议") + @GetMapping("/redirect") + public ResponseEntity redirectApp() { + return ResponseEntity.status(HttpStatus.FOUND) + .location(URI.create(deepLinkUrl)) + .header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate") + .build(); + } + + @Operation(summary = "HTML 页(同 /launch)") + @GetMapping(value = "/open", produces = "text/html;charset=UTF-8") + public ResponseEntity openAppPage() { + return htmlPage(); + } + + @Operation( + summary = "Opera 外层代理脚本(Tampermonkey / Console 注入)", + description = "安装在 Opera 主页面。内层 /launch iframe 发 CLOSE_SHELL,此外层全权销毁 Pop-up 外壳。" + ) + @GetMapping(value = "/opera-parent-close-listener.js", produces = "application/javascript;charset=UTF-8") + public ResponseEntity operaParentCloseListener() { + String script = OperaFlexLinkPageBuilder.buildOperaParentCloseListenerScript(operaFlexLinkOrigin); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/javascript;charset=UTF-8")) + .header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate") + .body(script); + } + + @Operation(summary = "Tampermonkey 脚本(自动在 Opera 域注入 closeModal 监听器)") + @GetMapping(value = "/opera-parent-close-listener.user.js", produces = "text/plain;charset=UTF-8") + public ResponseEntity operaParentCloseListenerUserScript() { + String script = OperaFlexLinkPageBuilder.buildOperaParentCloseListenerUserScript(operaFlexLinkOrigin); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("text/plain;charset=UTF-8")) + .header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate") + .body(script); + } + + @Operation(summary = "外层代理安装说明页") + @GetMapping(value = "/opera-proxy-install", produces = "text/html;charset=UTF-8") + public ResponseEntity operaProxyInstallPage() { + String html = OperaFlexLinkPageBuilder.buildOperaProxyInstallPage(operaFlexLinkOrigin); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("text/html;charset=UTF-8")) + .header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate") + .body(html); + } + + @Operation(summary = "JavaScript 片段") + @GetMapping(value = "/open.js", produces = "application/javascript;charset=UTF-8") + public ResponseEntity openAppScript() { + String script = "window.location.href = \"" + escapeForJsString(deepLinkUrl) + "\";"; + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("application/javascript;charset=UTF-8")) + .header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate") + .body(script); + } + + private ResponseEntity htmlPage() { + LocalExeLaunchResult launchResult = localExeLaunchService.launchIfEnabled(); + boolean useBrowserDeepLink = !launchResult.isLaunched(); + int openDelay = launchResult.isLaunched() ? 200 : operaOpenDelayMs; + + String html = OperaFlexLinkPageBuilder.buildLaunchPage( + deepLinkUrl, + operaLaunchMode, + useBrowserDeepLink, + launchResult.getMessage(), + operaClosePopup, + operaCloseModalMessage, + operaParentOrigin, + operaFlexLinkOrigin, + operaExtAppId, + openDelay, + operaCloseDelayMs, + operaDebugPage, + operaPopupWidth, + operaPopupHeight); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType("text/html;charset=UTF-8")) + .header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate") + .body(html); + } + + private static String escapeForJsString(String value) { + return String.valueOf(value) + .replace("\\", "\\\\") + .replace("\"", "\\\""); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/controllers/EsignAppUserController.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/controllers/EsignAppUserController.java new file mode 100644 index 0000000..ce80d77 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/controllers/EsignAppUserController.java @@ -0,0 +1,45 @@ +package com.sjmresorts.opsapp.esign.auth.controllers; + +import com.sjmresorts.opsapp.esign.auth.dtos.EsignAppUserResponse; +import com.sjmresorts.opsapp.esign.auth.dtos.EsignAppUserSaveRequest; +import com.sjmresorts.opsapp.esign.auth.dtos.EsignAppUserStatusRequest; +import com.sjmresorts.opsapp.esign.auth.services.EsignAppUserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@Tag(name = "Esign App Users", description = "CMS 管理平板 App 登录账号(esign_user)") +@RestController +@RequestMapping({"/api/v1/esign", "/api/v1/sinosecu"}) +@CrossOrigin(origins = "*") +@RequiredArgsConstructor +public class EsignAppUserController { + + private final EsignAppUserService appUserService; + + @Operation(summary = "CMS 平板账号列表", description = "免 JWT。") + @GetMapping(value = "/app-users", produces = "application/json;charset=UTF-8") + public ResponseEntity listAppUsers() { + return ResponseEntity.ok(appUserService.listAll()); + } + + @Operation(summary = "CMS 创建/更新平板账号", description = "免 JWT。密码明文存储,与 App 登录一致。") + @PostMapping(value = "/app-users/save", produces = "application/json;charset=UTF-8") + public ResponseEntity saveAppUser(@RequestBody EsignAppUserSaveRequest body) { + return ResponseEntity.ok(appUserService.save(body)); + } + + @Operation(summary = "CMS 更新平板账号状态", description = "免 JWT。ACTIVE / INACTIVE / LOCKED。") + @PostMapping(value = "/app-users/status", produces = "application/json;charset=UTF-8") + public ResponseEntity updateAppUserStatus( + @RequestBody EsignAppUserStatusRequest body) { + return ResponseEntity.ok(appUserService.updateStatus(body)); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/controllers/EsignHotelController.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/controllers/EsignHotelController.java new file mode 100644 index 0000000..fb9c911 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/controllers/EsignHotelController.java @@ -0,0 +1,115 @@ +package com.sjmresorts.opsapp.esign.auth.controllers; + +import com.sjmresorts.opsapp.esign.auth.dtos.EsignHotelListResponse; +import com.sjmresorts.opsapp.esign.auth.dtos.EsignHotelLogoRequest; +import com.sjmresorts.opsapp.esign.auth.dtos.EsignHotelMutationResponse; +import com.sjmresorts.opsapp.esign.auth.dtos.EsignHotelUpsertRequest; +import com.sjmresorts.opsapp.esign.common.dtos.EsignSimpleResponse; +import com.sjmresorts.opsapp.esign.auth.services.EsignHotelService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.DeleteMapping; +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; + +@Tag(name = "Esign Hotel", description = "旅宿 Hotel 主数据(Electron Hotel 页)") +@RestController +@RequestMapping({"/api/v1/esign/hotels", "/api/v1/sinosecu/hotels"}) +@CrossOrigin(origins = "*") +public class EsignHotelController { + + @Resource + private EsignHotelService esignHotelService; + + @Operation(summary = "Hotel 列表与默认选中", description = "免 JWT。首次访问自动种子化默认酒店。") + @GetMapping(produces = "application/json;charset=UTF-8") + public ResponseEntity listHotels( + @RequestParam(value = "includeLogos", defaultValue = "false") boolean includeLogos) { + return ResponseEntity.ok(esignHotelService.listHotels(includeLogos)); + } + + @Operation(summary = "新增酒店", description = "免 JWT。id 为 slug(小写字母、数字、连字符)。") + @PostMapping(consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") + public ResponseEntity createHotel(@RequestBody EsignHotelUpsertRequest request) { + EsignHotelMutationResponse body = esignHotelService.createHotel(request); + return mutationResponse(body); + } + + @Operation(summary = "更新酒店文字信息", description = "免 JWT。") + @PutMapping( + value = "/{hotelId}", + consumes = "application/json;charset=UTF-8", + produces = "application/json;charset=UTF-8") + public ResponseEntity updateHotel( + @PathVariable("hotelId") String hotelId, @RequestBody EsignHotelUpsertRequest request) { + EsignHotelMutationResponse body = esignHotelService.updateHotel(hotelId, request); + return mutationResponse(body); + } + + @Operation(summary = "删除酒店(软删除)", description = "免 JWT。至少保留一家启用酒店。") + @DeleteMapping(value = "/{hotelId}", produces = "application/json;charset=UTF-8") + public ResponseEntity deleteHotel(@PathVariable("hotelId") String hotelId) { + EsignSimpleResponse body = esignHotelService.deleteHotel(hotelId); + if (!body.isSuccess() && "404".equals(body.getCode())) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "设置默认酒店", description = "免 JWT。") + @PutMapping(value = "/default/{hotelId}", produces = "application/json;charset=UTF-8") + public ResponseEntity setDefault(@PathVariable("hotelId") String hotelId) { + EsignSimpleResponse body = esignHotelService.setDefaultHotel(hotelId); + if (!body.isSuccess() && "404".equals(body.getCode())) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "上传或清除 Hotel Logo", description = "免 JWT。body.logoData 为 data:image/* 或空。") + @PutMapping( + value = "/{hotelId}/logo", + consumes = "application/json;charset=UTF-8", + produces = "application/json;charset=UTF-8") + public ResponseEntity updateLogo( + @PathVariable("hotelId") String hotelId, @RequestBody(required = false) EsignHotelLogoRequest request) { + String logoData = request != null ? request.getLogoData() : null; + EsignSimpleResponse body = esignHotelService.updateLogo(hotelId, logoData); + if (!body.isSuccess() && "404".equals(body.getCode())) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + return ResponseEntity.ok(body); + } + + private static ResponseEntity mutationResponse(EsignHotelMutationResponse body) { + if (!body.isSuccess() && "404".equals(body.getCode())) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + if (!body.isSuccess() && "409".equals(body.getCode())) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(body); + } + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + return ResponseEntity.ok(body); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/controllers/EsignLoginController.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/controllers/EsignLoginController.java new file mode 100644 index 0000000..d134d06 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/controllers/EsignLoginController.java @@ -0,0 +1,92 @@ +// src/main/java/com/sjmresorts/opsapp/logging/controllers/EsignLoginController.java +package com.sjmresorts.opsapp.esign.auth.controllers; + +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import com.sjmresorts.opsapp.esign.auth.dtos.EsignBindDeviceTokenRequest; +import com.sjmresorts.opsapp.esign.auth.dtos.EsignLoginRequest; +import com.sjmresorts.opsapp.esign.auth.dtos.EsignLoginResponse; +import com.sjmresorts.opsapp.esign.common.dtos.EsignSimpleResponse; +import com.sjmresorts.opsapp.esign.auth.services.EsignLoginService; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.lang3.StringUtils; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Esign Authentication", description = "APIs for Esign user authentication") +@RestController +@RequestMapping({"/api/v1/esign/auth", "/api/v1/sinosecu/auth"}) +@CrossOrigin(origins = "*") +@RequiredArgsConstructor +public class EsignLoginController { + + private final EsignLoginService loginService; + + @Operation( + summary = "Esign User Login", + description = "API for Esign user login with username and password" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Login successful"), + @ApiResponse(responseCode = "401", description = "Invalid username or password"), + @ApiResponse(responseCode = "403", description = "User is inactive or locked") + }) + @PostMapping(value = "/login", produces = "application/json;charset=UTF-8") + public ResponseEntity login( + HttpServletRequest httpRequest, + @Parameter(description = "Login request info", required = true) + @Valid @RequestBody EsignLoginRequest request) { + + EsignLoginResponse response = loginService.login( + request, com.sjmresorts.opsapp.common.utils.NetworkUtil.getClientIpByRequest(httpRequest)); + + if (response.isSuccess()) { + return ResponseEntity.status(HttpStatus.OK).body(response); + } else if ("401".equals(response.getCode())) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); + } else if ("403".equals(response.getCode())) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(response); + } else { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); + } + } + + @Operation( + summary = "Bind device token for push notification", + description = "Save FCM/APNs token for current Esign user. Requires Bearer accessToken from login." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Token bound"), + @ApiResponse(responseCode = "401", description = "Unauthorized") + }) + @PostMapping(value = "/bind-device-token", produces = "application/json;charset=UTF-8") + public ResponseEntity bindDeviceToken( + HttpServletRequest request, + @Valid @RequestBody EsignBindDeviceTokenRequest body) { + LoginUserInfo loginUser = JwtTokenUtil.parseUserFromRequest(request); + Long userId = null; + if (loginUser != null && StringUtils.isNotBlank(loginUser.getEmployeeId())) { + try { + userId = Long.parseLong(loginUser.getEmployeeId()); + } catch (NumberFormatException ignored) { + userId = null; + } + } + EsignSimpleResponse response = loginService.bindDeviceToken(userId, body); + HttpStatus status = response.isSuccess() ? HttpStatus.OK : HttpStatus.UNAUTHORIZED; + if ("404".equals(response.getCode())) { + status = HttpStatus.NOT_FOUND; + } else if ("403".equals(response.getCode())) { + status = HttpStatus.FORBIDDEN; + } + return ResponseEntity.status(status).body(response); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignAppUserResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignAppUserResponse.java new file mode 100644 index 0000000..f5cd0e8 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignAppUserResponse.java @@ -0,0 +1,48 @@ +package com.sjmresorts.opsapp.esign.auth.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignAppUserResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignAppUserItem data; + private List items; + + @Data + @NoArgsConstructor + public static class EsignAppUserItem { + private Long id; + private String userName; + private String role; + private String hotelId; + private String status; + private String createdAt; + private String updatedAt; + private String lastLoginAt; + private String lastLoginIp; + private boolean hasDeviceToken; + } + + public static EsignAppUserResponse ok(String message) { + EsignAppUserResponse r = new EsignAppUserResponse(); + r.setMessage(message); + return r; + } + + public static EsignAppUserResponse fail(String code, String message) { + EsignAppUserResponse r = new EsignAppUserResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignAppUserSaveRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignAppUserSaveRequest.java new file mode 100644 index 0000000..72dc345 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignAppUserSaveRequest.java @@ -0,0 +1,23 @@ +package com.sjmresorts.opsapp.esign.auth.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignAppUserSaveRequest { + + private Long id; + /** 平板 App 登录账号 */ + private String userName; + /** 明文密码(新建必填;编辑时留空表示不修改) */ + private String password; + /** 角色,默认 STAFF */ + private String role; + /** 绑定酒店(推送等按 hotel_id 过滤) */ + private String hotelId; + /** ACTIVE / INACTIVE / LOCKED */ + private String status; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignAppUserStatusRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignAppUserStatusRequest.java new file mode 100644 index 0000000..dddfdbd --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignAppUserStatusRequest.java @@ -0,0 +1,13 @@ +package com.sjmresorts.opsapp.esign.auth.dtos; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EsignAppUserStatusRequest { + + private Long id; + /** ACTIVE / INACTIVE / LOCKED */ + private String status; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignBindDeviceTokenRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignBindDeviceTokenRequest.java new file mode 100644 index 0000000..6fff663 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignBindDeviceTokenRequest.java @@ -0,0 +1,28 @@ +package com.sjmresorts.opsapp.esign.auth.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "Bind FCM/APNs device token for Esign user") +public class EsignBindDeviceTokenRequest { + + @NotBlank(message = "deviceToken must not be blank") + @Schema(description = "FCM or APNs device token") + private String deviceToken; + + @Schema(description = "Device unique id from client") + private String deviceId; + + @Schema(description = "Device display name") + private String deviceName; + + @Schema(description = "OS version string") + private String deviceVersion; + + @Schema(description = "Platform: android | ios") + private String platform; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelDto.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelDto.java new file mode 100644 index 0000000..0c44ecb --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelDto.java @@ -0,0 +1,26 @@ +package com.sjmresorts.opsapp.esign.auth.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "旅宿 Hotel 配置项") +public class EsignHotelDto { + + @Schema(description = "酒店 ID(slug)") + private String id; + + private String area; + private String nameZh; + private String nameEn; + private String address; + private String website; + + @Schema(description = "可选 HTTPS Logo 地址") + private String logoUrl; + + @Schema(description = "是否已上传本机 Logo(仅存库,列表不返回像素数据)") + private boolean hasLogoData; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelListResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelListResponse.java new file mode 100644 index 0000000..07ac555 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelListResponse.java @@ -0,0 +1,26 @@ +package com.sjmresorts.opsapp.esign.auth.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Data +@NoArgsConstructor +@Schema(description = "Hotel 列表与默认选中") +public class EsignHotelListResponse { + + private boolean success = true; + private String code = "200"; + private String message = "ok"; + private EsignHotelListData data = new EsignHotelListData(); + + @Data + @NoArgsConstructor + public static class EsignHotelListData { + private List hotels = new ArrayList<>(); + private String defaultHotelId; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelLogoRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelLogoRequest.java new file mode 100644 index 0000000..010a49d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelLogoRequest.java @@ -0,0 +1,14 @@ +package com.sjmresorts.opsapp.esign.auth.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "上传或清除 Hotel Logo(data URL)") +public class EsignHotelLogoRequest { + + @Schema(description = "data:image/* Base64;传 null 或空字符串表示清除") + private String logoData; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelMutationResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelMutationResponse.java new file mode 100644 index 0000000..d7810c0 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelMutationResponse.java @@ -0,0 +1,31 @@ +package com.sjmresorts.opsapp.esign.auth.dtos; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EsignHotelMutationResponse { + + private boolean success; + private String code; + private String message; + private EsignHotelDto data; + + public static EsignHotelMutationResponse ok(String message, EsignHotelDto data) { + EsignHotelMutationResponse r = new EsignHotelMutationResponse(); + r.setSuccess(true); + r.setCode("200"); + r.setMessage(message != null ? message : "ok"); + r.setData(data); + return r; + } + + public static EsignHotelMutationResponse fail(String code, String message) { + EsignHotelMutationResponse r = new EsignHotelMutationResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelUpsertRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelUpsertRequest.java new file mode 100644 index 0000000..69a2a7d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignHotelUpsertRequest.java @@ -0,0 +1,26 @@ +package com.sjmresorts.opsapp.esign.auth.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "创建/更新 Hotel 文字信息") +public class EsignHotelUpsertRequest { + + @Schema(description = "酒店 ID(slug,仅创建时必填),如 venetian、grand-dragon") + private String id; + + private String area; + + @Schema(description = "中文名称", requiredMode = Schema.RequiredMode.REQUIRED) + private String nameZh; + + private String nameEn; + private String address; + private String website; + + @Schema(description = "可选 HTTPS Logo 地址(与 logo 上传二选一或并存,上传优先)") + private String logoUrl; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignLoginRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignLoginRequest.java new file mode 100644 index 0000000..63bee6a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignLoginRequest.java @@ -0,0 +1,31 @@ +// src/main/java/com/sjmresorts/opsapp/logging/dtos/EsignLoginRequest.java +package com.sjmresorts.opsapp.esign.auth.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Esign User Login Request") +public class EsignLoginRequest { + + @NotBlank(message = "userName is required") + @Schema(description = "用户名", example = "collector001", required = true) + private String userName; + + @NotBlank(message = "password is required") + @Schema(description = "密码", example = "password123", required = true) + private String password; + + @Schema(description = "FCM/APNs device token from mobile client") + private String deviceToken; + + @Schema(description = "Client device id") + private String deviceId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignLoginResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignLoginResponse.java new file mode 100644 index 0000000..1b54c10 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/dtos/EsignLoginResponse.java @@ -0,0 +1,61 @@ +// src/main/java/com/sjmresorts/opsapp/logging/dtos/EsignLoginResponse.java +package com.sjmresorts.opsapp.esign.auth.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Esign User Login Response") +public class EsignLoginResponse { + + @Schema(description = "登录是否成功") + private boolean success = false; + + @Schema(description = "状态码", example = "200") + private String code; + + @Schema(description = "结果描述", example = "Login successful") + private String message; + + @Schema(description = "JWT访问令牌") + private String accessToken; + + @Schema(description = "用户ID") + private String employeeId; + + @Schema(description = "用户名") + private String userName; + + @Schema(description = "酒店ID") + private String hotelId; + + @Schema(description = "用户角色") + private String role; + + public static EsignLoginResponse success(String accessToken, Long userId, String userName, String hotelId, String role) { + EsignLoginResponse response = new EsignLoginResponse(); + response.setSuccess(true); + response.setCode("200"); + response.setMessage("Login successful"); + response.setAccessToken(accessToken); + response.setEmployeeId(String.valueOf(userId)); + response.setUserName(userName); + response.setHotelId(hotelId); + response.setRole(role); + return response; + } + + public static EsignLoginResponse fail(String code, String message) { + EsignLoginResponse response = new EsignLoginResponse(); + response.setSuccess(false); + response.setCode(code); + response.setMessage(message); + return response; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/models/EsignHotel.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/models/EsignHotel.java new file mode 100644 index 0000000..5b88c49 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/models/EsignHotel.java @@ -0,0 +1,58 @@ +package com.sjmresorts.opsapp.esign.auth.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_hotels") +public class EsignHotel { + + @Id + @Column(name = "hotel_id", nullable = false, length = 64) + private String hotelId; + + @Column(name = "area", length = 128) + private String area; + + @Column(name = "name_zh", nullable = false, length = 256) + private String nameZh; + + @Column(name = "name_en", length = 512) + private String nameEn; + + @Column(name = "address", length = 1000) + private String address; + + @Column(name = "website", length = 256) + private String website; + + @Column(name = "logo_url", length = 2000) + private String logoUrl; + + @Lob + @Column(name = "logo_data") + private String logoData; + + @Column(name = "sort_order", nullable = false) + private int sortOrder; + + @Column(name = "enabled", nullable = false) + private boolean enabled = true; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/models/EsignHotelSetting.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/models/EsignHotelSetting.java new file mode 100644 index 0000000..8a42e79 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/models/EsignHotelSetting.java @@ -0,0 +1,31 @@ +package com.sjmresorts.opsapp.esign.auth.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_hotel_settings") +public class EsignHotelSetting { + + public static final String KEY_DEFAULT_HOTEL_ID = "default_hotel_id"; + + @Id + @Column(name = "setting_key", nullable = false, length = 64) + private String settingKey; + + @Column(name = "setting_value", length = 256) + private String settingValue; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/models/EsignUser.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/models/EsignUser.java new file mode 100644 index 0000000..3c44f13 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/models/EsignUser.java @@ -0,0 +1,63 @@ +// src/main/java/com/sjmresorts/opsapp/logging/models/EsignUser.java +package com.sjmresorts.opsapp.esign.auth.models; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_user") +public class EsignUser { + + @Id + @Column(name = "id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_name", nullable = false, length = 50) + private String userName; + + @Column(name = "password", nullable = false, length = 255) + private String password; + + @Column(name = "role", nullable = false, length = 20) + private String role; + + @Column(name = "hotel_id", length = 64) + private String hotelId; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "device_token", length = 512) + private String deviceToken; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "last_login_at") + private Instant lastLoginAt; + + @Column(name = "last_login_ip", length = 64) + private String lastLoginIp; + + @PrePersist + protected void onCreate() { + createdAt = Instant.now(); + updatedAt = Instant.now(); + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/repositories/EsignHotelRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/repositories/EsignHotelRepository.java new file mode 100644 index 0000000..d93ccfe --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/repositories/EsignHotelRepository.java @@ -0,0 +1,15 @@ +package com.sjmresorts.opsapp.esign.auth.repositories; + +import com.sjmresorts.opsapp.esign.auth.models.EsignHotel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface EsignHotelRepository extends JpaRepository { + + List findByEnabledTrueOrderBySortOrderAscHotelIdAsc(); + + long countByEnabledTrue(); + + java.util.Optional findFirstByEnabledTrueOrderBySortOrderDescHotelIdDesc(); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/repositories/EsignHotelSettingRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/repositories/EsignHotelSettingRepository.java new file mode 100644 index 0000000..95a0d9a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/repositories/EsignHotelSettingRepository.java @@ -0,0 +1,6 @@ +package com.sjmresorts.opsapp.esign.auth.repositories; + +import com.sjmresorts.opsapp.esign.auth.models.EsignHotelSetting; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EsignHotelSettingRepository extends JpaRepository {} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/repositories/EsignUserRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/repositories/EsignUserRepository.java new file mode 100644 index 0000000..de1996a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/repositories/EsignUserRepository.java @@ -0,0 +1,56 @@ +// src/main/java/com/sjmresorts/opsapp/logging/repositories/EsignUserRepository.java +package com.sjmresorts.opsapp.esign.auth.repositories; + +import com.sjmresorts.opsapp.esign.auth.models.EsignUser; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +@Repository +public interface EsignUserRepository extends JpaRepository { + + Optional findByUserName(String userName); + + Optional findByUserNameAndPassword(String userName, String password); + + List findByIdIn(Collection ids); + + Optional findByDeviceToken(String deviceToken); + + @Query(""" + SELECT u FROM EsignUser u + WHERE UPPER(u.status) = UPPER(:status) + AND u.deviceToken IS NOT NULL + AND TRIM(u.deviceToken) <> '' + AND u.hotelId IS NOT NULL + AND TRIM(u.hotelId) <> '' + AND UPPER(TRIM(u.hotelId)) = UPPER(TRIM(:hotelId)) + """) + List findPushRecipientsByHotel(@Param("hotelId") String hotelId, @Param("status") String status); + + @Query(""" + SELECT u FROM EsignUser u + WHERE UPPER(u.status) = UPPER(:status) + AND u.deviceToken IS NOT NULL + AND TRIM(u.deviceToken) <> '' + """) + List findActivePushRecipients(@Param("status") String status); + + @Query(""" + SELECT u FROM EsignUser u + WHERE UPPER(u.status) = UPPER(:status) + ORDER BY u.lastLoginAt DESC + """) + List findByStatusOrderByLastLoginAtDesc(@Param("status") String status); + + List findAllByOrderByUserNameAsc(); + + boolean existsByUserNameIgnoreCase(String userName); + + boolean existsByUserNameIgnoreCaseAndIdNot(String userName, Long id); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/services/EsignAppUserService.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/services/EsignAppUserService.java new file mode 100644 index 0000000..daca1d8 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/services/EsignAppUserService.java @@ -0,0 +1,179 @@ +package com.sjmresorts.opsapp.esign.auth.services; + +import com.sjmresorts.opsapp.esign.auth.dtos.EsignAppUserResponse; +import com.sjmresorts.opsapp.esign.auth.dtos.EsignAppUserSaveRequest; +import com.sjmresorts.opsapp.esign.auth.dtos.EsignAppUserStatusRequest; +import com.sjmresorts.opsapp.esign.auth.models.EsignUser; +import com.sjmresorts.opsapp.esign.auth.repositories.EsignHotelRepository; +import com.sjmresorts.opsapp.esign.auth.repositories.EsignUserRepository; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; + +@Slf4j +@Service +public class EsignAppUserService { + + private static final String DEFAULT_ROLE = "STAFF"; + private static final String DEFAULT_STATUS = "ACTIVE"; + private static final Set ALLOWED_STATUSES = Set.of("ACTIVE", "INACTIVE", "LOCKED"); + private static final Set ALLOWED_ROLES = Set.of("STAFF", "ADMIN"); + + @Resource + private EsignUserRepository esignUserRepository; + + @Resource + private EsignHotelRepository hotelRepository; + + @Transactional(readOnly = true) + public EsignAppUserResponse listAll() { + EsignAppUserResponse resp = EsignAppUserResponse.ok("ok"); + resp.setItems(toItems(esignUserRepository.findAllByOrderByUserNameAsc())); + return resp; + } + + @Transactional + public EsignAppUserResponse save(EsignAppUserSaveRequest request) { + if (request == null) { + return EsignAppUserResponse.fail("400", "请求体不能为空"); + } + String userName = trim(request.getUserName(), 50); + if (StringUtils.isBlank(userName)) { + return EsignAppUserResponse.fail("400", "请填写登录账号"); + } + String role = normalizeRole(request.getRole()); + String status = normalizeStatus(request.getStatus()); + String hotelId = trim(request.getHotelId(), 64); + if (StringUtils.isNotBlank(hotelId) && hotelRepository.findById(hotelId).isEmpty()) { + return EsignAppUserResponse.fail("400", "酒店不存在:" + hotelId); + } + + boolean creating = request.getId() == null; + EsignUser row; + if (creating) { + if (esignUserRepository.existsByUserNameIgnoreCase(userName)) { + return EsignAppUserResponse.fail("409", "该登录账号已存在"); + } + String password = StringUtils.trimToEmpty(request.getPassword()); + if (password.length() < 4) { + return EsignAppUserResponse.fail("400", "密码至少 4 位"); + } + row = new EsignUser(); + row.setUserName(userName); + row.setPassword(password); + row.setRole(role); + row.setStatus(status); + row.setHotelId(StringUtils.isBlank(hotelId) ? null : hotelId); + } else { + Optional opt = esignUserRepository.findById(request.getId()); + if (opt.isEmpty()) { + return EsignAppUserResponse.fail("404", "账号不存在"); + } + row = opt.get(); + if (!userName.equalsIgnoreCase(row.getUserName())) { + return EsignAppUserResponse.fail("400", "登录账号创建后不可修改"); + } + row.setRole(role); + row.setStatus(status); + row.setHotelId(StringUtils.isBlank(hotelId) ? null : hotelId); + String password = StringUtils.trimToEmpty(request.getPassword()); + if (StringUtils.isNotBlank(password)) { + if (password.length() < 4) { + return EsignAppUserResponse.fail("400", "密码至少 4 位"); + } + row.setPassword(password); + } + } + + row = esignUserRepository.save(row); + log.info("Esign app user {} id={} status={}", creating ? "created" : "updated", row.getId(), row.getStatus()); + EsignAppUserResponse resp = EsignAppUserResponse.ok(creating ? "已创建平板账号" : "已更新平板账号"); + resp.setData(toItem(row)); + return resp; + } + + @Transactional + public EsignAppUserResponse updateStatus(EsignAppUserStatusRequest request) { + if (request == null || request.getId() == null) { + return EsignAppUserResponse.fail("400", "请指定账号 ID"); + } + String status = normalizeStatus(request.getStatus()); + Optional opt = esignUserRepository.findById(request.getId()); + if (opt.isEmpty()) { + return EsignAppUserResponse.fail("404", "账号不存在"); + } + EsignUser row = opt.get(); + row.setStatus(status); + row = esignUserRepository.save(row); + EsignAppUserResponse resp = EsignAppUserResponse.ok("状态已更新"); + resp.setData(toItem(row)); + return resp; + } + + private List toItems(List rows) { + List items = new ArrayList<>(); + if (rows == null) { + return items; + } + for (EsignUser row : rows) { + items.add(toItem(row)); + } + return items; + } + + private EsignAppUserResponse.EsignAppUserItem toItem(EsignUser row) { + EsignAppUserResponse.EsignAppUserItem item = new EsignAppUserResponse.EsignAppUserItem(); + item.setId(row.getId()); + item.setUserName(row.getUserName()); + item.setRole(row.getRole()); + item.setHotelId(row.getHotelId()); + item.setStatus(row.getStatus()); + item.setCreatedAt(row.getCreatedAt() != null ? row.getCreatedAt().toString() : null); + item.setUpdatedAt(row.getUpdatedAt() != null ? row.getUpdatedAt().toString() : null); + item.setLastLoginAt(row.getLastLoginAt() != null ? row.getLastLoginAt().toString() : null); + item.setLastLoginIp(row.getLastLoginIp()); + item.setHasDeviceToken(StringUtils.isNotBlank(row.getDeviceToken())); + return item; + } + + private static String normalizeRole(String raw) { + String role = StringUtils.upperCase(StringUtils.trimToEmpty(raw), Locale.ROOT); + if (StringUtils.isBlank(role)) { + return DEFAULT_ROLE; + } + if (!ALLOWED_ROLES.contains(role)) { + return DEFAULT_ROLE; + } + return role; + } + + private static String normalizeStatus(String raw) { + String status = StringUtils.upperCase(StringUtils.trimToEmpty(raw), Locale.ROOT); + if (StringUtils.isBlank(status)) { + return DEFAULT_STATUS; + } + if (!ALLOWED_STATUSES.contains(status)) { + return DEFAULT_STATUS; + } + return status; + } + + private static String trim(String raw, int maxLen) { + if (raw == null) { + return ""; + } + String s = raw.trim(); + if (s.length() > maxLen) { + return s.substring(0, maxLen); + } + return s; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/services/EsignHotelService.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/services/EsignHotelService.java new file mode 100644 index 0000000..4c1a478 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/services/EsignHotelService.java @@ -0,0 +1,419 @@ +package com.sjmresorts.opsapp.esign.auth.services; + +import com.sjmresorts.opsapp.esign.auth.dtos.EsignHotelDto; +import com.sjmresorts.opsapp.esign.auth.dtos.EsignHotelListResponse; +import com.sjmresorts.opsapp.esign.auth.dtos.EsignHotelMutationResponse; +import com.sjmresorts.opsapp.esign.auth.dtos.EsignHotelUpsertRequest; +import com.sjmresorts.opsapp.esign.common.dtos.EsignSimpleResponse; +import com.sjmresorts.opsapp.esign.auth.models.EsignHotel; +import com.sjmresorts.opsapp.esign.auth.models.EsignHotelSetting; +import com.sjmresorts.opsapp.esign.auth.repositories.EsignHotelRepository; +import com.sjmresorts.opsapp.esign.auth.repositories.EsignHotelSettingRepository; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Pattern; + +@Service +public class EsignHotelService { + + public static final int LOGO_DATA_MAX_LEN = 700_000; + + private static final Pattern HOTEL_ID_PATTERN = Pattern.compile("^[a-z0-9]+(?:-[a-z0-9]+)*$"); + + private final EsignHotelRepository hotelRepository; + private final EsignHotelSettingRepository settingRepository; + + public EsignHotelService( + EsignHotelRepository hotelRepository, + EsignHotelSettingRepository settingRepository) { + this.hotelRepository = hotelRepository; + this.settingRepository = settingRepository; + } + + private void ensureSeeded() { + if (hotelRepository.count() > 0) { + return; + } + Instant now = Instant.now(); + int order = 0; + for (SeedHotel s : defaultSeedHotels()) { + EsignHotel h = new EsignHotel(); + h.setHotelId(s.id); + h.setArea(s.area); + h.setNameZh(s.nameZh); + h.setNameEn(s.nameEn); + h.setAddress(s.address); + h.setWebsite(s.website); + h.setSortOrder(order++); + h.setEnabled(true); + h.setCreatedAt(now); + h.setUpdatedAt(now); + hotelRepository.save(h); + } + EsignHotelSetting setting = new EsignHotelSetting(); + setting.setSettingKey(EsignHotelSetting.KEY_DEFAULT_HOTEL_ID); + setting.setSettingValue("venetian"); + setting.setUpdatedAt(now); + settingRepository.save(setting); + } + + @Transactional + public EsignHotelListResponse listHotels(boolean includeLogos) { + ensureSeeded(); + EsignHotelListResponse resp = new EsignHotelListResponse(); + EsignHotelListResponse.EsignHotelListData data = resp.getData(); + List dtos = new ArrayList<>(); + for (EsignHotel row : hotelRepository.findByEnabledTrueOrderBySortOrderAscHotelIdAsc()) { + dtos.add(toDto(row, includeLogos)); + } + data.setHotels(dtos); + data.setDefaultHotelId(resolveDefaultHotelId(dtos)); + return resp; + } + + /** 详情/採集:含 logo_data 解析后的 logoUrl */ + @Transactional(readOnly = true) + public EsignHotelDto getHotelForPayload(String hotelId) { + if (StringUtils.isBlank(hotelId)) { + return null; + } + return hotelRepository.findById(hotelId.trim()).filter(EsignHotel::isEnabled).map(this::toDtoWithLogo).orElse(null); + } + + @Transactional(readOnly = true) + public EsignHotelDto getDefaultHotelForPayload() { + EsignHotelListResponse list = listHotels(true); + String id = list.getData().getDefaultHotelId(); + if (StringUtils.isBlank(id)) { + return null; + } + return getHotelForPayload(id); + } + + @Transactional + public EsignSimpleResponse setDefaultHotel(String hotelId) { + if (StringUtils.isBlank(hotelId)) { + return EsignSimpleResponse.fail("400", "hotelId required"); + } + String id = hotelId.trim(); + if (!hotelRepository.existsById(id)) { + return EsignSimpleResponse.fail("404", "Hotel not found"); + } + EsignHotel row = hotelRepository.findById(id).orElse(null); + if (row == null || !row.isEnabled()) { + return EsignSimpleResponse.fail("404", "Hotel not found"); + } + Instant now = Instant.now(); + EsignHotelSetting setting = + settingRepository + .findById(EsignHotelSetting.KEY_DEFAULT_HOTEL_ID) + .orElseGet( + () -> { + EsignHotelSetting s = new EsignHotelSetting(); + s.setSettingKey(EsignHotelSetting.KEY_DEFAULT_HOTEL_ID); + return s; + }); + setting.setSettingValue(id); + setting.setUpdatedAt(now); + settingRepository.save(setting); + return EsignSimpleResponse.ok("default hotel updated"); + } + + @Transactional + public EsignHotelMutationResponse createHotel(EsignHotelUpsertRequest request) { + if (request == null) { + return EsignHotelMutationResponse.fail("400", "request body required"); + } + String id = normalizeHotelId(request.getId()); + if (StringUtils.isBlank(id)) { + return EsignHotelMutationResponse.fail("400", "id required (slug: a-z, 0-9, hyphen)"); + } + if (!HOTEL_ID_PATTERN.matcher(id).matches()) { + return EsignHotelMutationResponse.fail("400", "invalid id format"); + } + String nameZh = trimToNull(request.getNameZh()); + if (StringUtils.isBlank(nameZh)) { + return EsignHotelMutationResponse.fail("400", "nameZh required"); + } + Instant now = Instant.now(); + Optional existing = hotelRepository.findById(id); + EsignHotel h; + if (existing.isPresent()) { + h = existing.get(); + if (h.isEnabled()) { + return EsignHotelMutationResponse.fail("409", "hotel id already exists"); + } + applyTextFields(h, request, nameZh); + h.setEnabled(true); + h.setUpdatedAt(now); + } else { + h = new EsignHotel(); + h.setHotelId(id); + applyTextFields(h, request, nameZh); + h.setSortOrder(nextSortOrder()); + h.setEnabled(true); + h.setCreatedAt(now); + h.setUpdatedAt(now); + } + hotelRepository.save(h); + return EsignHotelMutationResponse.ok("hotel created", toDto(h, false)); + } + + @Transactional + public EsignHotelMutationResponse updateHotel(String hotelId, EsignHotelUpsertRequest request) { + if (StringUtils.isBlank(hotelId)) { + return EsignHotelMutationResponse.fail("400", "hotelId required"); + } + if (request == null) { + return EsignHotelMutationResponse.fail("400", "request body required"); + } + String id = hotelId.trim(); + Optional opt = hotelRepository.findById(id); + if (opt.isEmpty() || !opt.get().isEnabled()) { + return EsignHotelMutationResponse.fail("404", "Hotel not found"); + } + String nameZh = trimToNull(request.getNameZh()); + if (StringUtils.isBlank(nameZh)) { + return EsignHotelMutationResponse.fail("400", "nameZh required"); + } + EsignHotel h = opt.get(); + applyTextFields(h, request, nameZh); + h.setUpdatedAt(Instant.now()); + hotelRepository.save(h); + return EsignHotelMutationResponse.ok("hotel updated", toDto(h, false)); + } + + @Transactional + public EsignSimpleResponse deleteHotel(String hotelId) { + if (StringUtils.isBlank(hotelId)) { + return EsignSimpleResponse.fail("400", "hotelId required"); + } + String id = hotelId.trim(); + Optional opt = hotelRepository.findById(id); + if (opt.isEmpty() || !opt.get().isEnabled()) { + return EsignSimpleResponse.fail("404", "Hotel not found"); + } + if (hotelRepository.countByEnabledTrue() <= 1) { + return EsignSimpleResponse.fail("400", "cannot delete the last hotel"); + } + EsignHotel h = opt.get(); + h.setEnabled(false); + h.setUpdatedAt(Instant.now()); + hotelRepository.save(h); + + String defaultId = + settingRepository + .findById(EsignHotelSetting.KEY_DEFAULT_HOTEL_ID) + .map(EsignHotelSetting::getSettingValue) + .orElse(null); + if (id.equals(defaultId)) { + List remaining = hotelRepository.findByEnabledTrueOrderBySortOrderAscHotelIdAsc(); + if (!remaining.isEmpty()) { + EsignHotelSetting setting = + settingRepository + .findById(EsignHotelSetting.KEY_DEFAULT_HOTEL_ID) + .orElseGet( + () -> { + EsignHotelSetting s = new EsignHotelSetting(); + s.setSettingKey(EsignHotelSetting.KEY_DEFAULT_HOTEL_ID); + return s; + }); + setting.setSettingValue(remaining.get(0).getHotelId()); + setting.setUpdatedAt(Instant.now()); + settingRepository.save(setting); + } + } + return EsignSimpleResponse.ok("hotel deleted"); + } + + @Transactional + public EsignSimpleResponse updateLogo(String hotelId, String logoData) { + if (StringUtils.isBlank(hotelId)) { + return EsignSimpleResponse.fail("400", "hotelId required"); + } + Optional opt = hotelRepository.findById(hotelId.trim()); + if (opt.isEmpty() || !opt.get().isEnabled()) { + return EsignSimpleResponse.fail("404", "Hotel not found"); + } + EsignHotel h = opt.get(); + if (logoData == null || StringUtils.isBlank(logoData)) { + h.setLogoData(null); + } else { + String s = logoData.trim(); + if (!s.regionMatches(true, 0, "data:image/", 0, 11)) { + return EsignSimpleResponse.fail("400", "logoData must be data:image/*"); + } + if (s.length() > LOGO_DATA_MAX_LEN) { + return EsignSimpleResponse.fail("400", "logoData too large"); + } + h.setLogoData(s); + } + h.setUpdatedAt(Instant.now()); + hotelRepository.save(h); + return EsignSimpleResponse.ok("logo updated"); + } + + private void applyTextFields(EsignHotel h, EsignHotelUpsertRequest request, String nameZh) { + h.setNameZh(nameZh); + h.setArea(trimToNull(request.getArea())); + h.setNameEn(trimToNull(request.getNameEn())); + h.setAddress(trimToNull(request.getAddress())); + h.setWebsite(trimToNull(request.getWebsite())); + String logoUrl = trimToNull(request.getLogoUrl()); + if (logoUrl != null && logoUrl.length() > 2000) { + logoUrl = logoUrl.substring(0, 2000); + } + h.setLogoUrl(logoUrl); + } + + private int nextSortOrder() { + return hotelRepository + .findFirstByEnabledTrueOrderBySortOrderDescHotelIdDesc() + .map(r -> r.getSortOrder() + 1) + .orElse(0); + } + + private static String normalizeHotelId(String raw) { + if (raw == null) { + return null; + } + return raw.trim().toLowerCase(Locale.ROOT); + } + + private static String trimToNull(String s) { + if (s == null) { + return null; + } + String t = s.trim(); + return t.isEmpty() ? null : t; + } + + private String resolveDefaultHotelId(List hotels) { + String configured = + settingRepository + .findById(EsignHotelSetting.KEY_DEFAULT_HOTEL_ID) + .map(EsignHotelSetting::getSettingValue) + .filter(StringUtils::isNotBlank) + .orElse(null); + if (configured != null && hotels.stream().anyMatch(h -> configured.equals(h.getId()))) { + return configured; + } + return hotels.isEmpty() ? null : hotels.get(0).getId(); + } + + private EsignHotelDto toDto(EsignHotel row, boolean withResolvedLogoUrl) { + EsignHotelDto dto = new EsignHotelDto(); + dto.setId(row.getHotelId()); + dto.setArea(row.getArea()); + dto.setNameZh(row.getNameZh()); + dto.setNameEn(row.getNameEn()); + dto.setAddress(row.getAddress()); + dto.setWebsite(row.getWebsite()); + dto.setHasLogoData(StringUtils.isNotBlank(row.getLogoData())); + if (withResolvedLogoUrl) { + if (StringUtils.isNotBlank(row.getLogoData())) { + dto.setLogoUrl(row.getLogoData().trim()); + } else if (StringUtils.isNotBlank(row.getLogoUrl())) { + dto.setLogoUrl(row.getLogoUrl().trim()); + } else { + dto.setLogoUrl(faviconFromWebsite(row.getWebsite())); + } + } else { + dto.setLogoUrl(trimToNull(row.getLogoUrl())); + } + return dto; + } + + private EsignHotelDto toDtoWithLogo(EsignHotel row) { + return toDto(row, true); + } + + private static String faviconFromWebsite(String website) { + if (StringUtils.isBlank(website)) { + return null; + } + String w = + website.trim() + .toLowerCase() + .replaceFirst("^https?://", "") + .replaceFirst("^www\\.", ""); + String host = w.split("/")[0].replaceAll(":\\d+$", ""); + if (host.isEmpty()) { + return null; + } + return "https://www.google.com/s2/favicons?domain=" + + java.net.URLEncoder.encode(host, java.nio.charset.StandardCharsets.UTF_8) + + "&sz=96"; + } + + private static List defaultSeedHotels() { + List list = new ArrayList<>(); + list.add( + new SeedHotel( + "venetian", + "氹仔 Taipa", + "澳門威尼斯人酒店", + "The Venetian Macao (五星 5-Star)", + "澳門望德聖母灣大馬路,路氹金光大道 Estrada da Baía de N. Senhora da Esperança, s/n, Taipa, Macau", + "www.venetianmacao.com")); + list.add( + new SeedHotel( + "grand-dragon", + "氹仔 Taipa", + "駿龍酒店", + "Grand Dragon Hotel (四星 4-Star)", + "澳門氹仔沙維斯街 36 號 Rua de Chaves, n.º 36, Taipa, Macau", + "www.granddragon.com.mo")); + list.add( + new SeedHotel( + "grandview", + "氹仔 Taipa", + "君怡酒店", + "Grandview Hotel (四星 4-Star)", + "氹仔柯維納馬路 142 號和佛山街 16 號 Estrada Governador Albano de Oliveira, n.º 142 e Rua de Fat San, n.º 16, Taipa", + "www.grandview-hotel.com")); + list.add( + new SeedHotel( + "parisian", + "氹仔 Taipa", + "澳門巴黎人酒店", + "The Parisian Macao (三星 3-Star)", + "澳門路氹金光大道連貫公路 Estrada do Istmo, Lote 3, Cotai Strip, Macau", + "www.parisianmacao.com")); + list.add( + new SeedHotel( + "royal", + "澳門 Macao", + "皇都酒店", + "Royal Hotel (五星 5-Star)", + "澳門得勝馬路 2 號-4 號 Estrada da Vitória, nºs. 2-4, Macau", + "www.hotelroyal.com.mo")); + list.add( + new SeedHotel( + "landmark", + "澳門 Macao", + "新東方置地酒店", + "New Orient Landmark Hotel (五星 5-Star)", + "澳門新口岸友誼大馬路 555 號 555 Avenida da Amizade, Macau", + "www.landmarkhotel.com.mo")); + list.add( + new SeedHotel( + "rio", + "澳門 Macao", + "利澳酒店", + "Rio Macau (四星 4-Star)", + "澳門高美士街 33 號及宋玉生廣場 682 號 Rua de Luís Gonzaga Gomes, n.º 33 e Alameda Dr. Carlos D'Assumpção, n.º 682, Macau", + "www.riomacau.com")); + return list; + } + + private record SeedHotel( + String id, String area, String nameZh, String nameEn, String address, String website) {} +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/services/EsignLoginService.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/services/EsignLoginService.java new file mode 100644 index 0000000..fd2a830 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/services/EsignLoginService.java @@ -0,0 +1,118 @@ +// src/main/java/com/sjmresorts/opsapp/logging/services/EsignLoginService.java +package com.sjmresorts.opsapp.esign.auth.services; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.sjmresorts.opsapp.esign.auth.dtos.EsignBindDeviceTokenRequest; +import com.sjmresorts.opsapp.esign.auth.dtos.EsignLoginRequest; +import com.sjmresorts.opsapp.esign.auth.dtos.EsignLoginResponse; +import com.sjmresorts.opsapp.esign.common.dtos.EsignSimpleResponse; +import com.sjmresorts.opsapp.esign.auth.models.EsignUser; +import com.sjmresorts.opsapp.esign.auth.repositories.EsignUserRepository; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EsignLoginService { + + private final EsignUserRepository userRepository; + + @Transactional + public EsignLoginResponse login(EsignLoginRequest request, String clientIp) { + // 根据用户名和密码查找用户(明文验证) + EsignUser user = userRepository.findByUserNameAndPassword( + request.getUserName(), request.getPassword()).orElse(null); + + if (user == null) { + return EsignLoginResponse.fail("401", "Invalid username or password"); + } + + // 检查用户状态 + if (!"ACTIVE".equalsIgnoreCase(user.getStatus())) { + if ("INACTIVE".equalsIgnoreCase(user.getStatus())) { + return EsignLoginResponse.fail("403", "User is inactive"); + } else if ("LOCKED".equalsIgnoreCase(user.getStatus())) { + return EsignLoginResponse.fail("403", "User is locked"); + } + return EsignLoginResponse.fail("403", "User status is invalid"); + } + + user.setLastLoginAt(Instant.now()); + String normalizedIp = EsignUserPresenceService.normalizeClientIp(clientIp); + if (normalizedIp != null) { + user.setLastLoginIp(normalizedIp); + } + boolean tokenUpdated = applyDeviceToken(user, request.getDeviceToken()); + userRepository.save(user); + log.info("Esign login user={}, deviceTokenUpdated={}", user.getUserName(), tokenUpdated); + + // 生成JWT Token,包含 user_id, username, hotel_id, role + String token = generateToken(user); + + return EsignLoginResponse.success( + token, + user.getId(), + user.getUserName(), + user.getHotelId(), + user.getRole() + ); + } + + @Transactional + public EsignSimpleResponse bindDeviceToken(Long userId, EsignBindDeviceTokenRequest request) { + if (userId == null) { + return EsignSimpleResponse.fail("401", "Unauthorized"); + } + EsignUser user = userRepository.findById(userId).orElse(null); + if (user == null) { + return EsignSimpleResponse.fail("404", "User not found"); + } + if (!"ACTIVE".equalsIgnoreCase(user.getStatus())) { + return EsignSimpleResponse.fail("403", "User status is invalid"); + } + applyDeviceToken(user, request.getDeviceToken()); + userRepository.save(user); + return EsignSimpleResponse.ok("Device token bound"); + } + + private boolean applyDeviceToken(EsignUser user, String deviceToken) { + if (StringUtils.isNotBlank(deviceToken)) { + user.setDeviceToken(deviceToken.trim()); + return true; + } + return false; + } + + private String generateToken(EsignUser user) { + // 平板 App 长连接:使用 app_session 有效期,避免 5 分钟 accessToken 导致 WS 重连失败 + long expiryMinutes = JwtTokenUtil.getAppSessionExpiration(); + long expiryMillis = expiryMinutes * 60 * 1000; + Date now = new Date(); + Date expDate = new Date(now.getTime() + expiryMillis); + String uuid = UUID.randomUUID().toString(); + + // 正确的 JWT 生成方式 + return JWT.create() + .withIssuer("esign_login") + .withIssuedAt(now) + .withNotBefore(now) + .withExpiresAt(expDate) + .withJWTId(uuid) + .withClaim("employeeId", String.valueOf(user.getId())) + .withClaim("userName", user.getUserName()) + .withClaim("hotelId", user.getHotelId() != null ? user.getHotelId() : "") + .withClaim("role", user.getRole()) + .withSubject(String.valueOf(user.getId())) + .sign(Algorithm.HMAC256(JwtTokenUtil.getSecretKey())); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/auth/services/EsignUserPresenceService.java b/src/main/java/com/sjmresorts/opsapp/esign/auth/services/EsignUserPresenceService.java new file mode 100644 index 0000000..5a738cd --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/auth/services/EsignUserPresenceService.java @@ -0,0 +1,178 @@ +package com.sjmresorts.opsapp.esign.auth.services; + +import com.sjmresorts.opsapp.esign.auth.models.EsignUser; +import com.sjmresorts.opsapp.esign.auth.repositories.EsignUserRepository; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 记录 App 客户端 IP,并按 IP 将多台账号视为同一物理设备(保留最近活跃账号)。 + */ +@Slf4j +@Service +public class EsignUserPresenceService { + + @Resource + private EsignUserRepository esignUserRepository; + + public static String normalizeClientIp(String ip) { + if (StringUtils.isBlank(ip)) { + return null; + } + String t = ip.trim(); + if ("0:0:0:0:0:0:0:1".equals(t)) { + return "127.0.0.1"; + } + int comma = t.indexOf(','); + if (comma > 0) { + t = t.substring(0, comma).trim(); + } + return t.isEmpty() ? null : t; + } + + @Transactional + public void touchClientIp(Long userId, String clientIp) { + if (userId == null) { + return; + } + String ip = normalizeClientIp(clientIp); + if (ip == null) { + return; + } + esignUserRepository.findById(userId).ifPresent(user -> { + if (!ip.equals(user.getLastLoginIp())) { + user.setLastLoginIp(ip); + esignUserRepository.save(user); + log.debug("Updated client IP userId={}, ip={}", userId, ip); + } + }); + } + + @Transactional(readOnly = true) + public LinkedHashSet dedupeUserIdsByClientIp(Collection userIds) { + if (userIds == null || userIds.isEmpty()) { + return new LinkedHashSet<>(); + } + Set ids = userIds.stream().filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + if (ids.isEmpty()) { + return new LinkedHashSet<>(); + } + Map users = loadUsers(ids); + return dedupeUserIdsByClientIp(ids, users::get); + } + + @Transactional(readOnly = true) + public List dedupeUsersByClientIp(List users) { + if (users == null || users.isEmpty()) { + return List.of(); + } + Map bestByIp = new LinkedHashMap<>(); + List withoutIp = new ArrayList<>(); + for (EsignUser user : users) { + if (user == null || user.getId() == null) { + continue; + } + String ip = normalizeClientIp(user.getLastLoginIp()); + if (ip == null) { + withoutIp.add(user); + continue; + } + EsignUser existing = bestByIp.get(ip); + if (existing == null || isMoreRecentlyActive(user, existing)) { + bestByIp.put(ip, user); + } + } + List result = new ArrayList<>(bestByIp.values()); + result.addAll(withoutIp); + result.sort(Comparator + .comparing(EsignUser::getLastLoginAt, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(EsignUser::getId, Comparator.nullsLast(Comparator.reverseOrder()))); + return result; + } + + @Transactional(readOnly = true) + public int countUniqueClientIps(Collection userIds) { + return dedupeUserIdsByClientIp(userIds).size(); + } + + private LinkedHashSet dedupeUserIdsByClientIp( + Collection userIds, + Function userLoader) { + Map bestUserIdByIp = new LinkedHashMap<>(); + LinkedHashSet withoutIp = new LinkedHashSet<>(); + for (Long userId : userIds) { + if (userId == null) { + continue; + } + EsignUser user = userLoader.apply(userId); + String ip = user != null ? normalizeClientIp(user.getLastLoginIp()) : null; + if (ip == null) { + withoutIp.add(userId); + continue; + } + Long existingId = bestUserIdByIp.get(ip); + if (existingId == null) { + bestUserIdByIp.put(ip, userId); + continue; + } + EsignUser existing = userLoader.apply(existingId); + if (isMoreRecentlyActive(user, existing)) { + bestUserIdByIp.put(ip, userId); + } + } + LinkedHashSet result = new LinkedHashSet<>(bestUserIdByIp.values()); + result.addAll(withoutIp); + return result; + } + + private Map loadUsers(Set userIds) { + Map map = new LinkedHashMap<>(); + for (Long id : userIds) { + esignUserRepository.findById(id).ifPresent(u -> map.put(id, u)); + } + return map; + } + + private static boolean isMoreRecentlyActive(EsignUser candidate, EsignUser incumbent) { + if (candidate == null) { + return false; + } + if (incumbent == null) { + return true; + } + Instant c = candidate.getLastLoginAt(); + Instant i = incumbent.getLastLoginAt(); + if (c == null) { + return false; + } + if (i == null) { + return true; + } + int cmp = c.compareTo(i); + if (cmp != 0) { + return cmp > 0; + } + Long cId = candidate.getId(); + Long iId = incumbent.getId(); + if (cId == null || iId == null) { + return false; + } + return cId > iId; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/config/EsignCaptureSchemaMigrator.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/config/EsignCaptureSchemaMigrator.java new file mode 100644 index 0000000..e8e4f77 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/config/EsignCaptureSchemaMigrator.java @@ -0,0 +1,153 @@ +package com.sjmresorts.opsapp.esign.capture.config; + +import com.sjmresorts.opsapp.common.db.DbDialect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; + +/** 证件采集表结构迁移(多库方言;由 EsignCmsSchemaMigrator 在 JPA 启动前调用)。 */ +@Component("esignCaptureSchemaMigrator") +public class EsignCaptureSchemaMigrator { + + private static final Logger log = LoggerFactory.getLogger(EsignCaptureSchemaMigrator.class); + + private final JdbcTemplate jdbc; + private final DbDialect dialect; + + public EsignCaptureSchemaMigrator( + @Qualifier("opsAppDataSource") DataSource dataSource, DbDialect dialect) { + this.jdbc = new JdbcTemplate(dataSource); + this.dialect = dialect; + } + + public void ensureTables() { + try { + renameLegacyCaptureTablesIfNeeded(); + ensureCaptureTasksTable(); + ensureCaptureTaskImagesTable(); + renameLegacyImageObjectNamesIfNeeded(); + } catch (RuntimeException e) { + log.error("Esign capture migrate failed", e); + throw e; + } + if (!dialect.tableExists(jdbc, "esign_capture_tasks")) { + throw new IllegalStateException( + "esign_capture_tasks 仍不存在,请检查库权限或手工执行 scripts 下对应库的建表脚本"); + } + log.info("Esign capture migrate: esign_capture_tasks ready ({})", dialect.type()); + } + + private void renameLegacyCaptureTablesIfNeeded() { + if (!dialect.tableExists(jdbc, "esign_capture_tasks") + && dialect.tableExists(jdbc, "sinosecu_capture_tasks")) { + log.info("Esign capture migrate: renaming sinosecu_capture_tasks -> esign_capture_tasks"); + dialect.renameTable(jdbc, "sinosecu_capture_tasks", "esign_capture_tasks"); + } + if (!dialect.tableExists(jdbc, "esign_capture_task_images") + && dialect.tableExists(jdbc, "sinosecu_capture_task_images")) { + log.info( + "Esign capture migrate: renaming sinosecu_capture_task_images -> esign_capture_task_images"); + dialect.renameTable(jdbc, "sinosecu_capture_task_images", "esign_capture_task_images"); + } + } + + private void renameLegacyImageObjectNamesIfNeeded() { + if (!dialect.tableExists(jdbc, "esign_capture_task_images")) { + return; + } + renameObjectIfExists("uq_sinosecu_img_task_slot", "uq_esign_img_task_slot"); + renameObjectIfExists("fk_sinosecu_img_task", "fk_esign_capture_img_task"); + renameObjectIfExists("ix_sinosecu_capture_task_images_task", "ix_esign_capture_task_images_task"); + } + + private void renameObjectIfExists(String oldName, String newName) { + if (!dialect.objectExists(jdbc, oldName) || dialect.objectExists(jdbc, newName)) { + return; + } + log.info("Esign capture migrate: renaming {} -> {}", oldName, newName); + dialect.renameObject(jdbc, oldName, newName); + } + + private void ensureCaptureTasksTable() { + if (dialect.tableExists(jdbc, "esign_capture_tasks")) { + add("esign_capture_tasks", "signed_at", dialect.datetime() + " NULL"); + add("esign_capture_tasks", "sign_remark", dialect.varchar(2000) + " NULL"); + add("esign_capture_tasks", "sign_signature", dialect.text() + " NULL"); + return; + } + log.info("Esign capture migrate: creating esign_capture_tasks"); + jdbc.execute( + "CREATE TABLE esign_capture_tasks (" + + "task_id " + + dialect.varchar(36) + + " NOT NULL PRIMARY KEY, " + + "payload_json " + + dialect.text() + + " NOT NULL, " + + "employee_id " + + dialect.varchar(128) + + " NULL, " + + "status " + + dialect.varchar(32) + + " NOT NULL, " + + "created_at " + + dialect.datetime() + + " NOT NULL, " + + "signed_at " + + dialect.datetime() + + " NULL, " + + "sign_remark " + + dialect.varchar(2000) + + " NULL, " + + "sign_signature " + + dialect.text() + + " NULL)"); + } + + private void ensureCaptureTaskImagesTable() { + if (dialect.tableExists(jdbc, "esign_capture_task_images") + || dialect.tableExists(jdbc, "sinosecu_capture_task_images")) { + return; + } + if (!dialect.tableExists(jdbc, "esign_capture_tasks")) { + return; + } + if (dialect.objectExists(jdbc, "uq_sinosecu_img_task_slot") + || dialect.objectExists(jdbc, "uq_esign_img_task_slot")) { + log.warn( + "Esign capture migrate: skip creating esign_capture_task_images; " + + "unique constraint already exists (legacy sinosecu table may need rename)"); + return; + } + log.info("Esign capture migrate: creating esign_capture_task_images"); + jdbc.execute( + "CREATE TABLE esign_capture_task_images (" + + dialect.bigintIdentityPrimaryKey("id") + + ", " + + "task_id " + + dialect.varchar(36) + + " NOT NULL, " + + "slot_key " + + dialect.varchar(64) + + " NOT NULL, " + + "image_data " + + dialect.text() + + " NOT NULL, " + + "CONSTRAINT uq_esign_img_task_slot UNIQUE (task_id, slot_key), " + + "CONSTRAINT fk_esign_capture_img_task FOREIGN KEY (task_id) " + + "REFERENCES esign_capture_tasks (task_id) ON DELETE CASCADE)"); + jdbc.execute( + "CREATE INDEX ix_esign_capture_task_images_task ON esign_capture_task_images (task_id)"); + } + + private void add(String table, String column, String typeSql) { + if (!dialect.columnExists(jdbc, table, column)) { + log.info("Esign capture migrate: adding {}.{}", table, column); + jdbc.execute(dialect.alterAddColumn(table, column, typeSql)); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/controllers/EsignCaptureController.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/controllers/EsignCaptureController.java new file mode 100644 index 0000000..08441b4 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/controllers/EsignCaptureController.java @@ -0,0 +1,177 @@ +package com.sjmresorts.opsapp.esign.capture.controllers; + +import com.auth0.jwt.JWTVerifier; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import com.sjmresorts.opsapp.common.utils.NetworkUtil; +import com.sjmresorts.opsapp.esign.capture.dtos.EsignIosCaptureResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPendingListResponse; +import com.sjmresorts.opsapp.esign.reservation.dtos.EsignReservationTaskCompleteRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignSignTaskRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignTaskDetailResponse; +import com.sjmresorts.opsapp.esign.capture.services.EsignCaptureService; +import com.sjmresorts.opsapp.esign.reservation.services.EsignReservationTaskService; +import com.sjmresorts.opsapp.esign.auth.services.EsignUserPresenceService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +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 java.util.Map; + +@Tag(name = "Esign", description = "证件采集与待签任务") +@RestController +@RequestMapping({"/api/v1/esign", "/api/v1/sinosecu"}) +@CrossOrigin(origins = "*") +public class EsignCaptureController { + + @Resource + private EsignCaptureService esignCaptureService; + + @Resource + private EsignReservationTaskService esignReservationTaskService; + + @Resource + private EsignUserPresenceService userPresenceService; + + private Long resolveUserIdAndTouchClientIp(HttpServletRequest request) { + Long userId = resolveUserIdIfVerifiedJwt(request); + if (userId != null) { + userPresenceService.touchClientIp(userId, NetworkUtil.getClientIpByRequest(request)); + } + return userId; + } + + @Operation(summary = "提交证件采集结果(待 iOS 签名)", description = "免 JWT;任务进入待签池,由 GET ios-pending-tasks 广播拉取(不按工号过滤)。") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "创建成功"), + @ApiResponse(responseCode = "500", description = "服务器错误") + }) + @PostMapping(value = "/ios-capture-submit", produces = "application/json;charset=UTF-8") + public ResponseEntity submitIosCapture( + HttpServletRequest request, + @RequestBody Map body) { + String employeeId = resolveEmployeeIdIfVerifiedJwt(request); + + try { + String taskId = esignCaptureService.createTask(employeeId, body); + EsignIosCaptureResponse ok = new EsignIosCaptureResponse(); + ok.setSuccess(true); + ok.setCode("200"); + ok.setMessage("Capture task created"); + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(taskId); + ok.setData(data); + return ResponseEntity.status(HttpStatus.OK).body(ok); + } catch (JsonProcessingException e) { + EsignIosCaptureResponse err = new EsignIosCaptureResponse(); + err.setSuccess(false); + err.setCode("500"); + err.setMessage("Failed to serialize payload"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(err); + } + } + + @Operation(summary = "待签任务列表", description = "带有效 JWT 时仅返回推送给当前平板账号的虚拟打印机 PDF 待签;无 JWT 时广播证件/预订待签。可通过 hotelId 筛选酒店。") + @GetMapping(value = "/ios-pending-tasks", produces = "application/json;charset=UTF-8") + public ResponseEntity listPendingTasks( + HttpServletRequest request, + @RequestParam(value = "hotelId", required = false) String hotelId) { + Long userId = resolveUserIdAndTouchClientIp(request); + String clientIp = NetworkUtil.getClientIpByRequest(request); + EsignPendingListResponse body = esignCaptureService.listTaskBucket("pending", hotelId, userId, clientIp); + return ResponseEntity.status(HttpStatus.OK).body(body); + } + + @Operation(summary = "任务列表按桶查询", description = "带有效 JWT 时仅返回当前平板账号的虚拟打印机 PDF;无 JWT 时广播证件/预订。bucket=pending|signed|voided。") + @GetMapping(value = "/ios-task-list", produces = "application/json;charset=UTF-8") + public ResponseEntity listTasksByBucket( + HttpServletRequest request, + @RequestParam(value = "bucket", required = false) String bucket, + @RequestParam(value = "hotelId", required = false) String hotelId) { + Long userId = resolveUserIdAndTouchClientIp(request); + String clientIp = NetworkUtil.getClientIpByRequest(request); + EsignPendingListResponse body = esignCaptureService.listTaskBucket(bucket, hotelId, userId, clientIp); + return ResponseEntity.status(HttpStatus.OK).body(body); + } + + @Operation(summary = "iOS 签收提交", description = "免 JWT。仅待签收且创建未超 24 小时可提交。") + @PostMapping(value = "/ios-task-sign", consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") + public ResponseEntity signTask(@RequestBody EsignSignTaskRequest request) { + String taskId = request != null ? request.getTaskId() : null; + String remark = request != null ? request.getSignRemark() : null; + String signature = request != null ? request.getSignSignature() : null; + EsignIosCaptureResponse body = esignCaptureService.signTask(taskId, remark, signature); + return ResponseEntity.status(HttpStatus.OK).body(body); + } + + @Operation(summary = "预订待处理任务完成", description = "免 JWT。用户打开登记卡后调用,从待签列表移除。") + @PostMapping(value = "/ios-reservation-task-complete", consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") + public ResponseEntity completeReservationTask( + @RequestBody EsignReservationTaskCompleteRequest request) { + String taskId = request != null ? request.getTaskId() : null; + String reservationId = request != null ? request.getReservationId() : null; + String hotelId = request != null ? request.getHotelId() : null; + EsignIosCaptureResponse body = + esignReservationTaskService.completeTask(taskId, reservationId, hotelId); + return ResponseEntity.status(HttpStatus.OK).body(body); + } + + @Operation(summary = "待签任务详情", description = "免 JWT。返回落库时的完整 JSON payload,供 App 展示表单。") + @GetMapping(value = "/ios-task/{taskId}", produces = "application/json;charset=UTF-8") + public ResponseEntity getTaskDetail(@PathVariable("taskId") String taskId) { + EsignTaskDetailResponse body = esignCaptureService.getTaskDetail(taskId); + if (!body.isSuccess() && "404".equals(body.getCode())) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + if (!body.isSuccess() && "500".equals(body.getCode())) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body); + } + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + return ResponseEntity.status(HttpStatus.OK).body(body); + } + + /** 白名单接口:仅在校验通过后才从 JWT 取 employeeId,避免伪造。 */ + private static String resolveEmployeeIdIfVerifiedJwt(HttpServletRequest request) { + Long userId = resolveUserIdIfVerifiedJwt(request); + return userId != null ? String.valueOf(userId) : null; + } + + private static Long resolveUserIdIfVerifiedJwt(HttpServletRequest request) { + String auth = request.getHeader("Authorization"); + if (StringUtils.isBlank(auth) || !auth.startsWith("Bearer ")) { + return null; + } + String token = auth.substring(7).trim(); + if (StringUtils.isBlank(token)) { + return null; + } + try { + JWTVerifier jwtVerifier = JwtTokenUtil.getJwtVerifier(); + jwtVerifier.verify(token); + LoginUserInfo user = JwtTokenUtil.parseUserFromToken(token); + if (user == null || StringUtils.isBlank(user.getEmployeeId())) { + return null; + } + return Long.parseLong(user.getEmployeeId()); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/controllers/EsignLogController.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/controllers/EsignLogController.java new file mode 100644 index 0000000..29e559a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/controllers/EsignLogController.java @@ -0,0 +1,88 @@ +package com.sjmresorts.opsapp.esign.capture.controllers; + +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import com.sjmresorts.opsapp.esign.capture.dtos.CreateEsignLogRequest; +import com.sjmresorts.opsapp.esign.capture.dtos.CreateEsignLogResponse; +import com.sjmresorts.opsapp.esign.capture.dtos.EsignLogDetailDto; +import com.sjmresorts.opsapp.esign.capture.dtos.EsignLogListDto; +import com.sjmresorts.opsapp.esign.capture.dtos.EsignLogQueryRequest; +import com.sjmresorts.opsapp.esign.capture.models.EsignLog; +import com.sjmresorts.opsapp.esign.capture.services.EsignLogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Capture Logs", description = "证件采集日志(Capture Log);主路径 /api/v1/esign/capture-logs,兼容 /api/v1/logs") +@RestController +@RequestMapping({"/api/v1/esign/capture-logs", "/api/v1/logs"}) +@CrossOrigin(origins = "*") +@RequiredArgsConstructor +public class EsignLogController { + + private final EsignLogService esignLogService; + + @Operation( + summary = "写入证件采集日志", + description = "POST /api/v1/esign/capture-logs — 记录证件采集流程操作日志(/api/v1/logs 兼容至 2026-09-30)" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "写入成功"), + @ApiResponse(responseCode = "400", description = "请求参数错误"), + @ApiResponse(responseCode = "500", description = "服务器内部错误") + }) + @PostMapping(produces = "application/json;charset=UTF-8") + public ResponseEntity createLog( + @Valid @RequestBody CreateEsignLogRequest request, + HttpServletRequest httpRequest) { // 添加 httpRequest 参数 + try { + // 从 token 中获取 operatorId(即 employeeId) + String operatorId = extractOperatorIdFromToken(httpRequest); + EsignLog saved = esignLogService.createLog(request, operatorId); + return ResponseEntity.ok(CreateEsignLogResponse.ok(saved.getId())); + } catch (Exception e) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(CreateEsignLogResponse.fail(500, "日志写入失败: " + e.getMessage())); + } + } + + // ==================== 分页列表接口 ==================== + @Operation(summary = "查询submit日志列表(有效/无效批次)") + @GetMapping("/list") + public ResponseEntity> list( + @Valid EsignLogQueryRequest request, + @RequestParam(value = "hotelId", required = false) String hotelId) { + return ResponseEntity.ok(esignLogService.querySubmitLogs(request, hotelId)); + } + + // ==================== 详情接口 ==================== + @Operation(summary = "日志详情(左右展示ORC/RFID)") + @GetMapping("/detail/{id}") + public ResponseEntity detail(@PathVariable Long id) { + return ResponseEntity.ok(esignLogService.getLogDetail(id)); + } + + /** + * 从请求头的 JWT token 中提取 operatorId(即 employeeId) + */ + private String extractOperatorIdFromToken(HttpServletRequest request) { + try { + LoginUserInfo user = JwtTokenUtil.parseUserFromRequest(request); + if (user != null) { + return user.getEmployeeId(); + } + } catch (Exception e) { + // token 解析失败,返回 null + } + return null; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/CreateEsignLogRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/CreateEsignLogRequest.java new file mode 100644 index 0000000..f6ac51f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/CreateEsignLogRequest.java @@ -0,0 +1,46 @@ +package com.sjmresorts.opsapp.esign.capture.dtos; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonNaming(PropertyNamingStrategies.SnakeCaseStrategy.class) +@Schema(description = "写入 esign_log 请求") +public class CreateEsignLogRequest { + + @NotBlank(message = "batch_id 不能为空") + @Schema(description = "业务批次ID (UUID v4)", example = "f47ac10b-58cc-4372-a567-0e02b2c3d479") + private String batchId; + + @NotBlank(message = "action_type 不能为空") + @Schema(description = "操作类型: scan_front / scan_back / scan / submit", example = "scan_front") + private String actionType; + + // 新字段 + @Schema(description = "OCR识别当面完整数据(JSON)") + private String orcPayload; + + @Schema(description = "RFID芯片读取数据(JSON),暂未使用") + private String rfidPayload; + + @Schema(description = "酒店ID", example = "hotel_001") + private String hotelId; + + @Schema(description = "任务ID", example = "task_123") + private String taskId; + + @NotBlank(message = "status 不能为空") + @Schema(description = "状态: success / failed", example = "success") + private String status; + + @Schema(description = "错误信息") + private String errorMsg; + + @Schema(description = "操作员ID", example = "op_001") + private String operatorId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/CreateEsignLogResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/CreateEsignLogResponse.java new file mode 100644 index 0000000..32ae15f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/CreateEsignLogResponse.java @@ -0,0 +1,44 @@ +package com.sjmresorts.opsapp.esign.capture.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "写入 esign_log 响应") +public class CreateEsignLogResponse { + + @Schema(description = "状态码", example = "200") + private int code; + + @Schema(description = "说明", example = "ok") + private String msg; + + @Schema(description = "业务数据") + private LogData data; + + @Data + @NoArgsConstructor + public static class LogData { + @Schema(description = "日志ID", example = "1") + private Long id; + } + + public static CreateEsignLogResponse ok(Long id) { + CreateEsignLogResponse resp = new CreateEsignLogResponse(); + resp.setCode(200); + resp.setMsg("ok"); + LogData d = new LogData(); + d.setId(id); + resp.setData(d); + return resp; + } + + public static CreateEsignLogResponse fail(int code, String msg) { + CreateEsignLogResponse resp = new CreateEsignLogResponse(); + resp.setCode(code); + resp.setMsg(msg); + return resp; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/EsignIosCaptureResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/EsignIosCaptureResponse.java new file mode 100644 index 0000000..fa1f3c9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/EsignIosCaptureResponse.java @@ -0,0 +1,49 @@ +package com.sjmresorts.opsapp.esign.capture.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "证件采集提交结果") +public class EsignIosCaptureResponse { + + @Schema(description = "是否成功") + private boolean success; + + @Schema(description = "状态码", example = "200") + private String code; + + @Schema(description = "说明") + private String message; + + @Schema(description = "业务数据") + private EsignIosCaptureData data; + + @Data + @NoArgsConstructor + @Schema(description = "提交后的任务标识") + public static class EsignIosCaptureData { + @Schema(description = "待签任务 ID,供 iOS 通知或拉取") + private String taskId; + + @Schema(description = "从 PDF 解析的 OPERA 确认号") + private String confirmationNumber; + + @Schema(description = "OHIP reservation id(linkId)") + private String reservationId; + + @Schema(description = "是否已提交至 OPERA fileAttachments") + private Boolean ohipUploaded; + + @Schema(description = "签名 PDF 是否已归档至 Java 服务") + private Boolean archived; + + @Schema(description = "PDF 签收后自动打开的问卷 ID") + private String nextSurveyId; + + @Schema(description = "PDF 签收后自动打开的问卷标题") + private String nextSurveyTitle; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/EsignLogDetailDto.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/EsignLogDetailDto.java new file mode 100644 index 0000000..a4763db --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/EsignLogDetailDto.java @@ -0,0 +1,43 @@ +package com.sjmresorts.opsapp.esign.capture.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@Schema(description = "日志详情返回对象") +public class EsignLogDetailDto { + + @Schema(description = "日志ID") + private Long id; + + @Schema(description = "批次号") + private String batchId; + + @Schema(description = "操作类型") + private String actionType; + + @Schema(description = "OCR识别原始数据(左侧展示)", example = "{\"name\":\"张三\",\"idNo\":\"110***...\"}") + private String orcPayload; + + @Schema(description = "RFID读卡原始数据(右侧展示)", example = "{\"cardNo\":\"CARD***...\"}") + private String rfidPayload; + + @Schema(description = "酒店ID") + private String hotelId; + + @Schema(description = "任务ID") + private String taskId; + + @Schema(description = "状态") + private String status; + + @Schema(description = "错误信息") + private String errorMsg; + + @Schema(description = "操作人ID") + private String operatorId; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/EsignLogListDto.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/EsignLogListDto.java new file mode 100644 index 0000000..fb199a3 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/EsignLogListDto.java @@ -0,0 +1,37 @@ +package com.sjmresorts.opsapp.esign.capture.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import java.time.LocalDateTime; + +@Data +@Schema(description = "日志列表返回对象") +public class EsignLogListDto { + + @Schema(description = "日志ID") + private Long id; + + @Schema(description = "批次号") + private String batchId; + + @Schema(description = "操作类型") + private String actionType; + + @Schema(description = "酒店ID") + private String hotelId; + + @Schema(description = "任务ID") + private String taskId; + + @Schema(description = "状态") + private String status; + + @Schema(description = "错误信息") + private String errorMsg; + + @Schema(description = "操作人ID") + private String operatorId; + + @Schema(description = "创建时间") + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/EsignLogQueryRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/EsignLogQueryRequest.java new file mode 100644 index 0000000..eef06fd --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/dtos/EsignLogQueryRequest.java @@ -0,0 +1,24 @@ +package com.sjmresorts.opsapp.esign.capture.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; + +@Data +@Schema(description = "日志列表查询请求") +public class EsignLogQueryRequest { + + @Schema(description = "页码 从1开始", example = "1") + @Min(1) + private int page = 1; + + @Schema(description = "每页条数", example = "10") + @Min(1) @Max(100) + private int size = 10; + + @Schema(description = "类型:valid-有效批次 success / invalid-无效批次 fail", example = "valid") + private String type; // valid / invalid + + private String taskId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/mappers/EsignLogMapper.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/mappers/EsignLogMapper.java new file mode 100644 index 0000000..36d7c66 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/mappers/EsignLogMapper.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.esign.capture.mappers; + +import com.sjmresorts.opsapp.esign.capture.dtos.EsignLogDetailDto; +import com.sjmresorts.opsapp.esign.capture.dtos.EsignLogListDto; +import com.sjmresorts.opsapp.esign.capture.models.EsignLog; +import org.mapstruct.Mapper; +import org.mapstruct.ReportingPolicy; + +import java.util.List; + +@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.IGNORE) +public interface EsignLogMapper { + + // 列表 DTO + EsignLogListDto toEsignLogListDto(EsignLog esignLog); + + List toEsignLogListDtoList(List esignLogList); + + // 详情 DTO + EsignLogDetailDto toEsignLogDetailDto(EsignLog esignLog); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/models/EsignCaptureTask.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/models/EsignCaptureTask.java new file mode 100644 index 0000000..30c2364 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/models/EsignCaptureTask.java @@ -0,0 +1,50 @@ +package com.sjmresorts.opsapp.esign.capture.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_capture_tasks") +public class EsignCaptureTask { + + @Id + @Column(name = "task_id", nullable = false, length = 36) + private String taskId; + + @Lob + @Column(name = "payload_json", nullable = false) + private String payloadJson; + + @Column(name = "employee_id", length = 128) + private String employeeId; + + @Column(name = "status", nullable = false, length = 32) + private String status; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + /** 签收完成时间;仅 status=SIGNED 时有值 */ + @Column(name = "signed_at") + private Instant signedAt; + + /** 签收备注(App 提交,可选文字) */ + @Column(name = "sign_remark", length = 2000) + private String signRemark; + + /** 客户手写签名(PNG Base64 data URL) */ + @Lob + @Column(name = "sign_signature") + private String signSignature; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/models/EsignCaptureTaskImage.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/models/EsignCaptureTaskImage.java new file mode 100644 index 0000000..398401f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/models/EsignCaptureTaskImage.java @@ -0,0 +1,42 @@ +package com.sjmresorts.opsapp.esign.capture.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * 採集影像等大字段与主任务表分离,减轻列表/解析 payload 时的 IO 与内存。 + */ +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table( + name = "esign_capture_task_images", + uniqueConstraints = @UniqueConstraint(name = "uq_esign_img_task_slot", columnNames = {"task_id", "slot_key"}) +) +public class EsignCaptureTaskImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "task_id", nullable = false, length = 36) + private String taskId; + + @Column(name = "slot_key", nullable = false, length = 64) + private String slotKey; + + @Lob + @Column(name = "image_data", nullable = false) + private String imageData; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/models/EsignLog.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/models/EsignLog.java new file mode 100644 index 0000000..9be9c03 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/models/EsignLog.java @@ -0,0 +1,51 @@ +package com.sjmresorts.opsapp.esign.capture.models; + +import jakarta.persistence.*; +import lombok.Data; + +import java.time.LocalDateTime; + +@Entity +@Table(name = "esign_log") +@Data +public class EsignLog { + + @Id + @Column(name = "id", nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "batch_id", nullable = false, length = 64) + private String batchId; + + @Column(name = "action_type", nullable = false, length = 32) + private String actionType; + + // 替换原来的 person_name / person_id_number + @Lob + @Column(name = "orc_payload") + private String orcPayload; + + @Lob + @Column(name = "rfid_payload") + private String rfidPayload; + + @Column(name = "hotel_id", length = 64) + private String hotelId; + + @Column(name = "task_id", length = 128) + private String taskId; + + @Column(name = "status", nullable = false, length = 32) + private String status; + + @Lob + @Column(name = "error_msg") + private String errorMsg; + + @Column(name = "operator_id", length = 64) + private String operatorId; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/repositories/EsignCaptureTaskImageRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/repositories/EsignCaptureTaskImageRepository.java new file mode 100644 index 0000000..d7ef639 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/repositories/EsignCaptureTaskImageRepository.java @@ -0,0 +1,16 @@ +package com.sjmresorts.opsapp.esign.capture.repositories; + +import com.sjmresorts.opsapp.esign.capture.models.EsignCaptureTaskImage; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface EsignCaptureTaskImageRepository extends JpaRepository { + + List findByTaskIdOrderBySlotKeyAsc(String taskId); + + Optional findFirstByTaskIdAndSlotKey(String taskId, String slotKey); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/repositories/EsignCaptureTaskRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/repositories/EsignCaptureTaskRepository.java new file mode 100644 index 0000000..04145b6 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/repositories/EsignCaptureTaskRepository.java @@ -0,0 +1,23 @@ +package com.sjmresorts.opsapp.esign.capture.repositories; + +import com.sjmresorts.opsapp.esign.capture.models.EsignCaptureTask; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; + +@Repository +public interface EsignCaptureTaskRepository extends JpaRepository { + + /** 待签收:创建时间在最近 24 小时内(含边界) */ + List findTop100ByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc( + String status, Instant createdAtMinInclusive); + + /** 已签收 */ + List findTop100ByStatusOrderBySignedAtDesc(String status); + + /** 已作废:仍为待签状态但创建已超过 24 小时 */ + List findTop100ByStatusAndCreatedAtLessThanOrderByCreatedAtDesc( + String status, Instant createdAtMaxExclusive); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/repositories/EsignLogRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/repositories/EsignLogRepository.java new file mode 100644 index 0000000..2f224f3 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/repositories/EsignLogRepository.java @@ -0,0 +1,38 @@ +package com.sjmresorts.opsapp.esign.capture.repositories; + +import com.sjmresorts.opsapp.esign.capture.models.EsignLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface EsignLogRepository extends JpaRepository { + + List findByActionType(String actionType); + + EsignLog findByTaskId(String taskId); + + @Query("SELECT l FROM EsignLog l WHERE l.batchId IN :batchIds") + Page findByBatchIds(@Param("batchIds") List batchIds, Pageable pageable); + + // 查询所有不重复批次 + @Query("SELECT DISTINCT l.batchId FROM EsignLog l") + List findAllBatchIds(); + + + @Query("SELECT DISTINCT l.batchId FROM EsignLog l WHERE l.hotelId = :hotelId") + List findAllBatchIdsByHotelId(@Param("hotelId") String hotelId); + + + @Query("SELECT l FROM EsignLog l WHERE l.hotelId = :hotelId AND l.actionType = :actionType") + List findByHotelIdAndActionType(@Param("hotelId") String hotelId, @Param("actionType") String actionType); + + + @Query("SELECT l FROM EsignLog l WHERE l.batchId IN :batchIds AND l.hotelId = :hotelId") + Page findByBatchIdsAndHotelId(@Param("batchIds") List batchIds, @Param("hotelId") String hotelId, Pageable pageable); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/services/EsignCaptureService.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/services/EsignCaptureService.java new file mode 100644 index 0000000..7d43d14 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/services/EsignCaptureService.java @@ -0,0 +1,718 @@ +package com.sjmresorts.opsapp.esign.capture.services; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sjmresorts.opsapp.esign.capture.dtos.EsignIosCaptureResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPendingListResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignTaskDetailResponse; +import com.sjmresorts.opsapp.esign.capture.models.EsignCaptureTask; +import com.sjmresorts.opsapp.esign.capture.models.EsignCaptureTaskImage; +import com.sjmresorts.opsapp.esign.capture.repositories.EsignCaptureTaskImageRepository; +import com.sjmresorts.opsapp.esign.capture.repositories.EsignCaptureTaskRepository; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPdfTaskService; +import com.sjmresorts.opsapp.esign.reservation.services.EsignReservationTaskService; +import com.sjmresorts.opsapp.esign.survey.services.EsignSurveyPushService; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.regex.Pattern; + +@Service +public class EsignCaptureService { + + public static final String STATUS_PENDING_SIGNATURE = "PENDING_SIGNATURE"; + public static final String STATUS_SIGNED = "SIGNED"; + + private static final int SIGN_WINDOW_HOURS = 24; + /** 待签列表 API 最多返回条数(证件 + 预订 + PC 打印合并后) */ + private static final int PENDING_LIST_MAX_ITEMS = 50; + + private static final int SIGN_SIGNATURE_MAX_LEN = 800_000; + + /** 採集 JSON 中占位符:詳情接口從 esign_capture_task_images 還原像素數據 */ + public static final String PAYLOAD_IMAGE_DB_MARKER = "__db_image__"; + + /** 客戶手寫簽名在影像表中的 slot(與 sign_signature 欄位雙寫,便於詳情還原) */ + public static final String SIGNATURE_IMAGE_SLOT = "sign.customer"; + + private static final int INLINE_IMAGE_OFFLOAD_MIN_LEN = 480; + + /** 系統檔案編號:澳門本地日期時間 + 8 位隨機數,寫入 payload.systemRefNo */ + private static final ZoneId SYSTEM_REF_ZONE = ZoneId.of("Asia/Macau"); + + @Resource + private EsignCaptureTaskRepository esignCaptureTaskRepository; + + @Resource + private EsignCaptureTaskImageRepository esignCaptureTaskImageRepository; + + @Resource + private ObjectMapper objectMapper; + + @Resource + private EsignReservationTaskService esignReservationTaskService; + + @Resource + private EsignPrinterPdfTaskService esignPrinterPdfTaskService; + + @Resource + private EsignSurveyPushService esignSurveyPushService; + + private Instant signWindowCutoff() { + return Instant.now().minus(SIGN_WINDOW_HOURS, ChronoUnit.HOURS); + } + + /** yyyyMMddHHmmss + 8 位數字(0–99999999) */ + private String newSystemRefNo() { + String dt = ZonedDateTime.now(SYSTEM_REF_ZONE).format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + int rnd = ThreadLocalRandom.current().nextInt(100_000_000); + return dt + String.format("%08d", rnd); + } + + @Transactional + public String createTask(String employeeId, Map payload) throws JsonProcessingException { + String taskId = UUID.randomUUID().toString(); + Map stored = + objectMapper.convertValue(payload, new TypeReference>() { + }); + stored.put("systemRefNo", newSystemRefNo()); + List imageRows = extractImageRowsAndStripPayload(taskId, stored); + + EsignCaptureTask entity = new EsignCaptureTask(); + entity.setTaskId(taskId); + entity.setPayloadJson(objectMapper.writeValueAsString(stored)); + entity.setEmployeeId(employeeId); + entity.setStatus(STATUS_PENDING_SIGNATURE); + entity.setCreatedAt(Instant.now()); + esignCaptureTaskRepository.save(entity); + if (!imageRows.isEmpty()) { + esignCaptureTaskImageRepository.saveAll(imageRows); + } + return taskId; + } + + /** + * 兼容旧接口:仅返回 24 小时内待签收任务。 + */ + public EsignPendingListResponse listPendingBroadcast() { + return listTaskBucket("pending", null); + } + + /** + * @param bucket pending | signed | voided + */ + public EsignPendingListResponse listTaskBucket(String bucket) { + return listTaskBucket(bucket, null); + } + + /** + * @param bucket pending | signed | voided + * @param hotelId 酒店ID,为空时不按酒店筛选 + */ + @Transactional(readOnly = true) + public EsignPendingListResponse listTaskBucket(String bucket, String hotelId) { + return listTaskBucket(bucket, hotelId, null); + } + + public EsignPendingListResponse listTaskBucket(String bucket, String hotelId, Long userId) { + return listTaskBucket(bucket, hotelId, userId, null); + } + + @Transactional(readOnly = true) + public EsignPendingListResponse listTaskBucket( + String bucket, String hotelId, Long userId, String clientIp) { + EsignPendingListResponse out = new EsignPendingListResponse(); + out.setMessage("ok"); + EsignPendingListResponse.EsignPendingData data = new EsignPendingListResponse.EsignPendingData(); + out.setData(data); + String b = StringUtils.isBlank(bucket) ? "pending" : bucket.trim().toLowerCase(); + List items = new ArrayList<>(); + // 已登入 App:僅返回推送給當前平板賬號的虛擬打印機 PDF;證件/預訂廣播僅在未帶 JWT 時保留 + if (userId == null) { + Instant cutoff = signWindowCutoff(); + List rows; + if ("signed".equals(b)) { + rows = esignCaptureTaskRepository.findTop100ByStatusOrderBySignedAtDesc(STATUS_SIGNED); + } else if ("voided".equals(b)) { + rows = esignCaptureTaskRepository.findTop100ByStatusAndCreatedAtLessThanOrderByCreatedAtDesc( + STATUS_PENDING_SIGNATURE, cutoff); + } else { + rows = esignCaptureTaskRepository.findTop100ByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc( + STATUS_PENDING_SIGNATURE, cutoff); + } + boolean filterByHotel = StringUtils.isNotBlank(hotelId); + for (EsignCaptureTask row : rows) { + String payloadJson = row.getPayloadJson(); + + if (filterByHotel) { + String taskHotelId = readSelectedHotelId(payloadJson); + if (!hotelId.equals(taskHotelId)) { + continue; + } + } + + EsignPendingListResponse.EsignPendingItem it = new EsignPendingListResponse.EsignPendingItem(); + it.setTaskId(row.getTaskId()); + it.setCreatedAt(row.getCreatedAt() != null ? row.getCreatedAt().toString() : null); + it.setDocTitle(readDocTitle(row.getPayloadJson())); + it.setGuestName(readGuestName(payloadJson)); + it.setSelectedHotelNameZh(readSelectedHotelNameZh(payloadJson)); + it.setGuestNameZh(readGuestNameZh(payloadJson)); + it.setGuestNameEn(readGuestNameEn(payloadJson)); + it.setSignedAt(row.getSignedAt() != null ? row.getSignedAt().toString() : null); + it.setTaskType("CAPTURE"); + items.add(it); + } + List reservationItems = + esignReservationTaskService.listItemsForBucket(b, hotelId); + EsignReservationTaskService.mergeSortedByCreatedAt(items, reservationItems); + } + List printerItems = + esignPrinterPdfTaskService.listItemsForBucket(b, hotelId, userId, clientIp); + EsignReservationTaskService.mergeSortedByCreatedAt(items, printerItems); + if ("pending".equals(b) && userId != null) { + List surveyItems = + esignSurveyPushService.listPendingItemsForUser(userId, clientIp, hotelId); + EsignReservationTaskService.mergeSortedByCreatedAt(items, surveyItems); + } + if ("pending".equals(b) && items.size() > PENDING_LIST_MAX_ITEMS) { + items.subList(PENDING_LIST_MAX_ITEMS, items.size()).clear(); + } + data.setTasks(items); + return out; + } + + /** + * 从 payload_json 中读取 selectedHotelId + */ + private String readSelectedHotelId(String payloadJson) { + if (StringUtils.isBlank(payloadJson)) { + return null; + } + try { + JsonNode root = objectMapper.readTree(payloadJson); + JsonNode n = root.get("selectedHotelId"); + if (n != null && !n.isNull() && n.isTextual()) { + String t = n.asText("").trim(); + return StringUtils.isNotBlank(t) ? t : null; + } + } catch (Exception e) { + return null; + } + return null; + } + + @Transactional + public EsignIosCaptureResponse signTask(String taskId, String signRemark, String signSignature) { + EsignIosCaptureResponse resp = new EsignIosCaptureResponse(); + if (StringUtils.isBlank(taskId)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("taskId required"); + return resp; + } + if (StringUtils.isBlank(signSignature)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("请提供手写签名"); + return resp; + } + String sig = signSignature.trim(); + if (!sig.regionMatches(true, 0, "data:image/", 0, 11)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("签名格式无效"); + return resp; + } + if (sig.length() > SIGN_SIGNATURE_MAX_LEN) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("签名数据过大"); + return resp; + } + Optional opt = esignCaptureTaskRepository.findById(taskId.trim()); + if (opt.isEmpty()) { + resp.setSuccess(false); + resp.setCode("404"); + resp.setMessage("Task not found"); + return resp; + } + EsignCaptureTask t = opt.get(); + if (!STATUS_PENDING_SIGNATURE.equals(t.getStatus())) { + resp.setSuccess(false); + resp.setCode("409"); + resp.setMessage("任务已签收或状态不可签收"); + return resp; + } + Instant cutoff = signWindowCutoff(); + if (t.getCreatedAt() != null && t.getCreatedAt().isBefore(cutoff)) { + resp.setSuccess(false); + resp.setCode("410"); + resp.setMessage("已超过24小时未签收,任务已作废"); + return resp; + } + t.setStatus(STATUS_SIGNED); + t.setSignedAt(Instant.now()); + t.setSignSignature(sig); + if (signRemark != null) { + String trimmed = signRemark.trim(); + if (StringUtils.isNotBlank(trimmed)) { + int max = 2000; + t.setSignRemark(trimmed.length() > max ? trimmed.substring(0, max) : trimmed); + } + } + esignCaptureTaskRepository.save(t); + persistCustomerSignature(t.getTaskId(), sig); + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("Signed"); + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(t.getTaskId()); + resp.setData(data); + return resp; + } + + @Transactional(readOnly = true) + public EsignTaskDetailResponse getTaskDetail(String taskId) { + EsignTaskDetailResponse r = new EsignTaskDetailResponse(); + if (StringUtils.isBlank(taskId)) { + r.setSuccess(false); + r.setCode("400"); + r.setMessage("taskId required"); + return r; + } + Optional opt = esignCaptureTaskRepository.findById(taskId.trim()); + if (opt.isEmpty()) { + r.setSuccess(false); + r.setCode("404"); + r.setMessage("Task not found"); + return r; + } + EsignCaptureTask t = opt.get(); + try { + Map payload = objectMapper.readValue( + t.getPayloadJson(), + new TypeReference>() { + }); + EsignTaskDetailResponse.EsignTaskDetailData d = new EsignTaskDetailResponse.EsignTaskDetailData(); + d.setTaskId(t.getTaskId()); + d.setStatus(t.getStatus()); + d.setCreatedAt(t.getCreatedAt() != null ? t.getCreatedAt().toString() : null); + d.setSignedAt(t.getSignedAt() != null ? t.getSignedAt().toString() : null); + d.setSignSignature(resolveSignSignatureForDetail(t)); + d.setSignState(computeSignState(t)); + mergeStoredImagesIntoPayload(t.getTaskId(), payload); + d.setPayload(payload); + r.setSuccess(true); + r.setCode("200"); + r.setMessage("ok"); + r.setData(d); + } catch (Exception e) { + r.setSuccess(false); + r.setCode("500"); + r.setMessage("Failed to parse payload"); + } + return r; + } + + private String computeSignState(EsignCaptureTask t) { + if (STATUS_SIGNED.equals(t.getStatus())) { + return "signed"; + } + if (STATUS_PENDING_SIGNATURE.equals(t.getStatus())) { + Instant cutoff = signWindowCutoff(); + if (t.getCreatedAt() != null && t.getCreatedAt().isBefore(cutoff)) { + return "voided"; + } + return "pending"; + } + return "unknown"; + } + + private String readDocTitle(String payloadJson) { + if (StringUtils.isBlank(payloadJson)) { + return null; + } + try { + JsonNode root = objectMapper.readTree(payloadJson); + JsonNode n = root.get("docTitle"); + return n != null && !n.isNull() ? n.asText(null) : null; + } catch (Exception e) { + return null; + } + } + + private static final Pattern NAME_KEY_PATTERN = Pattern.compile( + "姓名|英文姓名|中文姓名|^名称$|住客|賓客|客人|Guest|^Name$", + Pattern.CASE_INSENSITIVE); + + private static final Pattern GUEST_NAME_ZH_KEY = Pattern.compile( + "中文姓名|中文名称|Chinese\\s*name|國語姓名|国语姓名", + Pattern.CASE_INSENSITIVE); + + private static final Pattern GUEST_NAME_EN_KEY = Pattern.compile( + "英文姓名|英文名称|English\\s*name|拼音|Pinyin|Surname|Given\\s*name", + Pattern.CASE_INSENSITIVE); + + private String readGuestName(String payloadJson) { + if (StringUtils.isBlank(payloadJson)) { + return null; + } + try { + JsonNode root = objectMapper.readTree(payloadJson); + String[] rowArrays = { + "formFrontRows", "formBackRows", "textRows", "formStampRows", "stampRows" + }; + for (String arrKey : rowArrays) { + JsonNode arr = root.get(arrKey); + if (arr == null || !arr.isArray()) { + continue; + } + for (JsonNode row : arr) { + if (row == null || !row.isObject()) { + continue; + } + JsonNode keyNode = row.get("key"); + JsonNode valueNode = row.get("value"); + if (keyNode == null || !keyNode.isTextual()) { + continue; + } + String k = keyNode.asText(""); + if (!NAME_KEY_PATTERN.matcher(k).find()) { + continue; + } + if (valueNode == null || valueNode.isNull()) { + continue; + } + String v = valueNode.isTextual() + ? valueNode.asText("").trim() + : valueNode.asText().trim(); + if (StringUtils.isNotBlank(v)) { + return v; + } + } + } + } catch (Exception e) { + return null; + } + return null; + } + + private String readSelectedHotelNameZh(String payloadJson) { + if (StringUtils.isBlank(payloadJson)) { + return null; + } + try { + JsonNode root = objectMapper.readTree(payloadJson); + JsonNode n = root.get("selectedHotelNameZh"); + if (n != null && !n.isNull() && n.isTextual()) { + String t = n.asText("").trim(); + return StringUtils.isNotBlank(t) ? t : null; + } + } catch (Exception e) { + return null; + } + return null; + } + + private String readGuestNameEn(String payloadJson) { + return readGuestValueByKeyPattern(payloadJson, GUEST_NAME_EN_KEY); + } + + /** + * 中文姓名:优先「中文姓名」等;否则取首个「姓名类」且非明显英文栏之值;仍无则回退 {@link #readGuestName}。 + */ + private String readGuestNameZh(String payloadJson) { + String zh = readGuestValueByKeyPattern(payloadJson, GUEST_NAME_ZH_KEY); + if (StringUtils.isNotBlank(zh)) { + return zh; + } + String generic = readGuestNameExcludingEnKey(payloadJson); + if (StringUtils.isNotBlank(generic)) { + return generic; + } + if (StringUtils.isNotBlank(readGuestNameEn(payloadJson))) { + return null; + } + return readGuestName(payloadJson); + } + + private String readGuestValueByKeyPattern(String payloadJson, Pattern keyPattern) { + if (StringUtils.isBlank(payloadJson)) { + return null; + } + try { + JsonNode root = objectMapper.readTree(payloadJson); + String[] rowArrays = { + "formFrontRows", "formBackRows", "textRows", "formStampRows", "stampRows" + }; + for (String arrKey : rowArrays) { + JsonNode arr = root.get(arrKey); + if (arr == null || !arr.isArray()) { + continue; + } + for (JsonNode row : arr) { + if (row == null || !row.isObject()) { + continue; + } + JsonNode keyNode = row.get("key"); + JsonNode valueNode = row.get("value"); + if (keyNode == null || !keyNode.isTextual()) { + continue; + } + String k = keyNode.asText(""); + if (!keyPattern.matcher(k).find()) { + continue; + } + if (valueNode == null || valueNode.isNull()) { + continue; + } + String v = valueNode.isTextual() + ? valueNode.asText("").trim() + : valueNode.asText().trim(); + if (StringUtils.isNotBlank(v)) { + return v; + } + } + } + } catch (Exception e) { + return null; + } + return null; + } + + /** 首个匹配「姓名类」且键名不像「英文姓名」专栏的行。 */ + private String readGuestNameExcludingEnKey(String payloadJson) { + if (StringUtils.isBlank(payloadJson)) { + return null; + } + try { + JsonNode root = objectMapper.readTree(payloadJson); + String[] rowArrays = { + "formFrontRows", "formBackRows", "textRows", "formStampRows", "stampRows" + }; + for (String arrKey : rowArrays) { + JsonNode arr = root.get(arrKey); + if (arr == null || !arr.isArray()) { + continue; + } + for (JsonNode row : arr) { + if (row == null || !row.isObject()) { + continue; + } + JsonNode keyNode = row.get("key"); + JsonNode valueNode = row.get("value"); + if (keyNode == null || !keyNode.isTextual()) { + continue; + } + String k = keyNode.asText(""); + if (!NAME_KEY_PATTERN.matcher(k).find()) { + continue; + } + if (GUEST_NAME_EN_KEY.matcher(k).find()) { + continue; + } + if (valueNode == null || valueNode.isNull()) { + continue; + } + String v = valueNode.isTextual() + ? valueNode.asText("").trim() + : valueNode.asText().trim(); + if (StringUtils.isNotBlank(v)) { + return v; + } + } + } + } catch (Exception e) { + return null; + } + return null; + } + + /** + * 將 images / bigImageSrc / faceThumbs 中的大字符串抽出到副表,並在 payload 中置為 {@link #PAYLOAD_IMAGE_DB_MARKER}。 + * 先改內存 payload,由調用方在保存主表後再 saveAll 返回的列表。 + */ + private List extractImageRowsAndStripPayload(String taskId, Map payload) { + List rows = new ArrayList<>(); + Object imgsObj = payload.get("images"); + if (imgsObj instanceof Map) { + @SuppressWarnings("unchecked") + Map imgs = (Map) imgsObj; + for (Map.Entry e : imgs.entrySet()) { + Object v = e.getValue(); + if (!(v instanceof String)) { + continue; + } + String s = ((String) v).trim(); + if (!shouldPersistImageString(s)) { + continue; + } + String slot = "img." + sanitizeSlotSegment(e.getKey()); + rows.add(newImageRow(taskId, slot, s)); + imgs.put(e.getKey(), PAYLOAD_IMAGE_DB_MARKER); + } + } + Object big = payload.get("bigImageSrc"); + if (big instanceof String) { + String s = ((String) big).trim(); + if (shouldPersistImageString(s)) { + rows.add(newImageRow(taskId, "big", s)); + payload.put("bigImageSrc", PAYLOAD_IMAGE_DB_MARKER); + } + } + Object facesObj = payload.get("faceThumbs"); + if (facesObj instanceof List) { + List faces = (List) facesObj; + for (int i = 0; i < faces.size(); i++) { + Object fo = faces.get(i); + if (!(fo instanceof Map)) { + continue; + } + @SuppressWarnings("unchecked") + Map fm = (Map) fo; + Object srcObj = fm.get("src"); + if (!(srcObj instanceof String)) { + continue; + } + String s = ((String) srcObj).trim(); + if (!shouldPersistImageString(s)) { + continue; + } + rows.add(newImageRow(taskId, "face." + i, s)); + fm.put("src", PAYLOAD_IMAGE_DB_MARKER); + } + } + return rows; + } + + private void persistCustomerSignature(String taskId, String imageData) { + if (StringUtils.isBlank(taskId) || StringUtils.isBlank(imageData)) { + return; + } + Optional existing = + esignCaptureTaskImageRepository.findFirstByTaskIdAndSlotKey(taskId, SIGNATURE_IMAGE_SLOT); + if (existing.isPresent()) { + EsignCaptureTaskImage row = existing.get(); + row.setImageData(imageData); + esignCaptureTaskImageRepository.save(row); + } else { + esignCaptureTaskImageRepository.save(newImageRow(taskId, SIGNATURE_IMAGE_SLOT, imageData)); + } + } + + private String resolveSignSignatureForDetail(EsignCaptureTask t) { + if (t == null) { + return null; + } + if (StringUtils.isNotBlank(t.getSignSignature())) { + return t.getSignSignature().trim(); + } + return esignCaptureTaskImageRepository + .findFirstByTaskIdAndSlotKey(t.getTaskId(), SIGNATURE_IMAGE_SLOT) + .map(EsignCaptureTaskImage::getImageData) + .filter(StringUtils::isNotBlank) + .map(String::trim) + .orElse(null); + } + + private void mergeStoredImagesIntoPayload(String taskId, Map payload) { + List list = esignCaptureTaskImageRepository.findByTaskIdOrderBySlotKeyAsc(taskId); + if (list.isEmpty()) { + return; + } + Map bySlot = new HashMap<>(); + for (EsignCaptureTaskImage r : list) { + bySlot.put(r.getSlotKey(), r.getImageData()); + } + Object imgsObj = payload.get("images"); + if (imgsObj instanceof Map) { + @SuppressWarnings("unchecked") + Map imgs = (Map) imgsObj; + for (Map.Entry e : imgs.entrySet()) { + if (!PAYLOAD_IMAGE_DB_MARKER.equals(e.getValue())) { + continue; + } + String slot = "img." + sanitizeSlotSegment(e.getKey()); + String data = bySlot.get(slot); + if (data != null) { + imgs.put(e.getKey(), data); + } + } + } + if (PAYLOAD_IMAGE_DB_MARKER.equals(payload.get("bigImageSrc"))) { + String data = bySlot.get("big"); + if (data != null) { + payload.put("bigImageSrc", data); + } + } + Object facesObj = payload.get("faceThumbs"); + if (facesObj instanceof List) { + List faces = (List) facesObj; + for (int i = 0; i < faces.size(); i++) { + Object fo = faces.get(i); + if (!(fo instanceof Map)) { + continue; + } + @SuppressWarnings("unchecked") + Map fm = (Map) fo; + if (!PAYLOAD_IMAGE_DB_MARKER.equals(fm.get("src"))) { + continue; + } + String data = bySlot.get("face." + i); + if (data != null) { + fm.put("src", data); + } + } + } + } + + private static EsignCaptureTaskImage newImageRow(String taskId, String slotKey, String imageData) { + EsignCaptureTaskImage im = new EsignCaptureTaskImage(); + im.setTaskId(taskId); + im.setSlotKey(slotKey); + im.setImageData(imageData); + return im; + } + + private static boolean shouldPersistImageString(String s) { + if (StringUtils.isBlank(s)) { + return false; + } + String t = s.trim(); + if ("__present__".equalsIgnoreCase(t) || "__PRESENT__".equals(t)) { + return false; + } + if (PAYLOAD_IMAGE_DB_MARKER.equals(t)) { + return false; + } + if (t.startsWith("data:image")) { + return true; + } + return t.length() >= INLINE_IMAGE_OFFLOAD_MIN_LEN; + } + + private static String sanitizeSlotSegment(String key) { + if (key == null || key.isEmpty()) { + return "x"; + } + String u = key.replaceAll("[^a-zA-Z0-9._-]", "_"); + return u.length() > 48 ? u.substring(0, 48) : u; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/capture/services/EsignLogService.java b/src/main/java/com/sjmresorts/opsapp/esign/capture/services/EsignLogService.java new file mode 100644 index 0000000..adc666d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/capture/services/EsignLogService.java @@ -0,0 +1,114 @@ +package com.sjmresorts.opsapp.esign.capture.services; + +import com.sjmresorts.opsapp.esign.capture.dtos.CreateEsignLogRequest; +import com.sjmresorts.opsapp.esign.capture.dtos.EsignLogDetailDto; +import com.sjmresorts.opsapp.esign.capture.dtos.EsignLogListDto; +import com.sjmresorts.opsapp.esign.capture.dtos.EsignLogQueryRequest; +import com.sjmresorts.opsapp.esign.capture.mappers.EsignLogMapper; +import com.sjmresorts.opsapp.esign.capture.models.EsignLog; +import com.sjmresorts.opsapp.esign.capture.repositories.EsignLogRepository; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class EsignLogService { + + private final Logger logger = LoggerFactory.getLogger(EsignLogService.class); + private final EsignLogRepository esignLogRepository; + private final EsignLogMapper esignLogMapper; + + + public EsignLog createLog(CreateEsignLogRequest request, String operatorId) { + EsignLog entity = new EsignLog(); + entity.setBatchId(request.getBatchId()); + entity.setActionType(request.getActionType()); + entity.setOrcPayload(request.getOrcPayload()); + entity.setRfidPayload(request.getRfidPayload()); + entity.setHotelId(request.getHotelId()); + entity.setTaskId(request.getTaskId()); + entity.setStatus(request.getStatus()); + entity.setErrorMsg(request.getErrorMsg()); + entity.setOperatorId(operatorId); + entity.setCreatedAt(LocalDateTime.now()); + return esignLogRepository.save(entity); + } + + + public Page querySubmitLogs(EsignLogQueryRequest req) { + return querySubmitLogs(req, null); + } + + public Page querySubmitLogs(EsignLogQueryRequest req, String hotelId) { + List targetBatchIds; + boolean filterByHotel = StringUtils.hasText(hotelId); + + if (req.getTaskId() != null && !req.getTaskId().trim().isEmpty()) { + EsignLog logByTask = esignLogRepository.findByTaskId(req.getTaskId()); + if (logByTask == null) { + return Page.empty(); + } + // 如果指定了酒店ID,检查任务所属酒店是否匹配 + if (filterByHotel && !hotelId.equals(logByTask.getHotelId())) { + return Page.empty(); + } + targetBatchIds = List.of(logByTask.getBatchId()); + } + else { + // 1. 所有批次 + List allBatchIds = filterByHotel + ? esignLogRepository.findAllBatchIdsByHotelId(hotelId) + : esignLogRepository.findAllBatchIds(); + + // 2. 有效批次(submit success) + List submitLogs = filterByHotel + ? esignLogRepository.findByHotelIdAndActionType(hotelId, "submit") + : esignLogRepository.findByActionType("submit"); + + List validBatchIds = submitLogs.stream() + .filter(log -> "success".equals(log.getStatus())) + .map(EsignLog::getBatchId) + .toList(); + + // 3. 有效 / 无效判断 + if ("valid".equals(req.getType())) { + targetBatchIds = validBatchIds; + } + // ✅ 无效批次 = 全部 - 有效 + else if ("invalid".equals(req.getType())) { + targetBatchIds = allBatchIds.stream() + .filter(batchId -> !validBatchIds.contains(batchId)) + .toList(); + } else { + throw new IllegalArgumentException("类型仅支持 valid / invalid"); + } + } + + Sort sort = Sort.by(Sort.Direction.ASC, "batchId") + .and(Sort.by(Sort.Direction.DESC, "createdAt")); + Pageable pageable = PageRequest.of(req.getPage() - 1, req.getSize(), sort); + + Page resultPage = filterByHotel + ? esignLogRepository.findByBatchIdsAndHotelId(targetBatchIds, hotelId, pageable) + : esignLogRepository.findByBatchIds(targetBatchIds, pageable); + return resultPage.map(esignLogMapper::toEsignLogListDto); + } + + // 详情不变 + public EsignLogDetailDto getLogDetail(Long id) { + EsignLog log = esignLogRepository.findById(id) + .orElseThrow(() -> new RuntimeException("日志不存在 ID:" + id)); + return esignLogMapper.toEsignLogDetailDto(log); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/common/EsignApiPaths.java b/src/main/java/com/sjmresorts/opsapp/esign/common/EsignApiPaths.java new file mode 100644 index 0000000..b69f9b3 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/common/EsignApiPaths.java @@ -0,0 +1,26 @@ +package com.sjmresorts.opsapp.esign.common; + +import java.time.LocalDate; + +/** + * E-Sign HTTP API 路径常量;{@link #LEGACY_PREFIX} 计划于 {@link #LEGACY_SUNSET_DATE} 下线。 + */ +public final class EsignApiPaths { + + public static final String PREFIX = "/api/v1/esign"; + public static final String LEGACY_PREFIX = "/api/v1/sinosecu"; + + /** 兼容路径计划移除日期(含当日仍可用,次日仅返回 410) */ + public static final LocalDate LEGACY_SUNSET_DATE = LocalDate.of(2026, 9, 30); + + public static final String DEPRECATION_HEADER = "Deprecation"; + public static final String SUNSET_HEADER = "Sunset"; + public static final String LINK_HEADER = "Link"; + + private EsignApiPaths() { + } + + public static boolean isLegacyPath(String uri) { + return uri != null && uri.contains(LEGACY_PREFIX); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/common/EsignLegacyApiProperties.java b/src/main/java/com/sjmresorts/opsapp/esign/common/EsignLegacyApiProperties.java new file mode 100644 index 0000000..94c0bf7 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/common/EsignLegacyApiProperties.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.esign.common; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "ops-app.esign.legacy-api") +public class EsignLegacyApiProperties { + + /** 是否响应 /api/v1/sinosecu/** 请求;false 时返回 410 Gone */ + private boolean enabled = true; + + /** 是否在响应头附加 Deprecation / Sunset 警告 */ + private boolean warnHeaders = true; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/common/config/EsignCmsSchemaMigrator.java b/src/main/java/com/sjmresorts/opsapp/esign/common/config/EsignCmsSchemaMigrator.java new file mode 100644 index 0000000..90fbb05 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/common/config/EsignCmsSchemaMigrator.java @@ -0,0 +1,743 @@ +package com.sjmresorts.opsapp.esign.common.config; + +import com.sjmresorts.opsapp.common.db.DbDialect; +import com.sjmresorts.opsapp.esign.capture.config.EsignCaptureSchemaMigrator; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; + +/** + * 启动时自动补齐 Esign CMS 表字段(display_type / image_url),避免手工执行迁移脚本。 + */ +@Component +public class EsignCmsSchemaMigrator implements InitializingBean { + + private static final Logger log = LoggerFactory.getLogger(EsignCmsSchemaMigrator.class); + + private final JdbcTemplate jdbc; + private final EsignCaptureSchemaMigrator captureSchemaMigrator; + private final DbDialect dialect; + + public EsignCmsSchemaMigrator( + @Qualifier("opsAppDataSource") DataSource dataSource, + EsignCaptureSchemaMigrator captureSchemaMigrator, + DbDialect dialect) { + this.jdbc = new JdbcTemplate(dataSource); + this.captureSchemaMigrator = captureSchemaMigrator; + this.dialect = dialect; + } + + @Override + public void afterPropertiesSet() { + captureSchemaMigrator.ensureTables(); + migrateTable("esign_hub_promotions", "DF_esign_hub_promo_display"); + ensureSurveyCoreTables(); + migrateTable("esign_surveys", "DF_esign_surveys_display"); + ensureSurveyExtendedColumns(); + ensureSurveySubmissionExtendedColumns(); + ensureSurveyPushTable(); + ensureApiLogsTable(); + ensureFormsMenu(); + ensureAppUsersRouter(); + ensureHubPageDesignRouter(); + ensureAppPageLayoutTable(); + ensureSignFieldOhipConfigTable(); + ensureSignFieldOhipMappingRouter(); + ensureSysMenusRouter(); + ensureHubSettingsExtraColumns(); + } + + /** hub 外观扩展字段:设定菜单 JSON、登记卡顶栏显隐 */ + private void ensureHubSettingsExtraColumns() { + for (String table : new String[] {"esign_hub_settings", "sinosecu_hub_settings"}) { + if (!tableExists(table)) { + continue; + } + addNullableColumn(table, "settings_menu_json", dialect.text() + " NULL"); + addNullableColumn(table, "printer_pdf_nav_bar_visible", dialect.boolType() + " NULL"); + } + } + + /** CMS 系统管理:侧栏菜单显示/隐藏 */ + private void ensureSysMenusRouter() { + if (!tableExists("sys_routers")) { + return; + } + Integer routerCount = jdbc.queryForObject( + "SELECT COUNT(*) FROM sys_routers WHERE id = ?", Integer.class, "ESIGN_SYS_MENUS"); + if (routerCount == null || routerCount == 0) { + log.info("Esign cms migrate: inserting ESIGN_SYS_MENUS router"); + jdbc.update( + "INSERT INTO sys_routers (id, parent_id, heading, section_title, route, sort_no, status) " + + "VALUES (?, ?, N'菜單顯示', NULL, ?, ?, ?)", + "ESIGN_SYS_MENUS", + "ESIGN_SYSTEM", + "/checkin/system/menus", + 65, + 1); + } + if (tableExists("permissions")) { + insertPermissionIfMissing(1037L, "ESIGN_SYS_MENUS", "VIEW"); + insertPermissionIfMissing(1038L, "ESIGN_SYS_MENUS", "EDIT"); + } + grantSysMenusToEsignAdminRole(); + } + + private void grantSysMenusToEsignAdminRole() { + if (!tableExists("roles") || !tableExists("role_permissions")) { + return; + } + List roleIds = jdbc.query( + dialect.limitOne("SELECT role_id FROM roles WHERE role_name = ?"), + (rs, rowNum) -> rs.getLong(1), + "e-sign 管理員"); + if (roleIds.isEmpty()) { + return; + } + long roleId = roleIds.get(0); + for (long permissionId : new long[] {1037L, 1038L}) { + Integer linked = jdbc.queryForObject( + "SELECT COUNT(*) FROM role_permissions WHERE role_id = ? AND permission_id = ?", + Integer.class, + roleId, + permissionId); + if (linked == null || linked == 0) { + log.info("Esign cms migrate: grant permission {} to e-sign admin role {}", permissionId, roleId); + jdbc.update( + "INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)", + roleId, + permissionId); + } + } + } + + private void ensureSignFieldOhipConfigTable() { + if (tableExists("esign_sign_field_ohip_config")) { + return; + } + log.info("Esign cms migrate: creating esign_sign_field_ohip_config"); + jdbc.execute( + "CREATE TABLE esign_sign_field_ohip_config (" + + "config_id " + + dialect.varchar(36) + + " NOT NULL PRIMARY KEY, " + + "hotel_id " + + dialect.varchar(64) + + " NULL, " + + "mappings_json " + + dialect.text() + + " NOT NULL, " + + "updated_at " + + dialect.datetime() + + " NOT NULL)"); + jdbc.update( + "INSERT INTO esign_sign_field_ohip_config (config_id, hotel_id, mappings_json, updated_at) " + + "VALUES (?, ?, ?, " + + dialect.utcTimestampFunction() + + ")", + "DEFAULT", + null, + "[]"); + } + + private void ensureSignFieldOhipMappingRouter() { + if (!tableExists("sys_routers")) { + return; + } + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM sys_routers WHERE id = ?", Integer.class, "ESIGN_SIGN_OHIP_MAP"); + if (count == null || count == 0) { + log.info("Esign cms migrate: inserting ESIGN_SIGN_OHIP_MAP router under ESIGN_SYSTEM"); + jdbc.update( + "INSERT INTO sys_routers (id, parent_id, heading, section_title, route, sort_no, status) " + + "VALUES (?, ?, N'OHIP 字段映射', NULL, ?, ?, ?)", + "ESIGN_SIGN_OHIP_MAP", + "ESIGN_SYSTEM", + "/checkin/sign-field-ohip-mapping", + 64, + 1); + } else { + Integer underForms = jdbc.queryForObject( + "SELECT COUNT(*) FROM sys_routers WHERE id = ? AND parent_id = ?", + Integer.class, + "ESIGN_SIGN_OHIP_MAP", + "ESIGN_FORMS"); + if (underForms != null && underForms > 0) { + log.info("Esign cms migrate: move ESIGN_SIGN_OHIP_MAP to ESIGN_SYSTEM"); + jdbc.update( + "UPDATE sys_routers SET parent_id = ?, sort_no = ? WHERE id = ?", + "ESIGN_SYSTEM", + 64, + "ESIGN_SIGN_OHIP_MAP"); + } + } + if (tableExists("permissions")) { + insertPermissionIfMissing(1035L, "ESIGN_SIGN_OHIP_MAP", "VIEW"); + insertPermissionIfMissing(1036L, "ESIGN_SIGN_OHIP_MAP", "EDIT"); + } + grantSignFieldOhipMapToEsignAdminRole(); + } + + private void grantSignFieldOhipMapToEsignAdminRole() { + if (!tableExists("roles") || !tableExists("role_permissions")) { + return; + } + List roleIds = jdbc.query( + dialect.limitOne("SELECT role_id FROM roles WHERE role_name = ?"), + (rs, rowNum) -> rs.getLong(1), + "e-sign 管理員"); + if (roleIds.isEmpty()) { + return; + } + long roleId = roleIds.get(0); + for (long permissionId : new long[] {1035L, 1036L}) { + Integer linked = jdbc.queryForObject( + "SELECT COUNT(*) FROM role_permissions WHERE role_id = ? AND permission_id = ?", + Integer.class, + roleId, + permissionId); + if (linked == null || linked == 0) { + log.info("Esign cms migrate: grant OHIP mapping permission {} to e-sign admin", permissionId); + jdbc.update( + "INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)", + roleId, + permissionId); + } + } + } + + private void ensureAppPageLayoutTable() { + if (tableExists("esign_app_page_layout")) { + return; + } + log.info("Esign cms migrate: creating esign_app_page_layout"); + jdbc.execute( + "CREATE TABLE esign_app_page_layout (" + + "layout_id " + + dialect.varchar(36) + + " NOT NULL PRIMARY KEY, " + + "page_key " + + dialect.varchar(64) + + dialect.notNullWithDefault("DF_esign_app_page_key", "'home'") + + ", " + + "hotel_id " + + dialect.varchar(64) + + " NULL, " + + "layout_json " + + dialect.text() + + " NOT NULL, " + + "published_layout_json " + + dialect.text() + + " NULL, " + + "status " + + dialect.varchar(16) + + dialect.notNullWithDefault("DF_esign_app_page_status", "'DRAFT'") + + ", " + + "layout_version " + + dialect.intType() + + dialect.notNullWithDefault("DF_esign_app_page_ver", "1") + + ", " + + "updated_at " + + dialect.datetime() + + " NOT NULL, " + + "published_at " + + dialect.datetime() + + " NULL)"); + String defaultJson = + "{\"version\":1,\"blocks\":[{\"id\":\"promo-1\",\"type\":\"promotions\",\"props\":{\"height\":180,\"limit\":5}}," + + "{\"id\":\"menu-1\",\"type\":\"menuGrid\",\"props\":{\"columns\":4,\"items\":[" + + "{\"icon\":\"bi-envelope\",\"label\":\"待簽消息\",\"path\":\"/pages/inbox/index\"}," + + "{\"icon\":\"bi-journal-check\",\"label\":\"簽收記錄\",\"path\":\"/pages/records/index\"}," + + "{\"icon\":\"bi-gear\",\"label\":\"設定\",\"path\":\"/pages/settings/index\"}]}}]}"; + jdbc.update( + "INSERT INTO esign_app_page_layout " + + "(layout_id, page_key, layout_json, published_layout_json, status, layout_version, updated_at, published_at) " + + "VALUES (?, ?, ?, ?, ?, ?, " + + dialect.utcTimestampFunction() + + ", " + + dialect.utcTimestampFunction() + + ")", + "DEFAULT_HOME", + "home", + defaultJson, + defaultJson, + "PUBLISHED", + 1); + } + + /** CMS 表單管理:PDF 歸檔從日誌管理移至表單管理 */ + private void ensureFormsMenu() { + if (!tableExists("sys_routers")) { + return; + } + Integer formsCount = jdbc.queryForObject( + "SELECT COUNT(*) FROM sys_routers WHERE id = ?", Integer.class, "ESIGN_FORMS"); + if (formsCount == null || formsCount == 0) { + log.info("Esign cms migrate: inserting ESIGN_FORMS router"); + jdbc.update( + "INSERT INTO sys_routers (id, parent_id, heading, section_title, route, font_icon, sort_no, status) " + + "VALUES (?, ?, NULL, N'表單管理', ?, ?, ?, ?)", + "ESIGN_FORMS", + "0", + "/checkin/forms", + "bi-file-earmark-text", + 35, + 1); + } + Integer archiveCount = jdbc.queryForObject( + "SELECT COUNT(*) FROM sys_routers WHERE id = ?", Integer.class, "ESIGN_PDF_ARCHIVE"); + if (archiveCount == null || archiveCount == 0) { + log.info("Esign cms migrate: inserting ESIGN_PDF_ARCHIVE under ESIGN_FORMS"); + jdbc.update( + "INSERT INTO sys_routers (id, parent_id, heading, section_title, route, sort_no, status) " + + "VALUES (?, ?, N'PDF 歸檔', NULL, ?, ?, ?)", + "ESIGN_PDF_ARCHIVE", + "ESIGN_FORMS", + "/checkin/pdf-archive", + 36, + 1); + insertPermissionIfMissing(1019L, "ESIGN_PDF_ARCHIVE", "VIEW"); + insertPermissionIfMissing(1020L, "ESIGN_PDF_ARCHIVE", "EDIT"); + grantPdfArchiveToEsignAdminRole(); + } else { + Integer underLogs = jdbc.queryForObject( + "SELECT COUNT(*) FROM sys_routers WHERE id = ? AND parent_id = ?", + Integer.class, + "ESIGN_PDF_ARCHIVE", + "ESIGN_LOGS"); + if (underLogs != null && underLogs > 0) { + log.info("Esign cms migrate: move ESIGN_PDF_ARCHIVE to ESIGN_FORMS"); + jdbc.update( + "UPDATE sys_routers SET parent_id = ?, sort_no = ? WHERE id = ?", + "ESIGN_FORMS", + 36, + "ESIGN_PDF_ARCHIVE"); + } + } + } + + private void grantPdfArchiveToEsignAdminRole() { + if (!tableExists("roles") || !tableExists("role_permissions")) { + return; + } + List roleIds = jdbc.query( + dialect.limitOne("SELECT role_id FROM roles WHERE role_name = ?"), + (rs, rowNum) -> rs.getLong(1), + "e-sign 管理員"); + if (roleIds.isEmpty()) { + return; + } + long roleId = roleIds.get(0); + for (long permissionId : new long[] {1019L, 1020L}) { + Integer linked = jdbc.queryForObject( + "SELECT COUNT(*) FROM role_permissions WHERE role_id = ? AND permission_id = ?", + Integer.class, + roleId, + permissionId); + if (linked == null || linked == 0) { + log.info("Esign cms migrate: grant PDF archive permission {} to e-sign admin", permissionId); + jdbc.update( + "INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)", + roleId, + permissionId); + } + } + } + + /** CMS App 內容:首頁 Block 拖拽設計 */ + private void ensureHubPageDesignRouter() { + if (!tableExists("sys_routers")) { + return; + } + Integer routerCount = jdbc.queryForObject( + "SELECT COUNT(*) FROM sys_routers WHERE id = ?", Integer.class, "ESIGN_HUB_PAGE_DESIGN"); + if (routerCount == null || routerCount == 0) { + log.info("Esign cms migrate: inserting ESIGN_HUB_PAGE_DESIGN router"); + jdbc.update( + "INSERT INTO sys_routers (id, parent_id, heading, section_title, route, sort_no, status) " + + "VALUES (?, ?, N'首頁設計', NULL, ?, ?, ?)", + "ESIGN_HUB_PAGE_DESIGN", + "ESIGN_CONTENT", + "/checkin/hub-page-design", + 33, + 1); + } + if (tableExists("permissions")) { + insertPermissionIfMissing(1033L, "ESIGN_HUB_PAGE_DESIGN", "VIEW"); + insertPermissionIfMissing(1034L, "ESIGN_HUB_PAGE_DESIGN", "EDIT"); + } + grantHubPageDesignToEsignAdminRole(); + } + + private void grantHubPageDesignToEsignAdminRole() { + if (!tableExists("roles") || !tableExists("role_permissions")) { + return; + } + List roleIds = jdbc.query( + dialect.limitOne("SELECT role_id FROM roles WHERE role_name = ?"), + (rs, rowNum) -> rs.getLong(1), + "e-sign 管理員"); + if (roleIds.isEmpty()) { + return; + } + long roleId = roleIds.get(0); + for (long permissionId : new long[] {1033L, 1034L}) { + Integer linked = jdbc.queryForObject( + "SELECT COUNT(*) FROM role_permissions WHERE role_id = ? AND permission_id = ?", + Integer.class, + roleId, + permissionId); + if (linked == null || linked == 0) { + log.info("Esign cms migrate: grant hub page design permission {} to e-sign admin", permissionId); + jdbc.update( + "INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)", + roleId, + permissionId); + } + } + } + + /** CMS 系统管理:平板 App 账号菜单(登录侧栏来自 sys_routers) */ + private void ensureAppUsersRouter() { + if (!tableExists("sys_routers")) { + return; + } + Integer routerCount = jdbc.queryForObject( + "SELECT COUNT(*) FROM sys_routers WHERE id = ?", Integer.class, "ESIGN_APP_USERS"); + if (routerCount == null || routerCount == 0) { + log.info("Esign cms migrate: inserting ESIGN_APP_USERS router"); + jdbc.update( + "INSERT INTO sys_routers (id, parent_id, heading, section_title, route, sort_no, status) " + + "VALUES (?, ?, N'平板賬號', NULL, ?, ?, ?)", + "ESIGN_APP_USERS", + "ESIGN_SYSTEM", + "/checkin/system/app-users", + 63, + 1); + } + if (tableExists("permissions")) { + insertPermissionIfMissing(1031L, "ESIGN_APP_USERS", "VIEW"); + insertPermissionIfMissing(1032L, "ESIGN_APP_USERS", "EDIT"); + } + grantAppUsersToEsignAdminRole(); + } + + private void insertPermissionIfMissing(long permissionId, String routerId, String permissionName) { + Integer count = jdbc.queryForObject( + "SELECT COUNT(*) FROM permissions WHERE permission_id = ?", Integer.class, permissionId); + if (count != null && count > 0) { + return; + } + log.info("Esign cms migrate: inserting permission {} for {}", permissionId, routerId); + jdbc.update( + "INSERT INTO permissions (permission_id, router_id, permission_name) VALUES (?, ?, ?)", + permissionId, + routerId, + permissionName); + } + + private void grantAppUsersToEsignAdminRole() { + if (!tableExists("roles") || !tableExists("role_permissions")) { + return; + } + List roleIds = jdbc.query( + dialect.limitOne("SELECT role_id FROM roles WHERE role_name = ?"), + (rs, rowNum) -> rs.getLong(1), + "e-sign 管理員"); + if (roleIds.isEmpty()) { + return; + } + long roleId = roleIds.get(0); + for (long permissionId : new long[] {1031L, 1032L}) { + Integer linked = jdbc.queryForObject( + "SELECT COUNT(*) FROM role_permissions WHERE role_id = ? AND permission_id = ?", + Integer.class, + roleId, + permissionId); + if (linked == null || linked == 0) { + log.info("Esign cms migrate: grant permission {} to e-sign admin role {}", permissionId, roleId); + jdbc.update( + "INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)", + roleId, + permissionId); + } + } + } + + private void ensureSurveyCoreTables() { + if (!tableExists("esign_surveys")) { + log.info("Esign cms migrate: creating esign_surveys"); + jdbc.execute( + "CREATE TABLE esign_surveys (" + + "survey_id " + + dialect.varchar(36) + + " NOT NULL PRIMARY KEY, " + + "title " + + dialect.varchar(256) + + " NOT NULL, " + + "description " + + dialect.varchar(1000) + + " NULL, " + + "reward_text " + + dialect.varchar(256) + + " NULL, " + + "display_type " + + dialect.varchar(16) + + dialect.notNullWithDefault("DF_esign_surveys_display", "'CARD'") + + ", " + + "image_url " + + dialect.text() + + " NULL, " + + "header_hotel_name " + + dialect.varchar(256) + + " NULL, " + + "hotel_id " + + dialect.varchar(64) + + " NULL, " + + "status " + + dialect.varchar(32) + + " NOT NULL, " + + "questions_json " + + dialect.text() + + dialect.notNullWithDefault("DF_esign_surveys_questions", "'[]'") + + ", " + + "sort_order " + + dialect.intType() + + dialect.notNullWithDefault("DF_esign_surveys_sort", "0") + + ", " + + "created_at " + + dialect.datetime() + + " NOT NULL, " + + "updated_at " + + dialect.datetime() + + " NOT NULL, " + + "published_at " + + dialect.datetime() + + " NULL, " + + "source_type " + + dialect.varchar(16) + + dialect.notNullWithDefault("DF_esign_surveys_source_type", "'NATIVE'") + + ", " + + "external_url " + + dialect.varchar(2000) + + " NULL, " + + "external_provider " + + dialect.varchar(32) + + " NULL, " + + "external_form_id " + + dialect.varchar(512) + + " NULL, " + + "external_embed_url " + + dialect.varchar(2000) + + " NULL, " + + "webhook_secret " + + dialect.varchar(64) + + " NULL, " + + "delivery_mode " + + dialect.varchar(32) + + dialect.notNullWithDefault( + "DF_esign_surveys_delivery_mode", "'MANUAL_PUSH'") + + ")"); + jdbc.execute( + "CREATE INDEX ix_esign_surveys_status_sort ON esign_surveys (status, sort_order, updated_at)"); + } + if (!tableExists("esign_survey_submissions")) { + log.info("Esign cms migrate: creating esign_survey_submissions"); + jdbc.execute( + "CREATE TABLE esign_survey_submissions (" + + "submission_id " + + dialect.varchar(36) + + " NOT NULL PRIMARY KEY, " + + "survey_id " + + dialect.varchar(36) + + " NOT NULL, " + + "answers_json " + + dialect.text() + + " NOT NULL, " + + "device_id " + + dialect.varchar(128) + + " NULL, " + + "hotel_id " + + dialect.varchar(64) + + " NULL, " + + "submitted_at " + + dialect.datetime() + + " NOT NULL, " + + "source_type " + + dialect.varchar(16) + + dialect.notNullWithDefault( + "DF_esign_survey_submissions_source_type", "'NATIVE'") + + ", " + + "external_response_id " + + dialect.varchar(256) + + " NULL, " + + "respondent_meta_json " + + dialect.text() + + " NULL, " + + "CONSTRAINT fk_esign_survey_submission FOREIGN KEY (survey_id) " + + "REFERENCES esign_surveys (survey_id) ON DELETE CASCADE)"); + jdbc.execute( + "CREATE INDEX ix_esign_survey_submissions_survey " + + "ON esign_survey_submissions (survey_id, submitted_at)"); + } + } + + private void ensureSurveyExtendedColumns() { + if (!tableExists("esign_surveys")) { + return; + } + addNullableColumn("esign_surveys", "header_hotel_name", dialect.varchar(256) + " NULL"); + addNotNullColumnWithDefault( + "esign_surveys", "source_type", 16, "DF_esign_surveys_source_type", "NATIVE"); + addNullableColumn("esign_surveys", "external_url", dialect.varchar(2000) + " NULL"); + addNullableColumn("esign_surveys", "external_provider", dialect.varchar(32) + " NULL"); + addNullableColumn("esign_surveys", "external_form_id", dialect.varchar(512) + " NULL"); + addNullableColumn("esign_surveys", "external_embed_url", dialect.varchar(2000) + " NULL"); + addNullableColumn("esign_surveys", "webhook_secret", dialect.varchar(64) + " NULL"); + addNotNullColumnWithDefault( + "esign_surveys", "delivery_mode", 32, "DF_esign_surveys_delivery_mode", "MANUAL_PUSH"); + } + + private void ensureSurveySubmissionExtendedColumns() { + if (!tableExists("esign_survey_submissions")) { + return; + } + addNotNullColumnWithDefault( + "esign_survey_submissions", + "source_type", + 16, + "DF_esign_survey_submissions_source_type", + "NATIVE"); + addNullableColumn("esign_survey_submissions", "external_response_id", dialect.varchar(256) + " NULL"); + addNullableColumn("esign_survey_submissions", "respondent_meta_json", dialect.text() + " NULL"); + } + + private void ensureSurveyPushTable() { + if (tableExists("esign_survey_push")) { + return; + } + if (!tableExists("esign_surveys")) { + return; + } + log.info("Esign cms migrate: creating esign_survey_push"); + jdbc.execute( + "CREATE TABLE esign_survey_push (" + + "survey_id " + + dialect.varchar(36) + + " NOT NULL, " + + "user_id " + + dialect.bigintType() + + " NOT NULL, " + + "client_ip " + + dialect.varchar(64) + + dialect.notNullWithDefault("DF_esign_survey_push_client_ip", "''") + + ", " + + "assigned_at " + + dialect.datetime() + + " NOT NULL, " + + "assigned_by " + + dialect.varchar(64) + + " NULL, " + + "group_id " + + dialect.varchar(36) + + " NULL, " + + "CONSTRAINT pk_esign_survey_push PRIMARY KEY (survey_id, user_id, client_ip), " + + "CONSTRAINT fk_esign_survey_push_survey FOREIGN KEY (survey_id) " + + "REFERENCES esign_surveys (survey_id) ON DELETE CASCADE)"); + jdbc.execute("CREATE INDEX ix_esign_survey_push_user ON esign_survey_push (user_id, assigned_at)"); + } + + /** CMS / 全局 API 请求日志(esign_db_init 精简库默认不含此表) */ + private void ensureApiLogsTable() { + if (tableExists("api_logs")) { + return; + } + log.info("Esign cms migrate: creating api_logs"); + jdbc.execute( + "CREATE TABLE api_logs (" + + dialect.bigintIdentityPrimaryKey("api_log_id") + + ", " + + "url " + + dialect.text() + + " NULL, " + + "type " + + dialect.varchar(255) + + " NULL, " + + "request_dtm " + + dialect.datetime() + + " NULL, " + + "response_dtm " + + dialect.datetime() + + " NULL, " + + "header " + + dialect.text() + + " NULL, " + + "request_body " + + dialect.text() + + " NULL, " + + "request_by " + + dialect.varchar(255) + + " NULL, " + + "response_body " + + dialect.text() + + " NULL, " + + "debug_level " + + dialect.varchar(64) + + " NULL, " + + "status_code " + + dialect.varchar(32) + + " NULL)"); + } + + private void addNullableColumn(String table, String column, String typeAndNullability) { + if (columnExists(table, column)) { + return; + } + log.info("Esign cms migrate: adding {}.{}", table, column); + jdbc.execute(dialect.alterAddColumn(table, column, typeAndNullability)); + } + + private void addNotNullColumnWithDefault( + String table, String column, int varcharLength, String constraintName, String defaultLiteral) { + if (columnExists(table, column)) { + return; + } + log.info("Esign cms migrate: adding {}.{} with default {}", table, column, defaultLiteral); + jdbc.execute( + dialect.alterAddNotNullVarcharWithDefault( + table, column, varcharLength, constraintName, defaultLiteral)); + } + + private void migrateTable(String table, String displayDefaultConstraint) { + if (!tableExists(table)) { + log.debug("Esign cms migrate: table {} not found, skip", table); + return; + } + if (!columnExists(table, "display_type")) { + log.info("Esign cms migrate: adding {}.display_type", table); + jdbc.execute( + dialect.alterAddNotNullVarcharWithDefault( + table, "display_type", 16, displayDefaultConstraint, "CARD")); + } + if (!columnExists(table, "image_url")) { + log.info("Esign cms migrate: adding {}.image_url", table); + jdbc.execute(dialect.alterAddColumn(table, "image_url", dialect.text() + " NULL")); + } + } + + private boolean tableExists(String table) { + return dialect.tableExists(jdbc, table); + } + + private boolean columnExists(String table, String column) { + return dialect.columnExists(jdbc, table, column); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/common/dtos/EsignSimpleResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/common/dtos/EsignSimpleResponse.java new file mode 100644 index 0000000..8968627 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/common/dtos/EsignSimpleResponse.java @@ -0,0 +1,29 @@ +package com.sjmresorts.opsapp.esign.common.dtos; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EsignSimpleResponse { + + private boolean success; + private String code; + private String message; + + public static EsignSimpleResponse ok(String message) { + EsignSimpleResponse r = new EsignSimpleResponse(); + r.setSuccess(true); + r.setCode("200"); + r.setMessage(message != null ? message : "ok"); + return r; + } + + public static EsignSimpleResponse fail(String code, String message) { + EsignSimpleResponse r = new EsignSimpleResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/common/dtos/OhipProfileFieldCatalogResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/common/dtos/OhipProfileFieldCatalogResponse.java new file mode 100644 index 0000000..15c34ae --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/common/dtos/OhipProfileFieldCatalogResponse.java @@ -0,0 +1,38 @@ +package com.sjmresorts.opsapp.esign.common.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class OhipProfileFieldCatalogResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private CatalogData data; + + @Data + @NoArgsConstructor + public static class CatalogData { + private List groups; + private List fields; + private List commonFields; + } + + public static OhipProfileFieldCatalogResponse ok( + List groups, List fields, List commonFields) { + OhipProfileFieldCatalogResponse r = new OhipProfileFieldCatalogResponse(); + r.setMessage("ok"); + CatalogData data = new CatalogData(); + data.setGroups(groups); + data.setFields(fields); + data.setCommonFields(commonFields); + r.setData(data); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/common/dtos/OhipProfileFieldDef.java b/src/main/java/com/sjmresorts/opsapp/esign/common/dtos/OhipProfileFieldDef.java new file mode 100644 index 0000000..205f349 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/common/dtos/OhipProfileFieldDef.java @@ -0,0 +1,26 @@ +package com.sjmresorts.opsapp.esign.common.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "OHIP profileDetails 固定字段定义") +public class OhipProfileFieldDef { + + @Schema(description = "稳定字段代码,存入 CMS 映射配置") + private String code; + + @Schema(description = "profileDetails 内路径") + private String path; + + @Schema(description = "分组:personNamePrimary / customer / identification …") + private String group; + + @Schema(description = "是否列入 CMS 下拉「常用字段」") + private Boolean common; + + private String labelEn; + private String labelZh; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/common/dtos/SignFieldOhipMappingItem.java b/src/main/java/com/sjmresorts/opsapp/esign/common/dtos/SignFieldOhipMappingItem.java new file mode 100644 index 0000000..860ee51 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/common/dtos/SignFieldOhipMappingItem.java @@ -0,0 +1,23 @@ +package com.sjmresorts.opsapp.esign.common.dtos; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class SignFieldOhipMappingItem { + + /** App 签名字段名,如 field_1、field_2 */ + private String fieldName; + + /** CMS 备注 / 显示名 */ + private String label; + + /** 固定 OHIP 字段代码,见 {@link com.sjmresorts.opsapp.esign.common.support.OhipProfileFieldCatalog} */ + private String ohipFieldCode; + + /** text | signature;signature 通常仅 PDF 附件,text 可映射到 OHIP 字段 */ + private String valueType; + + private Boolean enabled; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/common/filters/EsignAccessLogFilter.java b/src/main/java/com/sjmresorts/opsapp/esign/common/filters/EsignAccessLogFilter.java new file mode 100644 index 0000000..99cfdb0 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/common/filters/EsignAccessLogFilter.java @@ -0,0 +1,104 @@ +package com.sjmresorts.opsapp.esign.common.filters; + +import com.sjmresorts.opsapp.esign.common.support.EsignLogMaskUtil; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** Esign 请求/响应摘要日志(脱敏,不含 Base64)。 */ +@Slf4j +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +public class EsignAccessLogFilter extends OncePerRequestFilter { + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + if (uri == null || (!uri.contains("/esign/") && !uri.contains("/sinosecu/"))) { + return true; + } + // SSE 长连接不能被 ContentCachingResponseWrapper 缓冲 + return uri.contains("/printer-queue/stream"); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + long start = System.currentTimeMillis(); + String method = request.getMethod(); + String uri = request.getRequestURI(); + String query = request.getQueryString(); + + ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request); + ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response); + + log.info(">>> Esign {} {}{}", method, uri, query != null ? "?" + query : ""); + + try { + filterChain.doFilter(wrappedRequest, wrappedResponse); + } finally { + logRequestBodyIfPresent(wrappedRequest); + logResponseBodyIfPresent(wrappedResponse); + log.info( + "<<< Esign {} {} status={} {}ms", + method, + uri, + wrappedResponse.getStatus(), + System.currentTimeMillis() - start); + wrappedResponse.copyBodyToResponse(); + } + } + + private void logRequestBodyIfPresent(ContentCachingRequestWrapper request) { + if (!isJsonBody(request)) { + return; + } + byte[] buf = request.getContentAsByteArray(); + if (buf == null || buf.length == 0) { + return; + } + String raw = new String(buf, StandardCharsets.UTF_8); + log.info(" request-body: {}", EsignLogMaskUtil.maskJsonForLog(raw)); + } + + private void logResponseBodyIfPresent(ContentCachingResponseWrapper response) { + if (!isJsonResponse(response)) { + return; + } + byte[] buf = response.getContentAsByteArray(); + if (buf == null || buf.length == 0) { + return; + } + String raw = new String(buf, StandardCharsets.UTF_8); + log.info(" response-body: {}", EsignLogMaskUtil.maskJsonForLog(raw)); + } + + private static boolean isJsonBody(HttpServletRequest request) { + String method = request.getMethod(); + if (!"POST".equalsIgnoreCase(method) && !"PUT".equalsIgnoreCase(method) && !"PATCH".equalsIgnoreCase(method)) { + return false; + } + String ct = StringUtils.defaultString(request.getContentType()).toLowerCase(); + return ct.contains(MediaType.APPLICATION_JSON_VALUE) || ct.contains("json"); + } + + private static boolean isJsonResponse(HttpServletResponse response) { + String ct = StringUtils.defaultString(response.getContentType()).toLowerCase(); + return ct.contains(MediaType.APPLICATION_JSON_VALUE) || ct.contains("json"); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/common/filters/EsignCmsOperationAuditFilter.java b/src/main/java/com/sjmresorts/opsapp/esign/common/filters/EsignCmsOperationAuditFilter.java new file mode 100644 index 0000000..f510454 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/common/filters/EsignCmsOperationAuditFilter.java @@ -0,0 +1,104 @@ +package com.sjmresorts.opsapp.esign.common.filters; + +import com.sjmresorts.opsapp.esign.audit.services.EsignAuditLogService; +import com.sjmresorts.opsapp.esign.auth.services.EsignUserPresenceService; +import com.sjmresorts.opsapp.common.utils.NetworkUtil; +import jakarta.annotation.Resource; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.lang3.StringUtils; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Set; + +/** CMS 變更類請求寫入操作日誌(排除 App X-Client-Type=APP)。 */ +@Component +@Order(Ordered.LOWEST_PRECEDENCE - 20) +public class EsignCmsOperationAuditFilter extends OncePerRequestFilter { + + private static final Set MUTATING = Set.of("POST", "PUT", "PATCH", "DELETE"); + + private static final Set SKIP_PATH_FRAGMENTS = Set.of( + "/client-transport-log", + "/audit-logs", + "/printer-event-logs", + "/ios-pending-tasks", + "/ios-task-list", + "/ios-printer-pdf-task-signed", + "/ios-printer-pdf-usb-signed", + "/ios-printer-pdf-task-complete", + "/auth/", + "/surveys/submit", + "/app/deeplink"); + + @Resource(name = "esignAuditLogService") + private EsignAuditLogService auditLogService; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + if (uri == null || (!uri.contains("/esign/") && !uri.contains("/sinosecu/"))) { + return true; + } + String method = request.getMethod(); + if (method == null || !MUTATING.contains(method.toUpperCase())) { + return true; + } + if ("APP".equalsIgnoreCase(StringUtils.trimToEmpty(request.getHeader("X-Client-Type")))) { + return true; + } + for (String skip : SKIP_PATH_FRAGMENTS) { + if (uri.contains(skip)) { + return true; + } + } + return false; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + long start = System.currentTimeMillis(); + try { + filterChain.doFilter(request, response); + } finally { + try { + String operator = decodeCmsOperator(request.getHeader("X-CMS-Operator")); + String clientIp = EsignUserPresenceService.normalizeClientIp( + NetworkUtil.getClientIpByRequest(request)); + auditLogService.appendCmsOperation( + request.getMethod(), + request.getRequestURI(), + response.getStatus(), + System.currentTimeMillis() - start, + operator, + clientIp); + } catch (Exception ignored) { + // 審計失敗不影響主流程 + } + } + } + + /** 前端可能对非 ASCII 操作者做 URI 编码 */ + private static String decodeCmsOperator(String raw) { + String operator = StringUtils.trimToNull(raw); + if (operator == null || !operator.contains("%")) { + return operator; + } + try { + return URLDecoder.decode(operator, StandardCharsets.UTF_8); + } catch (IllegalArgumentException ex) { + return operator; + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/common/filters/EsignLegacyApiDeprecationFilter.java b/src/main/java/com/sjmresorts/opsapp/esign/common/filters/EsignLegacyApiDeprecationFilter.java new file mode 100644 index 0000000..a37db5b --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/common/filters/EsignLegacyApiDeprecationFilter.java @@ -0,0 +1,70 @@ +package com.sjmresorts.opsapp.esign.common.filters; + +import com.sjmresorts.opsapp.esign.common.EsignApiPaths; +import com.sjmresorts.opsapp.esign.common.EsignLegacyApiProperties; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.time.LocalDate; + +/** + * /api/v1/sinosecu/** 兼容层:可配置关闭、响应 Deprecation 头、Sunset 后返回 410。 + */ +@Slf4j +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 5) +@RequiredArgsConstructor +public class EsignLegacyApiDeprecationFilter extends OncePerRequestFilter { + + private final EsignLegacyApiProperties legacyApiProperties; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + return !EsignApiPaths.isLegacyPath(uri); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + if (!legacyApiProperties.isEnabled()) { + response.setStatus(HttpStatus.GONE.value()); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write( + "{\"code\":\"LEGACY_API_DISABLED\",\"message\":\"/api/v1/sinosecu/ 已下线,请改用 /api/v1/esign/\"}"); + return; + } + + LocalDate today = LocalDate.now(); + if (today.isAfter(EsignApiPaths.LEGACY_SUNSET_DATE)) { + response.setStatus(HttpStatus.GONE.value()); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write( + "{\"code\":\"LEGACY_API_SUNSET\",\"message\":\"/api/v1/sinosecu/ 已于 " + + EsignApiPaths.LEGACY_SUNSET_DATE + " 下线\"}"); + return; + } + + if (legacyApiProperties.isWarnHeaders()) { + response.setHeader(EsignApiPaths.DEPRECATION_HEADER, "true"); + response.setHeader(EsignApiPaths.SUNSET_HEADER, EsignApiPaths.LEGACY_SUNSET_DATE.toString()); + response.setHeader(EsignApiPaths.LINK_HEADER, + "<" + EsignApiPaths.PREFIX + ">; rel=\"successor-version\""); + } + + if (log.isDebugEnabled()) { + log.debug("Legacy sinosecu API: {} {}", request.getMethod(), request.getRequestURI()); + } + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/common/mappers/SignFieldOhipValueMapper.java b/src/main/java/com/sjmresorts/opsapp/esign/common/mappers/SignFieldOhipValueMapper.java new file mode 100644 index 0000000..3cbd456 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/common/mappers/SignFieldOhipValueMapper.java @@ -0,0 +1,207 @@ +package com.sjmresorts.opsapp.esign.common.mappers; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.sjmresorts.opsapp.esign.common.dtos.OhipProfileFieldDef; +import com.sjmresorts.opsapp.esign.common.dtos.SignFieldOhipMappingItem; +import com.sjmresorts.opsapp.esign.common.support.OhipProfileFieldCatalog; +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** 按 CMS 映射将 App signaturesJson + signFieldsJson 转为 OHIP 字段代码 → 值。 */ +public final class SignFieldOhipValueMapper { + + private SignFieldOhipValueMapper() { + } + + public static Map mapValues( + String signaturesJson, + String signFieldsJson, + List mappings) { + Map out = new LinkedHashMap<>(); + if (mappings == null || mappings.isEmpty()) { + return out; + } + JSONObject signatures = parseObject(signaturesJson); + JSONArray fields = parseArray(signFieldsJson); + if (signatures == null || signatures.isEmpty() || fields == null || fields.isEmpty()) { + return out; + } + + for (SignFieldOhipMappingItem mapping : mappings) { + if (mapping == null || Boolean.FALSE.equals(mapping.getEnabled())) { + continue; + } + String ohipCode = StringUtils.trimToEmpty(mapping.getOhipFieldCode()); + String fieldName = StringUtils.trimToEmpty(mapping.getFieldName()); + String ohipPath = OhipProfileFieldCatalog.resolvePath(ohipCode).orElse(null); + if (ohipPath == null || StringUtils.isBlank(fieldName)) { + continue; + } + JSONObject field = findFieldByName(fields, fieldName); + if (field == null) { + field = findFieldByOhipCode(fields, ohipCode); + } + if (field == null) { + continue; + } + String sigKey = StringUtils.defaultIfBlank(field.getString("id"), fieldName); + String raw = signatures.getString(sigKey); + if (StringUtils.isBlank(raw)) { + continue; + } + String valueType = normalizeValueType(mapping.getValueType(), field.getString("type")); + if ("signature".equals(valueType)) { + continue; + } + String text = StringUtils.trimToEmpty(raw); + if (text.startsWith("data:image")) { + continue; + } + if (!text.isEmpty()) { + out.put(ohipPath, text); + } + } + return out; + } + + private static JSONObject findFieldByName(JSONArray fields, String fieldName) { + String target = fieldName.toLowerCase(Locale.ROOT); + for (int i = 0; i < fields.size(); i++) { + JSONObject obj = fields.getJSONObject(i); + if (obj == null) { + continue; + } + String name = StringUtils.trimToEmpty(obj.getString("fieldName")); + if (name.equalsIgnoreCase(fieldName)) { + return obj; + } + String id = StringUtils.trimToEmpty(obj.getString("id")); + if (id.equalsIgnoreCase(fieldName) || id.toLowerCase(Locale.ROOT).equals(target)) { + return obj; + } + } + return null; + } + + /** CMS 映射 fieldName 与热区不一致时,按 OHIP 字段类型匹配 phone/email 文字热区。 */ + private static JSONObject findFieldByOhipCode(JSONArray fields, String ohipCode) { + String code = StringUtils.trimToEmpty(ohipCode).toLowerCase(Locale.ROOT); + String wantFormat = null; + if (code.startsWith("phone")) { + wantFormat = "phone"; + } else if (code.startsWith("email")) { + wantFormat = "email"; + } + if (wantFormat == null) { + return null; + } + for (int i = 0; i < fields.size(); i++) { + JSONObject obj = fields.getJSONObject(i); + if (obj == null) { + continue; + } + if (!"text".equalsIgnoreCase(StringUtils.trimToEmpty(obj.getString("type")))) { + continue; + } + if (wantFormat.equals(normalizeInputFormat(obj.getString("inputFormat")))) { + return obj; + } + } + return null; + } + + private static String normalizeInputFormat(String raw) { + String s = StringUtils.trimToEmpty(raw).toLowerCase(Locale.ROOT); + if (s.isEmpty()) { + return "text"; + } + if (s.contains("mail") || s.contains("邮") || s.contains("郵") || "email".equals(s)) { + return "email"; + } + if (s.contains("phone") || s.contains("tel") || s.contains("电话") || s.contains("電話")) { + return "phone"; + } + return s; + } + + private static String normalizeValueType(String mappingType, String fieldType) { + String mt = StringUtils.trimToEmpty(mappingType).toLowerCase(Locale.ROOT); + if ("text".equals(mt) || "signature".equals(mt)) { + return mt; + } + if (fieldType == null) { + return "text"; + } + return "text".equalsIgnoreCase(StringUtils.trimToEmpty(fieldType)) ? "text" : "signature"; + } + + private static JSONObject parseObject(String json) { + if (StringUtils.isBlank(json)) { + return new JSONObject(); + } + try { + JSONObject obj = JSON.parseObject(json.trim()); + return obj != null ? obj : new JSONObject(); + } catch (Exception e) { + return new JSONObject(); + } + } + + private static JSONArray parseArray(String json) { + if (StringUtils.isBlank(json)) { + return new JSONArray(); + } + try { + JSONArray arr = JSON.parseArray(json.trim()); + return arr != null ? arr : new JSONArray(); + } catch (Exception e) { + return new JSONArray(); + } + } + + public static List normalizeMappings(List raw) { + List out = new ArrayList<>(); + if (raw == null) { + return out; + } + for (SignFieldOhipMappingItem item : raw) { + if (item == null) { + continue; + } + String fieldName = sanitizeFieldName(item.getFieldName()); + String ohipFieldCode = resolveStoredOhipCode(item.getOhipFieldCode()); + if (StringUtils.isBlank(fieldName) && StringUtils.isBlank(ohipFieldCode)) { + continue; + } + SignFieldOhipMappingItem next = new SignFieldOhipMappingItem(); + next.setFieldName(fieldName); + next.setLabel(StringUtils.trimToNull(item.getLabel())); + next.setOhipFieldCode(ohipFieldCode); + next.setValueType(normalizeValueType(item.getValueType(), null)); + next.setEnabled(item.getEnabled() == null || Boolean.TRUE.equals(item.getEnabled())); + out.add(next); + } + return out; + } + + private static String sanitizeFieldName(String raw) { + String s = StringUtils.trimToEmpty(raw); + if (s.isEmpty()) { + return ""; + } + return s.replaceAll("[^A-Za-z0-9_]", "_").replaceAll("_+", "_").replaceAll("^_|_$", ""); + } + + private static String resolveStoredOhipCode(String raw) { + return OhipProfileFieldCatalog.findByCodeOrPath(StringUtils.trimToEmpty(raw)) + .map(OhipProfileFieldDef::getCode) + .orElse(""); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/common/support/EsignLogMaskUtil.java b/src/main/java/com/sjmresorts/opsapp/esign/common/support/EsignLogMaskUtil.java new file mode 100644 index 0000000..80f22e5 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/common/support/EsignLogMaskUtil.java @@ -0,0 +1,103 @@ +package com.sjmresorts.opsapp.esign.common.support; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import org.apache.commons.lang3.StringUtils; + +import java.util.Locale; +import java.util.Set; + +/** 日志脱敏:Base64 / 大字段只打长度,不打内容。 */ +public final class EsignLogMaskUtil { + + private static final Set BINARY_KEYS = Set.of( + "base64", + "pdfbase64", + "fileattachment", + "signedpdfbase64", + "image", + "data"); + + private static final int MAX_TEXT_LEN = 240; + + private EsignLogMaskUtil() { + } + + public static String maskJsonForLog(String raw) { + if (StringUtils.isBlank(raw)) { + return ""; + } + String trimmed = raw.trim(); + try { + Object parsed = JSON.parse(trimmed); + return JSON.toJSONString(maskNode(parsed)); + } catch (Exception e) { + return truncate(trimmed); + } + } + + public static String sizeTag(Object value) { + if (value == null) { + return "[null]"; + } + if (value instanceof byte[] bytes) { + return "[bytes len=" + bytes.length + "]"; + } + String s = String.valueOf(value); + if (s.isEmpty()) { + return "[empty]"; + } + return "[len=" + s.length() + "]"; + } + + public static String truncate(String text) { + if (text == null) { + return ""; + } + String oneLine = text.replace('\n', ' ').replace('\r', ' '); + if (oneLine.length() <= MAX_TEXT_LEN) { + return oneLine; + } + return oneLine.substring(0, MAX_TEXT_LEN) + "…"; + } + + private static Object maskNode(Object node) { + if (node instanceof JSONObject obj) { + JSONObject masked = new JSONObject(); + for (String key : obj.keySet()) { + Object value = obj.get(key); + if (isBinaryKey(key)) { + masked.put(key, sizeTag(value)); + } else if ("signaturesjson".equals(key.toLowerCase(Locale.ROOT))) { + masked.put(key, truncate(String.valueOf(value))); + } else { + masked.put(key, maskNode(value)); + } + } + return masked; + } + if (node instanceof JSONArray arr) { + JSONArray masked = new JSONArray(); + for (int i = 0; i < arr.size(); i++) { + masked.add(maskNode(arr.get(i))); + } + return masked; + } + if (node instanceof String s && s.length() > MAX_TEXT_LEN) { + return truncate(s); + } + return node; + } + + private static boolean isBinaryKey(String key) { + if (key == null) { + return false; + } + String lower = key.toLowerCase(Locale.ROOT); + if (BINARY_KEYS.contains(lower)) { + return true; + } + return lower.contains("base64") || lower.contains("attachment"); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/common/support/EsignReservationParamUtil.java b/src/main/java/com/sjmresorts/opsapp/esign/common/support/EsignReservationParamUtil.java new file mode 100644 index 0000000..58f34cd --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/common/support/EsignReservationParamUtil.java @@ -0,0 +1,24 @@ +package com.sjmresorts.opsapp.esign.common.support; + +/** 预订/酒店 ID 等请求参数的规范化(去空白、去误传引号)。 */ +public final class EsignReservationParamUtil { + + private EsignReservationParamUtil() { + } + + /** 去掉首尾空白及误传的引号(如 'grand-dragon') */ + public static String normalizeParam(String value) { + if (value == null) { + return null; + } + String trimmed = value.trim(); + if (trimmed.length() >= 2) { + char first = trimmed.charAt(0); + char last = trimmed.charAt(trimmed.length() - 1); + if ((first == '\'' && last == '\'') || (first == '"' && last == '"')) { + trimmed = trimmed.substring(1, trimmed.length() - 1).trim(); + } + } + return trimmed; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/common/support/OhipProfileFieldCatalog.java b/src/main/java/com/sjmresorts/opsapp/esign/common/support/OhipProfileFieldCatalog.java new file mode 100644 index 0000000..784cb2f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/common/support/OhipProfileFieldCatalog.java @@ -0,0 +1,135 @@ +package com.sjmresorts.opsapp.esign.common.support; + +import com.sjmresorts.opsapp.esign.common.dtos.OhipProfileFieldDef; +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** OHIP CRM profileDetails 固定字段目录(CMS 仅能选择此处定义的字段)。 */ +public final class OhipProfileFieldCatalog { + + private static final List COMMON_FIELD_CODES = List.of( + "primary.givenName", + "primary.surname", + "alternate.givenName", + "alternate.surname", + "customer.gender", + "customer.birthDate", + "customer.nationality", + "id.idType", + "id.idNumber", + "phone.number", + "email.address"); + + private static final List FIELDS = List.of( + def("primary.givenName", "customer.personName[Primary].givenName", "personNamePrimary", "First name", "英文名", true), + def("primary.middleName", "customer.personName[Primary].middleName", "personNamePrimary", "Middle name", "中间名", false), + def("primary.surname", "customer.personName[Primary].surname", "personNamePrimary", "Last name", "英文姓", true), + def("primary.nameTitle", "customer.personName[Primary].nameTitle", "personNamePrimary", "Title", "称谓", false), + def("alternate.givenName", "customer.personName[Alternate].givenName", "personNameAlternate", "Alt. first name", "中文名", true), + def("alternate.surname", "customer.personName[Alternate].surname", "personNameAlternate", "Alt. full name", "中文全名", true), + def("customer.language", "customer.language", "customer", "Language", "语言", false), + def("customer.gender", "customer.gender", "customer", "Gender (M/F/U)", "性别", true), + def("customer.birthDate", "customer.birthDate", "customer", "Date of birth", "出生日期", true), + def("customer.birthPlace", "customer.birthPlace", "customer", "Place of birth", "出生地", false), + def("customer.nationality", "customer.nationality", "customer", "Nationality", "国籍", true), + def("id.idType", "customer.identifications.identificationInfo[0].identification.idType", "identification", "Document type", "证件类型", true), + def("id.idNumber", "customer.identifications.identificationInfo[0].identification.idNumber", "identification", "Document number", "证件号码", true), + def("id.issuedCountry", "customer.identifications.identificationInfo[0].identification.issuedCountry", "identification", "Issue country", "签发国", false), + def("id.issuedDate", "customer.identifications.identificationInfo[0].identification.issuedDate", "identification", "Issue date", "签发日期", false), + def("id.issuedPlace", "customer.identifications.identificationInfo[0].identification.issuedPlace", "identification", "Issue place", "签发地", false), + def("id.expirationDate", "customer.identifications.identificationInfo[0].identification.expirationDate", "identification", "Expiration date", "到期日", false), + def("address.line1", "addresses.addressInfo[0].address.addressLine[0]", "address", "Address line 1", "地址行 1", false), + def("address.line2", "addresses.addressInfo[0].address.addressLine[1]", "address", "Address line 2", "地址行 2", false), + def("address.city", "addresses.addressInfo[0].address.cityName", "address", "City", "城市", false), + def("address.state", "addresses.addressInfo[0].address.state", "address", "State", "省/州", false), + def("address.country", "addresses.addressInfo[0].address.country.value", "address", "Country", "国家", false), + def("phone.useType", "telephones.telephoneInfo[0].telephone.phoneUseType", "telephone", "Phone type", "电话类型", false), + def("phone.number", "telephones.telephoneInfo[0].telephone.phoneNumber", "telephone", "Phone number", "电话号码", true), + def("email.address", "emails.emailInfo[0].email.emailAddress", "email", "Email", "电子邮箱", true), + def("email.type", "emails.emailInfo[0].email.type", "email", "Email type", "邮箱类型", false), + def("keyword.type", "keywords.keyword[0].type", "keyword", "Keyword type", "关键字类型", false), + def("keyword.value", "keywords.keyword[0].keyword", "keyword", "Keyword", "关键字", false)); + + private static final Map BY_CODE; + private static final Map BY_PATH; + + static { + Map byCode = new LinkedHashMap<>(); + Map byPath = new LinkedHashMap<>(); + for (OhipProfileFieldDef field : FIELDS) { + byCode.put(field.getCode(), field); + byPath.put(field.getPath(), field); + } + BY_CODE = Collections.unmodifiableMap(byCode); + BY_PATH = Collections.unmodifiableMap(byPath); + } + + private OhipProfileFieldCatalog() { + } + + public static List all() { + return FIELDS; + } + + public static List commonFields() { + List out = new ArrayList<>(); + for (String code : COMMON_FIELD_CODES) { + OhipProfileFieldDef field = BY_CODE.get(code); + if (field != null) { + out.add(field); + } + } + return out; + } + + public static Optional findByCode(String code) { + if (StringUtils.isBlank(code)) { + return Optional.empty(); + } + return Optional.ofNullable(BY_CODE.get(code.trim())); + } + + public static Optional findByCodeOrPath(String raw) { + String key = StringUtils.trimToNull(raw); + if (key == null) { + return Optional.empty(); + } + OhipProfileFieldDef byCode = BY_CODE.get(key); + if (byCode != null) { + return Optional.of(byCode); + } + return Optional.ofNullable(BY_PATH.get(key)); + } + + public static Optional resolvePath(String codeOrPath) { + return findByCodeOrPath(codeOrPath).map(OhipProfileFieldDef::getPath); + } + + public static List groupOrder() { + List groups = new ArrayList<>(); + for (OhipProfileFieldDef field : FIELDS) { + if (!groups.contains(field.getGroup())) { + groups.add(field.getGroup()); + } + } + return groups; + } + + private static OhipProfileFieldDef def( + String code, String path, String group, String labelEn, String labelZh, boolean common) { + OhipProfileFieldDef def = new OhipProfileFieldDef(); + def.setCode(code); + def.setPath(path); + def.setGroup(group); + def.setLabelEn(labelEn); + def.setLabelZh(labelZh); + def.setCommon(common); + return def; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/common/support/ProfileDetailsJsonReader.java b/src/main/java/com/sjmresorts/opsapp/esign/common/support/ProfileDetailsJsonReader.java new file mode 100644 index 0000000..8681ee8 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/common/support/ProfileDetailsJsonReader.java @@ -0,0 +1,188 @@ +package com.sjmresorts.opsapp.esign.common.support; + +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 从 OHIP GET profile 响应的 profileDetails 中按路径读取字符串值。 */ +public final class ProfileDetailsJsonReader { + + private static final Pattern SEGMENT = + Pattern.compile("([^.\\[]+)(?:\\[([^\\]]+)\\])?"); + + private ProfileDetailsJsonReader() { + } + + public static JSONObject unwrapProfileDetails(String responseBody) { + if (StringUtils.isBlank(responseBody)) { + return new JSONObject(); + } + try { + JSONObject root = JSONObject.parseObject(responseBody.trim()); + if (root == null) { + return new JSONObject(); + } + JSONObject details = root.getJSONObject("profileDetails"); + return details != null ? details : root; + } catch (Exception e) { + return new JSONObject(); + } + } + + public static String getString(JSONObject profileDetails, String path) { + if (profileDetails == null || profileDetails.isEmpty() || StringUtils.isBlank(path)) { + return null; + } + String normalized = normalizePath(path); + if (normalized == null) { + return null; + } + Object node = profileDetails; + List segments = parsePath(normalized); + if (segments.isEmpty()) { + return null; + } + for (int i = 0; i < segments.size() - 1; i++) { + node = navigate(node, segments.get(i)); + if (node == null) { + return null; + } + } + Segment leaf = segments.get(segments.size() - 1); + if (!(node instanceof JSONObject obj)) { + return null; + } + Selector leafSelector = leaf.selector(); + if (leafSelector != null && leafSelector.kind == SelectorKind.INDEX) { + JSONArray arr = obj.getJSONArray(leaf.name()); + if (arr == null || arr.size() <= leafSelector.index) { + return null; + } + Object item = arr.get(leafSelector.index); + return item != null ? StringUtils.trimToNull(String.valueOf(item)) : null; + } + return StringUtils.trimToNull(obj.getString(leaf.name())); + } + + private static String normalizePath(String raw) { + String path = StringUtils.trimToEmpty(raw); + if (path.startsWith("profileDetails.")) { + path = path.substring("profileDetails.".length()); + } + return StringUtils.trimToNull(path); + } + + private static List parsePath(String path) { + List out = new ArrayList<>(); + Matcher matcher = SEGMENT.matcher(path); + int pos = 0; + while (matcher.find(pos)) { + if (matcher.start() != pos) { + break; + } + String name = matcher.group(1); + String selector = StringUtils.trimToNull(matcher.group(2)); + out.add(new Segment(name, parseSelector(name, selector))); + pos = matcher.end(); + if (pos < path.length() && path.charAt(pos) == '.') { + pos++; + } + } + return out; + } + + private static Selector parseSelector(String segmentName, String raw) { + if (raw == null) { + return null; + } + if (raw.contains("=")) { + int eq = raw.indexOf('='); + return Selector.match(raw.substring(0, eq).trim(), raw.substring(eq + 1).trim()); + } + if (NumberUtils.isDigits(raw)) { + return Selector.index(Integer.parseInt(raw)); + } + if ("personName".equals(segmentName)) { + return Selector.match("nameType", raw); + } + if ("keyword".equals(segmentName)) { + return Selector.index(0); + } + return Selector.match("type", raw); + } + + private static Object navigate(Object current, Segment segment) { + if (!(current instanceof JSONObject obj)) { + return null; + } + Selector selector = segment.selector(); + if (selector == null) { + return obj.get(segment.name()); + } + Object node = obj.get(segment.name()); + if (!(node instanceof JSONArray arr)) { + return null; + } + if (selector.kind == SelectorKind.INDEX) { + if (arr.size() <= selector.index) { + return null; + } + return arr.get(selector.index); + } + for (int i = 0; i < arr.size(); i++) { + Object item = arr.get(i); + if (item instanceof JSONObject itemObj && matches(itemObj, selector)) { + return itemObj; + } + } + return null; + } + + private static boolean matches(JSONObject obj, Selector selector) { + if (selector.kind != SelectorKind.MATCH) { + return false; + } + String actual = StringUtils.trimToEmpty(obj.getString(selector.matchKey)); + return actual.equalsIgnoreCase(selector.matchValue); + } + + private record Segment(String name, Selector selector) { + } + + private enum SelectorKind { + INDEX, + MATCH + } + + private static final class Selector { + private final SelectorKind kind; + private final int index; + private final String matchKey; + private final String matchValue; + + private Selector(SelectorKind kind, int index, String matchKey, String matchValue) { + this.kind = kind; + this.index = index; + this.matchKey = matchKey; + this.matchValue = matchValue; + } + + static Selector index(int index) { + return new Selector(SelectorKind.INDEX, index, null, null); + } + + static Selector match(String key, String value) { + return new Selector( + SelectorKind.MATCH, + -1, + StringUtils.trimToEmpty(key), + StringUtils.trimToEmpty(value)); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/controllers/EsignAppPageLayoutController.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/controllers/EsignAppPageLayoutController.java new file mode 100644 index 0000000..0eed40d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/controllers/EsignAppPageLayoutController.java @@ -0,0 +1,63 @@ +package com.sjmresorts.opsapp.esign.hub.controllers; + +import com.sjmresorts.opsapp.esign.hub.dtos.EsignAppPageLayoutResponse; +import com.sjmresorts.opsapp.esign.hub.dtos.EsignAppPageLayoutSaveRequest; +import com.sjmresorts.opsapp.esign.hub.services.EsignAppPageLayoutService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Esign App Page Layout", description = "UniApp 页面拖拽布局 JSON") +@RestController +@RequestMapping({"/api/v1/esign/app-page-layout", "/api/v1/sinosecu/app-page-layout"}) +@CrossOrigin(origins = "*") +public class EsignAppPageLayoutController { + + @Resource + private EsignAppPageLayoutService layoutService; + + @Operation(summary = "管理端:读取页面布局(含草稿)") + @GetMapping(produces = "application/json;charset=UTF-8") + public ResponseEntity getForAdmin( + @RequestParam(value = "pageKey", required = false, defaultValue = "home") String pageKey) { + return ResponseEntity.ok(layoutService.getForAdmin(pageKey)); + } + + @Operation(summary = "App:读取已发布页面布局") + @GetMapping(value = "/app", produces = "application/json;charset=UTF-8") + public ResponseEntity getForApp( + @RequestParam(value = "pageKey", required = false, defaultValue = "home") String pageKey, + @RequestParam(value = "hotelId", required = false) String hotelId) { + return ResponseEntity.ok(layoutService.getForApp(pageKey, hotelId)); + } + + @Operation(summary = "管理端:保存页面布局") + @PutMapping(produces = "application/json;charset=UTF-8") + public ResponseEntity save(@RequestBody EsignAppPageLayoutSaveRequest request) { + EsignAppPageLayoutResponse body = layoutService.save(request); + if (!body.isSuccess()) { + return ResponseEntity.badRequest().body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "管理端:发布当前草稿") + @PostMapping(value = "/publish", produces = "application/json;charset=UTF-8") + public ResponseEntity publish( + @RequestParam(value = "pageKey", required = false, defaultValue = "home") String pageKey) { + EsignAppPageLayoutResponse body = layoutService.publish(pageKey); + if (!body.isSuccess()) { + return ResponseEntity.badRequest().body(body); + } + return ResponseEntity.ok(body); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/controllers/EsignHubPromotionController.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/controllers/EsignHubPromotionController.java new file mode 100644 index 0000000..a718aaa --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/controllers/EsignHubPromotionController.java @@ -0,0 +1,175 @@ +package com.sjmresorts.opsapp.esign.hub.controllers; + +import com.sjmresorts.opsapp.esign.hub.dtos.EsignHubPromotionResponse; +import com.sjmresorts.opsapp.esign.hub.dtos.EsignHubPromotionSaveRequest; +import com.sjmresorts.opsapp.esign.hub.services.EsignHubPromotionMediaService; +import com.sjmresorts.opsapp.esign.hub.services.EsignHubPromotionService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.core.io.FileSystemResource; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.DeleteMapping; +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 org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; + +@Tag(name = "Esign Hub Promotion", description = "首页精选推广(Electron 配置 + App 轮播)") +@RestController +@RequestMapping({"/api/v1/esign/hub-promotions", "/api/v1/sinosecu/hub-promotions"}) +@CrossOrigin(origins = "*") +public class EsignHubPromotionController { + + @Resource + private EsignHubPromotionService promotionService; + + @Resource + private EsignHubPromotionMediaService mediaService; + + @Operation(summary = "管理端:推广列表", description = "免 JWT。Electron 推广配置管理。") + @GetMapping(produces = "application/json;charset=UTF-8") + public ResponseEntity listAll() { + return ResponseEntity.ok(promotionService.listAllForAdmin()); + } + + @Operation(summary = "App:已发布推广列表") + @GetMapping(value = "/published", produces = "application/json;charset=UTF-8") + public ResponseEntity listPublished( + @RequestParam(value = "hotelId", required = false) String hotelId) { + return ResponseEntity.ok(promotionService.listPublished(hotelId)); + } + + @Operation(summary = "推广详情") + @GetMapping(value = "/{promotionId}", produces = "application/json;charset=UTF-8") + public ResponseEntity getDetail(@PathVariable("promotionId") String promotionId) { + EsignHubPromotionResponse body = promotionService.getDetail(promotionId); + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "推广媒体文件(图片/视频),供 App 与 CMS 引用") + @GetMapping(value = "/{promotionId}/media") + public ResponseEntity getMedia(@PathVariable("promotionId") String promotionId) { + Optional path = mediaService.resolveMediaFile(promotionId); + if (path.isEmpty()) { + return ResponseEntity.notFound().build(); + } + try { + Path file = path.get(); + String contentType = mediaService.probeMimeForPath(file); + long size = Files.size(file); + long lastModifiedMs = Files.getLastModifiedTime(file).toMillis(); + String etag = "W/\"" + lastModifiedMs + "-" + size + "\""; + FileSystemResource body = new FileSystemResource(file); + // 媒体可被同 id 覆盖;禁止长缓存,靠 ETag/Last-Modified 协商,配合 URL ?v= 失效 + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(contentType)) + .eTag(etag) + .lastModified(lastModifiedMs) + .cacheControl(CacheControl.noCache().cachePublic().mustRevalidate()) + .contentLength(size) + .body(body); + } catch (IOException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @Operation(summary = "上传推广图片到服务器本地目录(推荐,勿把 Base64 写入 JSON)") + @PostMapping(value = "/{promotionId}/media", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadMedia( + @PathVariable("promotionId") String promotionId, + @RequestParam("file") MultipartFile file) { + if (file == null || file.isEmpty()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(EsignHubPromotionResponse.fail("400", "file required")); + } + try { + EsignHubPromotionResponse body = promotionService.uploadMedia( + promotionId, + file.getBytes(), + StringUtils.defaultIfBlank(file.getContentType(), MediaType.APPLICATION_OCTET_STREAM_VALUE)); + if (!body.isSuccess()) { + HttpStatus status = "404".equals(body.getCode()) ? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST; + return ResponseEntity.status(status).body(body); + } + return ResponseEntity.ok(body); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(EsignHubPromotionResponse.fail("500", "upload failed")); + } + } + + @Operation(summary = "创建或更新推广") + @PostMapping(consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") + public ResponseEntity save(@RequestBody EsignHubPromotionSaveRequest request) { + EsignHubPromotionResponse body = promotionService.save(request); + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "更新推广") + @PutMapping(value = "/{promotionId}", consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") + public ResponseEntity update( + @PathVariable("promotionId") String promotionId, + @RequestBody EsignHubPromotionSaveRequest request) { + if (request != null) { + request.setPromotionId(promotionId); + } + EsignHubPromotionResponse body = promotionService.save(request); + if (!body.isSuccess()) { + HttpStatus status = "404".equals(body.getCode()) ? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST; + return ResponseEntity.status(status).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "发布推广") + @PostMapping(value = "/{promotionId}/publish", produces = "application/json;charset=UTF-8") + public ResponseEntity publish(@PathVariable("promotionId") String promotionId) { + EsignHubPromotionResponse body = promotionService.publish(promotionId); + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "归档推广") + @PostMapping(value = "/{promotionId}/archive", produces = "application/json;charset=UTF-8") + public ResponseEntity archive(@PathVariable("promotionId") String promotionId) { + EsignHubPromotionResponse body = promotionService.archive(promotionId); + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "删除推广") + @DeleteMapping(value = "/{promotionId}", produces = "application/json;charset=UTF-8") + public ResponseEntity delete(@PathVariable("promotionId") String promotionId) { + EsignHubPromotionResponse body = promotionService.delete(promotionId); + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + return ResponseEntity.ok(body); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/controllers/EsignHubSettingsController.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/controllers/EsignHubSettingsController.java new file mode 100644 index 0000000..e0f294c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/controllers/EsignHubSettingsController.java @@ -0,0 +1,49 @@ +package com.sjmresorts.opsapp.esign.hub.controllers; + +import com.sjmresorts.opsapp.esign.hub.dtos.EsignHubSettingsResponse; +import com.sjmresorts.opsapp.esign.hub.dtos.EsignHubSettingsSaveRequest; +import com.sjmresorts.opsapp.esign.hub.services.EsignHubSettingsService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@Tag(name = "Esign Hub Settings", description = "App 首页外观(导航栏主题色等)") +@RestController +@RequestMapping({"/api/v1/esign/hub-settings", "/api/v1/sinosecu/hub-settings"}) +@CrossOrigin(origins = "*") +public class EsignHubSettingsController { + + @Resource + private EsignHubSettingsService settingsService; + + @Operation(summary = "管理端:读取首页外观配置") + @GetMapping(produces = "application/json;charset=UTF-8") + public ResponseEntity getForAdmin() { + return ResponseEntity.ok(settingsService.getForAdmin()); + } + + @Operation(summary = "App:读取首页外观配置") + @GetMapping(value = "/app", produces = "application/json;charset=UTF-8") + public ResponseEntity getForApp( + @RequestParam(value = "hotelId", required = false) String hotelId) { + return ResponseEntity.ok(settingsService.getForApp(hotelId)); + } + + @Operation(summary = "管理端:保存首页外观配置") + @PutMapping(produces = "application/json;charset=UTF-8") + public ResponseEntity save(@RequestBody EsignHubSettingsSaveRequest request) { + EsignHubSettingsResponse body = settingsService.save(request); + if (!body.isSuccess()) { + return ResponseEntity.badRequest().body(body); + } + return ResponseEntity.ok(body); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignAppPageLayoutResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignAppPageLayoutResponse.java new file mode 100644 index 0000000..9d6c752 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignAppPageLayoutResponse.java @@ -0,0 +1,50 @@ +package com.sjmresorts.opsapp.esign.hub.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignAppPageLayoutResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private AppPageLayoutData data; + + @Data + @NoArgsConstructor + public static class AppPageLayoutData { + private AppPageLayoutItem layout; + } + + @Data + @NoArgsConstructor + public static class AppPageLayoutItem { + private String layoutId; + private String pageKey; + private String hotelId; + private String layoutJson; + private String publishedLayoutJson; + private String status; + private Integer layoutVersion; + private String updatedAt; + private String publishedAt; + } + + public static EsignAppPageLayoutResponse ok(String message) { + EsignAppPageLayoutResponse r = new EsignAppPageLayoutResponse(); + r.setMessage(message); + return r; + } + + public static EsignAppPageLayoutResponse fail(String code, String message) { + EsignAppPageLayoutResponse r = new EsignAppPageLayoutResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignAppPageLayoutSaveRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignAppPageLayoutSaveRequest.java new file mode 100644 index 0000000..bbcee91 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignAppPageLayoutSaveRequest.java @@ -0,0 +1,18 @@ +package com.sjmresorts.opsapp.esign.hub.dtos; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EsignAppPageLayoutSaveRequest { + + /** home 等页面标识 */ + private String pageKey; + + /** 布局 JSON 字符串,含 version + blocks[] */ + private String layoutJson; + + /** true 时同时发布给 App */ + private Boolean publish; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignHubPromotionResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignHubPromotionResponse.java new file mode 100644 index 0000000..4084748 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignHubPromotionResponse.java @@ -0,0 +1,61 @@ +package com.sjmresorts.opsapp.esign.hub.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "首页精选推广 API 响应") +public class EsignHubPromotionResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignHubPromotionData data; + + @Data + @NoArgsConstructor + public static class EsignHubPromotionData { + private String promotionId; + private List promotions; + private EsignHubPromotionItem promotion; + } + + @Data + @NoArgsConstructor + public static class EsignHubPromotionItem { + private String promotionId; + private String title; + private String subtitle; + private String tag; + private String theme; + private String displayType; + private String imageUrl; + private String textPosition; + private String hotelId; + private String status; + private Integer sortOrder; + private String createdAt; + private String updatedAt; + private String publishedAt; + } + + public static EsignHubPromotionResponse ok(String message) { + EsignHubPromotionResponse r = new EsignHubPromotionResponse(); + r.setMessage(message); + return r; + } + + public static EsignHubPromotionResponse fail(String code, String message) { + EsignHubPromotionResponse r = new EsignHubPromotionResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignHubPromotionSaveRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignHubPromotionSaveRequest.java new file mode 100644 index 0000000..60342ea --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignHubPromotionSaveRequest.java @@ -0,0 +1,25 @@ +package com.sjmresorts.opsapp.esign.hub.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "保存首页推广") +public class EsignHubPromotionSaveRequest { + + private String promotionId; + private String title; + private String subtitle; + private String tag; + private String theme; + /** CARD | IMAGE */ + private String displayType; + /** 图片模式:URL 或 data URL */ + private String imageUrl; + /** TOP | CENTER | BOTTOM | NONE */ + private String textPosition; + private String hotelId; + private Integer sortOrder; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignHubSettingsResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignHubSettingsResponse.java new file mode 100644 index 0000000..80904c4 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignHubSettingsResponse.java @@ -0,0 +1,53 @@ +package com.sjmresorts.opsapp.esign.hub.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignHubSettingsResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private HubSettingsData data; + + @Data + @NoArgsConstructor + public static class HubSettingsData { + private HubSettingsItem settings; + } + + @Data + @NoArgsConstructor + public static class HubSettingsItem { + private String settingId; + private String hotelId; + private String navBarColor; + private String navBarColorDark; + /** 首页顶栏是否显示,默认 true */ + private Boolean navBarVisible; + /** 虚拟打印机登记卡页顶栏是否显示,默认 true */ + private Boolean printerPdfNavBarVisible; + private HubUserMenuStyle userMenuStyle; + /** 设定侧栏菜单显隐,默认全部 true */ + private HubSettingsMenu settingsMenu; + private String updatedAt; + } + + public static EsignHubSettingsResponse ok(String message) { + EsignHubSettingsResponse r = new EsignHubSettingsResponse(); + r.setMessage(message); + return r; + } + + public static EsignHubSettingsResponse fail(String code, String message) { + EsignHubSettingsResponse r = new EsignHubSettingsResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignHubSettingsSaveRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignHubSettingsSaveRequest.java new file mode 100644 index 0000000..dbe3d21 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/EsignHubSettingsSaveRequest.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.esign.hub.dtos; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class EsignHubSettingsSaveRequest { + + /** 留空表示全酒店默认 */ + private String hotelId; + private String navBarColor; + private String navBarColorDark; + /** 首页顶栏是否显示;不传则保持原值,新建默认 true */ + private Boolean navBarVisible; + /** 虚拟打印机登记卡页顶栏是否显示;不传则保持原值,新建默认 true */ + private Boolean printerPdfNavBarVisible; + private HubUserMenuStyle userMenuStyle; + /** 设定侧栏菜单显隐;不传则保持原值 */ + private HubSettingsMenu settingsMenu; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/HubSettingsMenu.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/HubSettingsMenu.java new file mode 100644 index 0000000..a64fef2 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/HubSettingsMenu.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.esign.hub.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * App 设定侧栏菜单项显隐(CMS「首页外观」配置,默认全部显示)。 + */ +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class HubSettingsMenu { + + /** 后端服务设定 */ + private Boolean apiSettings; + /** 连接打印 PC */ + private Boolean printerAgent; + /** 虚拟打印机登记卡 */ + private Boolean printerPdf; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/HubUserMenuStyle.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/HubUserMenuStyle.java new file mode 100644 index 0000000..a38fb8a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/dtos/HubUserMenuStyle.java @@ -0,0 +1,18 @@ +package com.sjmresorts.opsapp.esign.hub.dtos; + +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +public class HubUserMenuStyle { + + private String background; + private String textColor; + private String fontSize; + private String itemPadding; + private String borderRadius; + private String activeBackground; + private String maskBackground; + private String minWidth; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/models/EsignAppPageLayout.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/models/EsignAppPageLayout.java new file mode 100644 index 0000000..6cc5563 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/models/EsignAppPageLayout.java @@ -0,0 +1,50 @@ +package com.sjmresorts.opsapp.esign.hub.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_app_page_layout") +public class EsignAppPageLayout { + + @Id + @Column(name = "layout_id", nullable = false, length = 36) + private String layoutId; + + @Column(name = "page_key", nullable = false, length = 64) + private String pageKey; + + @Column(name = "hotel_id", length = 64) + private String hotelId; + + @Lob + @Column(name = "layout_json", nullable = false) + private String layoutJson; + + @Lob + @Column(name = "published_layout_json") + private String publishedLayoutJson; + + @Column(name = "status", nullable = false, length = 16) + private String status; + + @Column(name = "layout_version", nullable = false) + private int layoutVersion; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "published_at") + private Instant publishedAt; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/models/EsignHubPromotion.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/models/EsignHubPromotion.java new file mode 100644 index 0000000..41187ca --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/models/EsignHubPromotion.java @@ -0,0 +1,65 @@ +package com.sjmresorts.opsapp.esign.hub.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_hub_promotions") +public class EsignHubPromotion { + + @Id + @Column(name = "promotion_id", nullable = false, length = 36) + private String promotionId; + + @Column(name = "title", length = 128) + private String title; + + @Column(name = "subtitle", length = 256) + private String subtitle; + + @Column(name = "tag", length = 32) + private String tag; + + @Column(name = "theme", nullable = false, length = 32) + private String theme; + + @Column(name = "display_type", nullable = false, length = 16) + private String displayType; + + @Lob + @Column(name = "image_url") + private String imageUrl; + + /** 图片/视频上文字叠放位置:TOP | CENTER | BOTTOM | NONE */ + @Column(name = "text_position", length = 16) + private String textPosition; + + @Column(name = "hotel_id", length = 64) + private String hotelId; + + @Column(name = "status", nullable = false, length = 32) + private String status; + + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "published_at") + private Instant publishedAt; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/models/EsignHubSettings.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/models/EsignHubSettings.java new file mode 100644 index 0000000..ad93867 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/models/EsignHubSettings.java @@ -0,0 +1,53 @@ +package com.sjmresorts.opsapp.esign.hub.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_hub_settings") +public class EsignHubSettings { + + @Id + @Column(name = "setting_id", nullable = false, length = 36) + private String settingId; + + @Column(name = "hotel_id", length = 64) + private String hotelId; + + @Column(name = "nav_bar_color", nullable = false, length = 16) + private String navBarColor; + + @Column(name = "nav_bar_color_dark", length = 16) + private String navBarColorDark; + + /** 首页顶栏(铃铛/品牌/登出)是否显示;null 视为 true */ + @Column(name = "nav_bar_visible") + private Boolean navBarVisible; + + /** 虚拟打印机登记卡页顶栏(返回/标题/齿轮)是否显示;null 视为 true */ + @Column(name = "printer_pdf_nav_bar_visible") + private Boolean printerPdfNavBarVisible; + + @Lob + @Column(name = "user_menu_style_json") + private String userMenuStyleJson; + + /** App 设定侧栏菜单显隐 JSON;null 视为全部显示 */ + @Lob + @Column(name = "settings_menu_json") + private String settingsMenuJson; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/repositories/EsignAppPageLayoutRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/repositories/EsignAppPageLayoutRepository.java new file mode 100644 index 0000000..72cc68c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/repositories/EsignAppPageLayoutRepository.java @@ -0,0 +1,11 @@ +package com.sjmresorts.opsapp.esign.hub.repositories; + +import com.sjmresorts.opsapp.esign.hub.models.EsignAppPageLayout; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface EsignAppPageLayoutRepository extends JpaRepository { + + Optional findFirstByPageKeyAndHotelIdOrderByUpdatedAtDesc(String pageKey, String hotelId); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/repositories/EsignHubPromotionRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/repositories/EsignHubPromotionRepository.java new file mode 100644 index 0000000..e8f579f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/repositories/EsignHubPromotionRepository.java @@ -0,0 +1,15 @@ +package com.sjmresorts.opsapp.esign.hub.repositories; + +import com.sjmresorts.opsapp.esign.hub.models.EsignHubPromotion; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface EsignHubPromotionRepository extends JpaRepository { + + List findAllByOrderBySortOrderAscUpdatedAtDesc(); + + List findByStatusOrderBySortOrderAscUpdatedAtDesc(String status); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/repositories/EsignHubSettingsRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/repositories/EsignHubSettingsRepository.java new file mode 100644 index 0000000..ae69093 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/repositories/EsignHubSettingsRepository.java @@ -0,0 +1,13 @@ +package com.sjmresorts.opsapp.esign.hub.repositories; + +import com.sjmresorts.opsapp.esign.hub.models.EsignHubSettings; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface EsignHubSettingsRepository extends JpaRepository { + + Optional findFirstByHotelIdOrderByUpdatedAtDesc(String hotelId); + + Optional findFirstByHotelIdIsNullOrderByUpdatedAtDesc(); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/services/EsignAppPageLayoutService.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/services/EsignAppPageLayoutService.java new file mode 100644 index 0000000..c3d3683 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/services/EsignAppPageLayoutService.java @@ -0,0 +1,218 @@ +package com.sjmresorts.opsapp.esign.hub.services; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sjmresorts.opsapp.esign.hub.dtos.EsignAppPageLayoutResponse; +import com.sjmresorts.opsapp.esign.hub.dtos.EsignAppPageLayoutSaveRequest; +import com.sjmresorts.opsapp.esign.hub.models.EsignAppPageLayout; +import com.sjmresorts.opsapp.esign.hub.repositories.EsignAppPageLayoutRepository; +import com.sjmresorts.opsapp.esign.common.support.EsignReservationParamUtil; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Iterator; +import java.util.Set; + +@Service +public class EsignAppPageLayoutService { + + public static final String DEFAULT_LAYOUT_ID = "DEFAULT_HOME"; + public static final String DEFAULT_PAGE_KEY = "home"; + + private static final Set ALLOWED_BLOCK_TYPES = Set.of( + "promotions", + "menuGrid", + "banner", + "spacer", + "text"); + + private static final String DEFAULT_LAYOUT_JSON = """ + {"version":1,"blocks":[ + {"id":"promo-1","type":"promotions","props":{"height":180,"limit":5}}, + {"id":"menu-1","type":"menuGrid","props":{"columns":4,"items":[ + {"icon":"bi-envelope","label":"待簽消息","path":"/pages/inbox/index"}, + {"icon":"bi-journal-check","label":"簽收記錄","path":"/pages/records/index"}, + {"icon":"bi-gear","label":"設定","path":"/pages/settings/index"}]}} + ]}"""; + + @Resource + private EsignAppPageLayoutRepository layoutRepository; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + public EsignAppPageLayoutResponse getForAdmin(String pageKey) { + EsignAppPageLayout layout = ensureDefaultRow(normalizePageKey(pageKey)); + return buildOk(layout); + } + + public EsignAppPageLayoutResponse getForApp(String pageKey, String hotelId) { + String key = normalizePageKey(pageKey); + String normalizedHotelId = StringUtils.isNotBlank(hotelId) + ? EsignReservationParamUtil.normalizeParam(hotelId) + : null; + EsignAppPageLayout layout = null; + if (normalizedHotelId != null) { + layout = layoutRepository.findFirstByPageKeyAndHotelIdOrderByUpdatedAtDesc(key, normalizedHotelId) + .orElse(null); + } + if (layout == null) { + layout = ensureDefaultRow(key); + } + EsignAppPageLayoutResponse r = buildOk(layout); + if (r.getData() != null && r.getData().getLayout() != null) { + String published = layout.getPublishedLayoutJson(); + if (StringUtils.isNotBlank(published)) { + r.getData().getLayout().setLayoutJson(published); + } + } + return r; + } + + @Transactional + public EsignAppPageLayoutResponse save(EsignAppPageLayoutSaveRequest request) { + if (request == null || StringUtils.isBlank(request.getLayoutJson())) { + return EsignAppPageLayoutResponse.fail("400", "layoutJson required"); + } + String normalizedJson; + try { + normalizedJson = validateAndNormalizeLayoutJson(request.getLayoutJson()); + } catch (IllegalArgumentException e) { + return EsignAppPageLayoutResponse.fail("400", e.getMessage()); + } + + String pageKey = normalizePageKey(request.getPageKey()); + EsignAppPageLayout entity = ensureDefaultRow(pageKey); + entity.setLayoutJson(normalizedJson); + entity.setLayoutVersion(entity.getLayoutVersion() + 1); + entity.setUpdatedAt(Instant.now()); + + boolean publish = request.getPublish() == null || Boolean.TRUE.equals(request.getPublish()); + if (publish) { + entity.setPublishedLayoutJson(normalizedJson); + entity.setStatus("PUBLISHED"); + entity.setPublishedAt(Instant.now()); + } else { + entity.setStatus("DRAFT"); + } + + layoutRepository.save(entity); + EsignAppPageLayoutResponse r = buildOk(entity); + r.setMessage(publish ? "published" : "saved"); + return r; + } + + @Transactional + public EsignAppPageLayoutResponse publish(String pageKey) { + EsignAppPageLayout entity = ensureDefaultRow(normalizePageKey(pageKey)); + if (StringUtils.isBlank(entity.getLayoutJson())) { + return EsignAppPageLayoutResponse.fail("400", "no draft layout"); + } + entity.setPublishedLayoutJson(entity.getLayoutJson()); + entity.setStatus("PUBLISHED"); + entity.setPublishedAt(Instant.now()); + entity.setUpdatedAt(Instant.now()); + layoutRepository.save(entity); + EsignAppPageLayoutResponse r = buildOk(entity); + r.setMessage("published"); + return r; + } + + private EsignAppPageLayout ensureDefaultRow(String pageKey) { + if (DEFAULT_PAGE_KEY.equals(pageKey)) { + return layoutRepository.findById(DEFAULT_LAYOUT_ID).orElseGet(() -> { + EsignAppPageLayout row = new EsignAppPageLayout(); + row.setLayoutId(DEFAULT_LAYOUT_ID); + row.setPageKey(DEFAULT_PAGE_KEY); + row.setHotelId(null); + row.setLayoutJson(DEFAULT_LAYOUT_JSON); + row.setPublishedLayoutJson(DEFAULT_LAYOUT_JSON); + row.setStatus("PUBLISHED"); + row.setLayoutVersion(1); + row.setUpdatedAt(Instant.now()); + row.setPublishedAt(Instant.now()); + return layoutRepository.save(row); + }); + } + return layoutRepository.findFirstByPageKeyAndHotelIdOrderByUpdatedAtDesc(pageKey, null) + .orElseGet(() -> { + EsignAppPageLayout row = new EsignAppPageLayout(); + row.setLayoutId(DEFAULT_LAYOUT_ID + "_" + pageKey); + row.setPageKey(pageKey); + row.setHotelId(null); + row.setLayoutJson(DEFAULT_LAYOUT_JSON); + row.setPublishedLayoutJson(DEFAULT_LAYOUT_JSON); + row.setStatus("PUBLISHED"); + row.setLayoutVersion(1); + row.setUpdatedAt(Instant.now()); + row.setPublishedAt(Instant.now()); + return layoutRepository.save(row); + }); + } + + private String validateAndNormalizeLayoutJson(String raw) { + JsonNode root; + try { + root = objectMapper.readTree(raw); + } catch (Exception e) { + throw new IllegalArgumentException("layoutJson invalid JSON"); + } + if (!root.isObject()) { + throw new IllegalArgumentException("layoutJson must be object"); + } + JsonNode blocks = root.get("blocks"); + if (blocks == null || !blocks.isArray()) { + throw new IllegalArgumentException("layoutJson.blocks must be array"); + } + Iterator it = blocks.elements(); + while (it.hasNext()) { + JsonNode block = it.next(); + JsonNode typeNode = block.get("type"); + if (typeNode == null || !typeNode.isTextual()) { + throw new IllegalArgumentException("each block requires type"); + } + if (!ALLOWED_BLOCK_TYPES.contains(typeNode.asText())) { + throw new IllegalArgumentException("unsupported block type: " + typeNode.asText()); + } + if (block.get("id") == null || !block.get("id").isTextual()) { + throw new IllegalArgumentException("each block requires string id"); + } + } + try { + return objectMapper.writeValueAsString(root); + } catch (Exception e) { + throw new IllegalArgumentException("layoutJson serialize failed"); + } + } + + private String normalizePageKey(String pageKey) { + if (StringUtils.isBlank(pageKey)) { + return DEFAULT_PAGE_KEY; + } + return pageKey.trim().toLowerCase(); + } + + private EsignAppPageLayoutResponse buildOk(EsignAppPageLayout layout) { + EsignAppPageLayoutResponse r = EsignAppPageLayoutResponse.ok("ok"); + EsignAppPageLayoutResponse.AppPageLayoutData data = new EsignAppPageLayoutResponse.AppPageLayoutData(); + data.setLayout(toItem(layout)); + r.setData(data); + return r; + } + + private EsignAppPageLayoutResponse.AppPageLayoutItem toItem(EsignAppPageLayout layout) { + EsignAppPageLayoutResponse.AppPageLayoutItem item = new EsignAppPageLayoutResponse.AppPageLayoutItem(); + item.setLayoutId(layout.getLayoutId()); + item.setPageKey(layout.getPageKey()); + item.setHotelId(layout.getHotelId()); + item.setLayoutJson(layout.getLayoutJson()); + item.setPublishedLayoutJson(layout.getPublishedLayoutJson()); + item.setStatus(layout.getStatus()); + item.setLayoutVersion(layout.getLayoutVersion()); + item.setUpdatedAt(layout.getUpdatedAt() != null ? layout.getUpdatedAt().toString() : null); + item.setPublishedAt(layout.getPublishedAt() != null ? layout.getPublishedAt().toString() : null); + return item; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/services/EsignHubPromotionMediaService.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/services/EsignHubPromotionMediaService.java new file mode 100644 index 0000000..0fdc822 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/services/EsignHubPromotionMediaService.java @@ -0,0 +1,298 @@ +package com.sjmresorts.opsapp.esign.hub.services; + +import com.sjmresorts.opsapp.esign.hub.models.EsignHubPromotion; +import com.sjmresorts.opsapp.esign.hub.repositories.EsignHubPromotionRepository; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Instant; +import java.util.Base64; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +public class EsignHubPromotionMediaService { + + public static final String STORED_PREFIX = "stored://"; + private static final Pattern DATA_URL = Pattern.compile("^data:([^;]+);base64,(.+)$", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + + @Value("${ops-app.hub-promotion.media-dir:C:/auto-check-in/hub-promotions}") + private String mediaDir; + + @Resource + private EsignHubPromotionRepository promotionRepository; + + private Path mediaRoot; + + @PostConstruct + void init() throws IOException { + mediaRoot = Paths.get(mediaDir).toAbsolutePath().normalize(); + Files.createDirectories(mediaRoot); + } + + /** + * 将 data URL 落盘,DB 仅存 stored:// 引用,避免列表接口返回数 MB JSON。 + */ + public String persistIfNeeded(String promotionId, String displayType, String imageUrl) throws IOException { + if (StringUtils.isBlank(imageUrl)) { + deleteStoredFile(promotionId); + return null; + } + String trimmed = imageUrl.trim(); + if (trimmed.startsWith(STORED_PREFIX)) { + return trimmed; + } + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + deleteStoredFile(promotionId); + return trimmed; + } + if (!trimmed.startsWith("data:")) { + return trimmed; + } + ParsedDataUrl parsed = parseDataUrl(trimmed); + if (parsed == null) { + throw new IOException("invalid data URL"); + } + String ext = extensionForMime(parsed.mimeType(), displayType); + Path target = mediaRoot.resolve(promotionId + ext); + Files.write(target, parsed.bytes()); + deleteSiblingFiles(promotionId, target); + return STORED_PREFIX + promotionId; + } + + public String saveBytes(String promotionId, String displayType, byte[] bytes, String contentType) throws IOException { + if (StringUtils.isBlank(promotionId) || bytes == null || bytes.length == 0) { + throw new IOException("empty media"); + } + String ext = extensionForMime(StringUtils.defaultString(contentType), displayType); + Path target = mediaRoot.resolve(promotionId.trim() + ext); + Files.write(target, bytes); + deleteSiblingFiles(promotionId.trim(), target); + return STORED_PREFIX + promotionId.trim(); + } + + public Path getMediaRoot() { + return mediaRoot; + } + + public Optional load(String promotionId) { + if (StringUtils.isBlank(promotionId)) { + return Optional.empty(); + } + Optional opt = promotionRepository.findById(promotionId.trim()); + if (opt.isEmpty()) { + return Optional.empty(); + } + return load(opt.get()); + } + + public Optional load(EsignHubPromotion row) { + Optional path = resolveMediaFile(row); + if (path.isEmpty()) { + return Optional.empty(); + } + try { + Path file = path.get(); + return Optional.of(new MediaPayload(Files.readAllBytes(file), probeMime(file))); + } catch (IOException ignored) { + return Optional.empty(); + } + } + + /** 访问 /media 时优先读磁盘文件;遗留 data: 会先落盘再返回路径。 */ + @Transactional + public Optional resolveMediaFile(String promotionId) { + if (StringUtils.isBlank(promotionId)) { + return Optional.empty(); + } + Optional opt = promotionRepository.findById(promotionId.trim()); + return opt.flatMap(this::resolveMediaFile); + } + + @Transactional + public Optional resolveMediaFile(EsignHubPromotion row) { + if (row == null) { + return Optional.empty(); + } + String imageUrl = StringUtils.trimToEmpty(row.getImageUrl()); + if (imageUrl.startsWith("data:")) { + try { + String stored = persistIfNeeded(row.getPromotionId(), row.getDisplayType(), imageUrl); + row.setImageUrl(stored); + promotionRepository.save(row); + imageUrl = StringUtils.trimToEmpty(stored); + } catch (IOException ignored) { + return Optional.empty(); + } + } + String fileId = resolveStoredFileId(row.getPromotionId(), imageUrl); + return findStoredFilePath(fileId); + } + + public String probeMimeForPath(Path path) throws IOException { + return probeMime(path); + } + + private static String resolveStoredFileId(String promotionId, String imageUrl) { + if (StringUtils.isBlank(imageUrl)) { + return promotionId; + } + if (imageUrl.startsWith(STORED_PREFIX)) { + return imageUrl.substring(STORED_PREFIX.length()); + } + if (isHubMediaPath(imageUrl)) { + return promotionId; + } + return promotionId; + } + + private static boolean isHubMediaPath(String url) { + if (StringUtils.isBlank(url) || !url.contains("/esign/hub-promotions/")) { + return false; + } + String path = url.trim(); + int q = path.indexOf('?'); + if (q >= 0) { + path = path.substring(0, q); + } + int hash = path.indexOf('#'); + if (hash >= 0) { + path = path.substring(0, hash); + } + return path.endsWith("/media"); + } + + public boolean shouldUseMediaEndpoint(EsignHubPromotion row) { + if (row == null || StringUtils.isBlank(row.getImageUrl())) { + return false; + } + String imageUrl = row.getImageUrl().trim(); + return imageUrl.startsWith(STORED_PREFIX) + || imageUrl.startsWith("data:") + || isHubMediaPath(imageUrl); + } + + public String toRelativeMediaPath(String promotionId) { + return toRelativeMediaPath(promotionId, null); + } + + public String toRelativeMediaPath(String promotionId, Instant updatedAt) { + String path = "/esign/hub-promotions/" + promotionId + "/media"; + if (updatedAt == null) { + return path; + } + return path + "?v=" + updatedAt.toEpochMilli(); + } + + private Optional readStoredFile(String promotionId) { + return findStoredFilePath(promotionId).map(path -> { + try { + return new MediaPayload(Files.readAllBytes(path), probeMime(path)); + } catch (IOException e) { + return null; + } + }).filter(payload -> payload != null); + } + + private Optional findStoredFilePath(String promotionId) { + if (StringUtils.isBlank(promotionId)) { + return Optional.empty(); + } + for (Path path : candidatePaths(promotionId.trim())) { + if (Files.isRegularFile(path)) { + return Optional.of(path); + } + } + return Optional.empty(); + } + + private Path[] candidatePaths(String promotionId) { + return new Path[] { + mediaRoot.resolve(promotionId + ".jpg"), + mediaRoot.resolve(promotionId + ".jpeg"), + mediaRoot.resolve(promotionId + ".png"), + mediaRoot.resolve(promotionId + ".webp"), + mediaRoot.resolve(promotionId + ".gif"), + mediaRoot.resolve(promotionId + ".mp4"), + mediaRoot.resolve(promotionId + ".webm") + }; + } + + private void deleteStoredFile(String promotionId) throws IOException { + if (StringUtils.isBlank(promotionId)) { + return; + } + deleteSiblingFiles(promotionId, null); + } + + private void deleteSiblingFiles(String promotionId, Path keep) throws IOException { + String prefix = promotionId + "."; + if (!Files.isDirectory(mediaRoot)) { + return; + } + try (var stream = Files.list(mediaRoot)) { + stream.filter(p -> p.getFileName().toString().startsWith(prefix)) + .filter(p -> keep == null || !p.equals(keep)) + .forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException ignored) { + } + }); + } + } + + private static String extensionForMime(String mimeType, String displayType) { + String mime = StringUtils.defaultString(mimeType).toLowerCase(Locale.ROOT); + if (mime.contains("png")) return ".png"; + if (mime.contains("webp")) return ".webp"; + if (mime.contains("gif")) return ".gif"; + if (mime.contains("mp4")) return ".mp4"; + if (mime.contains("webm")) return ".webm"; + if ("VIDEO".equalsIgnoreCase(displayType)) return ".mp4"; + return ".jpg"; + } + + private static String probeMime(Path path) throws IOException { + String probed = Files.probeContentType(path); + if (StringUtils.isNotBlank(probed)) { + return probed; + } + String name = path.getFileName().toString().toLowerCase(Locale.ROOT); + if (name.endsWith(".png")) return "image/png"; + if (name.endsWith(".webp")) return "image/webp"; + if (name.endsWith(".gif")) return "image/gif"; + if (name.endsWith(".mp4")) return "video/mp4"; + if (name.endsWith(".webm")) return "video/webm"; + return "image/jpeg"; + } + + private static ParsedDataUrl parseDataUrl(String dataUrl) { + Matcher m = DATA_URL.matcher(dataUrl); + if (!m.matches()) { + return null; + } + try { + byte[] bytes = Base64.getDecoder().decode(m.group(2).replaceAll("\\s", "")); + return new ParsedDataUrl(StringUtils.defaultString(m.group(1)), bytes); + } catch (IllegalArgumentException e) { + return null; + } + } + + private record ParsedDataUrl(String mimeType, byte[] bytes) { + } + + public record MediaPayload(byte[] bytes, String contentType) { + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/services/EsignHubPromotionService.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/services/EsignHubPromotionService.java new file mode 100644 index 0000000..a47ba25 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/services/EsignHubPromotionService.java @@ -0,0 +1,398 @@ +package com.sjmresorts.opsapp.esign.hub.services; + +import com.sjmresorts.opsapp.esign.hub.dtos.EsignHubPromotionResponse; +import com.sjmresorts.opsapp.esign.hub.dtos.EsignHubPromotionSaveRequest; +import com.sjmresorts.opsapp.esign.hub.models.EsignHubPromotion; +import com.sjmresorts.opsapp.esign.hub.repositories.EsignHubPromotionRepository; +import com.sjmresorts.opsapp.esign.common.support.EsignReservationParamUtil; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +public class EsignHubPromotionService { + + public static final String STATUS_DRAFT = "DRAFT"; + public static final String STATUS_PUBLISHED = "PUBLISHED"; + public static final String STATUS_ARCHIVED = "ARCHIVED"; + + private static final Set ALLOWED_THEMES = Set.of("brand", "gold", "teal"); + private static final Set ALLOWED_DISPLAY_TYPES = Set.of("CARD", "IMAGE", "VIDEO"); + private static final Set ALLOWED_TEXT_POSITIONS = Set.of("TOP", "CENTER", "BOTTOM", "NONE"); + /** Base64 data URL 上限;约可容纳 10MB 原图 */ + private static final int MAX_IMAGE_URL_LENGTH = 15_000_000; + + @Resource + private EsignHubPromotionRepository promotionRepository; + + @Resource + private EsignHubPromotionMediaService mediaService; + + public EsignHubPromotionResponse listAllForAdmin() { + List rows = promotionRepository.findAllByOrderBySortOrderAscUpdatedAtDesc(); + for (EsignHubPromotion row : rows) { + persistMigrationIfNeeded(row); + } + EsignHubPromotionResponse r = EsignHubPromotionResponse.ok("ok"); + EsignHubPromotionResponse.EsignHubPromotionData data = new EsignHubPromotionResponse.EsignHubPromotionData(); + data.setPromotions(rows.stream().map(this::toClientItem).collect(Collectors.toList())); + r.setData(data); + return r; + } + + public EsignHubPromotionResponse listPublished(String hotelId) { + List rows = promotionRepository.findByStatusOrderBySortOrderAscUpdatedAtDesc(STATUS_PUBLISHED); + String normalizedHotelId = StringUtils.isNotBlank(hotelId) + ? EsignReservationParamUtil.normalizeParam(hotelId) + : null; + List filtered = new ArrayList<>(); + for (EsignHubPromotion row : rows) { + if (normalizedHotelId != null && StringUtils.isNotBlank(row.getHotelId()) + && !normalizedHotelId.equals(row.getHotelId())) { + continue; + } + persistMigrationIfNeeded(row); + filtered.add(toClientItem(row)); + } + EsignHubPromotionResponse r = EsignHubPromotionResponse.ok("ok"); + EsignHubPromotionResponse.EsignHubPromotionData data = new EsignHubPromotionResponse.EsignHubPromotionData(); + data.setPromotions(filtered); + r.setData(data); + return r; + } + + public EsignHubPromotionResponse getDetail(String promotionId) { + Optional opt = findPromotion(promotionId); + if (opt.isEmpty()) { + return EsignHubPromotionResponse.fail("404", "Promotion not found"); + } + EsignHubPromotionResponse r = EsignHubPromotionResponse.ok("ok"); + EsignHubPromotionResponse.EsignHubPromotionData data = new EsignHubPromotionResponse.EsignHubPromotionData(); + data.setPromotion(toClientItem(opt.get())); + r.setData(data); + return r; + } + + @Transactional + public EsignHubPromotionResponse save(EsignHubPromotionSaveRequest request) { + if (request == null) { + return EsignHubPromotionResponse.fail("400", "request required"); + } + + Instant now = Instant.now(); + EsignHubPromotion entity; + if (StringUtils.isNotBlank(request.getPromotionId())) { + Optional opt = findPromotion(request.getPromotionId()); + if (opt.isEmpty()) { + return EsignHubPromotionResponse.fail("404", "Promotion not found"); + } + entity = opt.get(); + } else { + entity = new EsignHubPromotion(); + entity.setPromotionId(UUID.randomUUID().toString()); + entity.setStatus(STATUS_DRAFT); + entity.setCreatedAt(now); + } + + entity.setTitle(trim(request.getTitle(), 128)); + entity.setSubtitle(trim(request.getSubtitle(), 256)); + entity.setTag(trim(request.getTag(), 32)); + entity.setTheme(normalizeTheme(request.getTheme())); + entity.setDisplayType(normalizeDisplayType(request.getDisplayType())); + entity.setTextPosition(normalizeTextPosition(request.getTextPosition())); + String imageUrl; + try { + imageUrl = resolveImageUrlForSave(entity, request.getImageUrl()); + } catch (IllegalStateException e) { + return EsignHubPromotionResponse.fail("500", "failed to store promotion media"); + } + if ("VIDEO".equals(entity.getDisplayType()) && StringUtils.isBlank(imageUrl)) { + return EsignHubPromotionResponse.fail("400", "imageUrl required for VIDEO display (video URL)"); + } + if ("CARD".equals(entity.getDisplayType()) + && StringUtils.isBlank(imageUrl) + && StringUtils.isBlank(entity.getTitle()) + && StringUtils.isBlank(entity.getSubtitle()) + && StringUtils.isBlank(entity.getTag())) { + return EsignHubPromotionResponse.fail("400", "card promotion needs title, subtitle, tag, or image"); + } + entity.setImageUrl(imageUrl); + String hotelId = trim(request.getHotelId(), 64); + entity.setHotelId(StringUtils.isNotBlank(hotelId) ? EsignReservationParamUtil.normalizeParam(hotelId) : null); + entity.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0); + entity.setUpdatedAt(now); + promotionRepository.save(entity); + + EsignHubPromotionResponse r = EsignHubPromotionResponse.ok("saved"); + EsignHubPromotionResponse.EsignHubPromotionData data = new EsignHubPromotionResponse.EsignHubPromotionData(); + data.setPromotionId(entity.getPromotionId()); + data.setPromotion(toClientItem(entity)); + r.setData(data); + return r; + } + + @Transactional + public EsignHubPromotionResponse publish(String promotionId) { + Optional opt = findPromotion(promotionId); + if (opt.isEmpty()) { + return EsignHubPromotionResponse.fail("404", "Promotion not found"); + } + EsignHubPromotion entity = opt.get(); + persistMigrationIfNeeded(entity); + if ("IMAGE".equalsIgnoreCase(entity.getDisplayType()) + && mediaService.resolveMediaFile(entity).isEmpty()) { + return EsignHubPromotionResponse.fail("400", "upload image before publish"); + } + entity.setStatus(STATUS_PUBLISHED); + entity.setPublishedAt(Instant.now()); + entity.setUpdatedAt(Instant.now()); + promotionRepository.save(entity); + return EsignHubPromotionResponse.ok("published"); + } + + @Transactional + public EsignHubPromotionResponse archive(String promotionId) { + Optional opt = findPromotion(promotionId); + if (opt.isEmpty()) { + return EsignHubPromotionResponse.fail("404", "Promotion not found"); + } + EsignHubPromotion entity = opt.get(); + entity.setStatus(STATUS_ARCHIVED); + entity.setUpdatedAt(Instant.now()); + promotionRepository.save(entity); + return EsignHubPromotionResponse.ok("archived"); + } + + @Transactional + public EsignHubPromotionResponse delete(String promotionId) { + Optional opt = findPromotion(promotionId); + if (opt.isEmpty()) { + return EsignHubPromotionResponse.fail("404", "Promotion not found"); + } + promotionRepository.delete(opt.get()); + try { + mediaService.persistIfNeeded(promotionId, "CARD", null); + } catch (Exception ignored) { + } + return EsignHubPromotionResponse.ok("deleted"); + } + + @Transactional + public EsignHubPromotionResponse uploadMedia(String promotionId, byte[] bytes, String contentType) { + if (StringUtils.isBlank(promotionId) || bytes == null || bytes.length == 0) { + return EsignHubPromotionResponse.fail("400", "file required"); + } + Optional opt = findPromotion(promotionId); + if (opt.isEmpty()) { + return EsignHubPromotionResponse.fail("404", "Promotion not found"); + } + EsignHubPromotion entity = opt.get(); + try { + String stored = mediaService.saveBytes( + entity.getPromotionId(), entity.getDisplayType(), bytes, contentType); + entity.setImageUrl(stored); + entity.setUpdatedAt(Instant.now()); + promotionRepository.save(entity); + } catch (Exception e) { + return EsignHubPromotionResponse.fail("500", "failed to store promotion media"); + } + EsignHubPromotionResponse r = EsignHubPromotionResponse.ok("media uploaded"); + EsignHubPromotionResponse.EsignHubPromotionData data = new EsignHubPromotionResponse.EsignHubPromotionData(); + data.setPromotionId(entity.getPromotionId()); + data.setPromotion(toClientItem(entity)); + r.setData(data); + return r; + } + + private Optional findPromotion(String promotionId) { + if (StringUtils.isBlank(promotionId)) { + return Optional.empty(); + } + return promotionRepository.findById(promotionId.trim()); + } + + private EsignHubPromotionResponse.EsignHubPromotionItem toClientItem(EsignHubPromotion row) { + EsignHubPromotionResponse.EsignHubPromotionItem item = toItem(row); + item.setImageUrl(resolveClientImageUrl(row)); + return item; + } + + private String resolveClientImageUrl(EsignHubPromotion row) { + if (row == null || StringUtils.isBlank(row.getImageUrl())) { + return null; + } + String imageUrl = row.getImageUrl().trim(); + if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) { + return imageUrl; + } + if (imageUrl.startsWith("data:") || mediaService.shouldUseMediaEndpoint(row)) { + return mediaService.toRelativeMediaPath(row.getPromotionId(), row.getUpdatedAt()); + } + return imageUrl; + } + + private String resolveImageUrlForSave(EsignHubPromotion entity, String requestImageUrl) { + String promotionId = entity.getPromotionId(); + String displayType = entity.getDisplayType(); + if (requestImageUrl == null) { + return normalizeExistingImageRef(entity.getImageUrl(), promotionId); + } + if (requestImageUrl.isEmpty()) { + try { + mediaService.persistIfNeeded(promotionId, displayType, null); + } catch (Exception ignored) { + } + return null; + } + return prepareImageUrl(promotionId, displayType, requestImageUrl); + } + + private String normalizeExistingImageRef(String existing, String promotionId) { + if (StringUtils.isBlank(existing)) { + return null; + } + String trimmed = existing.trim(); + if (trimmed.startsWith(EsignHubPromotionMediaService.STORED_PREFIX)) { + return trimmed; + } + if (trimmed.startsWith("data:")) { + return trimmed; + } + if (isHubMediaPath(trimmed)) { + return EsignHubPromotionMediaService.STORED_PREFIX + promotionId; + } + return trimmed; + } + + private static boolean isHubMediaPath(String url) { + if (StringUtils.isBlank(url) || !url.contains("/esign/hub-promotions/")) { + return false; + } + String path = url.trim(); + int q = path.indexOf('?'); + if (q >= 0) { + path = path.substring(0, q); + } + int hash = path.indexOf('#'); + if (hash >= 0) { + path = path.substring(0, hash); + } + return path.endsWith("/media"); + } + + private String prepareImageUrl(String promotionId, String displayType, String rawImageUrl) { + if (StringUtils.isBlank(rawImageUrl)) { + return null; + } + String trimmed = rawImageUrl.trim(); + if (trimmed.startsWith("data:") || trimmed.startsWith(EsignHubPromotionMediaService.STORED_PREFIX)) { + try { + return mediaService.persistIfNeeded(promotionId, displayType, trimmed); + } catch (Exception e) { + throw new IllegalStateException("failed to store promotion media", e); + } + } + if (isHubMediaPath(trimmed)) { + return EsignHubPromotionMediaService.STORED_PREFIX + promotionId; + } + return normalizeImageUrl(trimmed); + } + + private void persistMigrationIfNeeded(EsignHubPromotion entity) { + if (migrateLegacyMediaIfNeeded(entity)) { + entity.setUpdatedAt(Instant.now()); + promotionRepository.save(entity); + } + } + + private boolean migrateLegacyMediaIfNeeded(EsignHubPromotion entity) { + if (entity == null || StringUtils.isBlank(entity.getImageUrl())) { + return false; + } + if (!entity.getImageUrl().trim().startsWith("data:")) { + return false; + } + try { + String stored = mediaService.persistIfNeeded( + entity.getPromotionId(), entity.getDisplayType(), entity.getImageUrl()); + entity.setImageUrl(stored); + return true; + } catch (Exception ignored) { + return false; + } + } + + private EsignHubPromotionResponse.EsignHubPromotionItem toItem(EsignHubPromotion row) { + EsignHubPromotionResponse.EsignHubPromotionItem item = new EsignHubPromotionResponse.EsignHubPromotionItem(); + item.setPromotionId(row.getPromotionId()); + item.setTitle(row.getTitle()); + item.setSubtitle(row.getSubtitle()); + item.setTag(row.getTag()); + item.setTheme(row.getTheme()); + item.setDisplayType(StringUtils.defaultIfBlank(row.getDisplayType(), "CARD")); + item.setImageUrl(row.getImageUrl()); + item.setTextPosition(StringUtils.defaultIfBlank(row.getTextPosition(), "BOTTOM")); + item.setHotelId(row.getHotelId()); + item.setStatus(row.getStatus()); + item.setSortOrder(row.getSortOrder()); + item.setCreatedAt(row.getCreatedAt() != null ? row.getCreatedAt().toString() : null); + item.setUpdatedAt(row.getUpdatedAt() != null ? row.getUpdatedAt().toString() : null); + item.setPublishedAt(row.getPublishedAt() != null ? row.getPublishedAt().toString() : null); + return item; + } + + private static String normalizeTheme(String theme) { + if (StringUtils.isBlank(theme)) { + return "brand"; + } + String t = theme.trim().toLowerCase(); + return ALLOWED_THEMES.contains(t) ? t : "brand"; + } + + private static String normalizeDisplayType(String displayType) { + if (StringUtils.isBlank(displayType)) { + return "CARD"; + } + String t = displayType.trim().toUpperCase(); + return ALLOWED_DISPLAY_TYPES.contains(t) ? t : "CARD"; + } + + private static String normalizeTextPosition(String textPosition) { + if (StringUtils.isBlank(textPosition)) { + return "BOTTOM"; + } + String t = textPosition.trim().toUpperCase(); + return ALLOWED_TEXT_POSITIONS.contains(t) ? t : "BOTTOM"; + } + + private static String normalizeImageUrl(String imageUrl) { + if (StringUtils.isBlank(imageUrl)) { + return null; + } + String t = imageUrl.trim(); + if (t.length() > MAX_IMAGE_URL_LENGTH) { + return t.substring(0, MAX_IMAGE_URL_LENGTH); + } + return t; + } + + private static String trim(String s, int max) { + if (s == null) { + return null; + } + String t = s.trim(); + if (t.isEmpty()) { + return null; + } + return t.length() > max ? t.substring(0, max) : t; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/hub/services/EsignHubSettingsService.java b/src/main/java/com/sjmresorts/opsapp/esign/hub/services/EsignHubSettingsService.java new file mode 100644 index 0000000..c57e114 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/hub/services/EsignHubSettingsService.java @@ -0,0 +1,325 @@ +package com.sjmresorts.opsapp.esign.hub.services; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sjmresorts.opsapp.esign.hub.dtos.HubSettingsMenu; +import com.sjmresorts.opsapp.esign.hub.dtos.HubUserMenuStyle; +import com.sjmresorts.opsapp.esign.hub.dtos.EsignHubSettingsResponse; +import com.sjmresorts.opsapp.esign.hub.dtos.EsignHubSettingsSaveRequest; +import com.sjmresorts.opsapp.esign.hub.models.EsignHubSettings; +import com.sjmresorts.opsapp.esign.hub.repositories.EsignHubSettingsRepository; +import com.sjmresorts.opsapp.esign.common.support.EsignReservationParamUtil; +import jakarta.annotation.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Optional; +import java.util.regex.Pattern; + +@Service +public class EsignHubSettingsService { + + private static final Logger log = LoggerFactory.getLogger(EsignHubSettingsService.class); + + public static final String DEFAULT_SETTING_ID = "DEFAULT"; + public static final String DEFAULT_NAV_BAR_COLOR = "#266A62"; + public static final String DEFAULT_NAV_BAR_COLOR_DARK = "#1a3d36"; + + private static final Pattern HEX_COLOR = Pattern.compile("^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{3})$"); + private static final Pattern CSS_LENGTH = Pattern.compile("^\\d+(\\.\\d+)?(px|rpx)$"); + private static final Pattern CSS_PADDING = Pattern.compile( + "^\\d+(\\.\\d+)?(px|rpx)(\\s+\\d+(\\.\\d+)?(px|rpx)){0,3}$"); + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Resource + private EsignHubSettingsRepository settingsRepository; + + public EsignHubSettingsResponse getForAdmin() { + EsignHubSettings settings = ensureDefaultRow(); + return buildOk(settings); + } + + public EsignHubSettingsResponse getForApp(String hotelId) { + try { + String normalizedHotelId = StringUtils.isNotBlank(hotelId) + ? EsignReservationParamUtil.normalizeParam(hotelId) + : null; + EsignHubSettings settings = null; + if (normalizedHotelId != null) { + settings = settingsRepository.findFirstByHotelIdOrderByUpdatedAtDesc(normalizedHotelId).orElse(null); + } + if (settings == null) { + settings = ensureDefaultRow(); + } + return buildOk(settings); + } catch (Exception e) { + log.warn("hub-settings/app unavailable, returning defaults: {}", e.toString()); + return buildDefaultAppResponse(); + } + } + + @Transactional + public EsignHubSettingsResponse save(EsignHubSettingsSaveRequest request) { + if (request == null) { + return EsignHubSettingsResponse.fail("400", "request required"); + } + String navBarColor = normalizeColor(request.getNavBarColor(), DEFAULT_NAV_BAR_COLOR); + if (navBarColor == null) { + return EsignHubSettingsResponse.fail("400", "navBarColor invalid, use #RRGGBB"); + } + String navBarColorDark = StringUtils.isNotBlank(request.getNavBarColorDark()) + ? normalizeColor(request.getNavBarColorDark(), DEFAULT_NAV_BAR_COLOR_DARK) + : DEFAULT_NAV_BAR_COLOR_DARK; + if (navBarColorDark == null) { + return EsignHubSettingsResponse.fail("400", "navBarColorDark invalid, use #RRGGBB"); + } + + HubUserMenuStyle menuStyle = mergeUserMenuStyle(request.getUserMenuStyle()); + String menuStyleJson; + try { + menuStyleJson = objectMapper.writeValueAsString(menuStyle); + } catch (JsonProcessingException e) { + return EsignHubSettingsResponse.fail("400", "userMenuStyle invalid"); + } + + EsignHubSettings entity = ensureDefaultRow(); + entity.setNavBarColor(navBarColor); + entity.setNavBarColorDark(navBarColorDark); + if (request.getNavBarVisible() != null) { + entity.setNavBarVisible(request.getNavBarVisible()); + } else if (entity.getNavBarVisible() == null) { + entity.setNavBarVisible(Boolean.TRUE); + } + if (request.getPrinterPdfNavBarVisible() != null) { + entity.setPrinterPdfNavBarVisible(request.getPrinterPdfNavBarVisible()); + } else if (entity.getPrinterPdfNavBarVisible() == null) { + entity.setPrinterPdfNavBarVisible(Boolean.TRUE); + } + entity.setUserMenuStyleJson(menuStyleJson); + if (request.getSettingsMenu() != null) { + try { + entity.setSettingsMenuJson( + objectMapper.writeValueAsString(mergeSettingsMenu(request.getSettingsMenu()))); + } catch (JsonProcessingException e) { + return EsignHubSettingsResponse.fail("400", "settingsMenu invalid"); + } + } else if (StringUtils.isBlank(entity.getSettingsMenuJson())) { + entity.setSettingsMenuJson(serializeSettingsMenu(defaultSettingsMenu())); + } + entity.setUpdatedAt(Instant.now()); + settingsRepository.save(entity); + + EsignHubSettingsResponse r = buildOk(entity); + r.setMessage("saved"); + return r; + } + + private EsignHubSettings ensureDefaultRow() { + Optional opt = settingsRepository.findById(DEFAULT_SETTING_ID); + if (opt.isPresent()) { + return opt.get(); + } + EsignHubSettings row = new EsignHubSettings(); + row.setSettingId(DEFAULT_SETTING_ID); + row.setHotelId(null); + row.setNavBarColor(DEFAULT_NAV_BAR_COLOR); + row.setNavBarColorDark(DEFAULT_NAV_BAR_COLOR_DARK); + row.setNavBarVisible(Boolean.TRUE); + row.setPrinterPdfNavBarVisible(Boolean.TRUE); + row.setUserMenuStyleJson(serializeUserMenuStyle(defaultUserMenuStyle())); + row.setSettingsMenuJson(serializeSettingsMenu(defaultSettingsMenu())); + row.setUpdatedAt(Instant.now()); + return settingsRepository.save(row); + } + + private EsignHubSettingsResponse buildDefaultAppResponse() { + EsignHubSettingsResponse r = EsignHubSettingsResponse.ok("ok"); + EsignHubSettingsResponse.HubSettingsData data = new EsignHubSettingsResponse.HubSettingsData(); + EsignHubSettingsResponse.HubSettingsItem item = new EsignHubSettingsResponse.HubSettingsItem(); + item.setSettingId(DEFAULT_SETTING_ID); + item.setNavBarColor(DEFAULT_NAV_BAR_COLOR); + item.setNavBarColorDark(DEFAULT_NAV_BAR_COLOR_DARK); + item.setNavBarVisible(Boolean.TRUE); + item.setPrinterPdfNavBarVisible(Boolean.TRUE); + item.setUserMenuStyle(defaultUserMenuStyle()); + item.setSettingsMenu(defaultSettingsMenu()); + data.setSettings(item); + r.setData(data); + return r; + } + + private EsignHubSettingsResponse buildOk(EsignHubSettings settings) { + EsignHubSettingsResponse r = EsignHubSettingsResponse.ok("ok"); + EsignHubSettingsResponse.HubSettingsData data = new EsignHubSettingsResponse.HubSettingsData(); + data.setSettings(toItem(settings)); + r.setData(data); + return r; + } + + private EsignHubSettingsResponse.HubSettingsItem toItem(EsignHubSettings settings) { + EsignHubSettingsResponse.HubSettingsItem item = new EsignHubSettingsResponse.HubSettingsItem(); + item.setSettingId(settings.getSettingId()); + item.setHotelId(settings.getHotelId()); + item.setNavBarColor(settings.getNavBarColor()); + item.setNavBarColorDark(settings.getNavBarColorDark()); + item.setNavBarVisible(settings.getNavBarVisible() == null || Boolean.TRUE.equals(settings.getNavBarVisible())); + item.setPrinterPdfNavBarVisible( + settings.getPrinterPdfNavBarVisible() == null + || Boolean.TRUE.equals(settings.getPrinterPdfNavBarVisible())); + item.setUserMenuStyle(parseUserMenuStyle(settings.getUserMenuStyleJson())); + item.setSettingsMenu(parseSettingsMenu(settings.getSettingsMenuJson())); + item.setUpdatedAt(settings.getUpdatedAt() != null ? settings.getUpdatedAt().toString() : null); + return item; + } + + public static HubSettingsMenu defaultSettingsMenu() { + HubSettingsMenu menu = new HubSettingsMenu(); + menu.setApiSettings(Boolean.TRUE); + menu.setPrinterAgent(Boolean.TRUE); + menu.setPrinterPdf(Boolean.TRUE); + return menu; + } + + private HubSettingsMenu parseSettingsMenu(String json) { + if (StringUtils.isBlank(json)) { + return defaultSettingsMenu(); + } + try { + HubSettingsMenu parsed = objectMapper.readValue(json, HubSettingsMenu.class); + return mergeSettingsMenu(parsed); + } catch (Exception ignored) { + return defaultSettingsMenu(); + } + } + + private String serializeSettingsMenu(HubSettingsMenu menu) { + try { + return objectMapper.writeValueAsString(menu); + } catch (JsonProcessingException e) { + return null; + } + } + + private HubSettingsMenu mergeSettingsMenu(HubSettingsMenu input) { + HubSettingsMenu base = defaultSettingsMenu(); + if (input == null) { + return base; + } + if (input.getApiSettings() != null) { + base.setApiSettings(input.getApiSettings()); + } + if (input.getPrinterAgent() != null) { + base.setPrinterAgent(input.getPrinterAgent()); + } + if (input.getPrinterPdf() != null) { + base.setPrinterPdf(input.getPrinterPdf()); + } + return base; + } + + public static HubUserMenuStyle defaultUserMenuStyle() { + HubUserMenuStyle style = new HubUserMenuStyle(); + style.setBackground("rgba(38, 106, 98, 0.98)"); + style.setTextColor("#FFFFFF"); + style.setFontSize("14px"); + style.setItemPadding("12px 20px"); + style.setBorderRadius("8px"); + style.setActiveBackground("rgba(255, 255, 255, 0.15)"); + style.setMaskBackground("rgba(0, 0, 0, 0.08)"); + style.setMinWidth("148px"); + return style; + } + + private HubUserMenuStyle parseUserMenuStyle(String json) { + if (StringUtils.isBlank(json)) { + return defaultUserMenuStyle(); + } + try { + HubUserMenuStyle parsed = objectMapper.readValue(json, HubUserMenuStyle.class); + return mergeUserMenuStyle(parsed); + } catch (Exception ignored) { + return defaultUserMenuStyle(); + } + } + + private String serializeUserMenuStyle(HubUserMenuStyle style) { + try { + return objectMapper.writeValueAsString(style); + } catch (JsonProcessingException e) { + return null; + } + } + + private HubUserMenuStyle mergeUserMenuStyle(HubUserMenuStyle input) { + HubUserMenuStyle base = defaultUserMenuStyle(); + if (input == null) { + return base; + } + base.setBackground(normalizeCssColor(input.getBackground(), base.getBackground())); + base.setTextColor(normalizeHexColor(input.getTextColor(), base.getTextColor())); + base.setFontSize(normalizeCssLength(input.getFontSize(), base.getFontSize())); + base.setItemPadding(normalizeCssPadding(input.getItemPadding(), base.getItemPadding())); + base.setBorderRadius(normalizeCssLength(input.getBorderRadius(), base.getBorderRadius())); + base.setActiveBackground(normalizeCssColor(input.getActiveBackground(), base.getActiveBackground())); + base.setMaskBackground(normalizeCssColor(input.getMaskBackground(), base.getMaskBackground())); + base.setMinWidth(normalizeCssLength(input.getMinWidth(), base.getMinWidth())); + return base; + } + + private String normalizeHexColor(String raw, String fallback) { + String normalized = normalizeColor(raw, null); + return normalized != null ? normalized : fallback; + } + + private String normalizeCssColor(String raw, String fallback) { + if (StringUtils.isBlank(raw)) { + return fallback; + } + String trimmed = raw.trim(); + if (HEX_COLOR.matcher(trimmed).matches()) { + return normalizeColor(trimmed, fallback); + } + if (trimmed.toLowerCase().startsWith("rgba(") || trimmed.toLowerCase().startsWith("rgb(")) { + return trimmed.replaceAll("\\s+", " "); + } + return fallback; + } + + private String normalizeCssLength(String raw, String fallback) { + if (StringUtils.isBlank(raw)) { + return fallback; + } + String trimmed = raw.trim(); + return CSS_LENGTH.matcher(trimmed).matches() ? trimmed : fallback; + } + + private String normalizeCssPadding(String raw, String fallback) { + if (StringUtils.isBlank(raw)) { + return fallback; + } + String trimmed = raw.trim().replaceAll("\\s+", " "); + return CSS_PADDING.matcher(trimmed).matches() ? trimmed : fallback; + } + + private String normalizeColor(String raw, String fallback) { + if (StringUtils.isBlank(raw)) { + return fallback; + } + String trimmed = raw.trim(); + if (!HEX_COLOR.matcher(trimmed).matches()) { + return null; + } + if (trimmed.length() == 4) { + char r = trimmed.charAt(1); + char g = trimmed.charAt(2); + char b = trimmed.charAt(3); + return "#" + r + r + g + g + b + b; + } + return trimmed.toUpperCase(); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/config/EsignPrinterSchemaMigrator.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/config/EsignPrinterSchemaMigrator.java new file mode 100644 index 0000000..5475caa --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/config/EsignPrinterSchemaMigrator.java @@ -0,0 +1,79 @@ +package com.sjmresorts.opsapp.esign.printer.config; + +import com.sjmresorts.opsapp.common.db.DbDialect; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; + +/** 启动时补齐 esign_printer_pdf_tasks OCR 检索字段(多库方言)。 */ +@Component +public class EsignPrinterSchemaMigrator implements InitializingBean { + + private static final Logger log = LoggerFactory.getLogger(EsignPrinterSchemaMigrator.class); + + private final JdbcTemplate jdbc; + private final DbDialect dialect; + + public EsignPrinterSchemaMigrator( + @Qualifier("opsAppDataSource") DataSource dataSource, DbDialect dialect) { + this.jdbc = new JdbcTemplate(dataSource); + this.dialect = dialect; + } + + @Override + public void afterPropertiesSet() { + ensurePrinterPdfOcrColumns(); + ensurePrinterPdfSignedDeviceColumns(); + ensurePrinterPdfOhipColumns(); + } + + private void ensurePrinterPdfOcrColumns() { + if (!dialect.tableExists(jdbc, "esign_printer_pdf_tasks")) { + return; + } + add("esign_printer_pdf_tasks", "ocr_guest_name", dialect.varchar(256) + " NULL"); + add("esign_printer_pdf_tasks", "ocr_confirmation_no", dialect.varchar(64) + " NULL"); + add("esign_printer_pdf_tasks", "ocr_phone", dialect.varchar(64) + " NULL"); + add("esign_printer_pdf_tasks", "ocr_email", dialect.varchar(256) + " NULL"); + add("esign_printer_pdf_tasks", "ocr_room_no", dialect.varchar(32) + " NULL"); + add("esign_printer_pdf_tasks", "ocr_check_in_date", dialect.varchar(32) + " NULL"); + add("esign_printer_pdf_tasks", "ocr_check_out_date", dialect.varchar(32) + " NULL"); + add("esign_printer_pdf_tasks", "ocr_extracted_at", dialect.datetime() + " NULL"); + add("esign_printer_pdf_tasks", "ocr_status", dialect.varchar(16) + " NULL"); + } + + private void ensurePrinterPdfSignedDeviceColumns() { + if (!dialect.tableExists(jdbc, "esign_printer_pdf_tasks")) { + return; + } + add("esign_printer_pdf_tasks", "signed_by_user_id", dialect.bigintType() + " NULL"); + add("esign_printer_pdf_tasks", "signed_client_ip", dialect.varchar(64) + " NULL"); + add("esign_printer_pdf_tasks", "signed_device_id", dialect.varchar(64) + " NULL"); + add("esign_printer_pdf_tasks", "signed_device_name", dialect.varchar(128) + " NULL"); + } + + private void ensurePrinterPdfOhipColumns() { + if (!dialect.tableExists(jdbc, "esign_printer_pdf_tasks")) { + return; + } + add("esign_printer_pdf_tasks", "ohip_status", dialect.varchar(16) + " NULL"); + add("esign_printer_pdf_tasks", "ohip_last_error", dialect.varchar(512) + " NULL"); + add("esign_printer_pdf_tasks", "ohip_uploaded_at", dialect.datetime() + " NULL"); + add("esign_printer_pdf_tasks", "ohip_confirmation_no", dialect.varchar(64) + " NULL"); + add("esign_printer_pdf_tasks", "ohip_reservation_id", dialect.varchar(64) + " NULL"); + add("esign_printer_pdf_tasks", "ohip_retry_count", dialect.intType() + " NULL"); + add("esign_printer_pdf_tasks", "sign_fields_json", dialect.text() + " NULL"); + } + + private void add(String table, String column, String typeSql) { + if (!dialect.columnExists(jdbc, table, column)) { + log.info("Esign printer migrate: adding {}.{}", table, column); + jdbc.execute(dialect.alterAddColumn(table, column, typeSql)); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/config/EsignPrinterWebSocketConfig.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/config/EsignPrinterWebSocketConfig.java new file mode 100644 index 0000000..ccb0246 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/config/EsignPrinterWebSocketConfig.java @@ -0,0 +1,25 @@ +package com.sjmresorts.opsapp.esign.printer.config; + +import com.sjmresorts.opsapp.esign.printer.websocket.EsignPrinterWebSocketAuthInterceptor; +import com.sjmresorts.opsapp.esign.printer.websocket.EsignPrinterWebSocketHandler; +import lombok.RequiredArgsConstructor; +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 +@RequiredArgsConstructor +public class EsignPrinterWebSocketConfig implements WebSocketConfigurer { + + private final EsignPrinterWebSocketHandler printerWebSocketHandler; + private final EsignPrinterWebSocketAuthInterceptor authInterceptor; + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + registry.addHandler(printerWebSocketHandler, "/api/v1/esign/ws/printer-tasks", "/api/v1/sinosecu/ws/printer-tasks") + .addInterceptors(authInterceptor) + .setAllowedOriginPatterns("*"); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterDeviceLinkController.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterDeviceLinkController.java new file mode 100644 index 0000000..e70a169 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterDeviceLinkController.java @@ -0,0 +1,91 @@ +package com.sjmresorts.opsapp.esign.printer.controllers; + +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import com.sjmresorts.opsapp.common.utils.NetworkUtil; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterDeviceLinkRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterDeviceLinkResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterDeviceLinkReviewRequest; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterDeviceLinkService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@Tag(name = "Esign Printer Device Link", description = "App 发起 PC 连接申请,CMS 审批") +@RestController +@RequestMapping({"/api/v1/esign", "/api/v1/sinosecu"}) +@CrossOrigin(origins = "*") +@RequiredArgsConstructor +public class EsignPrinterDeviceLinkController { + + private final EsignPrinterDeviceLinkService linkService; + + @Operation(summary = "App 发起连接 PC 申请", description = "需 Bearer Token。填写 PC 状态页上的连接标识。") + @PostMapping(value = "/printer-device-link/request", produces = "application/json;charset=UTF-8") + public ResponseEntity requestLink( + HttpServletRequest request, + @RequestBody EsignPrinterDeviceLinkRequest body) { + Long userId = resolveUserId(request); + String clientIp = NetworkUtil.getClientIpByRequest(request); + EsignPrinterDeviceLinkResponse resp = linkService.requestLink(userId, body, clientIp); + return ResponseEntity.status(httpStatus(resp)).body(resp); + } + + @Operation(summary = "App 查看我的连接", description = "需 Bearer Token。") + @GetMapping(value = "/printer-device-link/my", produces = "application/json;charset=UTF-8") + public ResponseEntity myLinks(HttpServletRequest request) { + Long userId = resolveUserId(request); + EsignPrinterDeviceLinkResponse resp = linkService.listMyLinks(userId); + return ResponseEntity.status(httpStatus(resp)).body(resp); + } + + @Operation(summary = "CMS 连接申请列表", description = "免 JWT。含待审批与已批准记录。") + @GetMapping(value = "/printer-device-links", produces = "application/json;charset=UTF-8") + public ResponseEntity listLinks() { + return ResponseEntity.ok(linkService.listForCms()); + } + + @Operation(summary = "CMS 审批连接", description = "免 JWT。action=approve|reject") + @PostMapping(value = "/printer-device-link/review", produces = "application/json;charset=UTF-8") + public ResponseEntity reviewLink( + @RequestBody EsignPrinterDeviceLinkReviewRequest body) { + EsignPrinterDeviceLinkResponse resp = linkService.reviewLink(body); + return ResponseEntity.status(httpStatus(resp)).body(resp); + } + + private static Long resolveUserId(HttpServletRequest request) { + LoginUserInfo loginUser = JwtTokenUtil.parseUserFromRequest(request); + if (loginUser == null || StringUtils.isBlank(loginUser.getEmployeeId())) { + return null; + } + try { + return Long.parseLong(loginUser.getEmployeeId()); + } catch (NumberFormatException e) { + return null; + } + } + + private static HttpStatus httpStatus(EsignPrinterDeviceLinkResponse resp) { + if (resp.isSuccess()) { + return HttpStatus.OK; + } + return switch (StringUtils.defaultString(resp.getCode())) { + case "401" -> HttpStatus.UNAUTHORIZED; + case "403" -> HttpStatus.FORBIDDEN; + case "404" -> HttpStatus.NOT_FOUND; + case "409" -> HttpStatus.CONFLICT; + case "400" -> HttpStatus.BAD_REQUEST; + default -> HttpStatus.INTERNAL_SERVER_ERROR; + }; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterPcRegistryController.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterPcRegistryController.java new file mode 100644 index 0000000..3cdd442 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterPcRegistryController.java @@ -0,0 +1,48 @@ +package com.sjmresorts.opsapp.esign.printer.controllers; + +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPcRegistryDeleteRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPcRegistryResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPcRegistrySaveRequest; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPcRegistryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@Tag(name = "Esign Printer PC Registry", description = "CMS 预登记 PC 上报端(名称 + IP)") +@RestController +@RequestMapping({"/api/v1/esign", "/api/v1/sinosecu"}) +@CrossOrigin(origins = "*") +@RequiredArgsConstructor +public class EsignPrinterPcRegistryController { + + private final EsignPrinterPcRegistryService registryService; + + @Operation(summary = "CMS PC 登记列表", description = "免 JWT。") + @GetMapping(value = "/printer-pc-registry", produces = "application/json;charset=UTF-8") + public ResponseEntity listRegistry() { + return ResponseEntity.ok(registryService.listAll()); + } + + @Operation(summary = "CMS 添加/更新 PC 登记", description = "免 JWT。名称 + IP 生成连接标识 name@ip。") + @PostMapping(value = "/printer-pc-registry/save", produces = "application/json;charset=UTF-8") + public ResponseEntity saveRegistry( + @RequestBody EsignPrinterPcRegistrySaveRequest body) { + EsignPrinterPcRegistryResponse resp = registryService.save(body); + return ResponseEntity.ok(resp); + } + + @Operation(summary = "CMS 删除 PC 登记", description = "免 JWT。") + @PostMapping(value = "/printer-pc-registry/delete", produces = "application/json;charset=UTF-8") + public ResponseEntity deleteRegistry( + @RequestBody EsignPrinterPcRegistryDeleteRequest body) { + EsignPrinterPcRegistryResponse resp = registryService.delete(body); + return ResponseEntity.ok(resp); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterPdfTaskController.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterPdfTaskController.java new file mode 100644 index 0000000..322c23a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterPdfTaskController.java @@ -0,0 +1,488 @@ +package com.sjmresorts.opsapp.esign.printer.controllers; + +import com.auth0.jwt.interfaces.JWTVerifier; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import com.sjmresorts.opsapp.esign.capture.dtos.EsignIosCaptureResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterDashboardResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterDevicesResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterEventLogResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterQueueResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterQueueWatchResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfArchiveOcrBatchResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfArchiveResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfSignedSubmitRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfUsbSignedSubmitRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfTaskCompleteRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfFieldExtractRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfFieldExtractResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfFieldPrefillRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfFieldPrefillResponse; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPdfFieldPrefillService; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfTaskDetailResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfTaskSubmitRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfTaskUpdateRequest; +import com.sjmresorts.opsapp.esign.printer.filters.EsignPrinterPdfArchiveFilter; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPdfArchiveOcrBatchService; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterEventLogService; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPdfTaskService; +import com.sjmresorts.opsapp.esign.auth.services.EsignUserPresenceService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import com.sjmresorts.opsapp.common.utils.NetworkUtil; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +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.servlet.mvc.method.annotation.SseEmitter; +import com.sjmresorts.opsapp.esign.printer.support.EsignPrinterQueueSseHub; + +@Tag(name = "Esign Printer PDF", description = "PC 虚拟打印机 PDF 任务(Electron 上报 + App 补拉)") +@RestController +@RequestMapping({"/api/v1/esign", "/api/v1/sinosecu"}) +@CrossOrigin(origins = "*") +@Slf4j +public class EsignPrinterPdfTaskController { + + @Resource + private EsignPrinterPdfTaskService printerPdfTaskService; + + @Resource + private EsignPrinterPdfFieldPrefillService fieldPrefillService; + + @Resource + private EsignPrinterPdfArchiveOcrBatchService archiveOcrBatchService; + + @Resource + private EsignPrinterEventLogService printerEventLogService; + + @Resource + private EsignUserPresenceService userPresenceService; + + @Resource + private EsignPrinterQueueSseHub printerQueueSseHub; + + @Operation(summary = "e-sign 控制台聚合", description = "免 JWT。CMS 控制台/传输监控:任务统计、上报电脑聚合、接收平板列表与最近传输。") + @GetMapping(value = "/printer-dashboard", produces = "application/json;charset=UTF-8") + public ResponseEntity getDashboard() { + return ResponseEntity.status(HttpStatus.OK).body(printerPdfTaskService.getDashboard()); + } + + @Operation(summary = "24h PDF 队列", description = "免 JWT。CMS 平板队列:24h 内上报 PDF、按上报电脑筛选、推送目标设备。") + @GetMapping(value = "/printer-queue", produces = "application/json;charset=UTF-8") + public ResponseEntity getQueue( + @RequestParam(value = "agentSource", required = false) String agentSource, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "50") int size) { + return ResponseEntity.ok(printerPdfTaskService.getQueue(agentSource, status, page, size)); + } + + @Operation(summary = "24h PDF 队列变更探测", description = "免 JWT。CMS 轮询用:revision 变化表示有新上报/签收/状态变更。") + @GetMapping(value = "/printer-queue/watch", produces = "application/json;charset=UTF-8") + public ResponseEntity getQueueWatch() { + return ResponseEntity.ok(printerPdfTaskService.getQueueWatch()); + } + + @Operation(summary = "24h PDF 队列 SSE", description = "免 JWT。CMS 实时订阅:queue-changed 事件携带 revision,有变更时刷新列表。") + @GetMapping(value = "/printer-queue/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter streamQueue() { + return printerQueueSseHub.subscribe(); + } + + @Operation(summary = "设备管理", description = "免 JWT。PC 上报端与平板接收端列表。") + @GetMapping(value = "/printer-devices", produces = "application/json;charset=UTF-8") + public ResponseEntity getDevices() { + return ResponseEntity.ok(printerPdfTaskService.getDevices()); + } + + @Operation(summary = "PDF 归档列表", description = "免 JWT。已签收/已签名归档记录。") + @GetMapping(value = "/printer-pdf-archive", produces = "application/json;charset=UTF-8") + public ResponseEntity listArchive( + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "20") int size, + @RequestParam(value = "guestName", required = false) String guestName, + @RequestParam(value = "confirmationNo", required = false) String confirmationNo, + @RequestParam(value = "reservationId", required = false) String reservationId, + @RequestParam(value = "phone", required = false) String phone, + @RequestParam(value = "roomNo", required = false) String roomNo, + @RequestParam(value = "checkInDate", required = false) String checkInDate, + @RequestParam(value = "checkOutDate", required = false) String checkOutDate, + @RequestParam(value = "fileName", required = false) String fileName, + @RequestParam(value = "status", required = false) String status) { + var filter = EsignPrinterPdfArchiveFilter.of( + guestName, confirmationNo, reservationId, phone, roomNo, checkInDate, checkOutDate, fileName, status); + return ResponseEntity.ok(printerPdfTaskService.listArchive(page, size, filter)); + } + + @Operation(summary = "PDF 归档 OCR 批量重跑", description = "免 JWT。按筛选条件异步重跑 OCR,立即返回 jobId。") + @PostMapping(value = "/printer-pdf-archive/ocr-batch", produces = "application/json;charset=UTF-8") + public ResponseEntity startArchiveOcrBatch( + @RequestParam(value = "guestName", required = false) String guestName, + @RequestParam(value = "confirmationNo", required = false) String confirmationNo, + @RequestParam(value = "reservationId", required = false) String reservationId, + @RequestParam(value = "phone", required = false) String phone, + @RequestParam(value = "roomNo", required = false) String roomNo, + @RequestParam(value = "checkInDate", required = false) String checkInDate, + @RequestParam(value = "checkOutDate", required = false) String checkOutDate, + @RequestParam(value = "fileName", required = false) String fileName, + @RequestParam(value = "status", required = false) String status, + @RequestParam(value = "failedOnly", defaultValue = "true") boolean failedOnly) { + var filter = EsignPrinterPdfArchiveFilter.of( + guestName, confirmationNo, reservationId, phone, roomNo, checkInDate, checkOutDate, fileName, status); + EsignPrinterPdfArchiveOcrBatchResponse body = archiveOcrBatchService.startBatch(filter, failedOnly); + HttpStatus httpStatus = body.isSuccess() ? HttpStatus.OK : HttpStatus.BAD_REQUEST; + if ("404".equals(body.getCode())) { + httpStatus = HttpStatus.NOT_FOUND; + } + return ResponseEntity.status(httpStatus).body(body); + } + + @Operation(summary = "PDF 归档 OCR 批量任务状态", description = "免 JWT。轮询批量 OCR 进度。") + @GetMapping(value = "/printer-pdf-archive/ocr-batch/{jobId}", produces = "application/json;charset=UTF-8") + public ResponseEntity getArchiveOcrBatchStatus( + @PathVariable("jobId") String jobId) { + EsignPrinterPdfArchiveOcrBatchResponse body = archiveOcrBatchService.getBatchStatus(jobId); + HttpStatus httpStatus = body.isSuccess() ? HttpStatus.OK : HttpStatus.NOT_FOUND; + if (!body.isSuccess() && "400".equals(body.getCode())) { + httpStatus = HttpStatus.BAD_REQUEST; + } + return ResponseEntity.status(httpStatus).body(body); + } + + @Operation(summary = "PDF 归档 OCR 批量失败日志", description = "免 JWT。下载批量 OCR 失败明细。") + @GetMapping(value = "/printer-pdf-archive/ocr-batch/{jobId}/error-log", produces = "text/plain;charset=UTF-8") + public ResponseEntity downloadArchiveOcrBatchErrorLog(@PathVariable("jobId") String jobId) { + byte[] bytes = archiveOcrBatchService.readErrorLog(jobId); + if (bytes == null || bytes.length == 0) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } + ContentDisposition disposition = ContentDisposition.attachment() + .filename(archiveOcrBatchService.errorLogFileName(jobId)) + .build(); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .contentType(MediaType.TEXT_PLAIN) + .body(bytes); + } + + @Operation(summary = "打印机事件日志", description = "免 JWT。上报/签收/签名归档等操作日志。") + @GetMapping(value = "/printer-event-logs", produces = "application/json;charset=UTF-8") + public ResponseEntity listEventLogs( + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "50") int size, + @RequestParam(value = "eventType", required = false) String eventType, + @RequestParam(value = "taskId", required = false) String taskId) { + return ResponseEntity.ok(printerEventLogService.listLogs(page, size, eventType, taskId)); + } + + @Operation(summary = "Electron 上报 PDF 任务", description = "免 JWT。PC 收到 PDF 后写入任务池,供平板离线补拉。") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "保存成功"), + @ApiResponse(responseCode = "400", description = "参数无效"), + @ApiResponse(responseCode = "413", description = "PDF 过大") + }) + @PostMapping(value = "/printer-pdf-task-submit", consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") + public ResponseEntity submitTask(@RequestBody EsignPrinterPdfTaskSubmitRequest request) { + EsignIosCaptureResponse body = printerPdfTaskService.submitTask(request); + HttpStatus status = HttpStatus.OK; + if (!body.isSuccess()) { + if ("413".equals(body.getCode())) { + status = HttpStatus.PAYLOAD_TOO_LARGE; + } else { + status = HttpStatus.BAD_REQUEST; + } + } + return ResponseEntity.status(status).body(body); + } + + @Operation(summary = "CMS 更新 PDF 任务", description = "免 JWT。待签收任务可修改文件名或替换 PDF 内容;已签名归档可修改签名 PDF。") + @PostMapping(value = "/ios-printer-pdf-task-update", consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") + public ResponseEntity updateTask( + @RequestBody EsignPrinterPdfTaskUpdateRequest request) { + EsignIosCaptureResponse body = printerPdfTaskService.updateTask(request); + HttpStatus status = HttpStatus.OK; + if (!body.isSuccess()) { + if ("413".equals(body.getCode())) { + status = HttpStatus.PAYLOAD_TOO_LARGE; + } else if ("404".equals(body.getCode())) { + status = HttpStatus.NOT_FOUND; + } else { + status = HttpStatus.BAD_REQUEST; + } + } + return ResponseEntity.status(status).body(body); + } + + @Operation(summary = "已签名 PDF 重新推送 OPERA", description = "免 JWT。与平板签名归档后 OHIP 上传相同,将当前签名 PDF 再次提交至 OPERA。") + @PostMapping(value = "/ios-printer-pdf-task/{taskId}/ohip-reupload", produces = "application/json;charset=UTF-8") + public ResponseEntity reuploadSignedToOhip( + @PathVariable("taskId") String taskId) { + EsignIosCaptureResponse body = printerPdfTaskService.reuploadSignedToOhip(taskId); + HttpStatus status = HttpStatus.OK; + if (!body.isSuccess()) { + if ("404".equals(body.getCode())) { + status = HttpStatus.NOT_FOUND; + } else if ("502".equals(body.getCode())) { + status = HttpStatus.BAD_GATEWAY; + } else { + status = HttpStatus.BAD_REQUEST; + } + } + return ResponseEntity.status(status).body(body); + } + + @Operation(summary = "PDF 任务详情(元数据)", description = "免 JWT。不含 Base64,请用 …/pdf 下载二进制。") + @GetMapping(value = "/ios-printer-pdf-task/{taskId}", produces = "application/json;charset=UTF-8") + public ResponseEntity getTaskDetail( + @PathVariable("taskId") String taskId, + HttpServletRequest request) { + Long appUserId = resolveUserIdIfVerifiedJwt(request); + String clientIp = NetworkUtil.getClientIpByRequest(request); + if (appUserId != null) { + userPresenceService.touchClientIp(appUserId, clientIp); + } + EsignPrinterPdfTaskDetailResponse body = printerPdfTaskService.getTaskDetail(taskId, appUserId, clientIp); + if (!body.isSuccess() && "404".equals(body.getCode())) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + return ResponseEntity.status(HttpStatus.OK).body(body); + } + + @Operation( + summary = "签名字段预填", + description = "免 JWT。打开 PDF 时拉取:任务 OCR 元数据 + OHIP Profile,点击热区无需 OCR。") + @PostMapping( + value = "/ios-printer-pdf-task/{taskId}/field-prefill", + consumes = "application/json;charset=UTF-8", + produces = "application/json;charset=UTF-8") + public ResponseEntity getFieldPrefill( + @PathVariable("taskId") String taskId, + @RequestBody(required = false) EsignPrinterPdfFieldPrefillRequest request, + HttpServletRequest httpRequest) { + Long appUserId = resolveUserIdIfVerifiedJwt(httpRequest); + String clientIp = NetworkUtil.getClientIpByRequest(httpRequest); + if (appUserId != null) { + userPresenceService.touchClientIp(appUserId, clientIp); + } + String signFieldsJson = request != null ? request.getSignFieldsJson() : null; + EsignPrinterPdfFieldPrefillResponse body = + fieldPrefillService.buildPrefill(taskId, signFieldsJson, appUserId, clientIp); + if (!body.isSuccess() && "404".equals(body.getCode())) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "PDF 热区文字提取", description = "免 JWT。扫描件登记卡按热区比例 OCR 预填。") + @PostMapping( + value = "/ios-printer-pdf-task/{taskId}/extract-field-text", + consumes = "application/json;charset=UTF-8", + produces = "application/json;charset=UTF-8") + public ResponseEntity extractFieldText( + @PathVariable("taskId") String taskId, + @RequestBody EsignPrinterPdfFieldExtractRequest request, + HttpServletRequest httpRequest) { + Long appUserId = resolveUserIdIfVerifiedJwt(httpRequest); + String clientIp = NetworkUtil.getClientIpByRequest(httpRequest); + if (appUserId != null) { + userPresenceService.touchClientIp(appUserId, clientIp); + } + EsignPrinterPdfFieldExtractResponse body = + printerPdfTaskService.extractFieldText(taskId, request, appUserId, clientIp); + if (!body.isSuccess() && "404".equals(body.getCode())) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + if (!body.isSuccess() && "503".equals(body.getCode())) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(body); + } + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "热区截图 OCR", description = "免 JWT。优先 OCR 请求体 cropImageBase64(平板框内截图)。") + @PostMapping( + value = "/ios-printer-pdf-field-extract", + consumes = "application/json;charset=UTF-8", + produces = "application/json;charset=UTF-8") + public ResponseEntity extractFieldFromCrop( + @RequestBody EsignPrinterPdfFieldExtractRequest request, + HttpServletRequest httpRequest) { + Long appUserId = resolveUserIdIfVerifiedJwt(httpRequest); + String clientIp = NetworkUtil.getClientIpByRequest(httpRequest); + if (appUserId != null) { + userPresenceService.touchClientIp(appUserId, clientIp); + } + EsignPrinterPdfFieldExtractResponse body = + printerPdfTaskService.extractFieldText(null, request, appUserId, clientIp); + if (!body.isSuccess() && "404".equals(body.getCode())) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + if (!body.isSuccess() && "503".equals(body.getCode())) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(body); + } + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "PDF 任务二进制下载", description = "免 JWT。返回 application/pdf。") + @GetMapping(value = "/ios-printer-pdf-task/{taskId}/pdf", produces = MediaType.APPLICATION_PDF_VALUE) + public ResponseEntity downloadTaskPdf( + @PathVariable("taskId") String taskId, + HttpServletRequest request) { + Long appUserId = resolveUserIdIfVerifiedJwt(request); + String clientIp = NetworkUtil.getClientIpByRequest(request); + if (appUserId != null) { + userPresenceService.touchClientIp(appUserId, clientIp); + } + return printerPdfTaskService + .getTaskPdfContent(taskId, appUserId, clientIp) + .map((content) -> { + ContentDisposition disposition = ContentDisposition.inline() + .filename(content.fileName()) + .build(); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .contentType(MediaType.APPLICATION_PDF) + .body(content.bytes()); + }) + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).build()); + } + + @Operation(summary = "签名 PDF 下载", description = "免 JWT。返回签名后 application/pdf(若已回传)。") + @GetMapping(value = "/ios-printer-pdf-task/{taskId}/signed-pdf", produces = MediaType.APPLICATION_PDF_VALUE) + public ResponseEntity downloadTaskSignedPdf(@PathVariable("taskId") String taskId) { + return printerPdfTaskService + .getTaskSignedPdfContent(taskId) + .map((content) -> { + ContentDisposition disposition = ContentDisposition.inline() + .filename(content.fileName()) + .build(); + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString()) + .contentType(MediaType.APPLICATION_PDF) + .body(content.bytes()); + }) + .orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND).build()); + } + + @Operation(summary = "USB 直传签名归档", description = "平板 USB 收到 PDF 后签名:一次请求归档,OPERA 异步上传。免推送。") + @PostMapping(value = "/ios-printer-pdf-usb-signed", consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") + public ResponseEntity submitUsbDirectSigned( + @RequestBody EsignPrinterPdfUsbSignedSubmitRequest request, + HttpServletRequest httpRequest) { + log.info( + "POST /ios-printer-pdf-usb-signed reservationId={}, externalId={}, signatureCount={}", + request != null ? request.getReservationId() : null, + request != null ? request.getExternalId() : null, + request != null ? request.getSignatureCount() : null); + Long appUserId = resolveUserIdIfVerifiedJwt(httpRequest); + String clientIp = NetworkUtil.getClientIpByRequest(httpRequest); + EsignIosCaptureResponse body = printerPdfTaskService.submitUsbDirectSigned(request, appUserId, clientIp); + log.info( + "POST /ios-printer-pdf-usb-signed done success={} code={} message={}", + body.isSuccess(), + body.getCode(), + body.getMessage()); + HttpStatus status = HttpStatus.OK; + if (!body.isSuccess()) { + if ("413".equals(body.getCode())) { + status = HttpStatus.PAYLOAD_TOO_LARGE; + } else { + status = HttpStatus.BAD_REQUEST; + } + } + return ResponseEntity.status(status).body(body); + } + + @Operation(summary = "签名 PDF / 签名图层回传", description = "免 JWT。平板签名后归档至 Java。") + @PostMapping(value = "/ios-printer-pdf-task-signed", consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") + public ResponseEntity submitSignedPdf( + @RequestBody EsignPrinterPdfSignedSubmitRequest request, + HttpServletRequest httpRequest) { + log.info( + "POST /ios-printer-pdf-task-signed taskId={}, externalId={}, signatureCount={}", + request != null ? request.getTaskId() : null, + request != null ? request.getExternalId() : null, + request != null ? request.getSignatureCount() : null); + Long appUserId = resolveUserIdIfVerifiedJwt(httpRequest); + String clientIp = NetworkUtil.getClientIpByRequest(httpRequest); + EsignIosCaptureResponse body = printerPdfTaskService.submitSignedPdf(request, appUserId, clientIp); + log.info( + "POST /ios-printer-pdf-task-signed done success={} code={} message={}", + body.isSuccess(), + body.getCode(), + body.getMessage()); + HttpStatus status = HttpStatus.OK; + if (!body.isSuccess()) { + if ("413".equals(body.getCode())) { + status = HttpStatus.PAYLOAD_TOO_LARGE; + } else if ("404".equals(body.getCode())) { + status = HttpStatus.NOT_FOUND; + } else { + status = HttpStatus.BAD_REQUEST; + } + } + return ResponseEntity.status(status).body(body); + } + + @Operation(summary = "PDF 任务完成", description = "免 JWT。平板打开/签收后从待签列表移除。") + @PostMapping(value = "/ios-printer-pdf-task-complete", consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") + public ResponseEntity completeTask(@RequestBody EsignPrinterPdfTaskCompleteRequest request) { + String taskId = request != null ? request.getTaskId() : null; + String externalId = request != null ? request.getExternalId() : null; + EsignIosCaptureResponse body = printerPdfTaskService.completeTask(taskId, externalId); + return ResponseEntity.status(HttpStatus.OK).body(body); + } + + /** 白名单接口:仅在校验通过后才从 JWT 取 employeeId,避免伪造。 */ + private static Long resolveUserIdIfVerifiedJwt(HttpServletRequest request) { + String auth = request.getHeader("Authorization"); + if (StringUtils.isBlank(auth) || !auth.startsWith("Bearer ")) { + return null; + } + String token = auth.substring(7).trim(); + if (StringUtils.isBlank(token)) { + return null; + } + try { + JWTVerifier jwtVerifier = JwtTokenUtil.getJwtVerifier(); + var jwt = jwtVerifier.verify(token); + if (!"esign_login".equals(jwt.getIssuer())) { + return null; + } + LoginUserInfo user = JwtTokenUtil.parseUserFromToken(token); + if (user == null || StringUtils.isBlank(user.getEmployeeId())) { + return null; + } + return Long.parseLong(user.getEmployeeId()); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterPushController.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterPushController.java new file mode 100644 index 0000000..5b8045c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterPushController.java @@ -0,0 +1,92 @@ +package com.sjmresorts.opsapp.esign.printer.controllers; + +import com.sjmresorts.opsapp.esign.capture.dtos.EsignIosCaptureResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPushAssignRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPushCandidatesResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPushGroupListResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPushGroupSaveRequest; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPushService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@Tag(name = "Esign Printer Push", description = "PDF 队列推送目标:设备/组选择与 FCM") +@RestController +@RequestMapping({"/api/v1/esign", "/api/v1/sinosecu"}) +@CrossOrigin(origins = "*") +@RequiredArgsConstructor +public class EsignPrinterPushController { + + private final EsignPrinterPushService printerPushService; + + @Operation(summary = "推送候选(设备+组)", description = "免 JWT。按 taskId 返回可推送的已连接平板与推送组。") + @GetMapping(value = "/printer-push-candidates", produces = "application/json;charset=UTF-8") + public ResponseEntity getCandidates( + @RequestParam("taskId") String taskId) { + EsignPrinterPushCandidatesResponse body = printerPushService.getPushCandidates(taskId); + return ResponseEntity.status(httpStatus(body.isSuccess(), body.getCode())).body(body); + } + + @Operation(summary = "设置 PDF 推送目标", description = "免 JWT。选择设备 userIds 和/或 groupIds,可选 FCM 提醒。") + @PostMapping(value = "/printer-pdf-task-push", produces = "application/json;charset=UTF-8") + public ResponseEntity assignPush(@RequestBody EsignPrinterPushAssignRequest body) { + EsignIosCaptureResponse resp = printerPushService.assignPushTargets(body); + return ResponseEntity.status(httpStatus(resp.isSuccess(), resp.getCode())).body(resp); + } + + @Operation(summary = "快捷推送", description = "免 JWT。自动模式补推;手动模式按推送规则默认映射一键推送。") + @PostMapping(value = "/printer-pdf-task-quick-push", produces = "application/json;charset=UTF-8") + public ResponseEntity quickPush( + @RequestParam("taskId") String taskId, + @RequestParam(value = "sendFcm", defaultValue = "false") boolean sendFcm) { + EsignIosCaptureResponse resp = printerPushService.quickPushWithRules(taskId, sendFcm); + return ResponseEntity.status(httpStatus(resp.isSuccess(), resp.getCode())).body(resp); + } + + @Operation(summary = "推送组列表", description = "免 JWT。按 PC agentSource 查询。") + @GetMapping(value = "/printer-push-groups", produces = "application/json;charset=UTF-8") + public ResponseEntity listGroups( + @RequestParam("agentSource") String agentSource) { + return ResponseEntity.ok(printerPushService.listGroups(agentSource)); + } + + @Operation(summary = "保存推送组", description = "免 JWT。新建或更新组及成员。") + @PostMapping(value = "/printer-push-groups", produces = "application/json;charset=UTF-8") + public ResponseEntity saveGroup( + @RequestBody EsignPrinterPushGroupSaveRequest body) { + EsignPrinterPushGroupListResponse resp = printerPushService.saveGroup(body); + return ResponseEntity.status(httpStatus(resp.isSuccess(), resp.getCode())).body(resp); + } + + @Operation(summary = "删除推送组") + @DeleteMapping(value = "/printer-push-groups", produces = "application/json;charset=UTF-8") + public ResponseEntity deleteGroup( + @RequestParam("groupId") String groupId, + @RequestParam("agentSource") String agentSource) { + EsignPrinterPushGroupListResponse resp = printerPushService.deleteGroup(groupId, agentSource); + return ResponseEntity.status(httpStatus(resp.isSuccess(), resp.getCode())).body(resp); + } + + private static HttpStatus httpStatus(boolean success, String code) { + if (success) { + return HttpStatus.OK; + } + return switch (StringUtils.defaultString(code)) { + case "400" -> HttpStatus.BAD_REQUEST; + case "404" -> HttpStatus.NOT_FOUND; + case "409" -> HttpStatus.CONFLICT; + default -> HttpStatus.INTERNAL_SERVER_ERROR; + }; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterPushRuleController.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterPushRuleController.java new file mode 100644 index 0000000..add45ed --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterPushRuleController.java @@ -0,0 +1,60 @@ +package com.sjmresorts.opsapp.esign.printer.controllers; + +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPushRuleResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPushRuleSaveRequest; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPdfTaskService; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPushRuleService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +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 java.util.List; + +@Tag(name = "Esign Printer Push Rules", description = "PDF 推送规则:自动/手动与上报设备映射") +@RestController +@RequestMapping({"/api/v1/esign", "/api/v1/sinosecu"}) +@CrossOrigin(origins = "*") +@RequiredArgsConstructor +public class EsignPrinterPushRuleController { + + private final EsignPrinterPushRuleService pushRuleService; + private final EsignPrinterPdfTaskService printerPdfTaskService; + + @Operation(summary = "获取推送规则配置") + @GetMapping(value = "/printer-push-rules", produces = "application/json;charset=UTF-8") + public ResponseEntity getRules() { + List agents = printerPdfTaskService.listDistinctAgentSources24h(); + EsignPrinterPushRuleResponse body = pushRuleService.getRules(agents); + return ResponseEntity.ok(body); + } + + @Operation(summary = "保存推送规则配置") + @PostMapping(value = "/printer-push-rules", produces = "application/json;charset=UTF-8") + public ResponseEntity saveRules( + @RequestBody EsignPrinterPushRuleSaveRequest request) { + EsignPrinterPushRuleResponse body = pushRuleService.saveRules(request); + if (!body.isSuccess()) { + return ResponseEntity.status(httpStatus(body.getCode())).body(body); + } + List agents = printerPdfTaskService.listDistinctAgentSources24h(); + body = pushRuleService.getRules(agents); + return ResponseEntity.ok(body); + } + + private static HttpStatus httpStatus(String code) { + return switch (StringUtils.defaultString(code)) { + case "400" -> HttpStatus.BAD_REQUEST; + case "404" -> HttpStatus.NOT_FOUND; + default -> HttpStatus.INTERNAL_SERVER_ERROR; + }; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterTabletRegistryController.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterTabletRegistryController.java new file mode 100644 index 0000000..b26c061 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterTabletRegistryController.java @@ -0,0 +1,48 @@ +package com.sjmresorts.opsapp.esign.printer.controllers; + +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterTabletRegistryDeleteRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterTabletRegistryResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterTabletRegistrySaveRequest; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterTabletRegistryService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@Tag(name = "Esign Printer Tablet Registry", description = "CMS 预登记 App 平板(按登录账号 + 预绑 PC)") +@RestController +@RequestMapping({"/api/v1/esign", "/api/v1/sinosecu"}) +@CrossOrigin(origins = "*") +@RequiredArgsConstructor +public class EsignPrinterTabletRegistryController { + + private final EsignPrinterTabletRegistryService registryService; + + @Operation(summary = "CMS 平板登记列表", description = "免 JWT。") + @GetMapping(value = "/printer-tablet-registry", produces = "application/json;charset=UTF-8") + public ResponseEntity listRegistry() { + return ResponseEntity.ok(registryService.listAll()); + } + + @Operation(summary = "CMS 添加/更新平板登记", description = "免 JWT。按 App 账号登记,可选预绑 PC,无需 IP。") + @PostMapping(value = "/printer-tablet-registry/save", produces = "application/json;charset=UTF-8") + public ResponseEntity saveRegistry( + @RequestBody EsignPrinterTabletRegistrySaveRequest body) { + EsignPrinterTabletRegistryResponse resp = registryService.save(body); + return ResponseEntity.ok(resp); + } + + @Operation(summary = "CMS 删除平板登记", description = "免 JWT。") + @PostMapping(value = "/printer-tablet-registry/delete", produces = "application/json;charset=UTF-8") + public ResponseEntity deleteRegistry( + @RequestBody EsignPrinterTabletRegistryDeleteRequest body) { + EsignPrinterTabletRegistryResponse resp = registryService.delete(body); + return ResponseEntity.ok(resp); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterWebSocketStatusController.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterWebSocketStatusController.java new file mode 100644 index 0000000..f2342b9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/controllers/EsignPrinterWebSocketStatusController.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.esign.printer.controllers; + +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; + +@RestController +@RequestMapping({"/api/v1/esign/ws", "/api/v1/sinosecu/ws"}) +public class EsignPrinterWebSocketStatusController { + + @GetMapping("/printer-tasks/ready") + public Map ready() { + Map body = new LinkedHashMap<>(); + body.put("websocket", true); + body.put("path", "/api/v1/esign/ws/printer-tasks"); + return body; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignClientTransportLogRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignClientTransportLogRequest.java new file mode 100644 index 0000000..68f6e16 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignClientTransportLogRequest.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "App 上報輪詢 / WebSocket 連接事件") +public class EsignClientTransportLogRequest { + + @Schema(description = "POLL_FAILED | POLL_RECOVERED | WS_DISCONNECTED | WS_CONNECTED | WS_ERROR") + private String eventType; + + @Schema(description = "websocket | java-poll") + private String transport; + + private String message; + + private Long userId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPendingListResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPendingListResponse.java new file mode 100644 index 0000000..22b7c23 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPendingListResponse.java @@ -0,0 +1,61 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "证件待签任务列表") +public class EsignPendingListResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignPendingData data; + + @Data + @NoArgsConstructor + public static class EsignPendingData { + private List tasks; + } + + @Data + @NoArgsConstructor + public static class EsignPendingItem { + /** CAPTURE(默认)| RESERVATION | PRINTER_PDF */ + private String taskType; + private String taskId; + /** 预订任务:预订号 */ + private String reservationId; + /** 预订 / 打印机任务:酒店 ID */ + private String hotelId; + /** PC 打印机任务:Electron 推送 ID */ + private String externalId; + /** PC 打印机任务:文件名 */ + private String fileName; + /** PC 打印机任务:文件大小(字节) */ + private Long fileSize; + private String createdAt; + private String docTitle; + /** 预订推送标题/正文(列表展示用) */ + private String notificationTitle; + private String notificationBody; + /** 从采集 payload 中解析的住客姓名 */ + private String guestName; + /** payload.selectedHotelNameZh,供列表标题展示 */ + private String selectedHotelNameZh; + /** 从采集行解析的中文姓名(优先匹配「中文姓名」等) */ + private String guestNameZh; + /** 从采集行解析的英文姓名(优先匹配「英文姓名」等;无则空) */ + private String guestNameEn; + /** 签收时间(仅已签收列表有值) */ + private String signedAt; + /** PC 打印机任务:上报电脑标识(Electron agentSource) */ + private String agentSource; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDashboardResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDashboardResponse.java new file mode 100644 index 0000000..cf2c0eb --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDashboardResponse.java @@ -0,0 +1,77 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "e-sign 打印任务控制台聚合数据") +public class EsignPrinterDashboardResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignPrinterDashboardData data; + + @Data + @NoArgsConstructor + public static class EsignPrinterDashboardData { + private String generatedAt; + private int taskWindowHours; + private long pendingCount; + private long signedCount; + private long voidedCount; + private long totalSubmitted24h; + private Double completionRatePercent; + private long ohipPendingCount; + private long ohipFailedCount; + private List recentTasks; + /** 24h 内按上报电脑聚合 */ + private List pcSources; + /** 可接收任务的旅宿平板账号(含推送 Token 状态) */ + private List tabletDevices; + } + + @Data + @NoArgsConstructor + public static class EsignPrinterPcSourceStat { + private String agentSource; + private long submitted24h; + private long pendingCount; + private long signed24h; + private String lastSubmitAt; + } + + @Data + @NoArgsConstructor + public static class EsignPrinterTabletDeviceStat { + private Long userId; + private String userName; + private String hotelId; + private boolean pushEnabled; + private String lastLoginAt; + private String status; + private Boolean wsConnected; + private Boolean online; + } + + @Data + @NoArgsConstructor + public static class EsignPrinterDashboardRecentTask { + private String taskId; + private String externalId; + private String fileName; + private Long fileSize; + private String status; + private String displayStatus; + private String createdAt; + private String completedAt; + private String hotelId; + private String agentSource; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDeviceLinkRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDeviceLinkRequest.java new file mode 100644 index 0000000..ee38d2b --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDeviceLinkRequest.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignPrinterDeviceLinkRequest { + + /** PC 上报端标识(与 Electron 状态页「本机名称@IP」一致) */ + private String agentSource; + + /** App 端自定义设备备注(可选) */ + private String appLabel; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDeviceLinkResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDeviceLinkResponse.java new file mode 100644 index 0000000..07831cc --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDeviceLinkResponse.java @@ -0,0 +1,52 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignPrinterDeviceLinkResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignPrinterDeviceLinkItem data; + private List items; + + @Data + @NoArgsConstructor + public static class EsignPrinterDeviceLinkItem { + private String linkId; + private Long userId; + private String userName; + private String agentSource; + private String appLabel; + private String pcDisplayName; + private String status; + private String requestedAt; + private String reviewedAt; + private String reviewedBy; + private Boolean pushEnabled; + private String deliveryMode; + private String lastLoginAt; + private String lastLoginIp; + } + + public static EsignPrinterDeviceLinkResponse ok(String message) { + EsignPrinterDeviceLinkResponse r = new EsignPrinterDeviceLinkResponse(); + r.setMessage(message); + return r; + } + + public static EsignPrinterDeviceLinkResponse fail(String code, String message) { + EsignPrinterDeviceLinkResponse r = new EsignPrinterDeviceLinkResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDeviceLinkReviewRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDeviceLinkReviewRequest.java new file mode 100644 index 0000000..937c2be --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDeviceLinkReviewRequest.java @@ -0,0 +1,18 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignPrinterDeviceLinkReviewRequest { + + private String linkId; + + /** approve | reject */ + private String action; + + private String reviewedBy; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDevicesResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDevicesResponse.java new file mode 100644 index 0000000..0c56562 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterDevicesResponse.java @@ -0,0 +1,103 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "e-sign 设备管理:PC 上报端 + 平板接收端") +public class EsignPrinterDevicesResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignPrinterDevicesData data; + + @Data + @NoArgsConstructor + public static class EsignPrinterDevicesData { + private String generatedAt; + private int taskWindowHours; + private List pcDevices; + private List tabletDevices; + private List linkRequests; + private List approvedLinks; + } + + @Data + @NoArgsConstructor + public static class EsignPrinterDeviceLinkItem { + private String linkId; + private Long userId; + private String userName; + private String agentSource; + private String appLabel; + private String status; + private String requestedAt; + private String reviewedAt; + private Boolean pushEnabled; + private String deliveryMode; + private String lastLoginAt; + private String lastLoginIp; + } + + @Data + @NoArgsConstructor + public static class EsignPrinterPcDevice { + private String agentSource; + private long submitted24h; + private long pendingCount; + private long signed24h; + private String lastSubmitAt; + private String lastFileName; + /** 已批准连接的平板数量(按客户端 IP 去重) */ + private Integer linkedTabletCount; + /** CMS 预登记 */ + private Boolean registered; + private String registryId; + private String displayName; + private String hostIp; + private Boolean enabled; + private String notes; + } + + @Data + @NoArgsConstructor + public static class EsignPrinterTabletDevice { + private Long userId; + private String userName; + private String status; + private boolean pushEnabled; + private String lastLoginAt; + private String lastLoginIp; + private String deliveryMode; + /** WebSocket 实时连接 */ + private Boolean wsConnected; + /** 在线:WebSocket 已连接,或 30 分钟内有过 App 活跃 */ + private Boolean online; + /** 已批准连接的 PC 数量 */ + private Integer linkedPcCount; + private List linkedAgentSources; + /** CMS 预登记 */ + private Boolean registered; + private String registryId; + private String displayName; + private Boolean enabled; + private String notes; + /** 绑定 IP,空表示整账号 */ + private String boundClientIp; + /** CMS 预绑 PC(尚未登录时也可展示) */ + private List prelinkedAgentSources; + } + + public static EsignPrinterDevicesResponse ok(String message) { + EsignPrinterDevicesResponse r = new EsignPrinterDevicesResponse(); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterEventLogResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterEventLogResponse.java new file mode 100644 index 0000000..c17f4e6 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterEventLogResponse.java @@ -0,0 +1,42 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "e-sign 打印机事件日志列表") +public class EsignPrinterEventLogResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignPrinterEventLogData data; + + @Data + @NoArgsConstructor + public static class EsignPrinterEventLogData { + private long total; + private int page; + private int size; + private List logs; + } + + @Data + @NoArgsConstructor + public static class EsignPrinterEventLogItem { + private String logId; + private String taskId; + private String eventType; + private String message; + private String fileName; + private String agentSource; + private String hotelId; + private String createdAt; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterOhipStatusRow.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterOhipStatusRow.java new file mode 100644 index 0000000..317bcc3 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterOhipStatusRow.java @@ -0,0 +1,15 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import java.time.Instant; + +/** OHIP 状态轻量投影(队列列表批量填充)。 */ +public interface EsignPrinterOhipStatusRow { + + String getTaskId(); + + String getOhipStatus(); + + String getOhipLastError(); + + Instant getOhipUploadedAt(); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPcRegistryDeleteRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPcRegistryDeleteRequest.java new file mode 100644 index 0000000..b5eb7b6 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPcRegistryDeleteRequest.java @@ -0,0 +1,13 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignPrinterPcRegistryDeleteRequest { + + private String pcId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPcRegistryResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPcRegistryResponse.java new file mode 100644 index 0000000..3da382a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPcRegistryResponse.java @@ -0,0 +1,47 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignPrinterPcRegistryResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignPrinterPcRegistryItem data; + private List items; + + @Data + @NoArgsConstructor + public static class EsignPrinterPcRegistryItem { + private String pcId; + private String displayName; + private String hostIp; + private String agentSource; + private boolean enabled; + private String notes; + private String createdBy; + private String createdAt; + private String updatedAt; + } + + public static EsignPrinterPcRegistryResponse ok(String message) { + EsignPrinterPcRegistryResponse r = new EsignPrinterPcRegistryResponse(); + r.setMessage(message); + return r; + } + + public static EsignPrinterPcRegistryResponse fail(String code, String message) { + EsignPrinterPcRegistryResponse r = new EsignPrinterPcRegistryResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPcRegistrySaveRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPcRegistrySaveRequest.java new file mode 100644 index 0000000..b573cde --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPcRegistrySaveRequest.java @@ -0,0 +1,18 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignPrinterPcRegistrySaveRequest { + + private String pcId; + private String displayName; + private String hostIp; + private Boolean enabled; + private String notes; + private String createdBy; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPcSourceRow.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPcSourceRow.java new file mode 100644 index 0000000..c00e80b --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPcSourceRow.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import java.time.Instant; + +/** 按上报电脑聚合的 24h 传输统计 */ +public interface EsignPrinterPcSourceRow { + + String getAgentSource(); + + long getSubmitted24h(); + + long getPendingCount(); + + long getSigned24h(); + + Instant getLastSubmitAt(); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveListRow.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveListRow.java new file mode 100644 index 0000000..5cd7033 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveListRow.java @@ -0,0 +1,36 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import java.time.Instant; + +/** 归档列表轻量行(不含 PDF / JSON LOB)。 */ +public record EsignPrinterPdfArchiveListRow( + String taskId, + String externalId, + String fileName, + Long fileSize, + String hotelId, + String status, + Instant createdAt, + Instant completedAt, + Instant signedAt, + Instant archivedAt, + String agentSource, + Long signedByUserId, + String signedClientIp, + String signedDeviceId, + String signedDeviceName, + String ocrGuestName, + String ocrConfirmationNo, + String ocrPhone, + String ocrEmail, + String ocrRoomNo, + String ocrCheckInDate, + String ocrCheckOutDate, + String ocrStatus, + Instant ocrExtractedAt, + String ohipStatus, + String ohipLastError, + Instant ohipUploadedAt, + String ohipConfirmationNo, + String ohipReservationId, + Integer ohipRetryCount) {} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveOcrBatchResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveOcrBatchResponse.java new file mode 100644 index 0000000..387bd4b --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveOcrBatchResponse.java @@ -0,0 +1,33 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "PDF 归档 OCR 批量重跑") +public class EsignPrinterPdfArchiveOcrBatchResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignPrinterPdfArchiveOcrBatchData data; + + @Data + @NoArgsConstructor + public static class EsignPrinterPdfArchiveOcrBatchData { + private String jobId; + private String status; + private Integer total; + private Integer processed; + private Integer successCount; + private Integer failedCount; + private Integer skippedCount; + private Boolean hasErrorLog; + private String startedAt; + private String finishedAt; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveOcrEvent.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveOcrEvent.java new file mode 100644 index 0000000..c5d5e1a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveOcrEvent.java @@ -0,0 +1,4 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +/** 归档 PDF 异步 OCR 请求(事务提交后发布)。 */ +public record EsignPrinterPdfArchiveOcrEvent(String taskId) {} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveResponse.java new file mode 100644 index 0000000..702b672 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveResponse.java @@ -0,0 +1,69 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "e-sign PDF 归档列表") +public class EsignPrinterPdfArchiveResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignPrinterPdfArchiveData data; + + @Data + @NoArgsConstructor + public static class EsignPrinterPdfArchiveData { + private long total; + private int page; + private int size; + private List items; + } + + @Data + @NoArgsConstructor + public static class EsignPrinterPdfArchiveItem { + private String taskId; + private String externalId; + private String fileName; + private Long fileSize; + private String hotelId; + private String status; + private String createdAt; + private String completedAt; + private String signedAt; + private String archivedAt; + private boolean hasOriginalPdf = true; + private boolean hasSignedPdf; + private boolean hasSignedMeta; + private String agentSource; + private Long signedByUserId; + private String signedByUserName; + private String signedClientIp; + private String signedDeviceId; + private String signedDeviceName; + private String submittedDeviceLabel; + private String ocrGuestName; + private String ocrConfirmationNo; + private String ocrPhone; + private String ocrEmail; + private String ocrRoomNo; + private String ocrCheckInDate; + private String ocrCheckOutDate; + private String ocrStatus; + private String ocrExtractedAt; + private String ohipStatus; + private String ohipLastError; + private String ohipUploadedAt; + private String ohipConfirmationNo; + private String ohipReservationId; + private Integer ohipRetryCount; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveRow.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveRow.java new file mode 100644 index 0000000..e20adbb --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveRow.java @@ -0,0 +1,32 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import java.time.Instant; + +public interface EsignPrinterPdfArchiveRow { + + String getTaskId(); + + String getExternalId(); + + String getFileName(); + + Long getFileSize(); + + String getHotelId(); + + String getStatus(); + + Instant getCreatedAt(); + + Instant getCompletedAt(); + + Instant getSignedAt(); + + Instant getArchivedAt(); + + Boolean getHasSignedPdf(); + + Boolean getHasSignedMeta(); + + String getAgentSource(); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveSpecs.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveSpecs.java new file mode 100644 index 0000000..067a178 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfArchiveSpecs.java @@ -0,0 +1,116 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.sjmresorts.opsapp.esign.printer.filters.EsignPrinterPdfArchiveFilter; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPdfTask; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPdfArchiveOcrService; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.jpa.domain.Specification; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +public final class EsignPrinterPdfArchiveSpecs { + + private EsignPrinterPdfArchiveSpecs() { + } + + /** archivedAt → completedAt → signedAt → createdAt */ + public static Expression archiveSortTime( + Root root, CriteriaBuilder cb) { + return cb.coalesce( + root.get("archivedAt"), + cb.coalesce( + root.get("completedAt"), + cb.coalesce(root.get("signedAt"), root.get("createdAt")))); + } + + public static Specification archived(EsignPrinterPdfArchiveFilter filter) { + return (root, query, cb) -> { + List predicates = new ArrayList<>(); + predicates.add(cb.or( + cb.equal(root.get("status"), "COMPLETED"), + cb.isNotNull(root.get("signedAt")))); + + if (filter != null) { + like(predicates, cb, root.get("ocrGuestName"), filter.getGuestName()); + applyConfirmationFilter(predicates, cb, root, filter.getConfirmationNo()); + applyReservationFilter(predicates, cb, root, filter.getReservationId()); + like(predicates, cb, root.get("ocrPhone"), filter.getPhone()); + like(predicates, cb, root.get("ocrRoomNo"), filter.getRoomNo()); + like(predicates, cb, root.get("ocrCheckInDate"), filter.getCheckInDate()); + like(predicates, cb, root.get("ocrCheckOutDate"), filter.getCheckOutDate()); + like(predicates, cb, root.get("fileName"), filter.getFileName()); + applyStatusFilter(predicates, cb, root, filter.getStatus()); + } + + // COUNT 等聚合查询不能带 ORDER BY(SQL Server 会报错) + if (!Long.class.equals(query.getResultType()) && !long.class.equals(query.getResultType())) { + query.orderBy(cb.desc(archiveSortTime(root, cb))); + } + return cb.and(predicates.toArray(new Predicate[0])); + }; + } + + private static void like( + List predicates, + jakarta.persistence.criteria.CriteriaBuilder cb, + jakarta.persistence.criteria.Path path, + String value) { + if (StringUtils.isBlank(value)) { + return; + } + predicates.add(cb.like(cb.lower(path), "%" + value.trim().toLowerCase() + "%")); + } + + private static void applyConfirmationFilter( + List predicates, + jakarta.persistence.criteria.CriteriaBuilder cb, + jakarta.persistence.criteria.Root root, + String confirmationNo) { + if (StringUtils.isBlank(confirmationNo)) { + return; + } + String pattern = "%" + confirmationNo.trim().toLowerCase() + "%"; + predicates.add(cb.or( + cb.like(cb.lower(root.get("ocrConfirmationNo")), pattern), + cb.like(cb.lower(root.get("ohipConfirmationNo")), pattern))); + } + + private static void applyReservationFilter( + List predicates, + jakarta.persistence.criteria.CriteriaBuilder cb, + jakarta.persistence.criteria.Root root, + String reservationId) { + if (StringUtils.isBlank(reservationId)) { + return; + } + like(predicates, cb, root.get("ohipReservationId"), reservationId); + } + + private static void applyStatusFilter( + List predicates, + jakarta.persistence.criteria.CriteriaBuilder cb, + jakarta.persistence.criteria.Root root, + String status) { + if (StringUtils.isBlank(status) || "ALL".equalsIgnoreCase(status)) { + return; + } + if ("SIGNED".equalsIgnoreCase(status)) { + predicates.add(cb.isNotNull(root.get("signedAt"))); + return; + } + if ("COMPLETED".equalsIgnoreCase(status)) { + predicates.add(cb.equal(root.get("status"), "COMPLETED")); + predicates.add(cb.isNull(root.get("signedAt"))); + return; + } + if ("OCR_FAILED".equalsIgnoreCase(status)) { + predicates.add(cb.equal(root.get("ocrStatus"), EsignPrinterPdfArchiveOcrService.OCR_FAILED)); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfFieldExtractRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfFieldExtractRequest.java new file mode 100644 index 0000000..72d6ca3 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfFieldExtractRequest.java @@ -0,0 +1,37 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "PDF 热区文字提取请求") +public class EsignPrinterPdfFieldExtractRequest { + + @Schema(description = "页码,0 起") + private Integer page; + + @Schema(description = "热区 left,0~1") + private Double left; + + @Schema(description = "热区 top,0~1") + private Double top; + + @Schema(description = "热区 width,0~1") + private Double width; + + @Schema(description = "热区 height,0~1") + private Double height; + + @Schema(description = "输入格式:email / phone / text 等") + private String inputFormat; + + @Schema(description = "可选:任务 ID,用于服务端 PDF 回退 OCR") + private String taskId; + + @Schema(description = "平板裁剪的热区 PNG Base64(优先 OCR 此图)") + private String cropImageBase64; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfFieldExtractResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfFieldExtractResponse.java new file mode 100644 index 0000000..158e6df --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfFieldExtractResponse.java @@ -0,0 +1,44 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "PDF 热区文字提取响应") +public class EsignPrinterPdfFieldExtractResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignPrinterPdfFieldExtractData data; + + @Data + @NoArgsConstructor + public static class EsignPrinterPdfFieldExtractData { + private String text; + private String inputFormat; + private String ocrHint; + } + + public static EsignPrinterPdfFieldExtractResponse ok(String text, String inputFormat, String ocrHint) { + EsignPrinterPdfFieldExtractResponse resp = new EsignPrinterPdfFieldExtractResponse(); + EsignPrinterPdfFieldExtractData data = new EsignPrinterPdfFieldExtractData(); + data.setText(text); + data.setInputFormat(inputFormat); + data.setOcrHint(ocrHint); + resp.setData(data); + return resp; + } + + public static EsignPrinterPdfFieldExtractResponse fail(String code, String message) { + EsignPrinterPdfFieldExtractResponse resp = new EsignPrinterPdfFieldExtractResponse(); + resp.setSuccess(false); + resp.setCode(code); + resp.setMessage(message); + return resp; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfFieldPrefillRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfFieldPrefillRequest.java new file mode 100644 index 0000000..2fae3de --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfFieldPrefillRequest.java @@ -0,0 +1,14 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "登记卡签名字段预填请求") +public class EsignPrinterPdfFieldPrefillRequest { + + @Schema(description = "App 签名字段 JSON 数组(含 id、fieldName、inputFormat)") + private String signFieldsJson; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfFieldPrefillResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfFieldPrefillResponse.java new file mode 100644 index 0000000..3f0cd73 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfFieldPrefillResponse.java @@ -0,0 +1,47 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "登记卡签名字段预填响应") +public class EsignPrinterPdfFieldPrefillResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private FieldPrefillData data; + + @Data + @NoArgsConstructor + public static class FieldPrefillData { + /** fieldId → 预填文字 */ + private Map fieldPrefillMap = new LinkedHashMap<>(); + /** 任务 OCR 状态:PENDING | OK | FAILED | SKIPPED */ + private String ocrStatus; + /** 数据来源:task_ocr / ohip_profile */ + private List sources; + } + + public static EsignPrinterPdfFieldPrefillResponse ok(FieldPrefillData data) { + EsignPrinterPdfFieldPrefillResponse r = new EsignPrinterPdfFieldPrefillResponse(); + r.setData(data); + return r; + } + + public static EsignPrinterPdfFieldPrefillResponse fail(String code, String message) { + EsignPrinterPdfFieldPrefillResponse r = new EsignPrinterPdfFieldPrefillResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfSignedOhipEvent.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfSignedOhipEvent.java new file mode 100644 index 0000000..31d3763 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfSignedOhipEvent.java @@ -0,0 +1,11 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +/** 平板签名归档后异步上传 OPERA(事务提交后发布)。 */ +public record EsignPrinterPdfSignedOhipEvent( + String taskId, + String confirmationNumberFromRequest, + String signFieldsJson, + String profileId, + String profileType, + String reservationId, + String hotelId) {} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfSignedSubmitRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfSignedSubmitRequest.java new file mode 100644 index 0000000..ccd2ba9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfSignedSubmitRequest.java @@ -0,0 +1,53 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "平板/客户端回传签名 PDF 或签名图层") +public class EsignPrinterPdfSignedSubmitRequest { + + @Schema(description = "后端任务 ID") + private String taskId; + + @Schema(description = "Electron externalId(与 taskId 二选一)") + private String externalId; + + @Schema(description = "签名后完整 PDF Base64(可选)") + private String base64; + + @Schema(description = "签名图层 JSON(无合并 PDF 时存档)") + private String signaturesJson; + + @Schema(description = "签名数量") + private Integer signatureCount; + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "从 PDF 解析的确认号(可选,未传则服务端自动解析)") + private String confirmationNumber; + + @Schema(description = "酒店 ID(可选,默认取任务或 OAuth 配置)") + private String hotelId; + + @Schema(description = "签名热区 JSON(与 App loadSignFields 一致,用于服务端合成 PDF)") + private String signFieldsJson; + + @Schema(description = "OHIP reservation id(可选,用于解析 Profile ID)") + private String reservationId; + + @Schema(description = "OHIP Guest Profile ID(可选,未传且提供 reservationId 时服务端解析)") + private String profileId; + + @Schema(description = "OHIP profile.profileType(可选,例如 Guest)") + private String profileType; + + @Schema(description = "提交设备 ID(平板 UUID)") + private String deviceId; + + @Schema(description = "提交设备名称(平板展示名)") + private String deviceName; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskCompleteRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskCompleteRequest.java new file mode 100644 index 0000000..d27ac65 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskCompleteRequest.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "PC 虚拟打印机 PDF 任务完成") +public class EsignPrinterPdfTaskCompleteRequest { + + @Schema(description = "后端任务 ID") + private String taskId; + + @Schema(description = "Electron 推送 ID(与 taskId 二选一)") + private String externalId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskDetailResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskDetailResponse.java new file mode 100644 index 0000000..2fc4825 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskDetailResponse.java @@ -0,0 +1,35 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "PC 虚拟打印机 PDF 任务详情") +public class EsignPrinterPdfTaskDetailResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignPrinterPdfTaskDetailData data; + + @Data + @NoArgsConstructor + public static class EsignPrinterPdfTaskDetailData { + private String taskId; + private String externalId; + private String fileName; + private String filePath; + private Long fileSize; + private String hotelId; + /** 详情接口不再返回 Base64,请用 GET …/pdf 下载二进制 */ + private Boolean hasPdf; + private Boolean hasSignedPdf; + private Boolean hasSignedMeta; + private String createdAt; + private String status; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskListRow.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskListRow.java new file mode 100644 index 0000000..118370d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskListRow.java @@ -0,0 +1,29 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import java.time.Instant; + +/** + * 列表查询投影:不含 pdf_base64,避免 ios-task-list / ios-pending-tasks 拉取大字段占满连接池。 + */ +public interface EsignPrinterPdfTaskListRow { + + String getTaskId(); + + String getExternalId(); + + String getFileName(); + + Long getFileSize(); + + String getHotelId(); + + String getStatus(); + + Instant getCreatedAt(); + + Instant getCompletedAt(); + + Instant getSignedAt(); + + String getAgentSource(); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskSubmitRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskSubmitRequest.java new file mode 100644 index 0000000..61d904a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskSubmitRequest.java @@ -0,0 +1,32 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "PC 虚拟打印机 PDF 任务提交(Electron 代理)") +public class EsignPrinterPdfTaskSubmitRequest { + + @Schema(description = "Electron 侧推送 ID,用于去重", requiredMode = Schema.RequiredMode.REQUIRED) + private String externalId; + + @Schema(description = "PDF 文件名", requiredMode = Schema.RequiredMode.REQUIRED) + private String fileName; + + @Schema(description = "PC 本地路径(可选)") + private String filePath; + + @Schema(description = "PDF Base64(可带 data:application/pdf;base64, 前缀)", requiredMode = Schema.RequiredMode.REQUIRED) + private String base64; + + @Schema(description = "文件大小(字节)") + private Long fileSize; + + @Schema(description = "酒店 ID(可选,用于列表筛选)") + private String hotelId; + + @Schema(description = "PC 代理来源标识(如 IP:port)") + private String agentSource; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskUpdateRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskUpdateRequest.java new file mode 100644 index 0000000..24706e0 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfTaskUpdateRequest.java @@ -0,0 +1,23 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "CMS 更新待签收 PDF 任务(文件名 / 替换 PDF);已签名归档替换签名 PDF") +public class EsignPrinterPdfTaskUpdateRequest { + + @Schema(description = "任务 ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String taskId; + + @Schema(description = "新文件名(可选)") + private String fileName; + + @Schema(description = "替换 PDF Base64(可选,可带 data:application/pdf;base64, 前缀)") + private String base64; + + @Schema(description = "替换后文件大小(字节,可选)") + private Long fileSize; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfUsbSignedSubmitRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfUsbSignedSubmitRequest.java new file mode 100644 index 0000000..d209a3f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPdfUsbSignedSubmitRequest.java @@ -0,0 +1,47 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "USB 直传签名:一次请求完成归档,OPERA 异步上传") +public class EsignPrinterPdfUsbSignedSubmitRequest { + + @Schema(description = "USB localTaskId / 去重 ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String externalId; + + @Schema(description = "OHIP reservation id", requiredMode = Schema.RequiredMode.REQUIRED) + private String reservationId; + + @Schema(description = "酒店 ID") + private String hotelId; + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "原始 PDF Base64(用于服务端合成签名)", requiredMode = Schema.RequiredMode.REQUIRED) + private String pdfBase64; + + @Schema(description = "签名图层 JSON", requiredMode = Schema.RequiredMode.REQUIRED) + private String signaturesJson; + + @Schema(description = "签名热区 JSON") + private String signFieldsJson; + + @Schema(description = "签名数量") + private Integer signatureCount; + + @Schema(description = "OHIP Guest Profile ID(可选)") + private String profileId; + + @Schema(description = "OHIP profileType(可选)") + private String profileType; + + @Schema(description = "提交设备 ID") + private String deviceId; + + @Schema(description = "提交设备名称") + private String deviceName; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushAssignRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushAssignRequest.java new file mode 100644 index 0000000..f530f2a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushAssignRequest.java @@ -0,0 +1,19 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignPrinterPushAssignRequest { + + private String taskId; + private List userIds; + private List groupIds; + private boolean sendFcm = false; + private String assignedBy; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushCandidatesResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushCandidatesResponse.java new file mode 100644 index 0000000..b07bff0 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushCandidatesResponse.java @@ -0,0 +1,71 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignPrinterPushCandidatesResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignPrinterPushCandidatesData data; + + @Data + @NoArgsConstructor + public static class EsignPrinterPushCandidatesData { + private String taskId; + private String agentSource; + private List devices; + private List groups; + private List selectedUserIds; + private List selectedGroupIds; + /** 已选中的平板登记 ID(与 devices.registryId 对应) */ + private List selectedRegistryIds; + private String pushMode; + } + + @Data + @NoArgsConstructor + public static class EsignPrinterPushDeviceOption { + private Long userId; + private String userName; + private String appLabel; + private boolean pushEnabled; + private String deliveryMode; + /** CMS 平板登记显示名(如 hotel-1) */ + private String displayName; + /** 绑定的客户端 IP;空表示不限 IP */ + private String clientIp; + /** 平板登记 ID,推送时优先使用 */ + private String registryId; + } + + @Data + @NoArgsConstructor + public static class EsignPrinterPushGroupOption { + private String groupId; + private String groupName; + private int memberCount; + private List memberUserIds; + } + + public static EsignPrinterPushCandidatesResponse ok(String message) { + EsignPrinterPushCandidatesResponse r = new EsignPrinterPushCandidatesResponse(); + r.setMessage(message); + return r; + } + + public static EsignPrinterPushCandidatesResponse fail(String code, String message) { + EsignPrinterPushCandidatesResponse r = new EsignPrinterPushCandidatesResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushGroupListResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushGroupListResponse.java new file mode 100644 index 0000000..4aa483c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushGroupListResponse.java @@ -0,0 +1,44 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignPrinterPushGroupListResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private List data; + + @Data + @NoArgsConstructor + public static class EsignPrinterPushGroupItem { + private String groupId; + private String groupName; + private String agentSource; + private int memberCount; + private List memberUserIds; + private String createdAt; + private String updatedAt; + } + + public static EsignPrinterPushGroupListResponse ok(String message) { + EsignPrinterPushGroupListResponse r = new EsignPrinterPushGroupListResponse(); + r.setMessage(message); + return r; + } + + public static EsignPrinterPushGroupListResponse fail(String code, String message) { + EsignPrinterPushGroupListResponse r = new EsignPrinterPushGroupListResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushGroupSaveRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushGroupSaveRequest.java new file mode 100644 index 0000000..56b33d7 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushGroupSaveRequest.java @@ -0,0 +1,18 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignPrinterPushGroupSaveRequest { + + private String groupId; + private String groupName; + private String agentSource; + private List memberUserIds; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushRuleResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushRuleResponse.java new file mode 100644 index 0000000..ea4b2d1 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushRuleResponse.java @@ -0,0 +1,65 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignPrinterPushRuleResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignPrinterPushRuleData data; + + @Data + @NoArgsConstructor + public static class EsignPrinterPushRuleData { + private String pushMode; + private boolean autoSendFcm; + /** 指定 PC 對應(PER_AGENT)是否參與解析 */ + private boolean perAgentEnabled = true; + /** 未標識 PC 規則(UNKNOWN_AGENT)是否參與解析 */ + private boolean unknownAgentEnabled = true; + /** 兜底規則(AUTO=ALL_AGENTS / MANUAL=全局預設)是否參與解析 */ + private boolean fallbackEnabled = true; + private EsignPrinterPushRuleMappingItem allAgents; + private EsignPrinterPushRuleMappingItem unknownAgent; + private EsignPrinterPushRuleMappingItem manualDefault; + private List perAgentMappings; + private List knownAgentSources; + } + + @Data + @NoArgsConstructor + public static class EsignPrinterPushRuleMappingItem { + private List userIds; + private List groupIds; + } + + @Data + @NoArgsConstructor + public static class EsignPrinterPushRulePerAgentItem { + private String agentSource; + private List userIds; + private List groupIds; + } + + public static EsignPrinterPushRuleResponse ok(String message) { + EsignPrinterPushRuleResponse r = new EsignPrinterPushRuleResponse(); + r.setMessage(message); + return r; + } + + public static EsignPrinterPushRuleResponse fail(String code, String message) { + EsignPrinterPushRuleResponse r = new EsignPrinterPushRuleResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushRuleSaveRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushRuleSaveRequest.java new file mode 100644 index 0000000..9a4788d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterPushRuleSaveRequest.java @@ -0,0 +1,23 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignPrinterPushRuleSaveRequest { + + private String pushMode; + private Boolean autoSendFcm; + private Boolean perAgentEnabled; + private Boolean unknownAgentEnabled; + private Boolean fallbackEnabled; + private EsignPrinterPushRuleResponse.EsignPrinterPushRuleMappingItem allAgents; + private EsignPrinterPushRuleResponse.EsignPrinterPushRuleMappingItem unknownAgent; + private EsignPrinterPushRuleResponse.EsignPrinterPushRuleMappingItem manualDefault; + private List perAgentMappings; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterQueueResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterQueueResponse.java new file mode 100644 index 0000000..0d6a2b4 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterQueueResponse.java @@ -0,0 +1,71 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "24h PDF 队列(CMS 平板队列页)") +public class EsignPrinterQueueResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignPrinterQueueData data; + + @Data + @NoArgsConstructor + public static class EsignPrinterQueueData { + private String generatedAt; + private int taskWindowHours; + private long total; + private int page; + private int size; + private List agentSourceOptions; + private String pushMode; + private List items; + } + + @Data + @NoArgsConstructor + public static class EsignPrinterQueueItem { + private String taskId; + private String externalId; + private String fileName; + private Long fileSize; + private String hotelId; + private String status; + private String displayStatus; + private String agentSource; + private String createdAt; + private String completedAt; + private boolean pushConfigured; + private List pushTargets; + private String ohipStatus; + private String ohipLastError; + private String ohipUploadedAt; + } + + @Data + @NoArgsConstructor + public static class EsignPrinterPushTarget { + private Long userId; + private String userName; + private String agentSource; + private String appLabel; + private String linkStatus; + private boolean pushEnabled; + private String deliveryMode; + } + + public static EsignPrinterQueueResponse ok(String message) { + EsignPrinterQueueResponse r = new EsignPrinterQueueResponse(); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterQueueWatchResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterQueueWatchResponse.java new file mode 100644 index 0000000..290d269 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterQueueWatchResponse.java @@ -0,0 +1,37 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "24h PDF 队列变更探测(CMS 轮询用,轻量)") +public class EsignPrinterQueueWatchResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignPrinterQueueWatchData data; + + @Data + @NoArgsConstructor + public static class EsignPrinterQueueWatchData { + /** 合成版本号:队列有新增/签收/推送变化时改变 */ + private String revision; + private long total24h; + private long pendingCount; + private long signedCount; + private String latestCreatedAt; + private String latestCompletedAt; + private int taskWindowHours; + } + + public static EsignPrinterQueueWatchResponse ok(String message) { + EsignPrinterQueueWatchResponse r = new EsignPrinterQueueWatchResponse(); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterTabletRegistryDeleteRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterTabletRegistryDeleteRequest.java new file mode 100644 index 0000000..39e7c6e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterTabletRegistryDeleteRequest.java @@ -0,0 +1,13 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignPrinterTabletRegistryDeleteRequest { + + private String registryId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterTabletRegistryResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterTabletRegistryResponse.java new file mode 100644 index 0000000..ce95f1d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterTabletRegistryResponse.java @@ -0,0 +1,49 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignPrinterTabletRegistryResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignPrinterTabletRegistryItem data; + private List items; + + @Data + @NoArgsConstructor + public static class EsignPrinterTabletRegistryItem { + private String registryId; + private String userName; + private Long userId; + private String clientIp; + private String displayName; + private boolean enabled; + private String notes; + private String createdBy; + private String createdAt; + private String updatedAt; + private List agentSources; + } + + public static EsignPrinterTabletRegistryResponse ok(String message) { + EsignPrinterTabletRegistryResponse r = new EsignPrinterTabletRegistryResponse(); + r.setMessage(message); + return r; + } + + public static EsignPrinterTabletRegistryResponse fail(String code, String message) { + EsignPrinterTabletRegistryResponse r = new EsignPrinterTabletRegistryResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterTabletRegistrySaveRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterTabletRegistrySaveRequest.java new file mode 100644 index 0000000..fd81732 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignPrinterTabletRegistrySaveRequest.java @@ -0,0 +1,26 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignPrinterTabletRegistrySaveRequest { + + private String registryId; + /** App 登录账号 */ + private String userName; + /** 绑定的客户端 IP,留空表示整账号(多设备共用账号时请填写) */ + private String clientIp; + /** 设备备注,如「前台签收平板」 */ + private String displayName; + private Boolean enabled; + private String notes; + private String createdBy; + /** 预绑 PC 连接标识列表(来自 CMS 登记的 PC) */ + private List agentSources; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignSignTaskRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignSignTaskRequest.java new file mode 100644 index 0000000..b2de704 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignSignTaskRequest.java @@ -0,0 +1,20 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "iOS 签收提交") +public class EsignSignTaskRequest { + + @Schema(description = "任务 ID", requiredMode = Schema.RequiredMode.REQUIRED) + private String taskId; + + @Schema(description = "签收备注(可选文字)") + private String signRemark; + + @Schema(description = "客户手写签名 PNG Base64(data:image/png;base64,...)", requiredMode = Schema.RequiredMode.REQUIRED) + private String signSignature; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignTaskDetailResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignTaskDetailResponse.java new file mode 100644 index 0000000..10a5d70 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/EsignTaskDetailResponse.java @@ -0,0 +1,34 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "证件任务详情(含采集 JSON)") +public class EsignTaskDetailResponse { + + private boolean success; + private String code; + private String message; + private EsignTaskDetailData data; + + @Data + @NoArgsConstructor + public static class EsignTaskDetailData { + private String taskId; + private String status; + /** pending | signed | voided,由 status + 创建时间推导 */ + private String signState; + private String createdAt; + private String signedAt; + /** 已签收时返回手写签名图(data URL) */ + private String signSignature; + private Map payload; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/LocalExeLaunchResult.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/LocalExeLaunchResult.java new file mode 100644 index 0000000..99339e3 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/LocalExeLaunchResult.java @@ -0,0 +1,29 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import lombok.Getter; + +@Getter +public final class LocalExeLaunchResult { + + private final boolean launched; + private final boolean skipped; + private final String message; + + private LocalExeLaunchResult(boolean launched, boolean skipped, String message) { + this.launched = launched; + this.skipped = skipped; + this.message = message; + } + + public static LocalExeLaunchResult success(String exePath) { + return new LocalExeLaunchResult(true, false, "已启动: " + exePath); + } + + public static LocalExeLaunchResult skipped(String reason) { + return new LocalExeLaunchResult(false, true, reason); + } + + public static LocalExeLaunchResult failed(String reason) { + return new LocalExeLaunchResult(false, false, reason); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardMeta.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardMeta.java new file mode 100644 index 0000000..efe0447 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardMeta.java @@ -0,0 +1,107 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import lombok.Builder; +import lombok.Value; + +/** 登记卡 PDF OCR / 文本层解析出的检索字段。 */ +@Value +@Builder +public class RegistrationCardMeta { + String guestName; + String confirmationNo; + String phone; + String email; + String roomNo; + String checkInDate; + String checkOutDate; + + public boolean hasAnyField() { + return isPresent(guestName) + || isPresent(confirmationNo) + || isPresent(phone) + || isPresent(email) + || isPresent(roomNo) + || isPresent(checkInDate) + || isPresent(checkOutDate); + } + + /** 文本层与 OCR 结果合并:优先 primary,缺失字段用 secondary 补齐。 */ + public RegistrationCardMeta mergedWith(RegistrationCardMeta other) { + if (other == null) { + return this; + } + return RegistrationCardMeta.builder() + .guestName(firstNonBlank(guestName, other.guestName)) + .confirmationNo(firstNonBlank(confirmationNo, other.confirmationNo)) + .phone(firstNonBlank(phone, other.phone)) + .email(firstNonBlank(email, other.email)) + .roomNo(pickBetterRoomNo(roomNo, other.roomNo, phone, other.phone)) + .checkInDate(firstNonBlank(checkInDate, other.checkInDate)) + .checkOutDate(firstNonBlank(checkOutDate, other.checkOutDate)) + .build(); + } + + private static String firstNonBlank(String primary, String secondary) { + return isPresent(primary) ? primary : secondary; + } + + private static boolean isPresent(String v) { + return v != null && !v.isBlank(); + } + + /** 文本层与 OCR 房号冲突时,优先更长、更像真实房号的值(如 0202 优于 3 / 0101)。 */ + private static String pickBetterRoomNo(String primary, String secondary, String primaryPhone, String secondaryPhone) { + if (!isPresent(primary)) { + return secondary; + } + if (!isPresent(secondary)) { + return primary; + } + if (primary.matches("\\d{1,2}")) { + return secondary; + } + if (secondary.matches("\\d{1,2}")) { + return primary; + } + int primaryScore = roomNoScore(primary, primaryPhone, secondaryPhone); + int secondaryScore = roomNoScore(secondary, secondaryPhone, primaryPhone); + if (secondaryScore > primaryScore) { + return secondary; + } + if (primaryScore > secondaryScore) { + return primary; + } + if (secondary.length() > primary.length()) { + return secondary; + } + if (primary.length() > secondary.length()) { + return primary; + } + return secondary; + } + + private static int roomNoScore(String room, String ownPhone, String otherPhone) { + if (room == null || room.isBlank()) { + return 0; + } + String v = room.trim(); + if (RegistrationCardMetaExtractor.isPhoneDigitFragment(v, ownPhone) + || RegistrationCardMetaExtractor.isPhoneDigitFragment(v, otherPhone)) { + return 0; + } + if (!v.matches("\\d{1,5}")) { + return 50; + } + int len = v.length(); + if (len == 4 && v.startsWith("0")) { + return 124; + } + if (len >= 3) { + return 100 + len; + } + if (len == 2) { + return 0; + } + return 10; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardMetaExtractor.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardMetaExtractor.java new file mode 100644 index 0000000..d0b4973 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardMetaExtractor.java @@ -0,0 +1,732 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.sjmresorts.opsapp.esign.reservation.support.RegistrationConfirmationExtractor; +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 从登记卡 PDF 文本(文本层或 OCR)提取姓名、确认号、电话、房号、入退日期。 */ +public final class RegistrationCardMetaExtractor { + + private static final Pattern DATE_TOKEN = + Pattern.compile("(\\d{1,2}[-/.\\s]\\d{1,2}[-/.\\s]\\d{2,4})"); + + private static final Pattern PHONE_LIKE = + Pattern.compile("(\\+?\\d[\\d\\s\\-().]{6,18}\\d)"); + + private static final Pattern TITLE_NAME = + Pattern.compile("(?i)(?:mr\\.?|mrs\\.?|ms\\.?|miss\\.?|dr\\.?)\\s+([A-Za-z][A-Za-z\\s'.-]{2,48})"); + + private static final Pattern SLASH_NAME = + Pattern.compile("(?i)([A-Z][A-Z' -]{1,30}/[A-Z][A-Z' -]{1,30})"); + + private static final Pattern CJK_NAME = + Pattern.compile("(?:(?:姓名|住客姓名|賓客姓名|宾客姓名)\\s*[::]?\\s*)([\\u4e00-\\u9fff]{2,8})"); + + private static final Pattern EMAIL_VALUE = + Pattern.compile("([a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,})"); + + private static final Pattern STANDALONE_ROOM_LINE = Pattern.compile("^(\\d{3,5})$"); + + /** 仅同行匹配,禁止 (?s) 跨行,避免误抓日期/电话中的数字 */ + private static final Pattern[] ROOM_NUMBER_PATTERNS = { + Pattern.compile("(?i)room\\s*number\\s*房[号號]?\\s*[::]\\s*(\\d{3,5})\\b"), + Pattern.compile("(?i)room\\s*number(?![^\\n]{0,12}\\bof\\b)\\s*[::]\\s*(\\d{3,5})\\b"), + Pattern.compile("(?i)room\\s*no\\.?\\s*房[号號]?\\s*[::]\\s*(\\d{3,5})\\b"), + Pattern.compile("(?i)room\\s*(?:no\\.?\\b|#\\b|number\\b)[^\\n\\r]{0,40}?\\b(\\d{3,5})\\b"), + Pattern.compile("(?i)assigned\\s+room\\s*[::]?\\s*(\\d{3,5})\\b"), + Pattern.compile("(?i)(? confirmation = RegistrationConfirmationExtractor.extractFromText(normalized); + String confirmationNo = confirmation.orElse(null); + String checkOut = extractDateByLabels(normalized, CHECK_OUT_LABELS).orElse(null); + String checkIn = extractDateByLabels(normalized, CHECK_IN_LABELS) + .or(() -> extractCheckInFallback(normalized, checkOut)) + .orElse(null); + String phone = normalizePhone(extractByLabels(normalized, PHONE_LABELS, 48).orElse(null)); + return RegistrationCardMeta.builder() + .guestName(extractGuestName(normalized).orElse(null)) + .confirmationNo(confirmationNo) + .phone(phone) + .email(extractEmail(normalized).orElse(null)) + .roomNo(extractRoomNo(normalized, confirmationNo, phone).orElse(null)) + .checkInDate(checkIn) + .checkOutDate(checkOut) + .build(); + } + + private static Optional extractGuestName(String text) { + Optional nextLine = extractNextLineAfterLabel(text, GUEST_LABELS, 80); + if (nextLine.isPresent()) { + return nextLine; + } + Optional inline = extractByLabels(text, GUEST_LABELS, 80); + if (inline.isPresent()) { + return inline; + } + Optional title = extractFirst(TITLE_NAME, text, 1); + if (title.isPresent() && isPlausibleGuestName(title.get())) { + return title; + } + Optional slash = extractFirst(SLASH_NAME, text, 1); + if (slash.isPresent() && isPlausibleGuestName(slash.get())) { + return slash; + } + Optional cjk = extractFirst(CJK_NAME, text, 1); + if (cjk.isPresent()) { + return cjk; + } + return extractGuestNameNearConfirmation(text); + } + + private static Optional extractGuestNameNearConfirmation(String text) { + Optional confNo = RegistrationConfirmationExtractor.extractFromText(text); + if (confNo.isEmpty()) { + return Optional.empty(); + } + int idx = text.indexOf(confNo.get()); + if (idx <= 0) { + return Optional.empty(); + } + String before = text.substring(Math.max(0, idx - 240), idx); + String[] lines = before.split("\\n"); + for (int i = lines.length - 1; i >= 0; i--) { + String line = cleanValue(lines[i]); + if (line == null || !isPlausibleGuestName(line)) { + continue; + } + if (line.matches("(?i)[A-Za-z][A-Za-z\\s.,'/\\-]{2,60}")) { + return Optional.of(line); + } + } + return Optional.empty(); + } + + private static Optional extractFirst(Pattern pattern, String text, int group) { + Matcher m = pattern.matcher(text); + if (m.find()) { + return Optional.ofNullable(cleanValue(m.group(group))); + } + return Optional.empty(); + } + + private static Optional extractRoomNo(String text, String confirmationNo, String phone) { + List candidates = new ArrayList<>(); + collectRoomCandidatesFromLabelLines(text, confirmationNo, phone, candidates); + collectRoomCandidatesFromLabeledText(text, confirmationNo, phone, candidates); + collectRoomCandidatesFromPatterns(text, confirmationNo, phone, candidates); + return candidates.stream() + .max(Comparator.comparingInt(RoomCandidate::score)) + .map(RoomCandidate::value); + } + + private static void collectRoomCandidatesFromLabelLines( + String text, String confirmationNo, String phone, List candidates) { + String[] lines = text.split("\\n"); + for (int i = 0; i < lines.length; i++) { + if (!isRoomNumberLabelLine(lines[i])) { + continue; + } + addRoomCandidate(candidates, parseRoomToken(lines[i], confirmationNo, phone, true), 80); + collectRoomCandidatesAfterLabel(lines, i, confirmationNo, phone, candidates); + } + } + + private static void collectRoomCandidatesAfterLabel( + String[] lines, int labelIndex, String confirmationNo, String phone, List candidates) { + for (int j = labelIndex + 1; j < Math.min(lines.length, labelIndex + 10); j++) { + String candidate = lines[j].trim(); + if (StringUtils.isBlank(candidate) || isNonRoomMetaLine(candidate)) { + continue; + } + Matcher standalone = STANDALONE_ROOM_LINE.matcher(candidate); + if (standalone.matches()) { + addRoomCandidate( + candidates, + plausibleRoomOrNull(standalone.group(1), confirmationNo, phone), + 320); + continue; + } + addRoomCandidate( + candidates, parseRoomToken(candidate, confirmationNo, phone, true), 120); + } + } + + private static void collectRoomCandidatesFromLabeledText( + String text, String confirmationNo, String phone, List candidates) { + Optional nextLine = extractLabeledValueOnNextLine(text, ROOM_LABELS, 64); + nextLine.ifPresent(v -> addRoomCandidate( + candidates, parseRoomToken(v, confirmationNo, phone, true), 200)); + Optional block = extractRoomBlockAfterLabel(text, ROOM_LABELS, confirmationNo, phone); + block.ifPresent(v -> addRoomCandidate(candidates, v, 180)); + Optional inline = extractByRoomLabels(text, ROOM_LABELS, confirmationNo, phone); + inline.ifPresent(v -> addRoomCandidate(candidates, v, 160)); + } + + private static void collectRoomCandidatesFromPatterns( + String text, String confirmationNo, String phone, List candidates) { + for (Pattern pattern : ROOM_NUMBER_PATTERNS) { + Matcher m = pattern.matcher(text); + while (m.find()) { + addRoomCandidate( + candidates, + plausibleRoomOrNull(m.group(1), confirmationNo, phone), + 100); + } + } + for (Pattern pattern : ROOM_TYPE_PATTERNS) { + Matcher m = pattern.matcher(text); + if (m.find()) { + addRoomCandidate( + candidates, + parseRoomToken(m.group(1), confirmationNo, phone, false), + 30); + } + } + extractByLabels(text, ROOM_FALLBACK_LABELS, 48) + .map(v -> parseRoomToken(v, confirmationNo, phone, false)) + .ifPresent(v -> addRoomCandidate(candidates, v, 20)); + } + + private static String plausibleRoomOrNull(String digits, String confirmationNo, String phone) { + if (!isPlausibleRoomDigits(digits, confirmationNo, phone, true)) { + return null; + } + return digits; + } + + private static void addRoomCandidate(List candidates, String room, int bonus) { + if (StringUtils.isBlank(room)) { + return; + } + candidates.add(new RoomCandidate(room, roomDigitScore(room) + bonus)); + } + + private static final class RoomCandidate { + private final String value; + private final int score; + + private RoomCandidate(String value, int score) { + this.value = value; + this.score = score; + } + + private String value() { + return value; + } + + private int score() { + return score; + } + } + + private static Optional extractRoomBlockAfterLabel( + String text, String[] labels, String confirmationNo, String phone) { + for (String label : labels) { + Pattern p = Pattern.compile("(?i)" + label + "[^\\n]{0,48}\\n((?:\\s*[^\\n]+\\n?){1,3})"); + Matcher m = p.matcher(text); + if (m.find()) { + String block = m.group(1); + String best = null; + int bestScore = Integer.MIN_VALUE; + for (String line : block.split("\\n")) { + if (isNonRoomMetaLine(line)) { + continue; + } + String trimmed = line.trim(); + Matcher standalone = STANDALONE_ROOM_LINE.matcher(trimmed); + if (standalone.matches()) { + String room = plausibleRoomOrNull(standalone.group(1), confirmationNo, phone); + if (room != null) { + int score = roomDigitScore(room) + 300; + if (score > bestScore) { + bestScore = score; + best = room; + } + } + continue; + } + String room = parseRoomToken(line, confirmationNo, phone, true); + if (StringUtils.isNotBlank(room)) { + int score = roomDigitScore(room); + if (score > bestScore) { + bestScore = score; + best = room; + } + } + } + if (best != null) { + return Optional.of(best); + } + } + } + return Optional.empty(); + } + + private static boolean isNonRoomMetaLine(String line) { + String lower = line.toLowerCase(); + return lower.contains("room rate") + || lower.contains("room charge") + || lower.contains("room type") + || lower.contains("arrival") + || lower.contains("departure") + || lower.contains("guest") + || lower.contains("payment") + || lower.contains("confirmation") + || lower.contains("conf.") + || lower.contains("email") + || lower.contains("phone") + || lower.contains("telephone") + || lower.contains("mobile") + || lower.contains("tel") + || isPhoneLikeLine(line) + || line.contains("房价") + || line.contains("房型") + || line.contains("入住") + || line.contains("退房") + || line.contains("付款") + || line.contains("確認") + || line.contains("确认") + || line.contains("人數") + || line.contains("人数") + || isAmountOrRateLine(line); + } + + private static boolean isRoomNumberLabelLine(String line) { + String lower = line.toLowerCase(); + if (lower.contains("room type") || line.contains("房型")) { + return false; + } + return lower.contains("room number") || lower.contains("room no") || line.contains("房號") + || line.contains("房号"); + } + + /** 标签与冒号之间可有中文(如 Room Number房號: 0202) */ + private static Optional extractByRoomLabels( + String text, String[] labels, String confirmationNo, String phone) { + for (String label : labels) { + Pattern inlineDigits = Pattern.compile( + "(?i)" + label + "(?:[^\\n::\\d]{0,24})[::]?\\s*(\\d{3,5})\\b"); + Matcher digitMatch = inlineDigits.matcher(text); + if (digitMatch.find()) { + String room = plausibleRoomOrNull(digitMatch.group(1), confirmationNo, phone); + if (StringUtils.isNotBlank(room)) { + return Optional.of(room); + } + } + Pattern inlineValue = Pattern.compile( + "(?i)" + label + "(?:[^\\n::]{0,24})[::]?\\s*([^\\n\\r]{1,64})"); + Matcher valueMatch = inlineValue.matcher(text); + if (valueMatch.find()) { + String room = parseRoomToken(valueMatch.group(1), confirmationNo, phone, true); + if (StringUtils.isNotBlank(room)) { + return Optional.of(room); + } + } + } + return Optional.empty(); + } + + private static String parseRoomToken(String raw, String confirmationNo, String phone, boolean labeledContext) { + if (StringUtils.isBlank(raw)) { + return null; + } + String v = cleanValue(raw); + if (v == null || isDateLikeLine(v) || isPhoneLikeLine(v) || isAmountOrRateLine(v)) { + return null; + } + Matcher afterSlash = Pattern.compile("/\\s*(\\d{3,5})\\b").matcher(v); + if (afterSlash.find()) { + String room = afterSlash.group(1); + if (isPlausibleRoomDigits(room, confirmationNo, phone, labeledContext)) { + return room; + } + } + if (v.matches("\\d{3,5}")) { + return isPlausibleRoomDigits(v, confirmationNo, phone, labeledContext) ? v : null; + } + Matcher m = Pattern.compile("\\b(\\d{3,5})\\b").matcher(v); + String best = null; + int bestScore = Integer.MIN_VALUE; + while (m.find()) { + String cand = m.group(1); + if (!isPlausibleRoomDigits(cand, confirmationNo, phone, labeledContext)) { + continue; + } + int score = roomDigitScore(cand); + if (score > bestScore) { + bestScore = score; + best = cand; + } + } + return best; + } + + private static boolean isAmountOrRateLine(String line) { + if (StringUtils.isBlank(line)) { + return false; + } + String lower = line.toLowerCase(); + if (lower.contains("rate") + || lower.contains("charge") + || lower.contains("amount") + || lower.contains("total") + || lower.contains("balance") + || lower.contains("folio") + || line.contains("房价") + || line.contains("房價") + || line.contains("费用") + || line.contains("費用") + || line.contains("金额") + || line.contains("金額")) { + return true; + } + return AMOUNT_LINE.matcher(line).find(); + } + + private static boolean isPhoneLikeLine(String v) { + if (StringUtils.isBlank(v)) { + return false; + } + String lower = v.toLowerCase(); + if (lower.contains("phone") + || lower.contains("telephone") + || lower.contains("mobile") + || lower.contains("tel") + || v.contains("電話") + || v.contains("电话")) { + return true; + } + return PHONE_LIKE.matcher(v).find(); + } + + static boolean isPhoneDigitFragment(String digits, String phone) { + if (StringUtils.isBlank(digits) || StringUtils.isBlank(phone) || !digits.matches("\\d{3,5}")) { + return false; + } + String compactPhone = phone.replaceAll("\\D", ""); + return compactPhone.length() >= 7 && compactPhone.contains(digits); + } + + private static boolean isDateLikeLine(String v) { + return DATE_TOKEN.matcher(v).matches() + || v.matches("(?i).*(?:arrival|departure|check[-\\s]*in|check[-\\s]*out|入住|退房).*"); + } + + private static int roomDigitScore(String digits) { + int len = digits.length(); + if (len == 4 && digits.startsWith("0")) { + return 120 + len; + } + if (len >= 3 && len <= 5) { + return 100 + len; + } + if (len == 2) { + return 40; + } + return 10; + } + + private static boolean isPlausibleRoomDigits( + String digits, String confirmationNo, String phone, boolean labeledContext) { + if (StringUtils.isBlank(digits) || !digits.matches("\\d{3,5}")) { + return false; + } + if (StringUtils.isNotBlank(confirmationNo) && confirmationNo.equals(digits)) { + return false; + } + if (StringUtils.isNotBlank(confirmationNo) + && confirmationNo.contains(digits) + && digits.length() >= 3) { + return false; + } + if (isPhoneDigitFragment(digits, phone)) { + return false; + } + return true; + } + + private static Optional extractEmail(String text) { + for (String label : EMAIL_LABELS) { + Pattern inline = Pattern.compile("(?is)" + label + "\\s*[::]?\\s*" + EMAIL_VALUE.pattern()); + Matcher inlineMatch = inline.matcher(text); + if (inlineMatch.find()) { + return Optional.of(normalizeEmail(inlineMatch.group(1))); + } + Pattern nextLine = Pattern.compile( + "(?is)" + label + "[^\\n@]{0,48}\\n\\s*(" + EMAIL_VALUE.pattern() + ")"); + Matcher nextLineMatch = nextLine.matcher(text); + if (nextLineMatch.find()) { + return Optional.of(normalizeEmail(nextLineMatch.group(1))); + } + } + Matcher any = EMAIL_VALUE.matcher(text); + if (any.find()) { + return Optional.of(normalizeEmail(any.group(1))); + } + return Optional.empty(); + } + + private static String normalizeEmail(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + String v = raw.trim().toLowerCase(); + v = v.replaceAll("[,;]+$", ""); + return EMAIL_VALUE.matcher(v).matches() ? v : null; + } + + private static Optional extractLabeledValueOnNextLine(String text, String[] labels, int maxLen) { + for (String label : labels) { + Pattern p = Pattern.compile( + "(?is)" + label + "(?:[^\\n::]{0,24})[::]?[^\\n]{0,40}\\n\\s*([^\\n]{1," + maxLen + "})"); + Matcher m = p.matcher(text); + if (m.find()) { + String v = cleanValue(m.group(1)); + if (StringUtils.isNotBlank(v)) { + return Optional.of(v); + } + } + Pattern simple = Pattern.compile("(?is)" + label + "[^\\n]{0,40}\\n\\s*([^\\n]{1," + maxLen + "})"); + Matcher simpleMatch = simple.matcher(text); + if (simpleMatch.find()) { + String v = cleanValue(simpleMatch.group(1)); + if (StringUtils.isNotBlank(v)) { + return Optional.of(v); + } + } + } + return Optional.empty(); + } + + private static String normalizeRoomNo(String raw) { + return parseRoomToken(raw, null, null, false); + } + + private static String normalizePhone(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + String v = raw.trim().replaceAll("(?i)^[@\\s]*(?:lx|tel|phone)[::.\\s]*", "").trim(); + Matcher m = Pattern.compile("(\\+?\\d[\\d\\s\\-().]{6,18}\\d)").matcher(v); + if (m.find()) { + return cleanValue(m.group(1)); + } + return cleanValue(v); + } + + private static Optional extractNextLineAfterLabel(String text, String[] labels, int maxLen) { + for (String label : labels) { + Pattern p = Pattern.compile("(?is)" + label + "[^\\n]{0,40}\\n\\s*([^\\n]{2," + maxLen + "})"); + Matcher m = p.matcher(text); + if (m.find()) { + String v = cleanValue(m.group(1)); + if (isPlausibleGuestName(v)) { + return Optional.of(v); + } + } + } + return Optional.empty(); + } + + private static Optional extractByLabels(String text, String[] labels, int maxLen) { + for (String label : labels) { + Pattern p = Pattern.compile("(?is)" + label + "\\s*[::]?\\s*([^\\n\\r]{1," + maxLen + "})"); + Matcher m = p.matcher(text); + if (m.find()) { + String v = cleanValue(m.group(1)); + if (StringUtils.isNotBlank(v) && !looksLikeNextLabel(v)) { + return Optional.of(v); + } + } + } + return Optional.empty(); + } + + private static Optional extractDateByLabels(String text, String[] labels) { + for (String label : labels) { + Pattern p = Pattern.compile("(?is)" + label + "[^0-9]{0,40}" + DATE_TOKEN.pattern()); + Matcher m = p.matcher(text); + if (m.find()) { + return Optional.of(normalizeDateToken(m.group(1))); + } + } + return Optional.empty(); + } + + private static Optional extractCheckInFallback(String text, String checkOut) { + Matcher fuzzy = Pattern.compile("(?is)a\\w{0,8}val[^0-9]{0,48}" + DATE_TOKEN.pattern()).matcher(text); + if (fuzzy.find()) { + String date = normalizeDateToken(fuzzy.group(1)); + if (!date.equals(checkOut)) { + return Optional.of(date); + } + } + if (StringUtils.isBlank(checkOut)) { + return Optional.empty(); + } + List dates = collectDates(text); + if (dates.size() == 2) { + for (String date : dates) { + if (!date.equals(checkOut)) { + return Optional.of(date); + } + } + } + return Optional.empty(); + } + + private static List collectDates(String text) { + List dates = new ArrayList<>(); + Matcher m = DATE_TOKEN.matcher(text); + while (m.find()) { + String date = normalizeDateToken(m.group(1)); + if (!dates.contains(date)) { + dates.add(date); + } + } + return dates; + } + + private static String normalizeDateToken(String raw) { + if (raw == null) { + return null; + } + return raw.trim().replaceAll("\\s+", "-").replace('/', '-').replace('.', '-'); + } + + private static String cleanValue(String raw) { + if (raw == null) { + return null; + } + String v = raw.trim(); + v = v.replaceAll("\\s{2,}", " "); + v = v.replaceAll("[|_]+$", "").trim(); + return v.isEmpty() ? null : v; + } + + private static boolean isPlausibleGuestName(String v) { + if (StringUtils.isBlank(v) || v.length() < 2) { + return false; + } + if (DATE_TOKEN.matcher(v).matches()) { + return false; + } + if (v.matches("\\d{4,12}")) { + return false; + } + return !looksLikeNextLabel(v); + } + + private static boolean looksLikeNextLabel(String v) { + String lower = v.toLowerCase(); + return lower.contains("conf") + || lower.contains("phone") + || lower.contains("email") + || lower.contains("e-mail") + || lower.contains("room") + || lower.contains("number") + || lower.contains("arrival") + || lower.contains("departure") + || v.contains("確認") + || v.contains("确认") + || v.contains("电话") + || v.contains("電話") + || v.contains("房號") + || v.contains("房号") + || v.contains("入住") + || v.contains("退房"); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardResponseParser.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardResponseParser.java new file mode 100644 index 0000000..3273da3 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardResponseParser.java @@ -0,0 +1,117 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import org.apache.commons.lang3.StringUtils; + +import java.util.Base64; +import java.util.Optional; + +public final class RegistrationCardResponseParser { + + private RegistrationCardResponseParser() { + } + + public record PdfPayload(String pdfBase64, String fileName, String fileType) { + } + + public static Optional extract(String body) { + if (StringUtils.isBlank(body)) { + return Optional.empty(); + } + String trimmed = body.trim(); + if (trimmed.startsWith("%PDF")) { + return Optional.of(new PdfPayload( + Base64.getEncoder().encodeToString(trimmed.getBytes()), + "registration-card.pdf", + "PDF")); + } + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + Object parsed = JSON.parse(trimmed); + Optional fromJson = findInNode(parsed); + if (fromJson.isPresent()) { + return fromJson; + } + } catch (Exception ignored) { + // fall through + } + } + if (looksLikeBase64(trimmed)) { + return Optional.of(new PdfPayload(trimmed, "registration-card.pdf", "PDF")); + } + return Optional.empty(); + } + + private static Optional findInNode(Object node) { + if (node instanceof JSONObject obj) { + String fileData = firstNonBlank( + obj.getString("fileData"), + obj.getString("pdf"), + obj.getString("content"), + obj.getString("registrationCard")); + if (StringUtils.isNotBlank(fileData) && looksLikeBase64OrPdfMarker(fileData)) { + return Optional.of(buildPayload(obj, normalizeBase64(fileData))); + } + for (String key : obj.keySet()) { + Optional nested = findInNode(obj.get(key)); + if (nested.isPresent()) { + return nested; + } + } + } else if (node instanceof JSONArray arr) { + for (int i = 0; i < arr.size(); i++) { + Optional nested = findInNode(arr.get(i)); + if (nested.isPresent()) { + return nested; + } + } + } + return Optional.empty(); + } + + private static PdfPayload buildPayload(JSONObject obj, String pdfBase64) { + String fileName = firstNonBlank( + obj.getString("fileName"), + obj.getString("filename"), + "registration-card.pdf"); + String fileType = StringUtils.defaultIfBlank( + firstNonBlank(obj.getString("fileType"), obj.getString("mimeType")), + "PDF"); + return new PdfPayload(pdfBase64, fileName, fileType); + } + + private static String normalizeBase64(String value) { + String trimmed = value.trim(); + int comma = trimmed.indexOf(','); + if (trimmed.startsWith("data:") && comma > 0) { + return trimmed.substring(comma + 1).trim(); + } + return trimmed; + } + + private static boolean looksLikeBase64OrPdfMarker(String value) { + if (value.startsWith("%PDF") || value.startsWith("data:")) { + return true; + } + return looksLikeBase64(value); + } + + private static boolean looksLikeBase64(String value) { + if (StringUtils.isBlank(value) || value.length() < 32) { + return false; + } + String sample = value.length() > 256 ? value.substring(0, 256) : value; + return sample.matches("(?s)[A-Za-z0-9+/=\\r\\n-_.]+"); + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (StringUtils.isNotBlank(value)) { + return value.trim(); + } + } + return null; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardResult.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardResult.java new file mode 100644 index 0000000..94309d4 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardResult.java @@ -0,0 +1,55 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class RegistrationCardResult { + + private boolean invoked; + private boolean success; + private int statusCode; + private String errorMessage; + private String fileName; + private String fileType; + private String pdfBase64; + private String responseSummary; + + public static RegistrationCardResult skipped(String reason) { + RegistrationCardResult r = new RegistrationCardResult(); + r.setInvoked(false); + r.setSuccess(false); + r.setErrorMessage(reason); + return r; + } + + public static RegistrationCardResult failure(int statusCode, String summary, String errorMessage) { + RegistrationCardResult r = new RegistrationCardResult(); + r.setInvoked(true); + r.setSuccess(false); + r.setStatusCode(statusCode); + r.setResponseSummary(summary); + r.setErrorMessage(errorMessage); + return r; + } + + public static RegistrationCardResult success( + int statusCode, + String fileName, + String fileType, + String pdfBase64, + String responseSummary) { + RegistrationCardResult r = new RegistrationCardResult(); + r.setInvoked(true); + r.setSuccess(true); + r.setStatusCode(statusCode); + r.setFileName(fileName); + r.setFileType(fileType); + r.setPdfBase64(pdfBase64); + r.setResponseSummary(responseSummary); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardSignFieldParser.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardSignFieldParser.java new file mode 100644 index 0000000..d8a74da --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/RegistrationCardSignFieldParser.java @@ -0,0 +1,71 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.sjmresorts.opsapp.esign.printer.models.RegistrationCardSignFieldDefaults; +import org.apache.commons.lang3.StringUtils; + +import java.util.ArrayList; +import java.util.List; + +/** 解析 App 提交的签名热区 JSON(与 registrationCardSignFields.js 结构一致)。 */ +public final class RegistrationCardSignFieldParser { + + private RegistrationCardSignFieldParser() { + } + + public static List parse(String signFieldsJson) { + if (StringUtils.isBlank(signFieldsJson)) { + return RegistrationCardSignFieldDefaults.defaultFields(); + } + try { + JSONArray arr = JSON.parseArray(signFieldsJson.trim()); + if (arr == null || arr.isEmpty()) { + return RegistrationCardSignFieldDefaults.defaultFields(); + } + List fields = new ArrayList<>(); + for (int i = 0; i < arr.size(); i++) { + JSONObject obj = arr.getJSONObject(i); + if (obj == null) { + continue; + } + String id = StringUtils.defaultIfBlank(obj.getString("id"), "guest"); + int page = obj.getIntValue("page", 0); + double left = obj.getDoubleValue("left"); + double top = obj.getDoubleValue("top"); + double width = obj.getDoubleValue("width"); + double height = obj.getDoubleValue("height"); + if (width <= 0 || height <= 0) { + continue; + } + String type = StringUtils.defaultIfBlank(obj.getString("type"), "signature"); + double fontSize = obj.getDoubleValue("fontSize"); + String orientation = StringUtils.trimToNull(obj.getString("orientation")); + if (orientation == null) { + orientation = StringUtils.trimToNull(obj.getString("rotate")); + } + if (orientation == null) { + orientation = StringUtils.trimToNull(obj.getString("rotation")); + } + if (orientation != null && !"landscape".equalsIgnoreCase(orientation) + && !"portrait".equalsIgnoreCase(orientation)) { + // 兼容数值角度:90/-90 视为横签热区 + if ("90".equals(orientation) || "-90".equals(orientation)) { + orientation = "landscape"; + } else { + orientation = null; + } + } + fields.add(new SignedRegistrationPdfComposer.SignFieldRect( + id, type, page, left, top, width, height, fontSize, orientation)); + } + if (fields.isEmpty()) { + return RegistrationCardSignFieldDefaults.defaultFields(); + } + return fields; + } catch (Exception e) { + return RegistrationCardSignFieldDefaults.defaultFields(); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/SignedRegistrationPdfComposer.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/SignedRegistrationPdfComposer.java new file mode 100644 index 0000000..7a635ec --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/dtos/SignedRegistrationPdfComposer.java @@ -0,0 +1,336 @@ +package com.sjmresorts.opsapp.esign.printer.dtos; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import org.apache.commons.lang3.StringUtils; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.PDPageContentStream; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.graphics.image.PDImageXObject; +import org.apache.pdfbox.util.Matrix; + +import org.apache.pdfbox.pdmodel.font.PDFont; +import org.apache.pdfbox.pdmodel.font.PDType0Font; +import org.apache.pdfbox.pdmodel.font.PDType1Font; +import org.apache.pdfbox.pdmodel.font.Standard14Fonts; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.util.Base64; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 将签名 PNG 按热区比例叠加到原 PDF,生成可上传的签名 PDF。 + * 热区坐标与 App/pdf.js 一致:相对「旋转后可视页面」宽高比例,原点在左上角。 + */ +public final class SignedRegistrationPdfComposer { + + public record SignFieldRect( + String id, + String type, + int page, + double left, + double top, + double width, + double height, + double fontSize, + String orientation) { + + public SignFieldRect( + String id, + String type, + int page, + double left, + double top, + double width, + double height, + double fontSize) { + this(id, type, page, left, top, width, height, fontSize, null); + } + + public boolean isTextField() { + return "text".equalsIgnoreCase(type); + } + + public boolean isLandscape() { + return "landscape".equalsIgnoreCase(StringUtils.trimToEmpty(orientation)); + } + } + + private SignedRegistrationPdfComposer() { + } + + public static String composeBase64(String pdfBase64, String signaturesJson, List fields) { + if (StringUtils.isBlank(pdfBase64)) { + throw new IllegalArgumentException("PDF base64 required"); + } + Map signatures = parseSignatures(signaturesJson); + if (signatures.isEmpty()) { + return normalizeBase64(pdfBase64); + } + byte[] pdfBytes = Base64.getDecoder().decode(normalizeBase64(pdfBase64)); + try (PDDocument doc = Loader.loadPDF(pdfBytes); + ByteArrayOutputStream out = new ByteArrayOutputStream()) { + PDFont textFont = null; + for (SignFieldRect field : fields) { + String value = signatures.get(field.id()); + if (StringUtils.isBlank(value)) { + continue; + } + int pageIndex = Math.max(0, field.page()); + if (pageIndex >= doc.getNumberOfPages()) { + continue; + } + PDPage page = doc.getPage(pageIndex); + if (field.isTextField() || !looksLikeImageBase64(value)) { + if (textFont == null) { + textFont = resolveTextFont(doc); + } + try (PDPageContentStream cs = new PDPageContentStream( + doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) { + drawFieldText(cs, page, field, value, textFont); + } + continue; + } + byte[] imageBytes = decodeImageBase64(value); + if (imageBytes.length == 0) { + continue; + } + PDImageXObject image = PDImageXObject.createFromByteArray(doc, imageBytes, field.id()); + try (PDPageContentStream cs = new PDPageContentStream( + doc, page, PDPageContentStream.AppendMode.APPEND, true, true)) { + drawFieldImage(cs, page, field, image); + } + } + doc.save(out); + return Base64.getEncoder().encodeToString(out.toByteArray()); + } catch (Exception e) { + throw new IllegalStateException("Failed to compose signed PDF: " + e.getMessage(), e); + } + } + + /** + * 与 App renderjs / pdf.js viewport 一致:热区为「可视页面」宽高比例,原点在左上角。 + * 虚拟打印机 PDF 常带 /Rotate,必须把可视坐标映射到 PDF 用户空间,否则会出现错位和横变竖。 + */ + private static void drawFieldImage( + PDPageContentStream cs, + PDPage page, + SignFieldRect field, + PDImageXObject image) throws IOException { + VisualRect rect = resolveVisualRect(page, field); + cs.saveGraphicsState(); + cs.transform(visualToUserSpaceMatrix(page)); + if (field.isLandscape()) { + // 与 App applyFieldContentOrientation 一致:内容相对热区旋转 -90° + float cx = rect.x + rect.w / 2f; + float cy = rect.yFromBottom + rect.h / 2f; + cs.transform(Matrix.getTranslateInstance(cx, cy)); + cs.transform(Matrix.getRotateInstance(Math.toRadians(-90), 0, 0)); + cs.drawImage(image, -rect.h / 2f, -rect.w / 2f, rect.h, rect.w); + } else { + cs.drawImage(image, rect.x, rect.yFromBottom, rect.w, rect.h); + } + cs.restoreGraphicsState(); + } + + private static void drawFieldText( + PDPageContentStream cs, + PDPage page, + SignFieldRect field, + String text, + PDFont font) throws IOException { + VisualRect rect = resolveVisualRect(page, field); + float fontSize; + if (field.fontSize() > 0) { + fontSize = (float) Math.max(6, Math.min(field.fontSize(), 28)); + } else { + fontSize = Math.max(6f, Math.min(rect.h * 0.72f, 14f)); + } + String safe = StringUtils.defaultString(text).trim(); + if (safe.isEmpty()) { + return; + } + cs.saveGraphicsState(); + cs.transform(visualToUserSpaceMatrix(page)); + float padX = Math.max(2f, Math.min(6f, rect.w * 0.04f)); + float padY = Math.max(2f, Math.min(6f, rect.h * 0.12f)); + cs.setNonStrokingColor(1f, 1f, 1f); + cs.addRect(rect.x - padX, rect.yFromBottom - padY, rect.w + padX * 2f, rect.h + padY * 2f); + cs.fill(); + cs.setNonStrokingColor(0f, 0f, 0f); + if (field.isLandscape()) { + float cx = rect.x + rect.w / 2f; + float cy = rect.yFromBottom + rect.h / 2f; + cs.transform(Matrix.getTranslateInstance(cx, cy)); + cs.transform(Matrix.getRotateInstance(Math.toRadians(-90), 0, 0)); + float textW = rect.h; + float textY = -fontSize * 0.35f; + cs.setFont(font, fontSize); + cs.beginText(); + cs.newLineAtOffset(-textW / 2f + 2f, textY); + cs.showText(truncateToWidth(font, safe, fontSize, textW - 4f)); + cs.endText(); + } else { + float textY = rect.yFromBottom + Math.max(1f, (rect.h - fontSize) * 0.35f); + cs.setFont(font, fontSize); + cs.beginText(); + cs.newLineAtOffset(rect.x + 2f, textY); + cs.showText(truncateToWidth(font, safe, fontSize, rect.w - 4f)); + cs.endText(); + } + cs.restoreGraphicsState(); + } + + private static VisualRect resolveVisualRect(PDPage page, SignFieldRect field) { + PDRectangle crop = page.getCropBox(); + int rot = normalizeRotation(page.getRotation()); + float cropW = crop.getWidth(); + float cropH = crop.getHeight(); + float pageW = (rot == 90 || rot == 270) ? cropH : cropW; + float pageH = (rot == 90 || rot == 270) ? cropW : cropH; + float x = (float) (field.left() * pageW); + float w = (float) (field.width() * pageW); + float h = (float) (field.height() * pageH); + float yFromTop = (float) (field.top() * pageH); + float yFromBottom = pageH - yFromTop - h; + return new VisualRect(x, yFromBottom, w, h); + } + + /** + * 将「可视直立页面」坐标(原点左下、x 右、y 上)映射到 PDF 用户空间, + * 与页面 /Rotate 配套,使绘制内容随页面一起被阅读器旋转后位置正确。 + */ + private static Matrix visualToUserSpaceMatrix(PDPage page) { + PDRectangle crop = page.getCropBox(); + float llx = crop.getLowerLeftX(); + float lly = crop.getLowerLeftY(); + float cropW = crop.getWidth(); + float cropH = crop.getHeight(); + int rot = normalizeRotation(page.getRotation()); + return switch (rot) { + case 90 -> new Matrix(0, 1, -1, 0, llx + cropW, lly); + case 180 -> new Matrix(-1, 0, 0, -1, llx + cropW, lly + cropH); + case 270 -> new Matrix(0, -1, 1, 0, llx, lly + cropH); + default -> new Matrix(1, 0, 0, 1, llx, lly); + }; + } + + private static int normalizeRotation(int rotation) { + int rot = rotation % 360; + if (rot < 0) { + rot += 360; + } + if (rot == 90 || rot == 180 || rot == 270) { + return rot; + } + return 0; + } + + private record VisualRect(float x, float yFromBottom, float w, float h) { + } + + private static String truncateToWidth(PDFont font, String text, float fontSize, float maxWidth) + throws IOException { + if (maxWidth <= 0) { + return ""; + } + int end = text.length(); + while (end > 0) { + String part = text.substring(0, end); + float width = font.getStringWidth(part) / 1000f * fontSize; + if (width <= maxWidth) { + return part; + } + end--; + } + return ""; + } + + private static PDFont resolveTextFont(PDDocument doc) { + String[] candidates = { + "C:/Windows/Fonts/msyh.ttc", + "C:/Windows/Fonts/msyhbd.ttc", + "C:/Windows/Fonts/simhei.ttf", + "C:/Windows/Fonts/arial.ttf", + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/System/Library/Fonts/PingFang.ttc" + }; + for (String path : candidates) { + File file = new File(path); + if (!file.isFile()) { + continue; + } + try { + return PDType0Font.load(doc, file); + } catch (IOException ignored) { + // try next font + } + } + return new PDType1Font(Standard14Fonts.FontName.HELVETICA); + } + + public static boolean isImageSignatureValue(String value) { + return looksLikeImageBase64(value); + } + + private static boolean looksLikeImageBase64(String raw) { + if (StringUtils.isBlank(raw)) { + return false; + } + String value = raw.trim(); + if (value.startsWith("data:image")) { + return true; + } + String normalized = value.replaceAll("\\s", ""); + return normalized.length() > 200 && normalized.matches("^[A-Za-z0-9+/=]+$"); + } + + private static Map parseSignatures(String signaturesJson) { + Map map = new LinkedHashMap<>(); + if (StringUtils.isBlank(signaturesJson)) { + return map; + } + try { + JSONObject obj = JSON.parseObject(signaturesJson); + if (obj == null) { + return map; + } + for (String key : obj.keySet()) { + String value = obj.getString(key); + if (StringUtils.isNotBlank(value)) { + map.put(key, value); + } + } + } catch (Exception ignored) { + // ignore malformed json + } + return map; + } + + private static byte[] decodeImageBase64(String raw) { + if (StringUtils.isBlank(raw)) { + return new byte[0]; + } + try { + return Base64.getDecoder().decode(normalizeBase64(raw)); + } catch (IllegalArgumentException e) { + return new byte[0]; + } + } + + private static String normalizeBase64(String raw) { + String trimmed = raw.trim(); + int comma = trimmed.indexOf(','); + if (trimmed.startsWith("data:") && comma > 0) { + return trimmed.substring(comma + 1).trim(); + } + return trimmed.replaceAll("\\s", ""); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/filters/EsignPrinterPdfArchiveFilter.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/filters/EsignPrinterPdfArchiveFilter.java new file mode 100644 index 0000000..e94657c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/filters/EsignPrinterPdfArchiveFilter.java @@ -0,0 +1,47 @@ +package com.sjmresorts.opsapp.esign.printer.filters; + +import lombok.Builder; +import lombok.Value; +import org.apache.commons.lang3.StringUtils; + +@Value +@Builder +public class EsignPrinterPdfArchiveFilter { + String guestName; + String confirmationNo; + String reservationId; + String phone; + String roomNo; + String checkInDate; + String checkOutDate; + String fileName; + /** ALL | SIGNED | COMPLETED | OCR_FAILED */ + String status; + + public static EsignPrinterPdfArchiveFilter of( + String guestName, + String confirmationNo, + String reservationId, + String phone, + String roomNo, + String checkInDate, + String checkOutDate, + String fileName, + String status) { + return EsignPrinterPdfArchiveFilter.builder() + .guestName(trim(guestName)) + .confirmationNo(trim(confirmationNo)) + .reservationId(trim(reservationId)) + .phone(trim(phone)) + .roomNo(trim(roomNo)) + .checkInDate(trim(checkInDate)) + .checkOutDate(trim(checkOutDate)) + .fileName(trim(fileName)) + .status(trim(status)) + .build(); + } + + private static String trim(String v) { + return StringUtils.isBlank(v) ? null : v.trim(); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterDeviceLink.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterDeviceLink.java new file mode 100644 index 0000000..526d22e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterDeviceLink.java @@ -0,0 +1,74 @@ +package com.sjmresorts.opsapp.esign.printer.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_printer_device_link") +public class EsignPrinterDeviceLink { + + @Id + @Column(name = "link_id", nullable = false, length = 36) + private String linkId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "agent_source", nullable = false, length = 256) + private String agentSource; + + @Column(name = "app_label", length = 128) + private String appLabel; + + @Column(name = "pc_display_name", length = 128) + private String pcDisplayName; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @Column(name = "requested_at", nullable = false) + private Instant requestedAt; + + @Column(name = "reviewed_at") + private Instant reviewedAt; + + @Column(name = "reviewed_by", length = 64) + private String reviewedBy; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + if (requestedAt == null) { + requestedAt = now; + } + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterEventLog.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterEventLog.java new file mode 100644 index 0000000..c890912 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterEventLog.java @@ -0,0 +1,49 @@ +package com.sjmresorts.opsapp.esign.printer.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_printer_event_logs") +public class EsignPrinterEventLog { + + @Id + @Column(name = "log_id", nullable = false, length = 36) + private String logId; + + @Column(name = "task_id", length = 36) + private String taskId; + + @Column(name = "event_type", nullable = false, length = 64) + private String eventType; + + @Column(name = "message", length = 512) + private String message; + + @Column(name = "file_name", length = 512) + private String fileName; + + @Column(name = "agent_source", length = 256) + private String agentSource; + + @Column(name = "hotel_id", length = 64) + private String hotelId; + + @Lob + @Column(name = "detail_json") + private String detailJson; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPcRegistry.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPcRegistry.java new file mode 100644 index 0000000..b427d41 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPcRegistry.java @@ -0,0 +1,65 @@ +package com.sjmresorts.opsapp.esign.printer.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_printer_pc_registry") +public class EsignPrinterPcRegistry { + + @Id + @Column(name = "pc_id", nullable = false, length = 36) + private String pcId; + + @Column(name = "display_name", nullable = false, length = 128) + private String displayName; + + @Column(name = "host_ip", nullable = false, length = 64) + private String hostIp; + + @Column(name = "agent_source", nullable = false, length = 256) + private String agentSource; + + @Column(name = "enabled", nullable = false) + private boolean enabled = true; + + @Column(name = "notes", length = 256) + private String notes; + + @Column(name = "created_by", length = 64) + private String createdBy; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPdfTask.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPdfTask.java new file mode 100644 index 0000000..3ad5e85 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPdfTask.java @@ -0,0 +1,132 @@ +package com.sjmresorts.opsapp.esign.printer.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_printer_pdf_tasks") +public class EsignPrinterPdfTask { + + @Id + @Column(name = "task_id", nullable = false, length = 36) + private String taskId; + + @Column(name = "external_id", nullable = false, length = 64, unique = true) + private String externalId; + + @Column(name = "file_name", nullable = false, length = 512) + private String fileName; + + @Column(name = "file_path", length = 1024) + private String filePath; + + @Column(name = "file_size") + private Long fileSize; + + @Lob + @Column(name = "pdf_base64", nullable = false) + private String pdfBase64; + + @Column(name = "hotel_id", length = 64) + private String hotelId; + + @Column(name = "agent_source", length = 256) + private String agentSource; + + @Column(name = "status", nullable = false, length = 32) + private String status; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "completed_at") + private Instant completedAt; + + @Lob + @Column(name = "signed_pdf_base64") + private String signedPdfBase64; + + @Lob + @Column(name = "signed_meta_json") + private String signedMetaJson; + + @Column(name = "signed_at") + private Instant signedAt; + + @Column(name = "signed_by_user_id") + private Long signedByUserId; + + @Column(name = "signed_client_ip", length = 64) + private String signedClientIp; + + @Column(name = "signed_device_id", length = 64) + private String signedDeviceId; + + @Column(name = "signed_device_name", length = 128) + private String signedDeviceName; + + @Column(name = "archived_at") + private Instant archivedAt; + + @Column(name = "ocr_guest_name", length = 256) + private String ocrGuestName; + + @Column(name = "ocr_confirmation_no", length = 64) + private String ocrConfirmationNo; + + @Column(name = "ocr_phone", length = 64) + private String ocrPhone; + + @Column(name = "ocr_email", length = 256) + private String ocrEmail; + + @Column(name = "ocr_room_no", length = 32) + private String ocrRoomNo; + + @Column(name = "ocr_check_in_date", length = 32) + private String ocrCheckInDate; + + @Column(name = "ocr_check_out_date", length = 32) + private String ocrCheckOutDate; + + @Column(name = "ocr_extracted_at") + private Instant ocrExtractedAt; + + /** PENDING | OK | FAILED | SKIPPED */ + @Column(name = "ocr_status", length = 16) + private String ocrStatus; + + /** PENDING | SUCCESS | FAILED | SKIPPED */ + @Column(name = "ohip_status", length = 16) + private String ohipStatus; + + @Column(name = "ohip_last_error", length = 512) + private String ohipLastError; + + @Column(name = "ohip_uploaded_at") + private Instant ohipUploadedAt; + + @Column(name = "ohip_confirmation_no", length = 64) + private String ohipConfirmationNo; + + @Column(name = "ohip_reservation_id", length = 64) + private String ohipReservationId; + + @Column(name = "ohip_retry_count") + private Integer ohipRetryCount; + + @Lob + @Column(name = "sign_fields_json") + private String signFieldsJson; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPdfTaskPush.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPdfTaskPush.java new file mode 100644 index 0000000..a03b92e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPdfTaskPush.java @@ -0,0 +1,53 @@ +package com.sjmresorts.opsapp.esign.printer.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_printer_pdf_task_push") +@IdClass(EsignPrinterPdfTaskPush.TaskPushId.class) +public class EsignPrinterPdfTaskPush { + + @Id + @Column(name = "task_id", nullable = false, length = 36) + private String taskId; + + @Id + @Column(name = "user_id", nullable = false) + private Long userId; + + /** 推送目标设备 IP;空字符串表示不限 IP(整账号可见,兼容旧数据) */ + @Id + @Column(name = "client_ip", nullable = false, length = 64) + private String clientIp = ""; + + @Column(name = "assigned_at", nullable = false) + private Instant assignedAt; + + @Column(name = "assigned_by", length = 64) + private String assignedBy; + + @Column(name = "group_id", length = 36) + private String groupId; + + @Getter + @Setter + @NoArgsConstructor + public static class TaskPushId implements Serializable { + private String taskId; + private Long userId; + private String clientIp; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPushGroup.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPushGroup.java new file mode 100644 index 0000000..806bd94 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPushGroup.java @@ -0,0 +1,53 @@ +package com.sjmresorts.opsapp.esign.printer.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_printer_push_group") +public class EsignPrinterPushGroup { + + @Id + @Column(name = "group_id", nullable = false, length = 36) + private String groupId; + + @Column(name = "group_name", nullable = false, length = 128) + private String groupName; + + @Column(name = "agent_source", nullable = false, length = 256) + private String agentSource; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPushGroupMember.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPushGroupMember.java new file mode 100644 index 0000000..d1e7a9f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPushGroupMember.java @@ -0,0 +1,37 @@ +package com.sjmresorts.opsapp.esign.printer.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_printer_push_group_member") +@IdClass(EsignPrinterPushGroupMember.MemberId.class) +public class EsignPrinterPushGroupMember { + + @Id + @Column(name = "group_id", nullable = false, length = 36) + private String groupId; + + @Id + @Column(name = "user_id", nullable = false) + private Long userId; + + @Getter + @Setter + @NoArgsConstructor + public static class MemberId implements Serializable { + private String groupId; + private Long userId; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPushRuleConfig.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPushRuleConfig.java new file mode 100644 index 0000000..3568c2c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPushRuleConfig.java @@ -0,0 +1,53 @@ +package com.sjmresorts.opsapp.esign.printer.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_printer_push_rule_config") +public class EsignPrinterPushRuleConfig { + + public static final String DEFAULT_ID = "default"; + public static final String MODE_AUTO = "AUTO"; + public static final String MODE_MANUAL = "MANUAL"; + + @Id + @Column(name = "config_id", nullable = false, length = 32) + private String configId; + + @Column(name = "push_mode", nullable = false, length = 16) + private String pushMode; + + @Column(name = "auto_send_fcm", nullable = false) + private boolean autoSendFcm = false; + + @Column(name = "per_agent_enabled", nullable = false) + private boolean perAgentEnabled = true; + + @Column(name = "unknown_agent_enabled", nullable = false) + private boolean unknownAgentEnabled = true; + + @Column(name = "fallback_enabled", nullable = false) + private boolean fallbackEnabled = true; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + @PreUpdate + protected void touch() { + updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPushRuleMapping.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPushRuleMapping.java new file mode 100644 index 0000000..0202b39 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterPushRuleMapping.java @@ -0,0 +1,54 @@ +package com.sjmresorts.opsapp.esign.printer.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_printer_push_rule_mapping") +public class EsignPrinterPushRuleMapping { + + public static final String SCOPE_PER_AGENT = "PER_AGENT"; + public static final String SCOPE_ALL_AGENTS = "ALL_AGENTS"; + public static final String SCOPE_UNKNOWN_AGENT = "UNKNOWN_AGENT"; + public static final String SCOPE_MANUAL_DEFAULT = "MANUAL_DEFAULT"; + + @Id + @Column(name = "mapping_id", nullable = false, length = 36) + private String mappingId; + + @Column(name = "scope_type", nullable = false, length = 32) + private String scopeType; + + @Column(name = "agent_source", length = 256) + private String agentSource; + + @Lob + @Column(name = "user_ids_json") + private String userIdsJson; + + @Lob + @Column(name = "group_ids_json") + private String groupIdsJson; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + @PreUpdate + protected void touch() { + updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterTabletRegistry.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterTabletRegistry.java new file mode 100644 index 0000000..6094247 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterTabletRegistry.java @@ -0,0 +1,69 @@ +package com.sjmresorts.opsapp.esign.printer.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_printer_tablet_registry") +public class EsignPrinterTabletRegistry { + + @Id + @Column(name = "registry_id", nullable = false, length = 36) + private String registryId; + + @Column(name = "user_name", nullable = false, length = 50) + private String userName; + + @Column(name = "user_id") + private Long userId; + + /** 绑定的 App 客户端 IP;空字符串表示不限制(整账号,不推荐多设备共用账号时使用) */ + @Column(name = "client_ip", nullable = false, length = 64) + private String clientIp = ""; + + @Column(name = "display_name", length = 128) + private String displayName; + + @Column(name = "enabled", nullable = false) + private boolean enabled = true; + + @Column(name = "notes", length = 256) + private String notes; + + @Column(name = "created_by", length = 64) + private String createdBy; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @PrePersist + protected void onCreate() { + Instant now = Instant.now(); + if (createdAt == null) { + createdAt = now; + } + if (updatedAt == null) { + updatedAt = now; + } + } + + @PreUpdate + protected void onUpdate() { + updatedAt = Instant.now(); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterTabletRegistryPc.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterTabletRegistryPc.java new file mode 100644 index 0000000..f42ca26 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/EsignPrinterTabletRegistryPc.java @@ -0,0 +1,40 @@ +package com.sjmresorts.opsapp.esign.printer.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_printer_tablet_registry_pc") +public class EsignPrinterTabletRegistryPc { + + @Id + @Column(name = "binding_id", nullable = false, length = 36) + private String bindingId; + + @Column(name = "registry_id", nullable = false, length = 36) + private String registryId; + + @Column(name = "agent_source", nullable = false, length = 256) + private String agentSource; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @PrePersist + protected void onCreate() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/models/RegistrationCardSignFieldDefaults.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/RegistrationCardSignFieldDefaults.java new file mode 100644 index 0000000..18b876d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/models/RegistrationCardSignFieldDefaults.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.esign.printer.models; + +import com.sjmresorts.opsapp.esign.printer.dtos.SignedRegistrationPdfComposer; + +import java.util.List; + +/** 与 App registrationCardSignFields.js 默认热区一致。 */ +public final class RegistrationCardSignFieldDefaults { + + private RegistrationCardSignFieldDefaults() { + } + + public static List defaultFields() { + return List.of(new SignedRegistrationPdfComposer.SignFieldRect( + "guest", "signature", 0, 0.06, 0.88, 0.44, 0.08, 0)); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterDeviceLinkRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterDeviceLinkRepository.java new file mode 100644 index 0000000..5807e99 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterDeviceLinkRepository.java @@ -0,0 +1,24 @@ +package com.sjmresorts.opsapp.esign.printer.repositories; + +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterDeviceLink; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface EsignPrinterDeviceLinkRepository extends JpaRepository { + + List findByStatusOrderByRequestedAtDesc(String status); + + List findByStatusInOrderByRequestedAtDesc(Collection statuses); + + List findByUserIdOrderByRequestedAtDesc(Long userId); + + List findByAgentSourceAndStatus(String agentSource, String status); + + List findByUserIdAndStatus(Long userId, String status); + + Optional findFirstByUserIdAndAgentSourceAndStatusIn( + Long userId, String agentSource, Collection statuses); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterEventLogRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterEventLogRepository.java new file mode 100644 index 0000000..bf08ea6 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterEventLogRepository.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.esign.printer.repositories; + +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterEventLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface EsignPrinterEventLogRepository extends JpaRepository { + + Page findByOrderByCreatedAtDesc(Pageable pageable); + + Page findByEventTypeOrderByCreatedAtDesc(String eventType, Pageable pageable); + + Page findByTaskIdOrderByCreatedAtDesc(String taskId, Pageable pageable); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPcRegistryRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPcRegistryRepository.java new file mode 100644 index 0000000..377f405 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPcRegistryRepository.java @@ -0,0 +1,20 @@ +package com.sjmresorts.opsapp.esign.printer.repositories; + +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPcRegistry; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface EsignPrinterPcRegistryRepository extends JpaRepository { + + List findAllByOrderByDisplayNameAsc(); + + List findByEnabledTrueOrderByDisplayNameAsc(); + + Optional findByAgentSource(String agentSource); + + boolean existsByAgentSource(String agentSource); + + boolean existsByAgentSourceAndPcIdNot(String agentSource, String pcId); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPdfTaskPushRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPdfTaskPushRepository.java new file mode 100644 index 0000000..a17ccc7 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPdfTaskPushRepository.java @@ -0,0 +1,54 @@ +package com.sjmresorts.opsapp.esign.printer.repositories; + +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPdfTaskPush; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Set; + +public interface EsignPrinterPdfTaskPushRepository + extends JpaRepository { + + List findByTaskId(String taskId); + + List findByTaskIdIn(Collection taskIds); + + void deleteByTaskId(String taskId); + + @Query(""" + SELECT p.taskId FROM EsignPrinterPdfTaskPush p + WHERE p.userId = :userId + AND (p.clientIp = '' OR p.clientIp = :clientIp) + """) + Set findTaskIdsByUserIdAndClientIp( + @Param("userId") Long userId, @Param("clientIp") String clientIp); + + @Query(""" + SELECT p.taskId FROM EsignPrinterPdfTaskPush p + WHERE p.userId = :userId AND p.clientIp = '' + """) + Set findTaskIdsByUserIdWildcardOnly(@Param("userId") Long userId); + + /** @deprecated 请使用 {@link #findTaskIdsByUserIdAndClientIp} */ + @Deprecated + @Query("SELECT p.taskId FROM EsignPrinterPdfTaskPush p WHERE p.userId = :userId") + Set findTaskIdsByUserId(@Param("userId") Long userId); + + @Query("SELECT COUNT(p) > 0 FROM EsignPrinterPdfTaskPush p WHERE p.taskId = :taskId") + boolean existsByTaskId(@Param("taskId") String taskId); + + @Query(""" + SELECT MAX(p.assignedAt) FROM EsignPrinterPdfTaskPush p, EsignPrinterPdfTask t + WHERE p.taskId = t.taskId AND t.createdAt >= :cutoff + """) + java.time.Instant findMaxAssignedAtForTasksSince(@Param("cutoff") java.time.Instant cutoff); + + @Query(""" + SELECT COUNT(DISTINCT p.taskId) FROM EsignPrinterPdfTaskPush p, EsignPrinterPdfTask t + WHERE p.taskId = t.taskId AND t.createdAt >= :cutoff + """) + long countDistinctPushedTasksSince(@Param("cutoff") java.time.Instant cutoff); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPdfTaskRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPdfTaskRepository.java new file mode 100644 index 0000000..73ad9f2 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPdfTaskRepository.java @@ -0,0 +1,300 @@ +package com.sjmresorts.opsapp.esign.printer.repositories; + +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPdfTask; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterOhipStatusRow; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfArchiveRow; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPcSourceRow; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfTaskListRow; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Repository +public interface EsignPrinterPdfTaskRepository + extends JpaRepository, JpaSpecificationExecutor { + + Optional findFirstByExternalId(String externalId); + + Optional findFirstByExternalIdAndStatus(String externalId, String status); + + @Query(""" + SELECT t.taskId AS taskId, t.externalId AS externalId, t.fileName AS fileName, + t.fileSize AS fileSize, t.hotelId AS hotelId, t.status AS status, + t.createdAt AS createdAt, t.completedAt AS completedAt, t.signedAt AS signedAt, + t.agentSource AS agentSource + FROM EsignPrinterPdfTask t + WHERE t.status = :status AND t.createdAt >= :createdAtMin + ORDER BY t.createdAt DESC + """) + List findListRowsByStatusAndCreatedAtGreaterThanEqual( + @Param("status") String status, + @Param("createdAtMin") Instant createdAtMin, + Pageable pageable); + + @Query(""" + SELECT t.taskId AS taskId, t.externalId AS externalId, t.fileName AS fileName, + t.fileSize AS fileSize, t.hotelId AS hotelId, t.status AS status, + t.createdAt AS createdAt, t.completedAt AS completedAt, t.signedAt AS signedAt, + t.agentSource AS agentSource + FROM EsignPrinterPdfTask t + WHERE t.status = :status AND t.createdAt < :createdAtMax + ORDER BY t.createdAt DESC + """) + List findListRowsByStatusAndCreatedAtLessThan( + @Param("status") String status, + @Param("createdAtMax") Instant createdAtMax, + Pageable pageable); + + @Query(""" + SELECT t.taskId AS taskId, t.externalId AS externalId, t.fileName AS fileName, + t.fileSize AS fileSize, t.hotelId AS hotelId, t.status AS status, + t.createdAt AS createdAt, t.completedAt AS completedAt, t.signedAt AS signedAt, + t.agentSource AS agentSource + FROM EsignPrinterPdfTask t + WHERE t.status = :status + ORDER BY t.completedAt DESC + """) + List findListRowsByStatusOrderByCompletedAtDesc( + @Param("status") String status, + Pageable pageable); + + @Query(""" + SELECT t.taskId AS taskId, t.externalId AS externalId, t.fileName AS fileName, + t.fileSize AS fileSize, t.hotelId AS hotelId, t.status AS status, + t.createdAt AS createdAt, t.completedAt AS completedAt, t.signedAt AS signedAt, + t.agentSource AS agentSource + FROM EsignPrinterPdfTask t + WHERE t.signedAt IS NOT NULL AND t.createdAt >= :createdAtMin + ORDER BY t.signedAt DESC + """) + List findListRowsSignedSince( + @Param("createdAtMin") Instant createdAtMin, + Pageable pageable); + + @Query(""" + SELECT t.taskId AS taskId, t.externalId AS externalId, t.fileName AS fileName, + t.fileSize AS fileSize, t.hotelId AS hotelId, t.status AS status, + t.createdAt AS createdAt, t.completedAt AS completedAt, t.signedAt AS signedAt, + t.agentSource AS agentSource + FROM EsignPrinterPdfTask t + WHERE t.signedAt IS NULL AND t.createdAt >= :createdAtMin + ORDER BY t.createdAt DESC + """) + List findListRowsUnsignedSince( + @Param("createdAtMin") Instant createdAtMin, + Pageable pageable); + + @Query(""" + SELECT t.taskId AS taskId, t.externalId AS externalId, t.fileName AS fileName, + t.fileSize AS fileSize, t.hotelId AS hotelId, t.status AS status, + t.createdAt AS createdAt, t.completedAt AS completedAt, t.signedAt AS signedAt, + t.agentSource AS agentSource + FROM EsignPrinterPdfTask t + WHERE t.signedAt IS NULL AND t.createdAt < :createdAtMax + ORDER BY t.createdAt DESC + """) + List findListRowsUnsignedBefore( + @Param("createdAtMax") Instant createdAtMax, + Pageable pageable); + + long countByStatusAndCreatedAtGreaterThanEqual(String status, Instant createdAtMin); + + long countByStatusAndCreatedAtLessThan(String status, Instant createdAtMax); + + long countByStatus(String status); + + long countByCreatedAtGreaterThanEqual(Instant createdAtMin); + + @Query("SELECT MAX(t.createdAt) FROM EsignPrinterPdfTask t WHERE t.createdAt >= :cutoff") + Instant findMaxCreatedAtSince(@Param("cutoff") Instant cutoff); + + @Query(""" + SELECT MAX(t.completedAt) FROM EsignPrinterPdfTask t + WHERE t.createdAt >= :cutoff AND t.completedAt IS NOT NULL + """) + Instant findMaxCompletedAtSince(@Param("cutoff") Instant cutoff); + + @Query(""" + SELECT CASE + WHEN t.agentSource IS NULL OR TRIM(t.agentSource) = '' THEN 'printer-agent' + ELSE t.agentSource + END AS agentSource, + COUNT(t) AS submitted24h, + SUM(CASE WHEN t.status = 'PENDING' THEN 1 ELSE 0 END) AS pendingCount, + SUM(CASE WHEN t.status = 'COMPLETED' THEN 1 ELSE 0 END) AS signed24h, + MAX(t.createdAt) AS lastSubmitAt + FROM EsignPrinterPdfTask t + WHERE t.createdAt >= :cutoff + GROUP BY CASE + WHEN t.agentSource IS NULL OR TRIM(t.agentSource) = '' THEN 'printer-agent' + ELSE t.agentSource + END + ORDER BY MAX(t.createdAt) DESC + """) + List aggregatePcSourcesSince(@Param("cutoff") Instant cutoff); + + @Query(""" + SELECT t.taskId AS taskId, t.externalId AS externalId, t.fileName AS fileName, + t.fileSize AS fileSize, t.hotelId AS hotelId, t.status AS status, + t.createdAt AS createdAt, t.completedAt AS completedAt, t.signedAt AS signedAt, + t.agentSource AS agentSource + FROM EsignPrinterPdfTask t + WHERE t.createdAt >= :cutoff + AND (:agentSource IS NULL OR :agentSource = '' + OR (CASE WHEN t.agentSource IS NULL OR TRIM(t.agentSource) = '' + THEN 'printer-agent' ELSE t.agentSource END) = :agentSource) + ORDER BY t.createdAt DESC + """) + List findQueueListRows( + @Param("cutoff") Instant cutoff, + @Param("agentSource") String agentSource, + Pageable pageable); + + @Query(""" + SELECT t.taskId AS taskId, t.externalId AS externalId, t.fileName AS fileName, + t.fileSize AS fileSize, t.hotelId AS hotelId, t.status AS status, + t.createdAt AS createdAt, t.completedAt AS completedAt, t.signedAt AS signedAt, + t.agentSource AS agentSource + FROM EsignPrinterPdfTask t + WHERE t.createdAt >= :cutoff + AND ( + :agentSource IS NULL OR :agentSource = '' + OR (CASE WHEN t.agentSource IS NULL OR TRIM(t.agentSource) = '' + THEN 'printer-agent' ELSE t.agentSource END) = :agentSource + OR ( + :agentHostIp IS NOT NULL AND :agentHostIp <> '' + AND ( + (CASE WHEN t.agentSource IS NULL OR TRIM(t.agentSource) = '' + THEN 'printer-agent' ELSE t.agentSource END) LIKE CONCAT('%@', :agentHostIp) + OR (CASE WHEN t.agentSource IS NULL OR TRIM(t.agentSource) = '' + THEN 'printer-agent' ELSE t.agentSource END) = :agentHostIp + ) + ) + ) + AND (:taskStatus IS NULL OR t.status = :taskStatus) + AND ( + :signFilter IS NULL + OR (:signFilter = 'SIGNED' AND t.signedAt IS NOT NULL) + OR (:signFilter = 'UNSIGNED' AND t.signedAt IS NULL) + ) + AND ( + :pushFilter IS NULL + OR (:pushFilter = 'PUSHED' AND EXISTS ( + SELECT 1 FROM EsignPrinterPdfTaskPush p WHERE p.taskId = t.taskId)) + OR (:pushFilter = 'NOT_PUSHED' AND NOT EXISTS ( + SELECT 1 FROM EsignPrinterPdfTaskPush p WHERE p.taskId = t.taskId)) + ) + ORDER BY t.createdAt DESC + """) + List findQueueListRowsFiltered( + @Param("cutoff") Instant cutoff, + @Param("agentSource") String agentSource, + @Param("agentHostIp") String agentHostIp, + @Param("taskStatus") String taskStatus, + @Param("signFilter") String signFilter, + @Param("pushFilter") String pushFilter, + Pageable pageable); + + @Query(""" + SELECT COUNT(t) FROM EsignPrinterPdfTask t + WHERE t.createdAt >= :cutoff + AND ( + :agentSource IS NULL OR :agentSource = '' + OR (CASE WHEN t.agentSource IS NULL OR TRIM(t.agentSource) = '' + THEN 'printer-agent' ELSE t.agentSource END) = :agentSource + OR ( + :agentHostIp IS NOT NULL AND :agentHostIp <> '' + AND ( + (CASE WHEN t.agentSource IS NULL OR TRIM(t.agentSource) = '' + THEN 'printer-agent' ELSE t.agentSource END) LIKE CONCAT('%@', :agentHostIp) + OR (CASE WHEN t.agentSource IS NULL OR TRIM(t.agentSource) = '' + THEN 'printer-agent' ELSE t.agentSource END) = :agentHostIp + ) + ) + ) + AND (:taskStatus IS NULL OR t.status = :taskStatus) + AND ( + :signFilter IS NULL + OR (:signFilter = 'SIGNED' AND t.signedAt IS NOT NULL) + OR (:signFilter = 'UNSIGNED' AND t.signedAt IS NULL) + ) + AND ( + :pushFilter IS NULL + OR (:pushFilter = 'PUSHED' AND EXISTS ( + SELECT 1 FROM EsignPrinterPdfTaskPush p WHERE p.taskId = t.taskId)) + OR (:pushFilter = 'NOT_PUSHED' AND NOT EXISTS ( + SELECT 1 FROM EsignPrinterPdfTaskPush p WHERE p.taskId = t.taskId)) + ) + """) + long countQueueRowsFiltered( + @Param("cutoff") Instant cutoff, + @Param("agentSource") String agentSource, + @Param("agentHostIp") String agentHostIp, + @Param("taskStatus") String taskStatus, + @Param("signFilter") String signFilter, + @Param("pushFilter") String pushFilter); + + @Query(""" + SELECT COUNT(t) FROM EsignPrinterPdfTask t + WHERE t.createdAt >= :cutoff + AND (:agentSource IS NULL OR :agentSource = '' + OR (CASE WHEN t.agentSource IS NULL OR TRIM(t.agentSource) = '' + THEN 'printer-agent' ELSE t.agentSource END) = :agentSource) + """) + long countQueueRows(@Param("cutoff") Instant cutoff, @Param("agentSource") String agentSource); + + @Query(""" + SELECT t.taskId AS taskId, t.externalId AS externalId, t.fileName AS fileName, + t.fileSize AS fileSize, t.hotelId AS hotelId, t.status AS status, + t.createdAt AS createdAt, t.completedAt AS completedAt, t.signedAt AS signedAt, + t.agentSource AS agentSource + FROM EsignPrinterPdfTask t + ORDER BY t.createdAt DESC + """) + List findRecentListRows(Pageable pageable); + + @Query(""" + SELECT t.taskId AS taskId, t.externalId AS externalId, t.fileName AS fileName, + t.fileSize AS fileSize, t.hotelId AS hotelId, t.status AS status, + t.createdAt AS createdAt, t.completedAt AS completedAt, + t.signedAt AS signedAt, t.archivedAt AS archivedAt, + CASE WHEN t.signedPdfBase64 IS NOT NULL AND LENGTH(t.signedPdfBase64) > 0 THEN true ELSE false END AS hasSignedPdf, + CASE WHEN t.signedMetaJson IS NOT NULL AND LENGTH(t.signedMetaJson) > 0 THEN true ELSE false END AS hasSignedMeta + FROM EsignPrinterPdfTask t + WHERE t.status = 'COMPLETED' OR t.signedAt IS NOT NULL + ORDER BY COALESCE(t.archivedAt, t.completedAt, t.signedAt, t.createdAt) DESC + """) + List findArchiveRows(Pageable pageable); + + @Query(""" + SELECT COUNT(t) FROM EsignPrinterPdfTask t + WHERE t.status = 'COMPLETED' OR t.signedAt IS NOT NULL + """) + long countArchiveRows(); + + @Query(""" + SELECT t FROM EsignPrinterPdfTask t + WHERE t.signedAt IS NOT NULL + AND t.ohipStatus IN ('PENDING', 'FAILED') + AND (t.ohipRetryCount IS NULL OR t.ohipRetryCount < 5) + ORDER BY COALESCE(t.signedAt, t.archivedAt) ASC + """) + List findOhipRetryCandidates(Pageable pageable); + + long countBySignedAtIsNotNullAndOhipStatus(String ohipStatus); + + @Query(""" + SELECT t.taskId AS taskId, t.ohipStatus AS ohipStatus, t.ohipLastError AS ohipLastError, + t.ohipUploadedAt AS ohipUploadedAt + FROM EsignPrinterPdfTask t + WHERE t.taskId IN :taskIds + """) + List findOhipStatusByTaskIdIn(@Param("taskIds") List taskIds); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPushGroupMemberRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPushGroupMemberRepository.java new file mode 100644 index 0000000..45b2e53 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPushGroupMemberRepository.java @@ -0,0 +1,22 @@ +package com.sjmresorts.opsapp.esign.printer.repositories; + +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPushGroupMember; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; + +public interface EsignPrinterPushGroupMemberRepository + extends JpaRepository { + + List findByGroupId(String groupId); + + List findByGroupIdIn(Collection groupIds); + + void deleteByGroupId(String groupId); + + @Query("SELECT m.userId FROM EsignPrinterPushGroupMember m WHERE m.groupId IN :groupIds") + List findUserIdsByGroupIdIn(@Param("groupIds") Collection groupIds); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPushGroupRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPushGroupRepository.java new file mode 100644 index 0000000..c9ebee5 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPushGroupRepository.java @@ -0,0 +1,11 @@ +package com.sjmresorts.opsapp.esign.printer.repositories; + +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPushGroup; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface EsignPrinterPushGroupRepository extends JpaRepository { + + List findByAgentSourceOrderByGroupNameAsc(String agentSource); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPushRuleConfigRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPushRuleConfigRepository.java new file mode 100644 index 0000000..154ed19 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPushRuleConfigRepository.java @@ -0,0 +1,7 @@ +package com.sjmresorts.opsapp.esign.printer.repositories; + +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPushRuleConfig; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EsignPrinterPushRuleConfigRepository extends JpaRepository { +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPushRuleMappingRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPushRuleMappingRepository.java new file mode 100644 index 0000000..0e2f2a1 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterPushRuleMappingRepository.java @@ -0,0 +1,18 @@ +package com.sjmresorts.opsapp.esign.printer.repositories; + +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPushRuleMapping; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface EsignPrinterPushRuleMappingRepository extends JpaRepository { + + List findByScopeTypeOrderByUpdatedAtDesc(String scopeType); + + Optional findFirstByScopeTypeAndAgentSource(String scopeType, String agentSource); + + List findByScopeTypeAndAgentSourceIsNotNullOrderByAgentSourceAsc(String scopeType); + + boolean existsByScopeTypeAndAgentSource(String scopeType, String agentSource); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterTabletRegistryPcRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterTabletRegistryPcRepository.java new file mode 100644 index 0000000..8fc5abd --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterTabletRegistryPcRepository.java @@ -0,0 +1,13 @@ +package com.sjmresorts.opsapp.esign.printer.repositories; + +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterTabletRegistryPc; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface EsignPrinterTabletRegistryPcRepository extends JpaRepository { + + List findByRegistryIdOrderByAgentSourceAsc(String registryId); + + void deleteByRegistryId(String registryId); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterTabletRegistryRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterTabletRegistryRepository.java new file mode 100644 index 0000000..aa7f3ba --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/repositories/EsignPrinterTabletRegistryRepository.java @@ -0,0 +1,24 @@ +package com.sjmresorts.opsapp.esign.printer.repositories; + +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterTabletRegistry; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface EsignPrinterTabletRegistryRepository extends JpaRepository { + + List findAllByOrderByUserNameAsc(); + + List findByEnabledTrueOrderByUserNameAsc(); + + List findAllByUserIdOrderByClientIpAsc(Long userId); + + List findAllByUserIdAndEnabledTrueOrderByClientIpAsc(Long userId); + + Optional findByUserIdAndClientIp(Long userId, String clientIp); + + boolean existsByUserIdAndClientIpAndRegistryIdNot(Long userId, String clientIp, String registryId); + + boolean existsByUserIdAndClientIp(Long userId, String clientIp); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterDeviceLinkService.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterDeviceLinkService.java new file mode 100644 index 0000000..28a0475 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterDeviceLinkService.java @@ -0,0 +1,395 @@ +package com.sjmresorts.opsapp.esign.printer.services; + +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterDeviceLinkRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterDeviceLinkResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterDeviceLinkReviewRequest; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterDeviceLink; +import com.sjmresorts.opsapp.esign.auth.models.EsignUser; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterDeviceLinkRepository; +import com.sjmresorts.opsapp.esign.auth.repositories.EsignUserRepository; +import com.sjmresorts.opsapp.esign.auth.services.EsignUserPresenceService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class EsignPrinterDeviceLinkService { + + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_APPROVED = "APPROVED"; + public static final String STATUS_REJECTED = "REJECTED"; + public static final String STATUS_REVOKED = "REVOKED"; + + @Resource + private EsignPrinterDeviceLinkRepository linkRepository; + + @Resource + private EsignUserRepository esignUserRepository; + + @Resource + private EsignUserPresenceService userPresenceService; + + @Resource + private EsignPrinterPcRegistryService pcRegistryService; + + @Resource + private EsignPrinterTabletRegistryService tabletRegistryService; + + @Transactional + public EsignPrinterDeviceLinkResponse requestLink( + Long userId, EsignPrinterDeviceLinkRequest request, String clientIp) { + if (userId == null) { + return EsignPrinterDeviceLinkResponse.fail("401", "请先登录 App"); + } + if (request == null || StringUtils.isBlank(request.getAgentSource())) { + return EsignPrinterDeviceLinkResponse.fail("400", "请填写 PC 连接标识(agentSource)"); + } + Optional userOpt = esignUserRepository.findById(userId); + if (userOpt.isEmpty() || !"ACTIVE".equalsIgnoreCase(userOpt.get().getStatus())) { + return EsignPrinterDeviceLinkResponse.fail("403", "账号不可用"); + } + userPresenceService.touchClientIp(userId, clientIp); + + String agentSource = normalizeAgentSource(request.getAgentSource()); + Optional existing = + linkRepository.findFirstByUserIdAndAgentSourceAndStatusIn( + userId, agentSource, Arrays.asList(STATUS_PENDING, STATUS_APPROVED)); + if (existing.isPresent()) { + EsignPrinterDeviceLink link = existing.get(); + String beforeStatus = link.getStatus(); + maybeAutoApproveRegisteredDevices(link, userId, clientIp); + if (!beforeStatus.equals(link.getStatus())) { + linkRepository.save(link); + } + EsignPrinterDeviceLinkResponse resp = EsignPrinterDeviceLinkResponse.ok( + STATUS_APPROVED.equals(link.getStatus()) ? "连接已批准" : "连接申请已存在"); + resp.setData(toItem(link, userOpt.get())); + return resp; + } + + EsignPrinterDeviceLink link = new EsignPrinterDeviceLink(); + link.setLinkId(UUID.randomUUID().toString()); + link.setUserId(userId); + link.setAgentSource(agentSource); + link.setAppLabel(trim(request.getAppLabel(), 128)); + pcRegistryService.findByAgentSource(agentSource).ifPresent(reg -> link.setPcDisplayName(reg.getDisplayName())); + link.setStatus(STATUS_PENDING); + link.setRequestedAt(Instant.now()); + maybeAutoApproveRegisteredDevices(link, userId, clientIp); + linkRepository.save(link); + log.info( + "Printer device link requested linkId={}, userId={}, agentSource={}, status={}", + link.getLinkId(), + userId, + agentSource, + link.getStatus()); + + String message = STATUS_APPROVED.equals(link.getStatus()) + ? "已连接(PC 与平板均已 CMS 登记,自动批准)" + : "连接申请已提交,等待 CMS 审批"; + EsignPrinterDeviceLinkResponse resp = EsignPrinterDeviceLinkResponse.ok(message); + resp.setData(toItem(link, userOpt.get())); + return resp; + } + + @Transactional(readOnly = true) + public EsignPrinterDeviceLinkResponse listMyLinks(Long userId) { + if (userId == null) { + return EsignPrinterDeviceLinkResponse.fail("401", "请先登录 App"); + } + List links = linkRepository.findByUserIdOrderByRequestedAtDesc(userId); + EsignPrinterDeviceLinkResponse resp = EsignPrinterDeviceLinkResponse.ok("ok"); + resp.setItems(toItems(links)); + return resp; + } + + @Transactional(readOnly = true) + public EsignPrinterDeviceLinkResponse listForCms() { + List links = + linkRepository.findByStatusInOrderByRequestedAtDesc( + Arrays.asList(STATUS_PENDING, STATUS_APPROVED, STATUS_REJECTED)); + EsignPrinterDeviceLinkResponse resp = EsignPrinterDeviceLinkResponse.ok("ok"); + resp.setItems(toItems(links)); + return resp; + } + + @Transactional + public EsignPrinterDeviceLinkResponse reviewLink(EsignPrinterDeviceLinkReviewRequest request) { + if (request == null || StringUtils.isBlank(request.getLinkId())) { + return EsignPrinterDeviceLinkResponse.fail("400", "linkId required"); + } + String action = StringUtils.defaultString(request.getAction()).trim().toLowerCase(); + if (!"approve".equals(action) && !"reject".equals(action)) { + return EsignPrinterDeviceLinkResponse.fail("400", "action must be approve or reject"); + } + Optional opt = linkRepository.findById(request.getLinkId().trim()); + if (opt.isEmpty()) { + return EsignPrinterDeviceLinkResponse.fail("404", "连接申请不存在"); + } + EsignPrinterDeviceLink link = opt.get(); + if (!STATUS_PENDING.equals(link.getStatus())) { + return EsignPrinterDeviceLinkResponse.fail("409", "该申请已处理"); + } + link.setStatus("approve".equals(action) ? STATUS_APPROVED : STATUS_REJECTED); + link.setReviewedAt(Instant.now()); + link.setReviewedBy(trim(request.getReviewedBy(), 64)); + linkRepository.save(link); + log.info( + "Printer device link reviewed linkId={}, action={}, by={}", + link.getLinkId(), + action, + link.getReviewedBy()); + + Optional userOpt = esignUserRepository.findById(link.getUserId()); + EsignPrinterDeviceLinkResponse resp = + EsignPrinterDeviceLinkResponse.ok("approve".equals(action) ? "已批准连接" : "已拒绝连接"); + resp.setData(toItem(link, userOpt.orElse(null))); + return resp; + } + + @Transactional(readOnly = true) + public Set approvedAgentSourcesForUser(Long userId) { + if (userId == null) { + return Set.of(); + } + return linkRepository.findByUserIdAndStatus(userId, STATUS_APPROVED).stream() + .map(EsignPrinterDeviceLink::getAgentSource) + .collect(Collectors.toSet()); + } + + public static final String UNIDENTIFIED_AGENT = "printer-agent"; + + public static boolean isUnidentifiedAgent(String agentSource) { + return UNIDENTIFIED_AGENT.equals(normalizeAgentSource(agentSource)); + } + + @Transactional(readOnly = true) + public List approvedLinksForAgent(String agentSource) { + return linkRepository.findByAgentSourceAndStatus(normalizeAgentSource(agentSource), STATUS_APPROVED); + } + + /** 全部已批准设备连接(不限 PC 标识),用于推送候选与映射。 */ + @Transactional(readOnly = true) + public List allApprovedLinks() { + return linkRepository.findByStatusOrderByRequestedAtDesc(STATUS_APPROVED); + } + + /** + * 推送目标解析:未标识 PC(printer-agent)可使用任意已批准平板,与 App 申请时填写的连接标识无关。 + */ + @Transactional(readOnly = true) + public List approvedLinksForPushTarget(String agentSource) { + if (isUnidentifiedAgent(agentSource)) { + return linkRepository.findByStatusOrderByRequestedAtDesc(STATUS_APPROVED); + } + return approvedLinksForAgent(agentSource); + } + + @Transactional(readOnly = true) + public Set approvedUserIdsForPushTarget(String agentSource) { + Set raw = approvedLinksForPushTarget(agentSource).stream() + .map(EsignPrinterDeviceLink::getUserId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + return userPresenceService.dedupeUserIdsByClientIp(raw); + } + + /** 任意已批准连接的平板(按 IP 去重),用于 PC→平板推送映射。 */ + @Transactional(readOnly = true) + public Set approvedUserIdsWithAnyApprovedLink() { + Set raw = linkRepository.findByStatusOrderByRequestedAtDesc(STATUS_APPROVED).stream() + .map(EsignPrinterDeviceLink::getUserId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + return userPresenceService.dedupeUserIdsByClientIp(raw); + } + + @Transactional(readOnly = true) + public Map loadUsersById(List links) { + Set ids = links.stream().map(EsignPrinterDeviceLink::getUserId).collect(Collectors.toSet()); + Map map = new HashMap<>(); + for (Long id : ids) { + esignUserRepository.findById(id).ifPresent(u -> map.put(id, u)); + } + return map; + } + + @Transactional(readOnly = true) + public Map> approvedLinksByAgent() { + List links = linkRepository.findByStatusOrderByRequestedAtDesc(STATUS_APPROVED); + Map users = loadUsersById(links); + Map> map = new HashMap<>(); + for (EsignPrinterDeviceLink link : links) { + String key = link.getAgentSource(); + map.computeIfAbsent(key, k -> new ArrayList<>()) + .add(toItem(link, users.get(link.getUserId()))); + } + return map; + } + + @Transactional(readOnly = true) + public Map> approvedLinksByUser() { + List links = linkRepository.findByStatusOrderByRequestedAtDesc(STATUS_APPROVED); + Map users = loadUsersById(links); + Map> map = new HashMap<>(); + for (EsignPrinterDeviceLink link : links) { + map.computeIfAbsent(link.getUserId(), k -> new ArrayList<>()) + .add(toItem(link, users.get(link.getUserId()))); + } + return map; + } + + public static String normalizeAgentSource(String agentSource) { + if (StringUtils.isBlank(agentSource)) { + return "printer-agent"; + } + String t = agentSource.trim(); + return t.isEmpty() ? "printer-agent" : t; + } + + /** 从 Electron/CMS 上报标识解析主机 IP(如 frontdesk@192.168.3.9 或 192.168.3.9:39201)。 */ + public static String extractHostIp(String agentSource) { + if (StringUtils.isBlank(agentSource)) { + return null; + } + String t = stripPushPortSuffix(agentSource); + int at = t.lastIndexOf('@'); + if (at >= 0 && at < t.length() - 1) { + String ip = t.substring(at + 1).trim(); + return ip.isEmpty() ? null : ip; + } + return looksLikeHostIp(t) ? t : null; + } + + /** 连接标识相同,或解析出的 IP 相同(用于 PC 登记名与实际上报名不一致时的合并/匹配)。 */ + public static boolean sameHostIp(String left, String right) { + return agentSourcesEquivalent(left, right); + } + + /** 两个上报/登记 PC 标识是否指向同一台机器。 */ + public static boolean agentSourcesEquivalent(String left, String right) { + String a = normalizeAgentSource(left); + String b = normalizeAgentSource(right); + if (a.equals(b)) { + return true; + } + String ipA = extractHostIp(a); + String ipB = extractHostIp(b); + return StringUtils.isNotBlank(ipA) && ipA.equals(ipB); + } + + private static String stripPushPortSuffix(String agentSource) { + String t = agentSource.trim(); + t = t.replaceFirst("(?i)^https?://", ""); + int portIdx = t.indexOf(":39201"); + if (portIdx > 0) { + t = t.substring(0, portIdx); + } + return t.trim(); + } + + private static boolean looksLikeHostIp(String value) { + if (StringUtils.isBlank(value)) { + return false; + } + String t = value.trim(); + if (t.contains(":")) { + return false; + } + String[] parts = t.split("\\."); + if (parts.length != 4) { + return false; + } + for (String part : parts) { + if (part.isEmpty() || part.length() > 3) { + return false; + } + for (int i = 0; i < part.length(); i++) { + if (!Character.isDigit(part.charAt(i))) { + return false; + } + } + int n = Integer.parseInt(part); + if (n < 0 || n > 255) { + return false; + } + } + return true; + } + + private List toItems( + List links) { + Map users = loadUsersById(links); + List items = new ArrayList<>(); + for (EsignPrinterDeviceLink link : links) { + items.add(toItem(link, users.get(link.getUserId()))); + } + return items; + } + + private EsignPrinterDeviceLinkResponse.EsignPrinterDeviceLinkItem toItem( + EsignPrinterDeviceLink link, EsignUser user) { + EsignPrinterDeviceLinkResponse.EsignPrinterDeviceLinkItem item = + new EsignPrinterDeviceLinkResponse.EsignPrinterDeviceLinkItem(); + item.setLinkId(link.getLinkId()); + item.setUserId(link.getUserId()); + item.setUserName(user != null ? user.getUserName() : null); + item.setAgentSource(link.getAgentSource()); + item.setAppLabel(link.getAppLabel()); + item.setPcDisplayName(link.getPcDisplayName()); + item.setStatus(link.getStatus()); + item.setRequestedAt(link.getRequestedAt() != null ? link.getRequestedAt().toString() : null); + item.setReviewedAt(link.getReviewedAt() != null ? link.getReviewedAt().toString() : null); + item.setReviewedBy(link.getReviewedBy()); + if (user != null) { + boolean pushEnabled = StringUtils.isNotBlank(user.getDeviceToken()); + item.setPushEnabled(pushEnabled); + item.setDeliveryMode(pushEnabled ? "FCM 推送" : "仅轮询"); + item.setLastLoginAt(user.getLastLoginAt() != null ? user.getLastLoginAt().toString() : null); + item.setLastLoginIp(user.getLastLoginIp()); + } + return item; + } + + private void maybeAutoApproveRegisteredDevices( + EsignPrinterDeviceLink link, Long userId, String clientIp) { + if (link == null || !STATUS_PENDING.equals(link.getStatus())) { + return; + } + if (!pcRegistryService.isRegisteredAndEnabled(link.getAgentSource())) { + return; + } + if (!tabletRegistryService.isRegisteredAndEnabled(userId, clientIp)) { + return; + } + link.setStatus(STATUS_APPROVED); + link.setReviewedAt(Instant.now()); + link.setReviewedBy("pc-tablet-registry"); + } + + private static String trim(String s, int max) { + if (s == null) { + return null; + } + String t = s.trim(); + if (t.isEmpty()) { + return null; + } + return t.length() > max ? t.substring(0, max) : t; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterEventLogService.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterEventLogService.java new file mode 100644 index 0000000..f85fc0e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterEventLogService.java @@ -0,0 +1,105 @@ +package com.sjmresorts.opsapp.esign.printer.services; + +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterEventLogResponse; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterEventLog; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPdfTask; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterEventLogRepository; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +public class EsignPrinterEventLogService { + + public static final String EVENT_PDF_SUBMIT = "PDF_SUBMIT"; + public static final String EVENT_PDF_UPDATE = "PDF_UPDATE"; + public static final String EVENT_PDF_PUSH_ASSIGN = "PDF_PUSH_ASSIGN"; + public static final String EVENT_TASK_COMPLETE = "TASK_COMPLETE"; + public static final String EVENT_SIGNED_UPLOAD = "SIGNED_UPLOAD"; + public static final String EVENT_OHIP_REUPLOAD = "OHIP_REUPLOAD"; + + @Resource + private EsignPrinterEventLogRepository eventLogRepository; + + @Transactional + public void append(String eventType, EsignPrinterPdfTask task, String message, String detailJson) { + if (StringUtils.isBlank(eventType)) { + return; + } + EsignPrinterEventLog log = new EsignPrinterEventLog(); + log.setLogId(UUID.randomUUID().toString()); + log.setEventType(eventType.trim()); + log.setMessage(trim(message, 512)); + log.setDetailJson(detailJson); + log.setCreatedAt(Instant.now()); + if (task != null) { + log.setTaskId(task.getTaskId()); + log.setFileName(task.getFileName()); + log.setAgentSource(task.getAgentSource()); + log.setHotelId(task.getHotelId()); + } + eventLogRepository.save(log); + } + + @Transactional(readOnly = true) + public EsignPrinterEventLogResponse listLogs(int page, int size, String eventType, String taskId) { + int safePage = Math.max(page, 0); + int safeSize = size > 0 ? Math.min(size, 200) : 50; + PageRequest pageable = PageRequest.of(safePage, safeSize); + Page result; + if (StringUtils.isNotBlank(taskId)) { + result = eventLogRepository.findByTaskIdOrderByCreatedAtDesc(taskId.trim(), pageable); + } else if (StringUtils.isNotBlank(eventType)) { + result = eventLogRepository.findByEventTypeOrderByCreatedAtDesc(eventType.trim(), pageable); + } else { + result = eventLogRepository.findByOrderByCreatedAtDesc(pageable); + } + + List logs = new ArrayList<>(); + for (EsignPrinterEventLog row : result.getContent()) { + EsignPrinterEventLogResponse.EsignPrinterEventLogItem item = + new EsignPrinterEventLogResponse.EsignPrinterEventLogItem(); + item.setLogId(row.getLogId()); + item.setTaskId(row.getTaskId()); + item.setEventType(row.getEventType()); + item.setMessage(row.getMessage()); + item.setFileName(row.getFileName()); + item.setAgentSource(row.getAgentSource()); + item.setHotelId(row.getHotelId()); + item.setCreatedAt(row.getCreatedAt() != null ? row.getCreatedAt().toString() : null); + logs.add(item); + } + + EsignPrinterEventLogResponse resp = new EsignPrinterEventLogResponse(); + EsignPrinterEventLogResponse.EsignPrinterEventLogData data = + new EsignPrinterEventLogResponse.EsignPrinterEventLogData(); + data.setTotal(result.getTotalElements()); + data.setPage(safePage); + data.setSize(safeSize); + data.setLogs(logs); + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("ok"); + resp.setData(data); + return resp; + } + + private static String trim(String s, int max) { + if (s == null) { + return null; + } + String t = s.trim(); + if (t.isEmpty()) { + return null; + } + return t.length() > max ? t.substring(0, max) : t; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPcRegistryService.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPcRegistryService.java new file mode 100644 index 0000000..856dd13 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPcRegistryService.java @@ -0,0 +1,224 @@ +package com.sjmresorts.opsapp.esign.printer.services; + +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPcRegistryDeleteRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPcRegistryResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPcRegistrySaveRequest; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPcRegistry; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterPcRegistryRepository; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.regex.Pattern; + +@Slf4j +@Service +public class EsignPrinterPcRegistryService { + + private static final Pattern IPV4 = + Pattern.compile("^(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})(?:\\.(?:25[0-5]|2[0-4]\\d|1?\\d{1,2})){3}$"); + + @Resource + private EsignPrinterPcRegistryRepository registryRepository; + + @Transactional(readOnly = true) + public EsignPrinterPcRegistryResponse listAll() { + EsignPrinterPcRegistryResponse resp = EsignPrinterPcRegistryResponse.ok("ok"); + resp.setItems(toItems(registryRepository.findAllByOrderByDisplayNameAsc())); + return resp; + } + + @Transactional(readOnly = true) + public List listAllOrdered() { + return registryRepository.findAllByOrderByDisplayNameAsc(); + } + + @Transactional(readOnly = true) + public List listEnabled() { + return registryRepository.findByEnabledTrueOrderByDisplayNameAsc(); + } + + @Transactional(readOnly = true) + public List listEnabledAgentSources() { + List agents = new ArrayList<>(); + for (EsignPrinterPcRegistry row : listEnabled()) { + if (StringUtils.isNotBlank(row.getAgentSource())) { + agents.add(row.getAgentSource()); + } + } + return agents; + } + + @Transactional(readOnly = true) + public boolean isRegisteredAndEnabled(String agentSource) { + String key = EsignPrinterDeviceLinkService.normalizeAgentSource(agentSource); + if (EsignPrinterDeviceLinkService.isUnidentifiedAgent(key)) { + return false; + } + return registryRepository.findByAgentSource(key).filter(EsignPrinterPcRegistry::isEnabled).isPresent(); + } + + @Transactional(readOnly = true) + public Optional findByAgentSource(String agentSource) { + return registryRepository.findByAgentSource(EsignPrinterDeviceLinkService.normalizeAgentSource(agentSource)); + } + + public static String buildAgentSource(String displayName, String hostIp) { + String name = trim(displayName, 128); + String ip = trim(hostIp, 64); + if (StringUtils.isBlank(name)) { + throw new IllegalArgumentException("displayName required"); + } + if (StringUtils.isBlank(ip)) { + return name; + } + return name + "@" + ip; + } + + @Transactional + public EsignPrinterPcRegistryResponse save(EsignPrinterPcRegistrySaveRequest request) { + if (request == null) { + return EsignPrinterPcRegistryResponse.fail("400", "请求体不能为空"); + } + String displayName = trim(request.getDisplayName(), 128); + String hostIp = trim(request.getHostIp(), 64); + if (StringUtils.isBlank(displayName)) { + return EsignPrinterPcRegistryResponse.fail("400", "请填写 PC 名称"); + } + if (StringUtils.isBlank(hostIp)) { + return EsignPrinterPcRegistryResponse.fail("400", "请填写 PC IP"); + } + if (!isValidHostIp(hostIp)) { + return EsignPrinterPcRegistryResponse.fail("400", "IP 格式无效"); + } + + String agentSource; + try { + agentSource = EsignPrinterDeviceLinkService.normalizeAgentSource(buildAgentSource(displayName, hostIp)); + } catch (IllegalArgumentException e) { + return EsignPrinterPcRegistryResponse.fail("400", e.getMessage()); + } + + EsignPrinterPcRegistry row; + boolean creating = StringUtils.isBlank(request.getPcId()); + if (creating) { + if (registryRepository.existsByAgentSource(agentSource)) { + return EsignPrinterPcRegistryResponse.fail("409", "该连接标识已登记:" + agentSource); + } + row = new EsignPrinterPcRegistry(); + row.setPcId(UUID.randomUUID().toString()); + row.setCreatedBy(trim(request.getCreatedBy(), 64)); + } else { + Optional opt = registryRepository.findById(request.getPcId().trim()); + if (opt.isEmpty()) { + return EsignPrinterPcRegistryResponse.fail("404", "登记记录不存在"); + } + row = opt.get(); + if (registryRepository.existsByAgentSourceAndPcIdNot(agentSource, row.getPcId())) { + return EsignPrinterPcRegistryResponse.fail("409", "该连接标识已被其他 PC 使用"); + } + } + + row.setDisplayName(displayName); + row.setHostIp(hostIp); + row.setAgentSource(agentSource); + if (request.getEnabled() != null) { + row.setEnabled(request.getEnabled()); + } else if (creating) { + row.setEnabled(true); + } + row.setNotes(trim(request.getNotes(), 256)); + registryRepository.save(row); + log.info( + "Printer PC registry {} pcId={}, agentSource={}", + creating ? "created" : "updated", + row.getPcId(), + agentSource); + + EsignPrinterPcRegistryResponse resp = + EsignPrinterPcRegistryResponse.ok(creating ? "已添加 PC" : "已更新 PC"); + resp.setData(toItem(row)); + return resp; + } + + @Transactional + public EsignPrinterPcRegistryResponse delete(EsignPrinterPcRegistryDeleteRequest request) { + if (request == null || StringUtils.isBlank(request.getPcId())) { + return EsignPrinterPcRegistryResponse.fail("400", "pcId required"); + } + Optional opt = registryRepository.findById(request.getPcId().trim()); + if (opt.isEmpty()) { + return EsignPrinterPcRegistryResponse.fail("404", "登记记录不存在"); + } + registryRepository.delete(opt.get()); + log.info("Printer PC registry deleted pcId={}", request.getPcId()); + return EsignPrinterPcRegistryResponse.ok("已删除 PC 登记"); + } + + public static List mergeAgentSources(List primary, List extra) { + Set merged = new LinkedHashSet<>(); + if (primary != null) { + for (String s : primary) { + if (StringUtils.isNotBlank(s)) { + merged.add(s.trim()); + } + } + } + if (extra != null) { + for (String s : extra) { + if (StringUtils.isNotBlank(s)) { + merged.add(s.trim()); + } + } + } + return new ArrayList<>(merged); + } + + private static boolean isValidHostIp(String ip) { + return IPV4.matcher(ip.trim()).matches(); + } + + private static String trim(String s, int max) { + if (s == null) { + return null; + } + String t = s.trim(); + if (t.isEmpty()) { + return null; + } + return t.length() > max ? t.substring(0, max) : t; + } + + private List toItems( + List rows) { + List items = new ArrayList<>(); + for (EsignPrinterPcRegistry row : rows) { + items.add(toItem(row)); + } + return items; + } + + private EsignPrinterPcRegistryResponse.EsignPrinterPcRegistryItem toItem(EsignPrinterPcRegistry row) { + EsignPrinterPcRegistryResponse.EsignPrinterPcRegistryItem item = + new EsignPrinterPcRegistryResponse.EsignPrinterPcRegistryItem(); + item.setPcId(row.getPcId()); + item.setDisplayName(row.getDisplayName()); + item.setHostIp(row.getHostIp()); + item.setAgentSource(row.getAgentSource()); + item.setEnabled(row.isEnabled()); + item.setNotes(row.getNotes()); + item.setCreatedBy(row.getCreatedBy()); + item.setCreatedAt(row.getCreatedAt() != null ? row.getCreatedAt().toString() : null); + item.setUpdatedAt(row.getUpdatedAt() != null ? row.getUpdatedAt().toString() : null); + return item; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfArchiveOcrBatchService.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfArchiveOcrBatchService.java new file mode 100644 index 0000000..45e7f59 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfArchiveOcrBatchService.java @@ -0,0 +1,302 @@ +package com.sjmresorts.opsapp.esign.printer.services; + +import com.sjmresorts.opsapp.esign.printer.filters.EsignPrinterPdfArchiveFilter; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfArchiveOcrBatchResponse; +import com.sjmresorts.opsapp.esign.printer.support.EsignPrinterPdfArchiveOcrBatchQuery; +import com.sjmresorts.opsapp.esign.printer.support.EsignPrinterPdfArchiveOcrBatchRunner; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; + +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +@Slf4j +@Service +public class EsignPrinterPdfArchiveOcrBatchService { + + private static final DateTimeFormatter TS_FMT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()); + private static final int PAGE_SIZE = 10; + + public static final String JOB_QUEUED = "QUEUED"; + public static final String JOB_RUNNING = "RUNNING"; + public static final String JOB_COMPLETED = "COMPLETED"; + public static final String JOB_FAILED = "FAILED"; + + @Value("${ops-app.esign.pdf-archive-ocr.log-dir:C:/auto-check-in/pdf-archive-ocr-logs}") + private String logDir; + + @Value("${ops-app.esign.pdf-archive-ocr.batch-max:2000}") + private int batchMax; + + private final Map jobs = new ConcurrentHashMap<>(); + + @Resource + private EsignPrinterPdfArchiveOcrBatchQuery batchQuery; + + @Resource + private EsignPrinterPdfArchiveOcrService ocrService; + + @Resource + @Lazy + private EsignPrinterPdfArchiveOcrBatchRunner batchRunner; + + public EsignPrinterPdfArchiveOcrBatchResponse startBatch( + EsignPrinterPdfArchiveFilter filter, boolean failedOnly) { + long total = batchQuery.countTaskIds(filter, failedOnly); + if (total <= 0) { + return error("404", "没有符合條件的歸檔記錄"); + } + if (total > batchMax) { + return error( + "400", + "符合條件的記錄共 " + total + " 條,超過單次上限 " + batchMax + ",請縮小篩選範圍"); + } + + String jobId = UUID.randomUUID().toString(); + BatchJobState state = new BatchJobState(jobId, (int) total); + jobs.put(jobId, state); + evictOldJobs(); + batchRunner.runAsync(jobId, filter, failedOnly); + + EsignPrinterPdfArchiveOcrBatchResponse resp = new EsignPrinterPdfArchiveOcrBatchResponse(); + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("批量 OCR 已在後台啟動"); + resp.setData(state.toDto()); + return resp; + } + + public EsignPrinterPdfArchiveOcrBatchResponse getBatchStatus(String jobId) { + if (StringUtils.isBlank(jobId)) { + return error("400", "jobId 無效"); + } + BatchJobState state = jobs.get(jobId.trim()); + if (state == null) { + return error("404", "任務不存在或已過期"); + } + EsignPrinterPdfArchiveOcrBatchResponse resp = new EsignPrinterPdfArchiveOcrBatchResponse(); + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("ok"); + resp.setData(state.toDto()); + return resp; + } + + public byte[] readErrorLog(String jobId) { + BatchJobState state = jobs.get(StringUtils.trimToEmpty(jobId)); + if (state == null || StringUtils.isBlank(state.errorLogPath)) { + return null; + } + Path path = Path.of(state.errorLogPath); + if (!Files.isRegularFile(path)) { + return null; + } + try { + return Files.readAllBytes(path); + } catch (IOException e) { + log.warn("read OCR batch error log failed jobId={}", jobId, e); + return null; + } + } + + public String errorLogFileName(String jobId) { + BatchJobState state = jobs.get(StringUtils.trimToEmpty(jobId)); + if (state == null || StringUtils.isBlank(state.errorLogFileName)) { + return "ocr-batch-errors.log"; + } + return state.errorLogFileName; + } + + public void executeBatch(String jobId, EsignPrinterPdfArchiveFilter filter, boolean failedOnly) { + BatchJobState state = jobs.get(jobId); + if (state == null) { + return; + } + state.status = JOB_RUNNING; + state.startedAt = Instant.now(); + + Path logPath = null; + try { + logPath = prepareErrorLogFile(jobId); + state.errorLogPath = logPath.toString(); + state.errorLogFileName = logPath.getFileName().toString(); + + try (BufferedWriter writer = Files.newBufferedWriter(logPath, StandardCharsets.UTF_8)) { + writer.write("PDF Archive OCR Batch Job"); + writer.newLine(); + writer.write("jobId: " + jobId); + writer.newLine(); + writer.write("startedAt: " + TS_FMT.format(state.startedAt)); + writer.newLine(); + writer.write("failedOnly: " + failedOnly); + writer.newLine(); + writer.write("total: " + state.total); + writer.newLine(); + writer.write("--- failures ---"); + writer.newLine(); + + int page = 0; + while (state.processed < state.total) { + var taskIds = batchQuery.findTaskIds(filter, failedOnly, page, PAGE_SIZE); + if (taskIds.isEmpty()) { + break; + } + for (String taskId : taskIds) { + EsignPrinterPdfArchiveOcrService.OcrExtractOutcome outcome = + ocrService.extractAndPersist(taskId); + state.processed++; + if (outcome.isOk()) { + state.successCount++; + } else if (EsignPrinterPdfArchiveOcrService.OCR_SKIPPED.equals(outcome.getOcrStatus())) { + state.skippedCount++; + writeFailureLine(writer, outcome); + } else { + state.failedCount++; + writeFailureLine(writer, outcome); + } + writer.flush(); + } + page++; + } + } + + state.hasErrorLog = state.failedCount + state.skippedCount > 0; + if (!state.hasErrorLog) { + Files.deleteIfExists(logPath); + state.errorLogPath = null; + state.errorLogFileName = null; + } + state.status = JOB_COMPLETED; + } catch (Exception e) { + log.error("OCR batch job failed jobId={}", jobId, e); + state.status = JOB_FAILED; + state.message = e.getMessage(); + if (logPath != null) { + try (BufferedWriter writer = Files.newBufferedWriter( + logPath, StandardCharsets.UTF_8, java.nio.file.StandardOpenOption.APPEND)) { + writer.newLine(); + writer.write("--- batch error ---"); + writer.newLine(); + writer.write(StringUtils.defaultString(e.getMessage())); + writer.newLine(); + } catch (IOException io) { + log.warn("append batch fatal error to log failed jobId={}", jobId, io); + } + state.hasErrorLog = true; + } + } finally { + state.finishedAt = Instant.now(); + } + } + + private static void writeFailureLine( + BufferedWriter writer, EsignPrinterPdfArchiveOcrService.OcrExtractOutcome outcome) + throws IOException { + writer.write(String.format( + "[%s] taskId=%s fileName=%s status=%s reason=%s", + TS_FMT.format(Instant.now()), + StringUtils.defaultString(outcome.getTaskId()), + StringUtils.defaultString(outcome.getFileName(), "—"), + StringUtils.defaultString(outcome.getOcrStatus()), + StringUtils.defaultString(outcome.getFailureReason(), "—"))); + writer.newLine(); + } + + private Path prepareErrorLogFile(String jobId) throws IOException { + Path dir = Path.of(StringUtils.defaultIfBlank(logDir, "C:/auto-check-in/pdf-archive-ocr-logs")); + Files.createDirectories(dir); + String stamp = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss").withZone(ZoneId.systemDefault()).format(Instant.now()); + return dir.resolve("ocr-batch-" + jobId.substring(0, 8) + "-" + stamp + ".log"); + } + + private void evictOldJobs() { + if (jobs.size() <= 100) { + return; + } + jobs.entrySet().stream() + .sorted((a, b) -> { + Instant left = a.getValue().finishedAt != null + ? a.getValue().finishedAt + : a.getValue().startedAt; + Instant right = b.getValue().finishedAt != null + ? b.getValue().finishedAt + : b.getValue().startedAt; + if (left == null && right == null) { + return 0; + } + if (left == null) { + return -1; + } + if (right == null) { + return 1; + } + return left.compareTo(right); + }) + .limit(Math.max(0, jobs.size() - 80)) + .map(Map.Entry::getKey) + .forEach(jobs::remove); + } + + private static EsignPrinterPdfArchiveOcrBatchResponse error(String code, String message) { + EsignPrinterPdfArchiveOcrBatchResponse resp = new EsignPrinterPdfArchiveOcrBatchResponse(); + resp.setSuccess(false); + resp.setCode(code); + resp.setMessage(message); + return resp; + } + + static final class BatchJobState { + final String jobId; + final int total; + String status = JOB_QUEUED; + int processed; + int successCount; + int failedCount; + int skippedCount; + boolean hasErrorLog; + String message; + String errorLogPath; + String errorLogFileName; + Instant startedAt; + Instant finishedAt; + + BatchJobState(String jobId, int total) { + this.jobId = jobId; + this.total = total; + } + + EsignPrinterPdfArchiveOcrBatchResponse.EsignPrinterPdfArchiveOcrBatchData toDto() { + EsignPrinterPdfArchiveOcrBatchResponse.EsignPrinterPdfArchiveOcrBatchData data = + new EsignPrinterPdfArchiveOcrBatchResponse.EsignPrinterPdfArchiveOcrBatchData(); + data.setJobId(jobId); + data.setStatus(status); + data.setTotal(total); + data.setProcessed(processed); + data.setSuccessCount(successCount); + data.setFailedCount(failedCount); + data.setSkippedCount(skippedCount); + data.setHasErrorLog(hasErrorLog); + if (startedAt != null) { + data.setStartedAt(startedAt.toString()); + } + if (finishedAt != null) { + data.setFinishedAt(finishedAt.toString()); + } + return data; + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfArchiveOcrService.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfArchiveOcrService.java new file mode 100644 index 0000000..668f33d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfArchiveOcrService.java @@ -0,0 +1,191 @@ +package com.sjmresorts.opsapp.esign.printer.services; + +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPdfTask; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterPdfTaskRepository; +import com.sjmresorts.opsapp.esign.printer.dtos.RegistrationCardMeta; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfArchiveOcrEvent; +import com.sjmresorts.opsapp.esign.printer.support.EsignPrinterPdfFileStorage; +import jakarta.annotation.Resource; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.time.Instant; +import java.util.Optional; + +@Slf4j +@Service +public class EsignPrinterPdfArchiveOcrService { + + public static final String OCR_PENDING = "PENDING"; + public static final String OCR_OK = "OK"; + public static final String OCR_FAILED = "FAILED"; + public static final String OCR_SKIPPED = "SKIPPED"; + + @Resource + private EsignPrinterPdfTaskRepository printerPdfTaskRepository; + + @Resource + private RegistrationCardMetaExtractService registrationCardMetaExtractService; + + @Resource + private EsignPrinterPdfFileStorage pdfFileStorage; + + @Resource + private ApplicationEventPublisher applicationEventPublisher; + + public void scheduleAfterArchive(String taskId) { + if (StringUtils.isBlank(taskId)) { + return; + } + String id = taskId.trim(); + applicationEventPublisher.publishEvent(new EsignPrinterPdfArchiveOcrEvent(id)); + } + + /** + * 同步 OCR:UniApp 提交/签收时立即解析 PDF 并写入 task 的 ocr_* 字段(调用方负责 save)。 + */ + public void extractFromPdfBytesAndApply(EsignPrinterPdfTask task, byte[] pdfBytes) { + extractFromPdfBytesAndApply(task, pdfBytes, null); + } + + public void extractFromPdfBytesAndApply( + EsignPrinterPdfTask task, byte[] pdfBytes, String confirmationFallback) { + if (task == null) { + return; + } + try { + if (pdfBytes == null || pdfBytes.length == 0) { + task.setOcrStatus(OCR_SKIPPED); + task.setOcrExtractedAt(Instant.now()); + return; + } + RegistrationCardMeta meta = registrationCardMetaExtractService.extractFromPdfBytes(pdfBytes); + applyMeta(task, meta); + if (StringUtils.isBlank(task.getOcrConfirmationNo()) + && StringUtils.isNotBlank(confirmationFallback)) { + task.setOcrConfirmationNo(trim(confirmationFallback, 64)); + } + task.setOcrExtractedAt(Instant.now()); + task.setOcrStatus( + StringUtils.isNotBlank(task.getOcrConfirmationNo()) + || meta.hasAnyField() + ? OCR_OK + : OCR_FAILED); + log.info( + "archive OCR inline taskId={}, status={}, guest={}, conf={}", + task.getTaskId(), + task.getOcrStatus(), + task.getOcrGuestName(), + task.getOcrConfirmationNo()); + } catch (Exception e) { + log.warn("archive OCR inline failed taskId={}", task.getTaskId(), e); + task.setOcrStatus(OCR_FAILED); + task.setOcrExtractedAt(Instant.now()); + } + } + + @Transactional + public OcrExtractOutcome extractAndPersist(String taskId) { + if (StringUtils.isBlank(taskId)) { + return OcrExtractOutcome.failed(null, null, "taskId 為空"); + } + String id = taskId.trim(); + try { + Optional opt = printerPdfTaskRepository.findById(id); + if (opt.isEmpty()) { + return OcrExtractOutcome.failed(id, null, "任務不存在"); + } + EsignPrinterPdfTask task = opt.get(); + String fileName = task.getFileName(); + byte[] pdfBytes = pdfFileStorage.readArchivePdfBytes(task).orElse(null); + if (pdfBytes == null || pdfBytes.length == 0) { + task.setOcrStatus(OCR_SKIPPED); + task.setOcrExtractedAt(Instant.now()); + printerPdfTaskRepository.save(task); + return OcrExtractOutcome.skipped(id, fileName, "無 PDF 內容"); + } + RegistrationCardMeta meta = registrationCardMetaExtractService.extractFromPdfBytes(pdfBytes); + applyMeta(task, meta); + task.setOcrExtractedAt(Instant.now()); + task.setOcrStatus( + StringUtils.isNotBlank(task.getOcrConfirmationNo()) || meta.hasAnyField() + ? OCR_OK + : OCR_FAILED); + printerPdfTaskRepository.save(task); + log.info( + "archive OCR saved taskId={}, status={}, guest={}, conf={}", + id, + task.getOcrStatus(), + task.getOcrGuestName(), + task.getOcrConfirmationNo()); + if (meta.hasAnyField()) { + return OcrExtractOutcome.ok(id, fileName, task.getOcrStatus()); + } + return OcrExtractOutcome.failed(id, fileName, "未識別到有效字段"); + } catch (Exception e) { + log.warn("archive OCR failed taskId={}", id, e); + markFailed(id, e.getMessage()); + return OcrExtractOutcome.failed(id, null, StringUtils.defaultIfBlank(e.getMessage(), "OCR 異常")); + } + } + + private void markFailed(String taskId, String reason) { + printerPdfTaskRepository.findById(taskId).ifPresent(task -> { + task.setOcrStatus(OCR_FAILED); + task.setOcrExtractedAt(Instant.now()); + printerPdfTaskRepository.save(task); + }); + } + + private static void applyMeta(EsignPrinterPdfTask task, RegistrationCardMeta meta) { + task.setOcrGuestName(trim(meta.getGuestName(), 256)); + task.setOcrConfirmationNo(trim(meta.getConfirmationNo(), 64)); + task.setOcrPhone(trim(meta.getPhone(), 64)); + task.setOcrEmail(trim(meta.getEmail(), 256)); + task.setOcrRoomNo(trim(meta.getRoomNo(), 32)); + task.setOcrCheckInDate(trim(meta.getCheckInDate(), 32)); + task.setOcrCheckOutDate(trim(meta.getCheckOutDate(), 32)); + } + + private static String trim(String v, int max) { + if (StringUtils.isBlank(v)) { + return null; + } + String t = v.trim(); + return t.length() <= max ? t : t.substring(0, max); + } + + @Getter + public static final class OcrExtractOutcome { + private final String taskId; + private final String fileName; + private final String ocrStatus; + private final String failureReason; + + private OcrExtractOutcome(String taskId, String fileName, String ocrStatus, String failureReason) { + this.taskId = taskId; + this.fileName = fileName; + this.ocrStatus = ocrStatus; + this.failureReason = failureReason; + } + + public static OcrExtractOutcome ok(String taskId, String fileName, String ocrStatus) { + return new OcrExtractOutcome(taskId, fileName, ocrStatus, null); + } + + public static OcrExtractOutcome failed(String taskId, String fileName, String reason) { + return new OcrExtractOutcome(taskId, fileName, OCR_FAILED, reason); + } + + public static OcrExtractOutcome skipped(String taskId, String fileName, String reason) { + return new OcrExtractOutcome(taskId, fileName, OCR_SKIPPED, reason); + } + + public boolean isOk() { + return OCR_OK.equals(ocrStatus); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfFieldPrefillService.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfFieldPrefillService.java new file mode 100644 index 0000000..5c5bddb --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfFieldPrefillService.java @@ -0,0 +1,286 @@ +package com.sjmresorts.opsapp.esign.printer.services; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.sjmresorts.opsapp.esign.common.dtos.SignFieldOhipMappingItem; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfFieldPrefillResponse; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPdfTask; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterPdfTaskRepository; +import com.sjmresorts.opsapp.esign.reservation.dtos.GuestProfileRef; +import com.sjmresorts.opsapp.esign.common.support.OhipProfileFieldCatalog; +import com.sjmresorts.opsapp.esign.common.support.ProfileDetailsJsonReader; +import com.sjmresorts.opsapp.esign.reservation.support.RegistrationConfirmationExtractor; +import com.sjmresorts.opsapp.esign.reservation.services.EsignSignFieldOhipMappingService; +import com.sjmresorts.opsapp.esign.reservation.services.ReservationThirdPartyClient; +import com.sjmresorts.opsapp.esign.printer.support.EsignPrinterPdfFileStorage; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +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.Locale; +import java.util.Map; +import java.util.Optional; + +/** + * 登记卡签名字段预填:优先任务 OCR 元数据,其次 OHIP Profile(免点击 OCR)。 + */ +@Slf4j +@Service +public class EsignPrinterPdfFieldPrefillService { + + private static final Map INPUT_FORMAT_TO_OHIP_CODE = Map.of( + "email", "email.address", + "phone", "phone.number"); + + @Resource + private EsignPrinterPdfTaskRepository printerPdfTaskRepository; + + @Resource + private EsignSignFieldOhipMappingService signFieldOhipMappingService; + + @Resource + private ReservationThirdPartyClient reservationThirdPartyClient; + + @Resource + private EsignPrinterPdfFileStorage pdfFileStorage; + + @Transactional(readOnly = true) + public EsignPrinterPdfFieldPrefillResponse buildPrefill( + String taskId, String signFieldsJson, Long appUserId, String clientIp) { + if (StringUtils.isBlank(taskId)) { + return EsignPrinterPdfFieldPrefillResponse.fail("400", "taskId required"); + } + Optional opt = printerPdfTaskRepository.findById(taskId.trim()); + if (opt.isEmpty()) { + return EsignPrinterPdfFieldPrefillResponse.fail("404", "Task not found"); + } + EsignPrinterPdfTask task = opt.get(); + JSONArray fields = parseSignFields(signFieldsJson); + Map prefill = new LinkedHashMap<>(); + List sources = new ArrayList<>(); + + applyTaskOcr(task, fields, prefill); + if (!prefill.isEmpty()) { + sources.add("task_ocr"); + } + + Map profileByPath = loadOhipProfileValues(task); + if (!profileByPath.isEmpty()) { + applyOhipMappings(task.getHotelId(), fields, profileByPath, prefill); + applyInputFormatProfileDefaults(fields, profileByPath, prefill); + sources.add("ohip_profile"); + } + + EsignPrinterPdfFieldPrefillResponse.FieldPrefillData data = + new EsignPrinterPdfFieldPrefillResponse.FieldPrefillData(); + data.setFieldPrefillMap(prefill); + data.setOcrStatus(task.getOcrStatus()); + data.setSources(sources.isEmpty() ? null : sources); + log.info( + "field prefill taskId={}, fields={}, ocrStatus={}, sources={}", + taskId, + prefill.size(), + task.getOcrStatus(), + sources); + return EsignPrinterPdfFieldPrefillResponse.ok(data); + } + + private void applyTaskOcr(EsignPrinterPdfTask task, JSONArray fields, Map out) { + if (fields == null || fields.isEmpty()) { + putIfBlank(out, "email", task.getOcrEmail()); + putIfBlank(out, "phone", task.getOcrPhone()); + return; + } + for (int i = 0; i < fields.size(); i++) { + JSONObject field = fields.getJSONObject(i); + if (field == null) { + continue; + } + String key = fieldKey(field); + if (key == null) { + continue; + } + String fmt = normalizeInputFormat(field.getString("inputFormat")); + String value = taskOcrValue(task, fmt); + putIfBlank(out, key, value); + } + } + + private static String taskOcrValue(EsignPrinterPdfTask task, String fmt) { + if (task == null || fmt == null) { + return null; + } + return switch (fmt) { + case "email" -> task.getOcrEmail(); + case "phone" -> task.getOcrPhone(); + default -> null; + }; + } + + private void applyOhipMappings( + String hotelId, + JSONArray fields, + Map profileByPath, + Map out) { + if (fields == null || fields.isEmpty() || profileByPath.isEmpty()) { + return; + } + List mappings = signFieldOhipMappingService.getMappingsForHotel(hotelId); + for (SignFieldOhipMappingItem mapping : mappings) { + if (mapping == null || Boolean.FALSE.equals(mapping.getEnabled())) { + continue; + } + if ("signature".equalsIgnoreCase(StringUtils.trimToEmpty(mapping.getValueType()))) { + continue; + } + String path = OhipProfileFieldCatalog.resolvePath(mapping.getOhipFieldCode()).orElse(null); + if (path == null) { + continue; + } + JSONObject field = findField(fields, mapping.getFieldName()); + if (field == null) { + continue; + } + String key = fieldKey(field); + putIfBlank(out, key, profileByPath.get(path)); + } + } + + private static void applyInputFormatProfileDefaults( + JSONArray fields, Map profileByPath, Map out) { + if (fields == null || fields.isEmpty()) { + return; + } + for (int i = 0; i < fields.size(); i++) { + JSONObject field = fields.getJSONObject(i); + if (field == null) { + continue; + } + String fmt = normalizeInputFormat(field.getString("inputFormat")); + String ohipCode = INPUT_FORMAT_TO_OHIP_CODE.get(fmt); + if (ohipCode == null) { + continue; + } + String path = OhipProfileFieldCatalog.resolvePath(ohipCode).orElse(null); + if (path == null) { + continue; + } + putIfBlank(out, fieldKey(field), profileByPath.get(path)); + } + } + + private Map loadOhipProfileValues(EsignPrinterPdfTask task) { + String hotelId = StringUtils.trimToNull(task.getHotelId()); + if (hotelId == null) { + return Map.of(); + } + String confirmation = StringUtils.firstNonBlank( + StringUtils.trimToNull(task.getOcrConfirmationNo()), + resolveConfirmationFromPdf(task).orElse(null)); + if (confirmation == null) { + return Map.of(); + } + Optional reservationId = + reservationThirdPartyClient.lookupReservationIdByConfirmation(hotelId, confirmation); + if (reservationId.isEmpty()) { + log.debug("field prefill no reservation hotelId={}, confirmation={}", hotelId, confirmation); + return Map.of(); + } + Optional guest = + reservationThirdPartyClient.fetchGuestProfile(hotelId, reservationId.get()); + if (guest.isEmpty() || StringUtils.isBlank(guest.get().profileId())) { + return Map.of(); + } + Optional profileDetails = + reservationThirdPartyClient.fetchGuestProfileDetails(hotelId, guest.get().profileId()); + if (profileDetails.isEmpty() || profileDetails.get().isEmpty()) { + return Map.of(); + } + Map out = new LinkedHashMap<>(); + for (var fieldDef : OhipProfileFieldCatalog.all()) { + String value = ProfileDetailsJsonReader.getString(profileDetails.get(), fieldDef.getPath()); + if (StringUtils.isNotBlank(value)) { + out.put(fieldDef.getPath(), value.trim()); + } + } + return out; + } + + private Optional resolveConfirmationFromPdf(EsignPrinterPdfTask task) { + if (task == null || !pdfFileStorage.hasOriginal(task)) { + return Optional.empty(); + } + return pdfFileStorage + .readOriginalBytes(task) + .flatMap(RegistrationConfirmationExtractor::extractFromPdfBytes); + } + + private static JSONArray parseSignFields(String signFieldsJson) { + if (StringUtils.isBlank(signFieldsJson)) { + return new JSONArray(); + } + try { + JSONArray arr = JSON.parseArray(signFieldsJson.trim()); + return arr != null ? arr : new JSONArray(); + } catch (Exception e) { + return new JSONArray(); + } + } + + private static JSONObject findField(JSONArray fields, String fieldName) { + if (fields == null || StringUtils.isBlank(fieldName)) { + return null; + } + String target = fieldName.trim(); + for (int i = 0; i < fields.size(); i++) { + JSONObject obj = fields.getJSONObject(i); + if (obj == null) { + continue; + } + if (target.equalsIgnoreCase(StringUtils.trimToEmpty(obj.getString("fieldName")))) { + return obj; + } + if (target.equalsIgnoreCase(StringUtils.trimToEmpty(obj.getString("id")))) { + return obj; + } + } + return null; + } + + private static String fieldKey(JSONObject field) { + if (field == null) { + return null; + } + return StringUtils.firstNonBlank(field.getString("id"), field.getString("fieldName")); + } + + private static void putIfBlank(Map out, String key, String value) { + if (StringUtils.isAnyBlank(key, value)) { + return; + } + String existing = out.get(key); + if (StringUtils.isBlank(existing)) { + out.put(key, value.trim()); + } + } + + static String normalizeInputFormat(String raw) { + String s = StringUtils.trimToEmpty(raw).toLowerCase(Locale.ROOT); + if (s.isEmpty()) { + return "text"; + } + if (s.contains("mail") || s.contains("邮") || s.contains("郵") || "email".equals(s)) { + return "email"; + } + if (s.contains("phone") || s.contains("tel") || s.contains("电话") || s.contains("電話")) { + return "phone"; + } + return s; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfOhipUploadService.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfOhipUploadService.java new file mode 100644 index 0000000..fca5fa1 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfOhipUploadService.java @@ -0,0 +1,282 @@ +package com.sjmresorts.opsapp.esign.printer.services; + +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPdfTask; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterPdfTaskRepository; +import com.sjmresorts.opsapp.esign.printer.support.EsignPrinterOhipStatus; +import com.sjmresorts.opsapp.esign.printer.support.SignedPdfArchiveReader; +import com.sjmresorts.opsapp.esign.reservation.dtos.SignedRegistrationCardUploadResult; +import com.sjmresorts.opsapp.esign.reservation.services.ReservationThirdPartyClient; +import com.sjmresorts.opsapp.esign.reservation.services.SignedRegistrationCardOhipService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +@Slf4j +@Service +public class EsignPrinterPdfOhipUploadService { + + @Resource + private EsignPrinterPdfTaskRepository printerPdfTaskRepository; + + @Resource + private SignedPdfArchiveReader signedPdfArchiveReader; + + @Resource + private SignedRegistrationCardOhipService signedRegistrationCardOhipService; + + @Resource + private ReservationThirdPartyClient reservationThirdPartyClient; + + @Resource + private EsignPrinterEventLogService printerEventLogService; + + @Transactional + public SignedRegistrationCardUploadResult uploadFromArchive( + String taskId, + String confirmationNumberFromRequest, + String signFieldsJson, + String profileId, + String profileType) { + return uploadFromArchive( + taskId, confirmationNumberFromRequest, signFieldsJson, profileId, profileType, false, false, null); + } + + @Transactional + public SignedRegistrationCardUploadResult reuploadFromArchive(String taskId) { + EsignPrinterPdfTask task = + printerPdfTaskRepository.findById(taskId.trim()).orElse(null); + String confirmation = task != null && StringUtils.isNotBlank(task.getOcrConfirmationNo()) + ? task.getOcrConfirmationNo().trim() + : null; + return uploadFromArchive(taskId, confirmation, null, null, null, false, true, null); + } + + @Transactional + public SignedRegistrationCardUploadResult uploadFromArchive( + String taskId, + String confirmationNumberFromRequest, + String signFieldsJson, + String profileId, + String profileType, + boolean incrementRetry, + boolean manualReupload) { + return uploadFromArchive( + taskId, + confirmationNumberFromRequest, + signFieldsJson, + profileId, + profileType, + incrementRetry, + manualReupload, + null); + } + + @Transactional + public SignedRegistrationCardUploadResult uploadFromArchive( + String taskId, + String confirmationNumberFromRequest, + String signFieldsJson, + String profileId, + String profileType, + boolean incrementRetry, + boolean manualReupload, + String reservationIdOverride) { + if (StringUtils.isBlank(taskId)) { + return SignedRegistrationCardUploadResult.skipped("taskId required"); + } + EsignPrinterPdfTask task = + printerPdfTaskRepository.findById(taskId.trim()).orElse(null); + if (task == null) { + return SignedRegistrationCardUploadResult.skipped("Task not found"); + } + if (StringUtils.isNotBlank(reservationIdOverride) + && !"printer-import".equalsIgnoreCase(reservationIdOverride.trim()) + && StringUtils.isBlank(task.getOhipReservationId())) { + task.setOhipReservationId(reservationIdOverride.trim()); + } + if (incrementRetry) { + int next = task.getOhipRetryCount() != null ? task.getOhipRetryCount() + 1 : 1; + task.setOhipRetryCount(next); + if (next > EsignPrinterOhipStatus.MAX_RETRY) { + task.setOhipStatus(EsignPrinterOhipStatus.FAILED); + task.setOhipLastError("超过最大重试次数"); + printerPdfTaskRepository.save(task); + return SignedRegistrationCardUploadResult.failure(429, null, "超过最大重试次数"); + } + } + + SignedRegistrationCardUploadResult result; + try { + String signedBase64 = signedPdfArchiveReader.requireSignedBase64(task); + // 若从 meta 重合成并回写了 ref,需要落库,避免下次再丢 + if (StringUtils.isNotBlank(task.getSignedPdfBase64())) { + printerPdfTaskRepository.save(task); + } + String reservationId = StringUtils.firstNonBlank( + task.getOhipReservationId(), + StringUtils.trimToNull(reservationIdOverride)); + log.info( + "OHIP upload from archive taskId={}, signedPdfLen={}, reservationId={}", + task.getTaskId(), + StringUtils.length(signedBase64), + reservationId); + + String resolvedProfileId = profileId; + String resolvedProfileType = profileType; + if (StringUtils.isBlank(resolvedProfileId) + && StringUtils.isNotBlank(reservationId) + && !"printer-import".equalsIgnoreCase(reservationId)) { + try { + var guest = reservationThirdPartyClient.fetchGuestProfile(task.getHotelId(), reservationId); + if (guest.isPresent()) { + resolvedProfileId = guest.get().profileId(); + resolvedProfileType = StringUtils.defaultIfBlank( + resolvedProfileType, guest.get().profileType()); + log.info( + "OHIP async resolved profile taskId={}, profileId={}", + task.getTaskId(), + resolvedProfileId); + } + } catch (Exception e) { + log.warn("OHIP async profile resolve failed taskId={}", task.getTaskId(), e); + } + } + + // 已有 reservationId:直传 OPERA,跳过确认号 OCR/查找(USB / 已知预订) + if (StringUtils.isNotBlank(reservationId) + && !"printer-import".equalsIgnoreCase(reservationId)) { + result = signedRegistrationCardOhipService.uploadForReservation( + reservationId, + task.getHotelId(), + null, + signedBase64, + task.getSignedMetaJson(), + task.getFileName(), + StringUtils.trimToNull(confirmationNumberFromRequest), + StringUtils.defaultIfBlank(signFieldsJson, task.getSignFieldsJson()), + resolvedProfileId, + resolvedProfileType); + } else { + log.info( + "OHIP upload from archive via confirmation lookup taskId={}, requestConfirmation={}, ohipConfirmation={}, ocrConfirmation={}", + task.getTaskId(), + StringUtils.trimToNull(confirmationNumberFromRequest), + StringUtils.trimToNull(task.getOhipConfirmationNo()), + StringUtils.trimToNull(task.getOcrConfirmationNo())); + result = signedRegistrationCardOhipService.uploadAfterSign( + task, + signedBase64, + task.getSignedMetaJson(), + null, + StringUtils.defaultIfBlank(signFieldsJson, task.getSignFieldsJson()), + resolvedProfileId, + resolvedProfileType); + } + } catch (IllegalStateException e) { + log.warn("OHIP upload from archive failed taskId={}: {}", task.getTaskId(), e.getMessage()); + result = SignedRegistrationCardUploadResult.failure(500, null, e.getMessage()); + } catch (Exception e) { + log.warn("OHIP upload from archive error taskId={}", task.getTaskId(), e); + result = SignedRegistrationCardUploadResult.failure( + 502, null, StringUtils.defaultIfBlank(e.getMessage(), "OHIP upload error")); + } + + applyResult(task, result); + printerPdfTaskRepository.save(task); + + if (task.getSignedAt() != null || StringUtils.isNotBlank(task.getSignedPdfBase64())) { + String eventType = manualReupload + ? EsignPrinterEventLogService.EVENT_OHIP_REUPLOAD + : EsignPrinterEventLogService.EVENT_SIGNED_UPLOAD; + printerEventLogService.append( + eventType, task, buildEventMessage(result), result.getErrorMessage()); + } + return result; + } + + @Transactional + public void markPending(EsignPrinterPdfTask task) { + if (task == null) { + return; + } + task.setOhipStatus(EsignPrinterOhipStatus.PENDING); + task.setOhipLastError(null); + task.setOhipRetryCount(0); + printerPdfTaskRepository.save(task); + } + + private static void applyResult(EsignPrinterPdfTask task, SignedRegistrationCardUploadResult result) { + if (result == null) { + task.setOhipStatus(EsignPrinterOhipStatus.FAILED); + task.setOhipLastError("无上传结果"); + return; + } + if (!result.isInvoked()) { + task.setOhipStatus(EsignPrinterOhipStatus.SKIPPED); + task.setOhipLastError(trimError(result.getErrorMessage())); + task.setOhipRetryCount(EsignPrinterOhipStatus.MAX_RETRY); + return; + } + if (result.isSuccess()) { + task.setOhipStatus(EsignPrinterOhipStatus.SUCCESS); + task.setOhipLastError(null); + task.setOhipUploadedAt(Instant.now()); + if (StringUtils.isNotBlank(result.getConfirmationNumber())) { + task.setOhipConfirmationNo(result.getConfirmationNumber().trim()); + } + if (StringUtils.isNotBlank(result.getReservationId())) { + task.setOhipReservationId(result.getReservationId().trim()); + } + return; + } + task.setOhipStatus(EsignPrinterOhipStatus.FAILED); + task.setOhipLastError(trimError(result.getErrorMessage())); + if (!isRetryable(result)) { + task.setOhipRetryCount(EsignPrinterOhipStatus.MAX_RETRY); + } + } + + private static String buildEventMessage(SignedRegistrationCardUploadResult result) { + if (result == null) { + return "OPERA 上传无结果"; + } + if (!result.isInvoked()) { + return "未调用 OPERA:" + StringUtils.defaultIfBlank(result.getErrorMessage(), "配置未就绪"); + } + if (result.isSuccess()) { + return "签名 PDF 已提交至 OPERA"; + } + return "提交 OPERA 失败:" + StringUtils.defaultIfBlank(result.getErrorMessage(), "unknown"); + } + + private static String trimError(String message) { + if (StringUtils.isBlank(message)) { + return null; + } + String t = message.trim(); + return t.length() <= 512 ? t : t.substring(0, 512); + } + + /** 是否值得自动重试(网络/网关/OAuth 临时故障)。 */ + public static boolean isRetryable(SignedRegistrationCardUploadResult result) { + if (result == null || result.isSuccess() || !result.isInvoked()) { + return false; + } + int code = result.getStatusCode(); + if (code == 404 || code == 400 || code == 429) { + return false; + } + if (code >= 500 || code <= 0) { + return true; + } + String msg = StringUtils.defaultString(result.getErrorMessage()).toLowerCase(); + return msg.contains("timeout") + || msg.contains("transport") + || msg.contains("oauth") + || msg.contains("token"); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfTaskService.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfTaskService.java new file mode 100644 index 0000000..5916a39 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPdfTaskService.java @@ -0,0 +1,2095 @@ +package com.sjmresorts.opsapp.esign.printer.services; + +import com.sjmresorts.opsapp.esign.capture.dtos.EsignIosCaptureResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPendingListResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterDashboardResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterDevicesResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterQueueResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterQueueWatchResponse; +import com.sjmresorts.opsapp.esign.printer.filters.EsignPrinterPdfArchiveFilter; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfArchiveResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfSignedSubmitRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfUsbSignedSubmitRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfFieldExtractRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfFieldExtractResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfTaskDetailResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfTaskSubmitRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfTaskUpdateRequest; +import com.sjmresorts.opsapp.esign.reservation.dtos.SignedRegistrationCardUploadResult; +import com.sjmresorts.opsapp.esign.printer.dtos.RegistrationCardSignFieldParser; +import com.sjmresorts.opsapp.esign.printer.dtos.SignedRegistrationPdfComposer; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPcRegistry; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPcSourceRow; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterTabletRegistry; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPdfTask; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfTaskListRow; +import com.sjmresorts.opsapp.esign.auth.models.EsignUser; +import com.sjmresorts.opsapp.esign.printer.support.EsignPrinterPdfFileStorage; +import com.sjmresorts.opsapp.esign.printer.support.EsignPrinterQueueNotifier; +import com.sjmresorts.opsapp.esign.printer.support.EsignPrinterPdfArchiveListQuery; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterOhipStatusRow; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfArchiveListRow; +import com.sjmresorts.opsapp.esign.common.support.EsignLogMaskUtil; +import com.sjmresorts.opsapp.esign.common.support.EsignReservationParamUtil; +import com.sjmresorts.opsapp.esign.auth.services.EsignUserPresenceService; +import com.sjmresorts.opsapp.esign.survey.services.EsignSurveyPushService; +import com.sjmresorts.opsapp.esign.printer.support.EsignPrinterOhipStatus; +import com.sjmresorts.opsapp.esign.printer.support.EsignPrinterPdfSignedSubmitAsyncScheduler; +import com.sjmresorts.opsapp.esign.printer.support.SignedPdfComposeValidator; +import com.sjmresorts.opsapp.esign.printer.support.PdfSignFieldTextExtractor; +import com.sjmresorts.opsapp.esign.printer.support.RegistrationConfirmationOcrExtractor; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterPdfTaskPushRepository; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterPdfTaskRepository; +import com.sjmresorts.opsapp.esign.auth.repositories.EsignUserRepository; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +@Slf4j +@Service +public class EsignPrinterPdfTaskService { + + public static final String TASK_TYPE = "PRINTER_PDF"; + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_COMPLETED = "COMPLETED"; + + private static final int TASK_WINDOW_HOURS = 24; + private static final int TABLET_ONLINE_MINUTES = 30; + private static final int LIST_PAGE_SIZE = 30; + private static final int DASHBOARD_RECENT_SIZE = 12; + private static final int ARCHIVE_PAGE_SIZE = 20; + private static final int MAX_BASE64_LEN = 20_000_000; + private static final int MAX_FILE_NAME_LEN = 512; + private static final int MAX_FILE_PATH_LEN = 1024; + + @Resource + private EsignPrinterPdfTaskRepository printerPdfTaskRepository; + + @Resource + private EsignPrinterPdfTaskPushRepository printerPdfTaskPushRepository; + + @Resource + private EsignPrinterEventLogService printerEventLogService; + + @Resource + private EsignUserRepository esignUserRepository; + + @Resource + private EsignPrinterDeviceLinkService printerDeviceLinkService; + + @Resource + private EsignPrinterPcRegistryService printerPcRegistryService; + + @Resource + private EsignPrinterTabletRegistryService printerTabletRegistryService; + + @Resource + private EsignPrinterPushService printerPushService; + + @Resource + private EsignUserPresenceService userPresenceService; + + @Resource + private com.sjmresorts.opsapp.esign.printer.websocket.EsignPrinterWebSocketHub printerWebSocketHub; + + @Resource + private EsignPrinterPdfArchiveOcrService archiveOcrService; + + @Resource + private EsignPrinterPdfArchiveListQuery archiveListQuery; + + @Resource + private EsignPrinterPdfFileStorage pdfFileStorage; + + @Resource + private EsignPrinterQueueNotifier printerQueueNotifier; + + @Resource + private EsignSurveyPushService surveyPushService; + + @Resource + private PdfSignFieldTextExtractor pdfSignFieldTextExtractor; + + @Resource + private RegistrationConfirmationOcrExtractor registrationConfirmationOcrExtractor; + + @Resource + private EsignPrinterPdfSignedSubmitAsyncScheduler signedSubmitAsyncScheduler; + + @Resource + private EsignPrinterPdfOhipUploadService ohipUploadService; + + private Instant taskWindowCutoff() { + return Instant.now().minus(TASK_WINDOW_HOURS, ChronoUnit.HOURS); + } + + @Transactional + public EsignIosCaptureResponse submitTask(EsignPrinterPdfTaskSubmitRequest request) { + EsignIosCaptureResponse resp = new EsignIosCaptureResponse(); + if (request == null) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("request body required"); + return resp; + } + String externalId = trim(request.getExternalId(), 64); + String fileName = trim(request.getFileName(), MAX_FILE_NAME_LEN); + String base64 = normalizePdfBase64(request.getBase64()); + if (StringUtils.isAnyBlank(externalId, fileName, base64)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("externalId, fileName and base64 required"); + return resp; + } + if (base64.length() > MAX_BASE64_LEN) { + resp.setSuccess(false); + resp.setCode("413"); + resp.setMessage("PDF too large"); + return resp; + } + + Instant now = Instant.now(); + Optional existing = printerPdfTaskRepository.findFirstByExternalId(externalId); + EsignPrinterPdfTask task; + if (existing.isPresent()) { + task = existing.get(); + if (STATUS_COMPLETED.equals(task.getStatus())) { + // PC 重复扫描同一 PDF 时,勿把已签收任务重新变为待签 + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("Already completed"); + EsignIosCaptureResponse.EsignIosCaptureData data = + new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(task.getTaskId()); + resp.setData(data); + return resp; + } + task.setCreatedAt(now); + } else { + task = new EsignPrinterPdfTask(); + task.setTaskId(UUID.randomUUID().toString()); + task.setExternalId(externalId); + task.setStatus(STATUS_PENDING); + task.setCreatedAt(now); + } + task.setFileName(fileName); + task.setFilePath(trim(request.getFilePath(), MAX_FILE_PATH_LEN)); + try { + task.setPdfBase64(pdfFileStorage.persistOriginalFromBase64(task.getTaskId(), base64)); + } catch (IOException e) { + log.error("Printer PDF persist failed taskId={}", task.getTaskId(), e); + resp.setSuccess(false); + resp.setCode("500"); + resp.setMessage("PDF 保存失败"); + return resp; + } + task.setFileSize(request.getFileSize()); + String hotelId = trim(request.getHotelId(), 64); + if (StringUtils.isNotBlank(hotelId)) { + task.setHotelId(EsignReservationParamUtil.normalizeParam(hotelId)); + } + task.setAgentSource(trim(request.getAgentSource(), 256)); + printerPdfTaskRepository.save(task); + archiveOcrService.scheduleAfterArchive(task.getTaskId()); + printerEventLogService.append( + EsignPrinterEventLogService.EVENT_PDF_SUBMIT, + task, + "PC 上报 PDF", + null); + printerPushService.autoAssignOnSubmit(task); + printerQueueNotifier.notifyChangedAfterCommit(); + log.info( + "Printer PDF task saved taskId={}, externalId={}, fileName={}", + task.getTaskId(), + externalId, + fileName); + + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("Task saved"); + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(task.getTaskId()); + resp.setData(data); + return resp; + } + + @Transactional + public EsignIosCaptureResponse updateTask(EsignPrinterPdfTaskUpdateRequest request) { + EsignIosCaptureResponse resp = new EsignIosCaptureResponse(); + if (request == null || StringUtils.isBlank(request.getTaskId())) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("taskId required"); + return resp; + } + String taskId = request.getTaskId().trim(); + Optional opt = printerPdfTaskRepository.findById(taskId); + if (opt.isEmpty()) { + resp.setSuccess(false); + resp.setCode("404"); + resp.setMessage("Task not found"); + return resp; + } + EsignPrinterPdfTask task = opt.get(); + boolean signedArchive = isSignedArchiveTask(task); + if (STATUS_COMPLETED.equals(task.getStatus()) && !signedArchive) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("已签收任务不可编辑"); + return resp; + } + + boolean changed = false; + boolean signedPdfReplaced = false; + if (request.getFileName() != null) { + String fileName = trim(request.getFileName(), MAX_FILE_NAME_LEN); + if (StringUtils.isBlank(fileName)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("fileName cannot be empty"); + return resp; + } + if (!fileName.equals(task.getFileName())) { + task.setFileName(fileName); + changed = true; + } + } + + if (request.getBase64() != null) { + String base64 = normalizePdfBase64(request.getBase64()); + if (StringUtils.isBlank(base64)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("base64 required when replacing PDF"); + return resp; + } + if (base64.length() > MAX_BASE64_LEN) { + resp.setSuccess(false); + resp.setCode("413"); + resp.setMessage("PDF too large"); + return resp; + } + try { + byte[] pdfBytes = Base64.getDecoder().decode(base64); + if (signedArchive) { + task.setSignedPdfBase64(pdfFileStorage.persistSigned(task.getTaskId(), pdfBytes)); + signedPdfReplaced = true; + archiveOcrService.extractFromPdfBytesAndApply(task, pdfBytes, null); + } else { + task.setPdfBase64(pdfFileStorage.persistOriginal(task.getTaskId(), pdfBytes)); + } + } catch (IOException e) { + log.error("Printer PDF update persist failed taskId={}", task.getTaskId(), e); + resp.setSuccess(false); + resp.setCode("500"); + resp.setMessage("PDF 保存失败"); + return resp; + } catch (IllegalArgumentException e) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("invalid PDF base64"); + return resp; + } + if (request.getFileSize() != null && request.getFileSize() > 0) { + task.setFileSize(request.getFileSize()); + } + changed = true; + } + + if (!changed) { + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("No changes"); + EsignIosCaptureResponse.EsignIosCaptureData data = + new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(task.getTaskId()); + resp.setData(data); + return resp; + } + + printerPdfTaskRepository.save(task); + printerEventLogService.append( + EsignPrinterEventLogService.EVENT_PDF_UPDATE, + task, + signedArchive ? "CMS 更新签名 PDF 归档" : "CMS 更新 PDF 任务", + null); + printerQueueNotifier.notifyChangedAfterCommit(); + log.info( + "Printer PDF task updated taskId={}, fileName={}, signedArchive={}", + task.getTaskId(), + task.getFileName(), + signedArchive); + + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("Task updated"); + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(task.getTaskId()); + resp.setData(data); + return resp; + } + + @Transactional + public EsignIosCaptureResponse reuploadSignedToOhip(String taskId) { + EsignIosCaptureResponse resp = new EsignIosCaptureResponse(); + if (StringUtils.isBlank(taskId)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("taskId required"); + return resp; + } + Optional opt = printerPdfTaskRepository.findById(taskId.trim()); + if (opt.isEmpty()) { + resp.setSuccess(false); + resp.setCode("404"); + resp.setMessage("Task not found"); + return resp; + } + EsignPrinterPdfTask task = opt.get(); + if (!isSignedArchiveTask(task)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("仅已签名归档可推送至 OPERA"); + return resp; + } + + SignedRegistrationCardUploadResult ohipResult = ohipUploadService.reuploadFromArchive(task.getTaskId()); + log.info( + "reuploadSignedToOhip taskId={}, success={}, statusCode={}", + task.getTaskId(), + ohipResult.isSuccess(), + ohipResult.getStatusCode()); + + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(task.getTaskId()); + data.setArchived(true); + return buildOhipUploadResponse(resp, data, ohipResult); + } + + private static boolean isSignedArchiveTask(EsignPrinterPdfTask task) { + return task != null + && (task.getSignedAt() != null + || StringUtils.isNotBlank(task.getSignedPdfBase64()) + || StringUtils.isNotBlank(task.getSignedMetaJson())); + } + + private static void applySignedSubmitIdentifiers( + EsignPrinterPdfTask task, String confirmationFromRequest, String reservationIdFromRequest) { + if (task == null) { + return; + } + if (StringUtils.isNotBlank(confirmationFromRequest)) { + if (StringUtils.isBlank(task.getOcrConfirmationNo())) { + task.setOcrConfirmationNo(confirmationFromRequest.trim()); + } + if (StringUtils.isBlank(task.getOhipConfirmationNo())) { + task.setOhipConfirmationNo(confirmationFromRequest.trim()); + } + } + if (StringUtils.isNotBlank(reservationIdFromRequest) + && !"printer-import".equalsIgnoreCase(reservationIdFromRequest.trim())) { + task.setOhipReservationId(reservationIdFromRequest.trim()); + } + } + + private static boolean shouldScheduleSignedArchiveOcr(EsignPrinterPdfTask task, boolean hasSignedPdf) { + if (!hasSignedPdf || task == null) { + return false; + } + if (StringUtils.isNotBlank(task.getOcrConfirmationNo()) + || StringUtils.isNotBlank(task.getOhipConfirmationNo())) { + return false; + } + if (StringUtils.isNotBlank(task.getOhipReservationId()) + && !"printer-import".equalsIgnoreCase(task.getOhipReservationId())) { + return false; + } + return !EsignPrinterPdfArchiveOcrService.OCR_OK.equals(task.getOcrStatus()); + } + + private static String resolveArchiveOcrDisplayStatus(EsignPrinterPdfArchiveListRow row) { + if (row == null) { + return null; + } + String status = row.ocrStatus(); + if (EsignPrinterPdfArchiveOcrService.OCR_PENDING.equals(status) && hasArchiveSearchIdentifiers(row)) { + return EsignPrinterPdfArchiveOcrService.OCR_SKIPPED; + } + return status; + } + + private static boolean hasArchiveSearchIdentifiers(EsignPrinterPdfArchiveListRow row) { + return row != null + && (StringUtils.isNotBlank(row.ocrConfirmationNo()) + || StringUtils.isNotBlank(row.ohipConfirmationNo()) + || (StringUtils.isNotBlank(row.ohipReservationId()) + && !"printer-import".equalsIgnoreCase(row.ohipReservationId())) + || EsignPrinterOhipStatus.SUCCESS.equals(row.ohipStatus())); + } + + private EsignIosCaptureResponse buildOhipUploadResponse( + EsignIosCaptureResponse resp, + EsignIosCaptureResponse.EsignIosCaptureData data, + SignedRegistrationCardUploadResult ohipResult) { + if (ohipResult.isInvoked()) { + data.setConfirmationNumber(ohipResult.getConfirmationNumber()); + data.setReservationId(ohipResult.getReservationId()); + data.setOhipUploaded(ohipResult.isSuccess()); + if (!ohipResult.isSuccess()) { + resp.setSuccess(false); + resp.setCode(ohipResult.getStatusCode() > 0 ? String.valueOf(ohipResult.getStatusCode()) : "502"); + resp.setMessage(StringUtils.defaultIfBlank( + ohipResult.getErrorMessage(), + "提交 OPERA 失败")); + resp.setData(data); + return resp; + } + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("已提交至 OPERA(确认号 " + + ohipResult.getConfirmationNumber() + + ",reservation " + + ohipResult.getReservationId() + ")"); + resp.setData(data); + return resp; + } + resp.setSuccess(false); + resp.setCode("502"); + resp.setMessage(StringUtils.defaultIfBlank( + ohipResult.getErrorMessage(), + "未上传 OPERA,请检查 signed-upload / OAuth 配置")); + resp.setData(data); + return resp; + } + + public record TaskPdfContent(byte[] bytes, String fileName) {} + + @Transactional(readOnly = true) + public Optional getTaskPdfContent(String taskId) { + return getTaskPdfContent(taskId, null, null); + } + + @Transactional(readOnly = true) + public Optional getTaskPdfContent(String taskId, Long appUserId) { + return getTaskPdfContent(taskId, appUserId, null); + } + + @Transactional(readOnly = true) + public Optional getTaskPdfContent(String taskId, Long appUserId, String clientIp) { + if (StringUtils.isBlank(taskId)) { + return Optional.empty(); + } + Optional opt = printerPdfTaskRepository.findById(taskId.trim()); + if (opt.isEmpty()) { + return Optional.empty(); + } + EsignPrinterPdfTask task = opt.get(); + if (appUserId != null + && !printerPushService.isTaskVisibleToUser(task.getTaskId(), appUserId, clientIp)) { + return Optional.empty(); + } + Optional pdfBytes = pdfFileStorage.readOriginalBytes(task); + if (pdfBytes.isEmpty()) { + return Optional.empty(); + } + String fileName = StringUtils.defaultIfBlank(task.getFileName(), "printer-import.pdf"); + return Optional.of(new TaskPdfContent(pdfBytes.get(), fileName)); + } + + @Transactional(readOnly = true) + public EsignPrinterPdfFieldExtractResponse extractFieldText( + String taskId, + EsignPrinterPdfFieldExtractRequest request, + Long appUserId, + String clientIp) { + if (request == null) { + return EsignPrinterPdfFieldExtractResponse.fail("400", "request required"); + } + String inputFormat = StringUtils.defaultIfBlank(request.getInputFormat(), "text"); + String resolvedTaskId = StringUtils.firstNonBlank( + StringUtils.trimToNull(taskId), + StringUtils.trimToNull(request.getTaskId())); + boolean hasCrop = StringUtils.isNotBlank(request.getCropImageBase64()); + int cropLen = hasCrop ? request.getCropImageBase64().trim().length() : 0; + log.info( + "field OCR request taskId={} format={} hasCrop={} cropLen={} page={} rect=[{},{},{},{}] userId={}", + resolvedTaskId, + inputFormat, + hasCrop, + cropLen, + request.getPage(), + request.getLeft(), + request.getTop(), + request.getWidth(), + request.getHeight(), + appUserId); + + if (hasCrop) { + Optional fromCrop = + pdfSignFieldTextExtractor.extractFromCropImage(request.getCropImageBase64(), inputFormat); + if (fromCrop.isPresent()) { + String text = fromCrop.get().trim(); + log.info( + "field OCR crop ok taskId={} format={} textLen={} sample={}", + resolvedTaskId, + inputFormat, + text.length(), + EsignLogMaskUtil.truncate(text)); + return EsignPrinterPdfFieldExtractResponse.ok( + text, + inputFormat, + registrationConfirmationOcrExtractor.getLastOcrHint()); + } + log.info( + "field OCR crop empty taskId={} format={} hint={}", + resolvedTaskId, + inputFormat, + registrationConfirmationOcrExtractor.getLastOcrHint()); + } + + if (StringUtils.isBlank(resolvedTaskId)) { + String unavailable = registrationConfirmationOcrExtractor.getUnavailableReason(); + String reason = StringUtils.firstNonBlank( + registrationConfirmationOcrExtractor.getLastOcrHint(), + unavailable, + "crop OCR failed and taskId missing"); + String code = isOcrInfrastructureFailure(reason, unavailable) ? "503" : "404"; + log.warn("field OCR fail code={} taskId={} format={} reason={}", code, resolvedTaskId, inputFormat, reason); + return EsignPrinterPdfFieldExtractResponse.fail(code, reason); + } + if (request.getLeft() == null + || request.getTop() == null + || request.getWidth() == null + || request.getHeight() == null) { + log.warn("field OCR fail code=400 taskId={} reason=field rect required", resolvedTaskId); + return EsignPrinterPdfFieldExtractResponse.fail("400", "field rect required"); + } + Optional pdfOpt = getTaskPdfContent(resolvedTaskId, appUserId, clientIp); + if (pdfOpt.isEmpty()) { + log.warn("field OCR fail code=404 taskId={} reason=PDF not found", resolvedTaskId); + return EsignPrinterPdfFieldExtractResponse.fail("404", "PDF not found"); + } + int page = request.getPage() != null ? Math.max(0, request.getPage()) : 0; + Optional text = pdfSignFieldTextExtractor.extract( + pdfOpt.get().bytes(), + page, + request.getLeft(), + request.getTop(), + request.getWidth(), + request.getHeight(), + inputFormat); + String ocrHint = registrationConfirmationOcrExtractor.getLastOcrHint(); + if (text.isEmpty() || StringUtils.isBlank(text.get())) { + String unavailable = registrationConfirmationOcrExtractor.getUnavailableReason(); + String reason = StringUtils.firstNonBlank(ocrHint, unavailable, "no text extracted"); + String code = isOcrInfrastructureFailure(reason, unavailable) ? "503" : "404"; + log.warn( + "field OCR region fail code={} taskId={} format={} page={} reason={}", + code, + resolvedTaskId, + inputFormat, + page, + reason); + return EsignPrinterPdfFieldExtractResponse.fail(code, reason); + } + String okText = text.get().trim(); + log.info( + "field OCR region ok taskId={} format={} page={} textLen={} sample={} hint={}", + resolvedTaskId, + inputFormat, + page, + okText.length(), + EsignLogMaskUtil.truncate(okText), + ocrHint); + return EsignPrinterPdfFieldExtractResponse.ok(okText, inputFormat, ocrHint); + } + + private static boolean isOcrInfrastructureFailure(String reason, String unavailable) { + String combined = StringUtils.defaultString(reason) + " " + StringUtils.defaultString(unavailable); + return combined.contains("Tesseract") + || combined.contains("tessdata") + || combined.contains("libtesseract") + || combined.contains("OCR 已关闭") + || combined.contains("Could not initialize") + || combined.contains("native library"); + } + + @Transactional(readOnly = true) + public Optional getTaskSignedPdfContent(String taskId) { + if (StringUtils.isBlank(taskId)) { + return Optional.empty(); + } + Optional opt = printerPdfTaskRepository.findById(taskId.trim()); + if (opt.isEmpty()) { + return Optional.empty(); + } + EsignPrinterPdfTask task = opt.get(); + String base64 = pdfFileStorage.materializeSignedBase64(task); + if (StringUtils.isBlank(base64) + && StringUtils.isNotBlank(task.getSignedMetaJson()) + && pdfFileStorage.hasOriginal(task) + && StringUtils.isNotBlank(task.getSignFieldsJson())) { + try { + var signFields = RegistrationCardSignFieldParser.parse(task.getSignFieldsJson()); + base64 = SignedRegistrationPdfComposer.composeBase64( + pdfFileStorage.materializeOriginalBase64(task), + task.getSignedMetaJson(), + signFields); + log.info( + "getTaskSignedPdfContent composed from meta taskId={}, signedPdfLen={}", + taskId, + StringUtils.length(base64)); + } catch (Exception e) { + log.warn("getTaskSignedPdfContent compose from meta failed taskId={}", taskId, e); + } + } + if (StringUtils.isBlank(base64)) { + return Optional.empty(); + } + try { + byte[] bytes = Base64.getDecoder().decode(base64); + if (bytes.length == 0) { + return Optional.empty(); + } + String fileName = StringUtils.defaultIfBlank(task.getFileName(), "signed.pdf"); + if (!fileName.toLowerCase().endsWith(".pdf")) { + fileName = fileName + "-signed.pdf"; + } + return Optional.of(new TaskPdfContent(bytes, fileName)); + } catch (IllegalArgumentException e) { + log.warn("Invalid signed PDF base64 taskId={}", taskId); + return Optional.empty(); + } + } + + @Transactional(readOnly = true) + public EsignPrinterPdfTaskDetailResponse getTaskDetail(String taskId) { + return getTaskDetail(taskId, null, null); + } + + @Transactional(readOnly = true) + public EsignPrinterPdfTaskDetailResponse getTaskDetail(String taskId, Long appUserId, String clientIp) { + EsignPrinterPdfTaskDetailResponse resp = new EsignPrinterPdfTaskDetailResponse(); + if (StringUtils.isBlank(taskId)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("taskId required"); + return resp; + } + Optional opt = printerPdfTaskRepository.findById(taskId.trim()); + if (opt.isEmpty()) { + resp.setSuccess(false); + resp.setCode("404"); + resp.setMessage("Task not found"); + return resp; + } + EsignPrinterPdfTask task = opt.get(); + if (appUserId != null + && !printerPushService.isTaskVisibleToUser(task.getTaskId(), appUserId, clientIp)) { + resp.setSuccess(false); + resp.setCode("404"); + resp.setMessage("Task not found"); + return resp; + } + return toDetailResponse(task, resp); + } + + @Transactional(readOnly = true) + public EsignPrinterPdfTaskDetailResponse getTaskDetail(String taskId, Long appUserId) { + return getTaskDetail(taskId, appUserId, null); + } + + @Transactional(readOnly = true) + public List listItemsForBucket( + String bucket, String hotelId, Long userId, String clientIp) { + if (userId == null) { + return new ArrayList<>(); + } + String b = StringUtils.isBlank(bucket) ? "pending" : bucket.trim().toLowerCase(); + Instant cutoff = taskWindowCutoff(); + PageRequest page = PageRequest.of(0, LIST_PAGE_SIZE); + List rows; + if ("signed".equals(b)) { + rows = printerPdfTaskRepository.findListRowsSignedSince(cutoff, page); + } else if ("voided".equals(b)) { + rows = printerPdfTaskRepository.findListRowsUnsignedBefore(cutoff, page); + } else { + rows = printerPdfTaskRepository.findListRowsUnsignedSince(cutoff, page); + } + + boolean filterByHotel = StringUtils.isNotBlank(hotelId); + String normalizedHotelId = filterByHotel ? EsignReservationParamUtil.normalizeParam(hotelId) : null; + Set assignedTaskIds = printerPushService.assignedTaskIdsForUser(userId, clientIp); + List items = new ArrayList<>(); + for (EsignPrinterPdfTaskListRow row : rows) { + if (filterByHotel + && StringUtils.isNotBlank(row.getHotelId()) + && !normalizedHotelId.equals(row.getHotelId())) { + continue; + } + if (!assignedTaskIds.contains(row.getTaskId())) { + continue; + } + items.add(toPendingItem(row, "signed".equals(b))); + } + return items; + } + + /** @deprecated 使用 {@link #listItemsForBucket(String, String, Long, String)} */ + @Transactional(readOnly = true) + public List listItemsForBucket( + String bucket, String hotelId, Long userId) { + return listItemsForBucket(bucket, hotelId, userId, null); + } + + /** @deprecated 使用 {@link #listItemsForBucket(String, String, Long, String)} */ + @Transactional(readOnly = true) + public List listItemsForBucket(String bucket, String hotelId) { + return listItemsForBucket(bucket, hotelId, null, null); + } + + @Transactional(readOnly = true) + public EsignPrinterDashboardResponse getDashboard() { + Instant cutoff = taskWindowCutoff(); + Instant now = Instant.now(); + + long pendingCount = printerPdfTaskRepository.countByStatusAndCreatedAtGreaterThanEqual( + STATUS_PENDING, cutoff); + long voidedCount = printerPdfTaskRepository.countByStatusAndCreatedAtLessThan( + STATUS_PENDING, cutoff); + long signedCount = printerPdfTaskRepository.countByStatus(STATUS_COMPLETED); + long totalSubmitted24h = printerPdfTaskRepository.countByCreatedAtGreaterThanEqual(cutoff); + long signedInWindow = printerPdfTaskRepository.countByStatusAndCreatedAtGreaterThanEqual( + STATUS_COMPLETED, cutoff); + + Double completionRate = null; + if (totalSubmitted24h > 0) { + completionRate = Math.round((signedInWindow * 10000.0) / totalSubmitted24h) / 100.0; + } + + List recentRows = printerPdfTaskRepository.findRecentListRows( + PageRequest.of(0, DASHBOARD_RECENT_SIZE)); + Map registryByIp = buildPcRegistryByIpMap(); + List recentTasks = + new ArrayList<>(); + for (EsignPrinterPdfTaskListRow row : recentRows) { + recentTasks.add(toDashboardRecentTask(row, cutoff, registryByIp)); + } + + List pcSources = new ArrayList<>(); + for (EsignPrinterPcSourceRow row : printerPdfTaskRepository.aggregatePcSourcesSince(cutoff)) { + pcSources.add(toPcSourceStat(row, registryByIp)); + } + + List tabletDevices = new ArrayList<>(); + List activeUsers = + userPresenceService.dedupeUsersByClientIp( + esignUserRepository.findByStatusOrderByLastLoginAtDesc("ACTIVE")); + for (EsignUser user : activeUsers) { + tabletDevices.add(toTabletDeviceStat(user)); + } + tabletDevices.sort(Comparator + .comparing( + (EsignPrinterDashboardResponse.EsignPrinterTabletDeviceStat t) -> + Boolean.TRUE.equals(t.getOnline()), + Comparator.reverseOrder()) + .thenComparing(EsignPrinterDashboardResponse.EsignPrinterTabletDeviceStat::isPushEnabled) + .reversed() + .thenComparing( + EsignPrinterDashboardResponse.EsignPrinterTabletDeviceStat::getLastLoginAt, + Comparator.nullsLast(Comparator.reverseOrder()))); + + EsignPrinterDashboardResponse resp = new EsignPrinterDashboardResponse(); + EsignPrinterDashboardResponse.EsignPrinterDashboardData data = + new EsignPrinterDashboardResponse.EsignPrinterDashboardData(); + data.setGeneratedAt(now.toString()); + data.setTaskWindowHours(TASK_WINDOW_HOURS); + data.setPendingCount(pendingCount); + data.setSignedCount(signedCount); + data.setVoidedCount(voidedCount); + data.setTotalSubmitted24h(totalSubmitted24h); + data.setCompletionRatePercent(completionRate); + data.setOhipPendingCount(printerPdfTaskRepository.countBySignedAtIsNotNullAndOhipStatus( + EsignPrinterOhipStatus.PENDING)); + data.setOhipFailedCount(printerPdfTaskRepository.countBySignedAtIsNotNullAndOhipStatus( + EsignPrinterOhipStatus.FAILED)); + data.setRecentTasks(recentTasks); + data.setPcSources(pcSources); + data.setTabletDevices(tabletDevices); + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("ok"); + resp.setData(data); + return resp; + } + + @Transactional + public EsignIosCaptureResponse completeTask(String taskId, String externalId) { + EsignIosCaptureResponse resp = new EsignIosCaptureResponse(); + Optional opt = resolveTask(taskId, externalId); + if (opt.isEmpty()) { + resp.setSuccess(false); + resp.setCode("404"); + resp.setMessage("Printer PDF task not found"); + return resp; + } + EsignPrinterPdfTask task = opt.get(); + if (STATUS_COMPLETED.equals(task.getStatus())) { + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("Already completed"); + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(task.getTaskId()); + resp.setData(data); + return resp; + } + task.setStatus(STATUS_COMPLETED); + Instant now = Instant.now(); + task.setCompletedAt(now); + task.setArchivedAt(now); + byte[] pdfBytes = pdfFileStorage.readOriginalBytes(task).orElse(null); + archiveOcrService.extractFromPdfBytesAndApply(task, pdfBytes, null); + printerPdfTaskRepository.save(task); + printerEventLogService.append( + EsignPrinterEventLogService.EVENT_TASK_COMPLETE, + task, + "平板签收完成", + null); + printerQueueNotifier.notifyChangedAfterCommit(); + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("Completed"); + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(task.getTaskId()); + resp.setData(data); + return resp; + } + + @Transactional + public EsignIosCaptureResponse submitUsbDirectSigned( + EsignPrinterPdfUsbSignedSubmitRequest request, Long signedByUserId, String signedClientIp) { + EsignIosCaptureResponse resp = new EsignIosCaptureResponse(); + if (request == null) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("request body required"); + return resp; + } + String reservationId = trim(request.getReservationId(), 64); + String externalId = trim(request.getExternalId(), 64); + String originalBase64 = normalizePdfBase64(request.getPdfBase64()); + String signaturesJson = trim(request.getSignaturesJson(), 2_000_000); + if (StringUtils.isBlank(reservationId) || "printer-import".equalsIgnoreCase(reservationId)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("reservationId required"); + return resp; + } + if (StringUtils.isBlank(originalBase64)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("pdfBase64 required"); + return resp; + } + if (StringUtils.isBlank(signaturesJson)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("signaturesJson required"); + return resp; + } + if (originalBase64.length() > MAX_BASE64_LEN) { + resp.setSuccess(false); + resp.setCode("413"); + resp.setMessage("PDF too large"); + return resp; + } + if (StringUtils.isBlank(externalId)) { + externalId = "usb-" + reservationId + "-" + UUID.randomUUID().toString().replace("-", "").substring(0, 12); + } + + Instant now = Instant.now(); + Optional existing = printerPdfTaskRepository.findFirstByExternalId(externalId); + EsignPrinterPdfTask task; + if (existing.isPresent()) { + task = existing.get(); + if (STATUS_COMPLETED.equals(task.getStatus()) && StringUtils.isNotBlank(task.getSignedPdfBase64())) { + EsignIosCaptureResponse.EsignIosCaptureData data = + new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(task.getTaskId()); + data.setArchived(true); + applyNextSurveyAfterPdfSign(data, task.getHotelId()); + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("Already archived"); + resp.setData(data); + return resp; + } + } else { + task = new EsignPrinterPdfTask(); + task.setTaskId(UUID.randomUUID().toString()); + task.setExternalId(externalId); + task.setCreatedAt(now); + } + + String fileName = trim(request.getFileName(), MAX_FILE_NAME_LEN); + if (StringUtils.isBlank(fileName)) { + fileName = "registration-" + reservationId + ".pdf"; + } + task.setFileName(fileName); + task.setStatus(STATUS_PENDING); + String hotelId = trim(request.getHotelId(), 64); + if (StringUtils.isNotBlank(hotelId)) { + task.setHotelId(EsignReservationParamUtil.normalizeParam(hotelId)); + } + task.setAgentSource("USB"); + task.setOhipReservationId(reservationId); + try { + task.setPdfBase64(pdfFileStorage.persistOriginalFromBase64(task.getTaskId(), originalBase64)); + } catch (IOException e) { + log.error("USB signed persist original failed taskId={}", task.getTaskId(), e); + resp.setSuccess(false); + resp.setCode("500"); + resp.setMessage("PDF 保存失败"); + return resp; + } + + String signedBase64; + try { + var signFields = RegistrationCardSignFieldParser.parse(request.getSignFieldsJson()); + signedBase64 = SignedRegistrationPdfComposer.composeBase64( + originalBase64, signaturesJson, signFields); + SignedPdfComposeValidator.assertRequiredSignaturesPresent( + signaturesJson, request.getSignFieldsJson()); + } catch (IllegalStateException e) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage(e.getMessage()); + return resp; + } catch (Exception e) { + log.warn("USB compose signed PDF failed externalId={}", externalId, e); + resp.setSuccess(false); + resp.setCode("500"); + resp.setMessage("签名 PDF 合成失败,无法归档"); + return resp; + } + + try { + byte[] signedPdfBytes = Base64.getDecoder().decode(signedBase64); + task.setSignedPdfBase64(pdfFileStorage.persistSigned(task.getTaskId(), signedPdfBytes)); + } catch (Exception e) { + log.error("USB persist signed PDF failed taskId={}", task.getTaskId(), e); + resp.setSuccess(false); + resp.setCode("500"); + resp.setMessage("签名 PDF 保存失败"); + return resp; + } + + task.setSignedMetaJson(signaturesJson); + if (StringUtils.isNotBlank(request.getSignFieldsJson())) { + task.setSignFieldsJson(trim(request.getSignFieldsJson(), 2_000_000)); + } + task.setSignedAt(now); + task.setStatus(STATUS_COMPLETED); + task.setCompletedAt(now); + task.setArchivedAt(now); + if (signedByUserId != null) { + task.setSignedByUserId(signedByUserId); + } + if (StringUtils.isNotBlank(signedClientIp)) { + task.setSignedClientIp(trim(signedClientIp, 64)); + } + if (StringUtils.isNotBlank(request.getDeviceId())) { + task.setSignedDeviceId(trim(request.getDeviceId(), 64)); + } + if (StringUtils.isNotBlank(request.getDeviceName())) { + task.setSignedDeviceName(trim(request.getDeviceName(), 128)); + } + task.setOcrStatus(EsignPrinterPdfArchiveOcrService.OCR_SKIPPED); + task.setOcrExtractedAt(now); + task.setOhipStatus(EsignPrinterOhipStatus.PENDING); + task.setOhipLastError(null); + task.setOhipRetryCount(0); + printerPdfTaskRepository.save(task); + // 不调用 autoAssignOnSubmit:平板已持有 PDF,避免再推一条 Java PRINTER_PDF + printerEventLogService.append( + EsignPrinterEventLogService.EVENT_SIGNED_UPLOAD, + task, + "USB 直传签名已归档", + request.getSignatureCount() != null + ? "{\"signatureCount\":" + request.getSignatureCount() + ",\"source\":\"USB\"}" + : "{\"source\":\"USB\"}"); + printerQueueNotifier.notifyChangedAfterCommit(); + + // Profile 与 OPERA 上传全部异步,避免阻塞平板 + String profileId = trim(request.getProfileId(), 64); + String profileType = trim(request.getProfileType(), 64); + signedSubmitAsyncScheduler.scheduleAfterSignedSubmit( + task.getTaskId(), + null, + request.getSignFieldsJson(), + profileId, + profileType, + reservationId, + task.getHotelId(), + false); + + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(task.getTaskId()); + data.setArchived(true); + applyNextSurveyAfterPdfSign(data, task.getHotelId()); + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("签名已归档,OPERA 后台上传中"); + resp.setData(data); + log.info( + "USB signed archived taskId={}, reservationId={}, hotelId={}", + task.getTaskId(), + reservationId, + task.getHotelId()); + return resp; + } + + @Transactional + public EsignIosCaptureResponse submitSignedPdf( + EsignPrinterPdfSignedSubmitRequest request, Long signedByUserId, String signedClientIp) { + log.info( + "submitSignedPdf taskId={}, externalId={}, signatureCount={}, hasBase64={}, hasSignaturesJson={}, hasSignFieldsJson={}", + request != null ? request.getTaskId() : null, + request != null ? request.getExternalId() : null, + request != null ? request.getSignatureCount() : null, + request != null && StringUtils.isNotBlank(request.getBase64()), + request != null && StringUtils.isNotBlank(request.getSignaturesJson()), + request != null && StringUtils.isNotBlank(request.getSignFieldsJson())); + EsignIosCaptureResponse resp = new EsignIosCaptureResponse(); + if (request == null) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("request body required"); + return resp; + } + Optional opt = resolveTask(request.getTaskId(), request.getExternalId()); + if (opt.isEmpty()) { + resp.setSuccess(false); + resp.setCode("404"); + resp.setMessage("Printer PDF task not found"); + return resp; + } + String signedBase64 = normalizePdfBase64(request.getBase64()); + String signaturesJson = trim(request.getSignaturesJson(), 2_000_000); + if (StringUtils.isAllBlank(signedBase64, signaturesJson)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("base64 or signaturesJson required"); + return resp; + } + if (signedBase64 != null && signedBase64.length() > MAX_BASE64_LEN) { + resp.setSuccess(false); + resp.setCode("413"); + resp.setMessage("Signed PDF too large"); + return resp; + } + + EsignPrinterPdfTask task = opt.get(); + log.info( + "submitSignedPdf task resolved: taskId={}, hotelId={}, fileName={}, hasOriginalPdf={}", + task.getTaskId(), + task.getHotelId(), + task.getFileName(), + pdfFileStorage.hasOriginal(task)); + Instant now = Instant.now(); + if (StringUtils.isBlank(signedBase64) && StringUtils.isNotBlank(signaturesJson) + && pdfFileStorage.hasOriginal(task)) { + try { + var signFields = RegistrationCardSignFieldParser.parse(request.getSignFieldsJson()); + signedBase64 = com.sjmresorts.opsapp.esign.printer.dtos.SignedRegistrationPdfComposer.composeBase64( + pdfFileStorage.materializeOriginalBase64(task), + signaturesJson, + signFields); + log.info( + "submitSignedPdf composed signed PDF taskId={}, signedPdfLen={}, signFields={}", + task.getTaskId(), + StringUtils.length(signedBase64), + signFields.size()); + } catch (Exception e) { + log.warn("Compose signed PDF before archive failed taskId={}", task.getTaskId(), e); + } + } + if (StringUtils.isBlank(signedBase64) && StringUtils.isNotBlank(signaturesJson)) { + log.warn( + "submitSignedPdf no signed PDF after compose taskId={}, hasOriginalPdf={}", + task.getTaskId(), + pdfFileStorage.hasOriginal(task)); + resp.setSuccess(false); + resp.setCode("500"); + resp.setMessage("签名 PDF 合成失败,无法归档"); + return resp; + } + try { + SignedPdfComposeValidator.assertRequiredSignaturesPresent(signaturesJson, request.getSignFieldsJson()); + } catch (IllegalStateException e) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage(e.getMessage()); + return resp; + } + byte[] signedPdfBytes = null; + if (StringUtils.isNotBlank(signedBase64)) { + try { + signedPdfBytes = Base64.getDecoder().decode(signedBase64); + task.setSignedPdfBase64( + pdfFileStorage.persistSigned(task.getTaskId(), signedPdfBytes)); + } catch (IOException e) { + log.error("submitSignedPdf persist signed PDF failed taskId={}", task.getTaskId(), e); + resp.setSuccess(false); + resp.setCode("500"); + resp.setMessage("签名 PDF 保存失败"); + return resp; + } catch (IllegalArgumentException e) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("invalid signed PDF base64"); + return resp; + } + } + // OPERA 只读归档 signed.pdf:未落盘则不得标完成 / 触发 OHIP + if (signedPdfBytes == null + || signedPdfBytes.length == 0 + || StringUtils.isBlank(task.getSignedPdfBase64()) + || !pdfFileStorage.hasSigned(task)) { + log.error( + "submitSignedPdf missing signed archive taskId={}, bytes={}, ref={}, hasSignedFile={}", + task.getTaskId(), + signedPdfBytes != null ? signedPdfBytes.length : 0, + task.getSignedPdfBase64(), + pdfFileStorage.hasSignedFile(task.getTaskId())); + resp.setSuccess(false); + resp.setCode("500"); + resp.setMessage("签名 PDF 归档失败,无法上传 OPERA"); + return resp; + } + if (StringUtils.isNotBlank(signaturesJson)) { + task.setSignedMetaJson(signaturesJson); + } + if (StringUtils.isNotBlank(request.getSignFieldsJson())) { + task.setSignFieldsJson(trim(request.getSignFieldsJson(), 2_000_000)); + } + task.setOhipStatus(EsignPrinterOhipStatus.PENDING); + task.setOhipLastError(null); + task.setOhipRetryCount(0); + if (signedByUserId != null) { + task.setSignedByUserId(signedByUserId); + } + String normalizedClientIp = EsignUserPresenceService.normalizeClientIp(signedClientIp); + if (StringUtils.isNotBlank(normalizedClientIp)) { + task.setSignedClientIp(normalizedClientIp); + } + String deviceId = trim(request.getDeviceId(), 64); + String deviceName = trim(request.getDeviceName(), 128); + if (StringUtils.isNotBlank(deviceId)) { + task.setSignedDeviceId(deviceId); + } + if (StringUtils.isNotBlank(deviceName)) { + task.setSignedDeviceName(deviceName); + } + task.setSignedAt(now); + task.setStatus(STATUS_COMPLETED); + if (task.getCompletedAt() == null) { + task.setCompletedAt(now); + } + if (task.getArchivedAt() == null) { + task.setArchivedAt(now); + } + String confirmationFromRequest = null; + String ignoredConfirmationFromRequest = trim(request.getConfirmationNumber(), 64); + String reservationIdFromRequest = trim(request.getReservationId(), 64); + if (StringUtils.isNotBlank(ignoredConfirmationFromRequest)) { + log.info( + "submitSignedPdf ignore request confirmation for printer flow taskId={}, requestConfirmation={}", + task.getTaskId(), + ignoredConfirmationFromRequest); + } + applySignedSubmitIdentifiers(task, confirmationFromRequest, reservationIdFromRequest); + boolean scheduleSignedArchiveOcr = + shouldScheduleSignedArchiveOcr(task, signedPdfBytes != null && signedPdfBytes.length > 0); + if (scheduleSignedArchiveOcr) { + task.setOcrStatus(EsignPrinterPdfArchiveOcrService.OCR_PENDING); + } else if (signedPdfBytes != null && signedPdfBytes.length > 0) { + if (StringUtils.isBlank(task.getOcrStatus()) + || EsignPrinterPdfArchiveOcrService.OCR_PENDING.equals(task.getOcrStatus())) { + task.setOcrStatus(EsignPrinterPdfArchiveOcrService.OCR_SKIPPED); + task.setOcrExtractedAt(now); + } + } + printerPdfTaskRepository.save(task); + printerEventLogService.append( + EsignPrinterEventLogService.EVENT_SIGNED_UPLOAD, + task, + "签名结果已归档", + request.getSignatureCount() != null ? "{\"signatureCount\":" + request.getSignatureCount() + "}" : null); + log.info( + "submitSignedPdf archived taskId={}, ocrStatus={}, guest={}, conf={}, reservation={}, archivedAt={}", + task.getTaskId(), + task.getOcrStatus(), + task.getOcrGuestName(), + StringUtils.firstNonBlank(task.getOhipConfirmationNo(), task.getOcrConfirmationNo()), + task.getOhipReservationId(), + task.getArchivedAt()); + printerQueueNotifier.notifyChangedAfterCommit(); + + // Profile 解析与 OPERA 上传均异步,避免阻塞平板「归档」等待 + String profileId = trim(request.getProfileId(), 64); + String profileType = trim(request.getProfileType(), 64); + signedSubmitAsyncScheduler.scheduleAfterSignedSubmit( + task.getTaskId(), + confirmationFromRequest, + request.getSignFieldsJson(), + profileId, + profileType, + reservationIdFromRequest, + StringUtils.defaultIfBlank(trim(request.getHotelId(), 64), task.getHotelId()), + scheduleSignedArchiveOcr); + + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(task.getTaskId()); + data.setArchived(true); + applyNextSurveyAfterPdfSign(data, task.getHotelId()); + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("签名已归档"); + resp.setData(data); + return resp; + } + + @Transactional(readOnly = true) + public EsignPrinterPdfArchiveResponse listArchive(int page, int size, EsignPrinterPdfArchiveFilter filter) { + int safePage = Math.max(page, 0); + int safeSize = size > 0 ? Math.min(size, 100) : ARCHIVE_PAGE_SIZE; + PageRequest pageable = PageRequest.of(safePage, safeSize); + EsignPrinterPdfArchiveFilter safeFilter = + filter != null ? filter : EsignPrinterPdfArchiveFilter.builder().build(); + + long total = archiveListQuery.count(safeFilter); + List rows = archiveListQuery.findPage(safeFilter, pageable); + Map userNameById = resolveSignedByUserNames(rows); + + List items = new ArrayList<>(); + for (EsignPrinterPdfArchiveListRow row : rows) { + items.add(toArchiveItem(row, userNameById)); + } + + EsignPrinterPdfArchiveResponse resp = new EsignPrinterPdfArchiveResponse(); + EsignPrinterPdfArchiveResponse.EsignPrinterPdfArchiveData data = + new EsignPrinterPdfArchiveResponse.EsignPrinterPdfArchiveData(); + data.setTotal(total); + data.setPage(safePage); + data.setSize(safeSize); + data.setItems(items); + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("ok"); + resp.setData(data); + return resp; + } + + private EsignPrinterPdfArchiveResponse.EsignPrinterPdfArchiveItem toArchiveItem( + EsignPrinterPdfArchiveListRow row, Map userNameById) { + EsignPrinterPdfArchiveResponse.EsignPrinterPdfArchiveItem item = + new EsignPrinterPdfArchiveResponse.EsignPrinterPdfArchiveItem(); + item.setTaskId(row.taskId()); + item.setExternalId(row.externalId()); + item.setFileName(row.fileName()); + item.setFileSize(row.fileSize()); + item.setHotelId(row.hotelId()); + item.setStatus(row.status()); + item.setCreatedAt(row.createdAt() != null ? row.createdAt().toString() : null); + item.setCompletedAt(row.completedAt() != null ? row.completedAt().toString() : null); + item.setSignedAt(row.signedAt() != null ? row.signedAt().toString() : null); + item.setArchivedAt(row.archivedAt() != null ? row.archivedAt().toString() : null); + item.setAgentSource(row.agentSource()); + item.setSignedByUserId(row.signedByUserId()); + item.setSignedClientIp(row.signedClientIp()); + item.setSignedDeviceId(row.signedDeviceId()); + item.setSignedDeviceName(row.signedDeviceName()); + String signedByUserName = + row.signedByUserId() != null ? userNameById.get(row.signedByUserId()) : null; + item.setSignedByUserName(signedByUserName); + item.setSubmittedDeviceLabel(buildSubmittedDeviceLabel(row, signedByUserName)); + item.setHasOriginalPdf(pdfFileStorage.hasOriginalFile(row.taskId())); + boolean signed = row.signedAt() != null; + item.setHasSignedPdf(signed || pdfFileStorage.hasSignedFile(row.taskId())); + item.setHasSignedMeta(signed); + item.setOcrGuestName(row.ocrGuestName()); + item.setOcrConfirmationNo( + StringUtils.firstNonBlank(row.ohipConfirmationNo(), row.ocrConfirmationNo())); + item.setOcrPhone(row.ocrPhone()); + item.setOcrEmail(row.ocrEmail()); + item.setOcrRoomNo(row.ocrRoomNo()); + item.setOcrCheckInDate(row.ocrCheckInDate()); + item.setOcrCheckOutDate(row.ocrCheckOutDate()); + item.setOcrStatus(resolveArchiveOcrDisplayStatus(row)); + item.setOcrExtractedAt(row.ocrExtractedAt() != null ? row.ocrExtractedAt().toString() : null); + item.setOhipStatus(row.ohipStatus()); + item.setOhipLastError(row.ohipLastError()); + item.setOhipUploadedAt(row.ohipUploadedAt() != null ? row.ohipUploadedAt().toString() : null); + item.setOhipConfirmationNo(row.ohipConfirmationNo()); + item.setOhipReservationId(row.ohipReservationId()); + item.setOhipRetryCount(row.ohipRetryCount()); + return item; + } + + private Map resolveSignedByUserNames(List rows) { + if (rows == null || rows.isEmpty()) { + return Map.of(); + } + Set userIds = new LinkedHashSet<>(); + for (EsignPrinterPdfArchiveListRow row : rows) { + if (row.signedByUserId() != null) { + userIds.add(row.signedByUserId()); + } + } + if (userIds.isEmpty()) { + return Map.of(); + } + Map map = new HashMap<>(); + for (Long userId : userIds) { + esignUserRepository.findById(userId).ifPresent(user -> { + if (StringUtils.isNotBlank(user.getUserName())) { + map.put(userId, user.getUserName().trim()); + } + }); + } + return map; + } + + private static String buildSubmittedDeviceLabel( + EsignPrinterPdfArchiveListRow row, String signedByUserName) { + List parts = new ArrayList<>(); + if (StringUtils.isNotBlank(signedByUserName)) { + parts.add(signedByUserName.trim()); + } + if (StringUtils.isNotBlank(row.signedDeviceName())) { + parts.add(row.signedDeviceName().trim()); + } else if (StringUtils.isNotBlank(row.signedDeviceId())) { + parts.add(row.signedDeviceId().trim()); + } + String clientIp = EsignUserPresenceService.normalizeClientIp(row.signedClientIp()); + if (StringUtils.isNotBlank(clientIp)) { + parts.add(clientIp); + } + return String.join(" · ", parts); + } + + /** 兼容旧调用:无筛选条件 */ + @Transactional(readOnly = true) + public EsignPrinterPdfArchiveResponse listArchive(int page, int size) { + return listArchive(page, size, EsignPrinterPdfArchiveFilter.builder().build()); + } + + @Transactional + public EsignPrinterQueueResponse getQueue(String agentSource, String displayStatus, int page, int size) { + Instant cutoff = taskWindowCutoff(); + int safePage = Math.max(page, 0); + int safeSize = size > 0 ? Math.min(size, 100) : 10; + String normalizedAgent = trim(agentSource, 256); + if (StringUtils.isBlank(normalizedAgent)) { + normalizedAgent = null; + } + + QueueSqlFilter sqlFilter = resolveQueueSqlFilter(displayStatus); + String agentHostIp = normalizedAgent != null + ? EsignPrinterDeviceLinkService.extractHostIp(normalizedAgent) + : null; + Map registryByIp = buildPcRegistryByIpMap(); + PageRequest pageable = PageRequest.of(safePage, safeSize); + List rows = printerPdfTaskRepository.findQueueListRowsFiltered( + cutoff, + normalizedAgent, + agentHostIp, + sqlFilter.taskStatus(), + sqlFilter.signFilter(), + sqlFilter.pushFilter(), + pageable); + long total = printerPdfTaskRepository.countQueueRowsFiltered( + cutoff, + normalizedAgent, + agentHostIp, + sqlFilter.taskStatus(), + sqlFilter.signFilter(), + sqlFilter.pushFilter()); + + List items = new ArrayList<>(); + List taskIds = rows.stream() + .map(EsignPrinterPdfTaskListRow::getTaskId) + .filter(StringUtils::isNotBlank) + .toList(); + Map ohipByTaskId = loadOhipStatusByTaskId(taskIds); + for (EsignPrinterPdfTaskListRow row : rows) { + if (printerPushService.isAutoMode() + && STATUS_PENDING.equals(row.getStatus()) + && row.getSignedAt() == null + && !printerPushService.isTaskPushConfigured(row.getTaskId())) { + printerPdfTaskRepository.findById(row.getTaskId()).ifPresent(printerPushService::autoAssignOnSubmit); + } + String ds = resolveDisplayStatus(row, cutoff); + if (!matchesDisplayStatusFilter(displayStatus, ds)) { + continue; + } + items.add(toQueueItem(row, cutoff, registryByIp, ohipByTaskId.get(row.getTaskId()))); + } + + List agentSourceOptions = listDistinctAgentSources24h(registryByIp); + + EsignPrinterQueueResponse resp = EsignPrinterQueueResponse.ok("ok"); + EsignPrinterQueueResponse.EsignPrinterQueueData data = + new EsignPrinterQueueResponse.EsignPrinterQueueData(); + data.setGeneratedAt(Instant.now().toString()); + data.setTaskWindowHours(TASK_WINDOW_HOURS); + data.setTotal(total); + data.setPage(safePage); + data.setSize(safeSize); + data.setAgentSourceOptions(agentSourceOptions); + data.setPushMode(printerPushService.getPushMode()); + data.setItems(items); + resp.setData(data); + return resp; + } + + @Transactional(readOnly = true) + public EsignPrinterQueueWatchResponse getQueueWatch() { + Instant cutoff = taskWindowCutoff(); + long total24h = printerPdfTaskRepository.countByCreatedAtGreaterThanEqual(cutoff); + long pending = printerPdfTaskRepository.countByStatusAndCreatedAtGreaterThanEqual(STATUS_PENDING, cutoff); + long signed = printerPdfTaskRepository.countByStatusAndCreatedAtGreaterThanEqual(STATUS_COMPLETED, cutoff); + Instant latestCreated = printerPdfTaskRepository.findMaxCreatedAtSince(cutoff); + Instant latestCompleted = printerPdfTaskRepository.findMaxCompletedAtSince(cutoff); + long pushedCount = printerPdfTaskPushRepository.countDistinctPushedTasksSince(cutoff); + Instant latestPushAssigned = printerPdfTaskPushRepository.findMaxAssignedAtForTasksSince(cutoff); + + String revision = total24h + + "|" + + pending + + "|" + + signed + + "|" + + pushedCount + + "|" + + (latestCreated != null ? latestCreated.toEpochMilli() : 0) + + "|" + + (latestCompleted != null ? latestCompleted.toEpochMilli() : 0) + + "|" + + (latestPushAssigned != null ? latestPushAssigned.toEpochMilli() : 0); + + EsignPrinterQueueWatchResponse resp = EsignPrinterQueueWatchResponse.ok("ok"); + EsignPrinterQueueWatchResponse.EsignPrinterQueueWatchData data = + new EsignPrinterQueueWatchResponse.EsignPrinterQueueWatchData(); + data.setRevision(revision); + data.setTotal24h(total24h); + data.setPendingCount(pending); + data.setSignedCount(signed); + data.setLatestCreatedAt(latestCreated != null ? latestCreated.toString() : null); + data.setLatestCompletedAt(latestCompleted != null ? latestCompleted.toString() : null); + data.setTaskWindowHours(TASK_WINDOW_HOURS); + resp.setData(data); + return resp; + } + + @Transactional(readOnly = true) + public List listDistinctAgentSources24h() { + return listDistinctAgentSources24h(buildPcRegistryByIpMap()); + } + + private List listDistinctAgentSources24h(Map registryByIp) { + Instant cutoff = taskWindowCutoff(); + LinkedHashSet canonical = new LinkedHashSet<>(); + for (EsignPrinterPcSourceRow pc : printerPdfTaskRepository.aggregatePcSourcesSince(cutoff)) { + if (StringUtils.isNotBlank(pc.getAgentSource())) { + String key = normalizeAgentSource(pc.getAgentSource()); + canonical.add(resolveCanonicalPcAgentKey(key, registryByIp)); + } + } + for (String regSource : printerPcRegistryService.listEnabledAgentSources()) { + if (StringUtils.isNotBlank(regSource)) { + canonical.add(normalizeAgentSource(regSource)); + } + } + return new ArrayList<>(canonical); + } + + private Map buildPcRegistryByIpMap() { + Map registryByIp = new LinkedHashMap<>(); + for (EsignPrinterPcRegistry reg : printerPcRegistryService.listAllOrdered()) { + if (StringUtils.isNotBlank(reg.getHostIp())) { + registryByIp.putIfAbsent(reg.getHostIp().trim(), reg); + } + } + return registryByIp; + } + + @Transactional(readOnly = true) + public EsignPrinterDevicesResponse getDevices() { + Instant cutoff = taskWindowCutoff(); + List recentRows = + printerPdfTaskRepository.findQueueListRows(cutoff, null, PageRequest.of(0, 200)); + Map lastFileByAgent = new HashMap<>(); + for (EsignPrinterPdfTaskListRow row : recentRows) { + String key = normalizeAgentSource(row.getAgentSource()); + lastFileByAgent.putIfAbsent(key, row.getFileName()); + } + + List pcDevices = new ArrayList<>(); + Map> + linksByAgent = printerDeviceLinkService.approvedLinksByAgent(); + Map registryByIp = new LinkedHashMap<>(); + for (EsignPrinterPcRegistry reg : printerPcRegistryService.listAllOrdered()) { + if (StringUtils.isNotBlank(reg.getHostIp())) { + registryByIp.putIfAbsent(reg.getHostIp().trim(), reg); + } + } + Map pcByAgent = new LinkedHashMap<>(); + for (EsignPrinterPcSourceRow row : printerPdfTaskRepository.aggregatePcSourcesSince(cutoff)) { + String reportedKey = normalizeAgentSource(row.getAgentSource()); + String canonicalKey = resolveCanonicalPcAgentKey(reportedKey, registryByIp); + EsignPrinterDevicesResponse.EsignPrinterPcDevice pc = pcByAgent.get(canonicalKey); + if (pc == null) { + pc = buildPcDeviceRow(row, canonicalKey, linksByAgent, lastFileByAgent); + pcByAgent.put(canonicalKey, pc); + } else { + mergeReportingStatsIntoPc(pc, row, lastFileByAgent, reportedKey); + } + } + for (EsignPrinterPcRegistry reg : printerPcRegistryService.listAllOrdered()) { + String key = normalizeAgentSource(reg.getAgentSource()); + EsignPrinterDevicesResponse.EsignPrinterPcDevice existing = pcByAgent.get(key); + if (existing != null) { + applyRegistryMeta(existing, reg); + existing.setLinkedTabletCount(countLinkedTablets(linksByAgent, key)); + } else { + EsignPrinterDevicesResponse.EsignPrinterPcDevice pc = + buildRegisteredPcDeviceRow(reg, linksByAgent); + pcByAgent.put(key, pc); + } + } + pcDevices.addAll(pcByAgent.values()); + + Map> + linksByUser = printerDeviceLinkService.approvedLinksByUser(); + Map tabletByKey = new LinkedHashMap<>(); + List activeUsers = + userPresenceService.dedupeUsersByClientIp( + esignUserRepository.findByStatusOrderByLastLoginAtDesc("ACTIVE")); + for (EsignUser user : activeUsers) { + String ip = EsignUserPresenceService.normalizeClientIp(user.getLastLoginIp()); + String key = tabletDeviceKey(user.getId(), EsignPrinterTabletRegistryService.boundClientIpOrEmpty(ip)); + tabletByKey.put(key, buildTabletDeviceRow(user, linksByUser)); + } + for (EsignPrinterTabletRegistry reg : printerTabletRegistryService.listAllOrdered()) { + if (reg.getUserId() == null) { + continue; + } + List prelinked = printerTabletRegistryService.listAgentSourcesForRegistry(reg.getRegistryId()); + String bound = EsignPrinterTabletRegistryService.boundClientIpOrEmpty(reg.getClientIp()); + String key = tabletDeviceKey(reg.getUserId(), bound); + EsignPrinterDevicesResponse.EsignPrinterTabletDevice existing = tabletByKey.get(key); + if (existing != null) { + applyTabletRegistryMeta(existing, reg, prelinked); + } else { + esignUserRepository.findById(reg.getUserId()).ifPresent(user -> { + EsignPrinterDevicesResponse.EsignPrinterTabletDevice tab = + buildRegisteredTabletDeviceRow(user, reg, linksByUser, prelinked); + if (StringUtils.isNotBlank(bound)) { + tab.setLastLoginIp(bound); + } + tabletByKey.put(key, tab); + }); + } + } + List tabletDevices = + new ArrayList<>(tabletByKey.values()); + tabletDevices.sort(Comparator + .comparing( + (EsignPrinterDevicesResponse.EsignPrinterTabletDevice t) -> + Boolean.TRUE.equals(t.getOnline()), + Comparator.reverseOrder()) + .thenComparing(EsignPrinterDevicesResponse.EsignPrinterTabletDevice::isPushEnabled) + .reversed() + .thenComparing( + EsignPrinterDevicesResponse.EsignPrinterTabletDevice::getLastLoginAt, + Comparator.nullsLast(Comparator.reverseOrder()))); + + EsignPrinterDevicesResponse resp = EsignPrinterDevicesResponse.ok("ok"); + EsignPrinterDevicesResponse.EsignPrinterDevicesData data = + new EsignPrinterDevicesResponse.EsignPrinterDevicesData(); + data.setGeneratedAt(Instant.now().toString()); + data.setTaskWindowHours(TASK_WINDOW_HOURS); + data.setPcDevices(pcDevices); + data.setTabletDevices(tabletDevices); + List allLinks = + printerDeviceLinkService.listForCms().getItems(); + if (allLinks != null) { + List pending = new ArrayList<>(); + List approved = new ArrayList<>(); + for (var link : allLinks) { + EsignPrinterDevicesResponse.EsignPrinterDeviceLinkItem item = + copyLinkItem(link); + if (EsignPrinterDeviceLinkService.STATUS_PENDING.equals(link.getStatus())) { + pending.add(item); + } else if (EsignPrinterDeviceLinkService.STATUS_APPROVED.equals(link.getStatus())) { + approved.add(item); + } + } + data.setLinkRequests(pending); + data.setApprovedLinks(approved); + } + resp.setData(data); + return resp; + } + + private EsignPrinterDevicesResponse.EsignPrinterPcDevice buildPcDeviceRow( + EsignPrinterPcSourceRow row, + String canonicalAgentKey, + Map> + linksByAgent, + Map lastFileByAgent) { + String reportedKey = normalizeAgentSource(row.getAgentSource()); + EsignPrinterDevicesResponse.EsignPrinterPcDevice pc = + new EsignPrinterDevicesResponse.EsignPrinterPcDevice(); + pc.setAgentSource(canonicalAgentKey); + pc.setSubmitted24h(row.getSubmitted24h()); + pc.setPendingCount(row.getPendingCount()); + pc.setSigned24h(row.getSigned24h()); + pc.setLastSubmitAt(row.getLastSubmitAt() != null ? row.getLastSubmitAt().toString() : null); + pc.setLastFileName(lastFileByAgent.get(reportedKey)); + pc.setLinkedTabletCount(countLinkedTablets(linksByAgent, canonicalAgentKey)); + pc.setRegistered(false); + return pc; + } + + private static String resolveCanonicalPcAgentKey( + String reportedKey, Map registryByIp) { + String ip = EsignPrinterDeviceLinkService.extractHostIp(reportedKey); + if (ip != null) { + EsignPrinterPcRegistry reg = registryByIp.get(ip); + if (reg != null && StringUtils.isNotBlank(reg.getAgentSource())) { + return normalizeAgentSource(reg.getAgentSource()); + } + } + return reportedKey; + } + + private void mergeReportingStatsIntoPc( + EsignPrinterDevicesResponse.EsignPrinterPcDevice pc, + EsignPrinterPcSourceRow row, + Map lastFileByAgent, + String reportedKey) { + pc.setSubmitted24h(pc.getSubmitted24h() + row.getSubmitted24h()); + pc.setPendingCount(pc.getPendingCount() + row.getPendingCount()); + pc.setSigned24h(pc.getSigned24h() + row.getSigned24h()); + String latestFile = lastFileByAgent.get(reportedKey); + if (StringUtils.isNotBlank(latestFile)) { + pc.setLastFileName(latestFile); + } + if (row.getLastSubmitAt() != null) { + String existing = pc.getLastSubmitAt(); + if (existing == null || row.getLastSubmitAt().isAfter(Instant.parse(existing))) { + pc.setLastSubmitAt(row.getLastSubmitAt().toString()); + } + } + } + + private EsignPrinterDevicesResponse.EsignPrinterPcDevice buildRegisteredPcDeviceRow( + EsignPrinterPcRegistry reg, + Map> + linksByAgent) { + String key = normalizeAgentSource(reg.getAgentSource()); + EsignPrinterDevicesResponse.EsignPrinterPcDevice pc = + new EsignPrinterDevicesResponse.EsignPrinterPcDevice(); + pc.setAgentSource(key); + pc.setSubmitted24h(0); + pc.setPendingCount(0); + pc.setSigned24h(0); + pc.setLinkedTabletCount(countLinkedTablets(linksByAgent, key)); + applyRegistryMeta(pc, reg); + return pc; + } + + private static void applyRegistryMeta( + EsignPrinterDevicesResponse.EsignPrinterPcDevice pc, EsignPrinterPcRegistry reg) { + pc.setRegistered(true); + pc.setRegistryId(reg.getPcId()); + pc.setDisplayName(reg.getDisplayName()); + pc.setHostIp(reg.getHostIp()); + pc.setEnabled(reg.isEnabled()); + pc.setNotes(reg.getNotes()); + } + + private int countLinkedTablets( + Map> + linksByAgent, + String agentKey) { + Set linkedUserIds = new LinkedHashSet<>(); + for (Map.Entry> + entry : linksByAgent.entrySet()) { + if (!EsignPrinterDeviceLinkService.agentSourcesEquivalent(entry.getKey(), agentKey)) { + continue; + } + for (com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterDeviceLinkResponse.EsignPrinterDeviceLinkItem + link : entry.getValue()) { + if (link.getUserId() != null) { + linkedUserIds.add(link.getUserId()); + } + } + } + return userPresenceService.countUniqueClientIps(new ArrayList<>(linkedUserIds)); + } + + private EsignPrinterDevicesResponse.EsignPrinterTabletDevice buildTabletDeviceRow( + EsignUser user, + Map> + linksByUser) { + EsignPrinterDevicesResponse.EsignPrinterTabletDevice tab = + new EsignPrinterDevicesResponse.EsignPrinterTabletDevice(); + tab.setUserId(user.getId()); + tab.setUserName(user.getUserName()); + tab.setStatus(user.getStatus()); + boolean pushEnabled = StringUtils.isNotBlank(user.getDeviceToken()); + tab.setPushEnabled(pushEnabled); + tab.setDeliveryMode(pushEnabled ? "FCM 推送" : "仅轮询"); + tab.setLastLoginAt(user.getLastLoginAt() != null ? user.getLastLoginAt().toString() : null); + tab.setLastLoginIp(user.getLastLoginIp()); + boolean wsConnected = printerWebSocketHub.isUserConnected(user.getId()); + tab.setWsConnected(wsConnected); + tab.setOnline(wsConnected || isTabletRecentlyActive(user.getLastLoginAt())); + applyTabletLinks(tab, linksByUser.getOrDefault(user.getId(), List.of())); + tab.setRegistered(false); + return tab; + } + + private EsignPrinterDevicesResponse.EsignPrinterTabletDevice buildRegisteredTabletDeviceRow( + EsignUser user, + EsignPrinterTabletRegistry reg, + Map> + linksByUser, + List prelinked) { + EsignPrinterDevicesResponse.EsignPrinterTabletDevice tab = buildTabletDeviceRow(user, linksByUser); + applyTabletRegistryMeta(tab, reg, prelinked); + return tab; + } + + private static void applyTabletLinks( + EsignPrinterDevicesResponse.EsignPrinterTabletDevice tab, + List + userLinks) { + tab.setLinkedPcCount(userLinks.size()); + tab.setLinkedAgentSources( + userLinks.stream() + .map( + com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterDeviceLinkResponse + .EsignPrinterDeviceLinkItem::getAgentSource) + .toList()); + } + + private static void applyTabletRegistryMeta( + EsignPrinterDevicesResponse.EsignPrinterTabletDevice tab, + EsignPrinterTabletRegistry reg, + List prelinked) { + tab.setRegistered(true); + tab.setRegistryId(reg.getRegistryId()); + tab.setDisplayName(reg.getDisplayName()); + tab.setEnabled(reg.isEnabled()); + tab.setNotes(reg.getNotes()); + tab.setBoundClientIp(StringUtils.isBlank(reg.getClientIp()) ? null : reg.getClientIp()); + tab.setPrelinkedAgentSources(prelinked != null ? prelinked : List.of()); + } + + private static String tabletDeviceKey(Long userId, String clientIp) { + return userId + "#" + (clientIp != null ? clientIp : ""); + } + + private static EsignPrinterDevicesResponse.EsignPrinterDeviceLinkItem copyLinkItem( + com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterDeviceLinkResponse.EsignPrinterDeviceLinkItem src) { + EsignPrinterDevicesResponse.EsignPrinterDeviceLinkItem item = + new EsignPrinterDevicesResponse.EsignPrinterDeviceLinkItem(); + item.setLinkId(src.getLinkId()); + item.setUserId(src.getUserId()); + item.setUserName(src.getUserName()); + item.setAgentSource(src.getAgentSource()); + item.setAppLabel(src.getAppLabel()); + item.setStatus(src.getStatus()); + item.setRequestedAt(src.getRequestedAt()); + item.setReviewedAt(src.getReviewedAt()); + item.setPushEnabled(src.getPushEnabled()); + item.setDeliveryMode(src.getDeliveryMode()); + item.setLastLoginAt(src.getLastLoginAt()); + item.setLastLoginIp(src.getLastLoginIp()); + return item; + } + + private Optional resolveTask(String taskId, String externalId) { + if (StringUtils.isNotBlank(taskId)) { + return printerPdfTaskRepository.findById(taskId.trim()); + } + String ext = trim(externalId, 64); + if (StringUtils.isBlank(ext)) { + return Optional.empty(); + } + return printerPdfTaskRepository.findFirstByExternalIdAndStatus(ext, STATUS_PENDING) + .or(() -> printerPdfTaskRepository.findFirstByExternalId(ext)); + } + + private EsignPrinterPdfTaskDetailResponse toDetailResponse( + EsignPrinterPdfTask task, EsignPrinterPdfTaskDetailResponse resp) { + EsignPrinterPdfTaskDetailResponse.EsignPrinterPdfTaskDetailData data = + new EsignPrinterPdfTaskDetailResponse.EsignPrinterPdfTaskDetailData(); + data.setTaskId(task.getTaskId()); + data.setExternalId(task.getExternalId()); + data.setFileName(task.getFileName()); + data.setFilePath(task.getFilePath()); + data.setFileSize(task.getFileSize()); + data.setHotelId(task.getHotelId()); + data.setHasPdf(pdfFileStorage.hasOriginal(task)); + data.setHasSignedPdf(pdfFileStorage.hasSigned(task)); + data.setHasSignedMeta(StringUtils.isNotBlank(task.getSignedMetaJson())); + data.setCreatedAt(task.getCreatedAt() != null ? task.getCreatedAt().toString() : null); + data.setStatus(task.getStatus()); + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("ok"); + resp.setData(data); + return resp; + } + + private EsignPendingListResponse.EsignPendingItem toPendingItem( + EsignPrinterPdfTaskListRow row, boolean completedBucket) { + EsignPendingListResponse.EsignPendingItem it = new EsignPendingListResponse.EsignPendingItem(); + it.setTaskType(TASK_TYPE); + it.setTaskId(row.getTaskId()); + it.setExternalId(row.getExternalId()); + it.setFileName(row.getFileName()); + it.setFileSize(row.getFileSize()); + it.setHotelId(row.getHotelId()); + it.setCreatedAt(row.getCreatedAt() != null ? row.getCreatedAt().toString() : null); + it.setDocTitle(row.getFileName()); + it.setAgentSource(row.getAgentSource()); + if (row.getSignedAt() != null) { + it.setSignedAt(row.getSignedAt().toString()); + } else if (completedBucket && row.getCompletedAt() != null) { + it.setSignedAt(row.getCompletedAt().toString()); + } + return it; + } + + private EsignPrinterDashboardResponse.EsignPrinterDashboardRecentTask toDashboardRecentTask( + EsignPrinterPdfTaskListRow row, + Instant cutoff, + Map registryByIp) { + EsignPrinterDashboardResponse.EsignPrinterDashboardRecentTask it = + new EsignPrinterDashboardResponse.EsignPrinterDashboardRecentTask(); + it.setTaskId(row.getTaskId()); + it.setExternalId(row.getExternalId()); + it.setFileName(row.getFileName()); + it.setFileSize(row.getFileSize()); + it.setStatus(row.getStatus()); + it.setHotelId(row.getHotelId()); + it.setCreatedAt(row.getCreatedAt() != null ? row.getCreatedAt().toString() : null); + it.setCompletedAt(row.getCompletedAt() != null ? row.getCompletedAt().toString() : null); + String reported = normalizeAgentSource(row.getAgentSource()); + it.setAgentSource(resolveCanonicalPcAgentKey(reported, registryByIp)); + it.setDisplayStatus(resolveDisplayStatus(row, cutoff)); + return it; + } + + private EsignPrinterDashboardResponse.EsignPrinterPcSourceStat toPcSourceStat( + EsignPrinterPcSourceRow row, + Map registryByIp) { + EsignPrinterDashboardResponse.EsignPrinterPcSourceStat stat = + new EsignPrinterDashboardResponse.EsignPrinterPcSourceStat(); + String reported = normalizeAgentSource(row.getAgentSource()); + stat.setAgentSource(resolveCanonicalPcAgentKey(reported, registryByIp)); + stat.setSubmitted24h(row.getSubmitted24h()); + stat.setPendingCount(row.getPendingCount()); + stat.setSigned24h(row.getSigned24h()); + stat.setLastSubmitAt(row.getLastSubmitAt() != null ? row.getLastSubmitAt().toString() : null); + return stat; + } + + private EsignPrinterDashboardResponse.EsignPrinterTabletDeviceStat toTabletDeviceStat( + EsignUser user) { + EsignPrinterDashboardResponse.EsignPrinterTabletDeviceStat stat = + new EsignPrinterDashboardResponse.EsignPrinterTabletDeviceStat(); + stat.setUserId(user.getId()); + stat.setUserName(user.getUserName()); + stat.setHotelId(user.getHotelId()); + stat.setStatus(user.getStatus()); + stat.setPushEnabled(StringUtils.isNotBlank(user.getDeviceToken())); + stat.setLastLoginAt(user.getLastLoginAt() != null ? user.getLastLoginAt().toString() : null); + boolean wsConnected = printerWebSocketHub.isUserConnected(user.getId()); + stat.setWsConnected(wsConnected); + stat.setOnline(wsConnected || isTabletRecentlyActive(user.getLastLoginAt())); + return stat; + } + + private boolean isTabletRecentlyActive(Instant lastLoginAt) { + if (lastLoginAt == null) { + return false; + } + return lastLoginAt.isAfter(Instant.now().minus(TABLET_ONLINE_MINUTES, ChronoUnit.MINUTES)); + } + + private Map loadOhipStatusByTaskId(List taskIds) { + if (taskIds == null || taskIds.isEmpty()) { + return Map.of(); + } + Map map = new HashMap<>(); + for (EsignPrinterOhipStatusRow row : printerPdfTaskRepository.findOhipStatusByTaskIdIn(taskIds)) { + if (row != null && StringUtils.isNotBlank(row.getTaskId())) { + map.put(row.getTaskId(), row); + } + } + return map; + } + + private EsignPrinterQueueResponse.EsignPrinterQueueItem toQueueItem( + EsignPrinterPdfTaskListRow row, + Instant cutoff, + Map registryByIp, + EsignPrinterOhipStatusRow ohipRow) { + EsignPrinterQueueResponse.EsignPrinterQueueItem item = + new EsignPrinterQueueResponse.EsignPrinterQueueItem(); + item.setTaskId(row.getTaskId()); + item.setExternalId(row.getExternalId()); + item.setFileName(row.getFileName()); + item.setFileSize(row.getFileSize()); + item.setHotelId(row.getHotelId()); + item.setStatus(row.getStatus()); + item.setDisplayStatus(resolveDisplayStatus(row, cutoff)); + String reported = normalizeAgentSource(row.getAgentSource()); + item.setAgentSource(resolveCanonicalPcAgentKey(reported, registryByIp)); + item.setCreatedAt(row.getCreatedAt() != null ? row.getCreatedAt().toString() : null); + item.setCompletedAt(row.getCompletedAt() != null ? row.getCompletedAt().toString() : null); + List assigned = + printerPushService.resolveAssignedPushTargets(row.getTaskId()); + item.setPushConfigured(!assigned.isEmpty()); + item.setPushTargets(assigned); + if (ohipRow != null) { + item.setOhipStatus(ohipRow.getOhipStatus()); + item.setOhipLastError(ohipRow.getOhipLastError()); + item.setOhipUploadedAt( + ohipRow.getOhipUploadedAt() != null ? ohipRow.getOhipUploadedAt().toString() : null); + } + return item; + } + + private static boolean matchesDisplayStatusFilter(String filter, String displayStatus) { + if (StringUtils.isBlank(filter) || "all".equalsIgnoreCase(filter.trim())) { + return true; + } + String f = filter.trim().toLowerCase(); + if ("awaiting_push".equals(f) || "await_push".equals(f)) { + return "待推送".equals(displayStatus); + } + if ("pending".equals(f)) { + return "待簽收".equals(displayStatus); + } + if ("signed".equals(f)) { + return "已簽收".equals(displayStatus); + } + return true; + } + + private record QueueSqlFilter(String taskStatus, String signFilter, String pushFilter) {} + + private QueueSqlFilter resolveQueueSqlFilter(String displayStatus) { + if (StringUtils.isBlank(displayStatus) || "all".equalsIgnoreCase(displayStatus.trim())) { + return new QueueSqlFilter(null, null, null); + } + String f = displayStatus.trim().toLowerCase(); + if ("signed".equals(f)) { + return new QueueSqlFilter(null, "SIGNED", null); + } + if ("awaiting_push".equals(f) || "await_push".equals(f)) { + return new QueueSqlFilter(null, "UNSIGNED", "NOT_PUSHED"); + } + if ("pending".equals(f)) { + if (printerPushService.isAutoMode()) { + return new QueueSqlFilter(null, "UNSIGNED", null); + } + return new QueueSqlFilter(null, "UNSIGNED", "PUSHED"); + } + return new QueueSqlFilter(null, null, null); + } + + private static String normalizeAgentSource(String agentSource) { + if (StringUtils.isBlank(agentSource)) { + return "printer-agent"; + } + String t = agentSource.trim(); + return t.isEmpty() ? "printer-agent" : t; + } + + private String resolveDisplayStatus(EsignPrinterPdfTaskListRow row, Instant cutoff) { + if (row.getSignedAt() != null) { + return "已簽收"; + } + if (row.getCreatedAt() != null && row.getCreatedAt().isBefore(cutoff)) { + return "超時未簽"; + } + if (!printerPushService.isAutoMode() + && !printerPushService.isTaskPushConfigured(row.getTaskId())) { + return "待推送"; + } + return "待簽收"; + } + + private static String normalizePdfBase64(String raw) { + if (StringUtils.isBlank(raw)) { + return null; + } + String t = raw.trim(); + int comma = t.indexOf(','); + if (t.regionMatches(true, 0, "data:", 0, 5) && comma > 0) { + t = t.substring(comma + 1).trim(); + } + return StringUtils.isBlank(t) ? null : t; + } + + private void applyNextSurveyAfterPdfSign( + EsignIosCaptureResponse.EsignIosCaptureData data, String hotelId) { + if (data == null) { + return; + } + surveyPushService.resolveAutoSurveyAfterPdfSign(hotelId).ifPresent(survey -> { + data.setNextSurveyId(survey.getSurveyId()); + data.setNextSurveyTitle(survey.getTitle()); + }); + } + + private static String trim(String s, int max) { + if (s == null) { + return null; + } + String t = s.trim(); + if (t.isEmpty()) { + return null; + } + return t.length() > max ? t.substring(0, max) : t; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPushRuleService.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPushRuleService.java new file mode 100644 index 0000000..f885262 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPushRuleService.java @@ -0,0 +1,406 @@ +package com.sjmresorts.opsapp.esign.printer.services; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPushRuleResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPushRuleSaveRequest; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPushRuleConfig; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPushRuleMapping; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterPushRuleConfigRepository; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterPushRuleMappingRepository; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class EsignPrinterPushRuleService { + + @Resource + private EsignPrinterPushRuleConfigRepository configRepository; + + @Resource + private EsignPrinterPushRuleMappingRepository mappingRepository; + + @Resource + private ObjectMapper objectMapper; + + @Transactional(readOnly = true) + public boolean isAutoMode() { + return EsignPrinterPushRuleConfig.MODE_AUTO.equalsIgnoreCase(loadConfig().getPushMode()); + } + + @Transactional(readOnly = true) + public boolean isAutoSendFcm() { + return loadConfig().isAutoSendFcm(); + } + + @Transactional(readOnly = true) + public String getPushMode() { + return loadConfig().getPushMode(); + } + + @Transactional(readOnly = true) + public EsignPrinterPushRuleResponse getRules(List knownAgentSources) { + EsignPrinterPushRuleConfig config = loadConfig(); + EsignPrinterPushRuleResponse resp = EsignPrinterPushRuleResponse.ok("ok"); + EsignPrinterPushRuleResponse.EsignPrinterPushRuleData data = + new EsignPrinterPushRuleResponse.EsignPrinterPushRuleData(); + data.setPushMode(config.getPushMode()); + data.setAutoSendFcm(config.isAutoSendFcm()); + data.setPerAgentEnabled(config.isPerAgentEnabled()); + data.setUnknownAgentEnabled(config.isUnknownAgentEnabled()); + data.setFallbackEnabled(config.isFallbackEnabled()); + data.setAllAgents(toMappingItem(findSingleton(EsignPrinterPushRuleMapping.SCOPE_ALL_AGENTS))); + data.setUnknownAgent(toMappingItem(findSingleton(EsignPrinterPushRuleMapping.SCOPE_UNKNOWN_AGENT))); + data.setManualDefault(toMappingItem(findSingleton(EsignPrinterPushRuleMapping.SCOPE_MANUAL_DEFAULT))); + + List perAgentRows = + mappingRepository.findByScopeTypeAndAgentSourceIsNotNullOrderByAgentSourceAsc( + EsignPrinterPushRuleMapping.SCOPE_PER_AGENT); + List perAgent = new ArrayList<>(); + for (EsignPrinterPushRuleMapping row : perAgentRows) { + EsignPrinterPushRuleResponse.EsignPrinterPushRulePerAgentItem item = + new EsignPrinterPushRuleResponse.EsignPrinterPushRulePerAgentItem(); + item.setAgentSource(row.getAgentSource()); + item.setUserIds(readUserIds(row)); + item.setGroupIds(readGroupIds(row)); + perAgent.add(item); + } + data.setPerAgentMappings(perAgent); + data.setKnownAgentSources(knownAgentSources != null ? knownAgentSources : List.of()); + resp.setData(data); + return resp; + } + + @Transactional + public EsignPrinterPushRuleResponse saveRules(EsignPrinterPushRuleSaveRequest request) { + if (request == null) { + return EsignPrinterPushRuleResponse.fail("400", "request body required"); + } + String mode = StringUtils.trimToEmpty(request.getPushMode()).toUpperCase(); + if (!EsignPrinterPushRuleConfig.MODE_AUTO.equals(mode) + && !EsignPrinterPushRuleConfig.MODE_MANUAL.equals(mode)) { + return EsignPrinterPushRuleResponse.fail("400", "pushMode 须为 AUTO 或 MANUAL"); + } + + EsignPrinterPushRuleConfig config = loadConfig(); + config.setPushMode(mode); + if (request.getAutoSendFcm() != null) { + config.setAutoSendFcm(request.getAutoSendFcm()); + } + if (request.getPerAgentEnabled() != null) { + config.setPerAgentEnabled(request.getPerAgentEnabled()); + } + if (request.getUnknownAgentEnabled() != null) { + config.setUnknownAgentEnabled(request.getUnknownAgentEnabled()); + } + if (request.getFallbackEnabled() != null) { + config.setFallbackEnabled(request.getFallbackEnabled()); + } + configRepository.save(config); + + saveSingleton(EsignPrinterPushRuleMapping.SCOPE_ALL_AGENTS, request.getAllAgents()); + saveSingleton(EsignPrinterPushRuleMapping.SCOPE_UNKNOWN_AGENT, request.getUnknownAgent()); + saveSingleton(EsignPrinterPushRuleMapping.SCOPE_MANUAL_DEFAULT, request.getManualDefault()); + + List existingPerAgent = + mappingRepository.findByScopeTypeAndAgentSourceIsNotNullOrderByAgentSourceAsc( + EsignPrinterPushRuleMapping.SCOPE_PER_AGENT); + for (EsignPrinterPushRuleMapping row : existingPerAgent) { + mappingRepository.delete(row); + } + if (request.getPerAgentMappings() != null) { + Set seen = new LinkedHashSet<>(); + for (EsignPrinterPushRuleResponse.EsignPrinterPushRulePerAgentItem item : + request.getPerAgentMappings()) { + if (item == null || StringUtils.isBlank(item.getAgentSource())) { + continue; + } + String agentKey = EsignPrinterDeviceLinkService.normalizeAgentSource(item.getAgentSource()); + if (!seen.add(agentKey)) { + continue; + } + EsignPrinterPushRuleMapping row = new EsignPrinterPushRuleMapping(); + row.setMappingId(UUID.randomUUID().toString()); + row.setScopeType(EsignPrinterPushRuleMapping.SCOPE_PER_AGENT); + row.setAgentSource(agentKey); + writeTargets(row, item.getUserIds(), item.getGroupIds()); + mappingRepository.save(row); + } + } + + return getRules(List.of()); + } + + /** + * 自动模式解析推送目标:PER_AGENT → UNKNOWN_AGENT → ALL_AGENTS + */ + @Transactional(readOnly = true) + public ResolvedPushTargets resolveAutoTargets(String agentSource) { + String key = EsignPrinterDeviceLinkService.normalizeAgentSource(agentSource); + EsignPrinterPushRuleConfig config = loadConfig(); + + if (config.isPerAgentEnabled()) { + Optional perAgent = findPerAgentMapping(key); + if (perAgent.isPresent() && hasTargets(perAgent.get())) { + return toResolved(perAgent.get()); + } + } + + if (config.isUnknownAgentEnabled() && "printer-agent".equals(key)) { + Optional unknown = + findSingleton(EsignPrinterPushRuleMapping.SCOPE_UNKNOWN_AGENT); + if (unknown.isPresent() && hasTargets(unknown.get())) { + return toResolved(unknown.get()); + } + } + + if (config.isFallbackEnabled()) { + Optional all = + findSingleton(EsignPrinterPushRuleMapping.SCOPE_ALL_AGENTS); + if (all.isPresent() && hasTargets(all.get())) { + return toResolved(all.get()); + } + } + + return ResolvedPushTargets.empty(); + } + + /** + * 手动模式预填:按上报 PC 映射 → 未标识 PC 统一规则 → 全局默认 + */ + @Transactional(readOnly = true) + public ResolvedPushTargets resolveManualPrefill(String agentSource) { + String key = EsignPrinterDeviceLinkService.normalizeAgentSource(agentSource); + EsignPrinterPushRuleConfig config = loadConfig(); + + if (config.isPerAgentEnabled()) { + Optional perAgent = findPerAgentMapping(key); + if (perAgent.isPresent() && hasTargets(perAgent.get())) { + return toResolved(perAgent.get()); + } + } + + if (config.isUnknownAgentEnabled() && EsignPrinterDeviceLinkService.isUnidentifiedAgent(key)) { + Optional unknown = + findSingleton(EsignPrinterPushRuleMapping.SCOPE_UNKNOWN_AGENT); + if (unknown.isPresent() && hasTargets(unknown.get())) { + return toResolved(unknown.get()); + } + } + + if (config.isFallbackEnabled()) { + Optional manualDefault = + findSingleton(EsignPrinterPushRuleMapping.SCOPE_MANUAL_DEFAULT); + if (manualDefault.isPresent() && hasTargets(manualDefault.get())) { + return toResolved(manualDefault.get()); + } + } + + return ResolvedPushTargets.empty(); + } + + @Transactional(readOnly = true) + public ResolvedPushTargets resolveManualDefault() { + EsignPrinterPushRuleConfig config = loadConfig(); + if (!config.isFallbackEnabled()) { + return ResolvedPushTargets.empty(); + } + Optional row = + findSingleton(EsignPrinterPushRuleMapping.SCOPE_MANUAL_DEFAULT); + if (row.isEmpty() || !hasTargets(row.get())) { + return ResolvedPushTargets.empty(); + } + return toResolved(row.get()); + } + + private EsignPrinterPushRuleConfig loadConfig() { + return configRepository.findById(EsignPrinterPushRuleConfig.DEFAULT_ID).orElseGet(() -> { + EsignPrinterPushRuleConfig c = new EsignPrinterPushRuleConfig(); + c.setConfigId(EsignPrinterPushRuleConfig.DEFAULT_ID); + c.setPushMode(EsignPrinterPushRuleConfig.MODE_MANUAL); + c.setAutoSendFcm(false); + c.setPerAgentEnabled(true); + c.setUnknownAgentEnabled(true); + c.setFallbackEnabled(true); + return configRepository.save(c); + }); + } + + private Optional findSingleton(String scopeType) { + List rows = + mappingRepository.findByScopeTypeOrderByUpdatedAtDesc(scopeType); + return rows.isEmpty() ? Optional.empty() : Optional.of(rows.get(0)); + } + + /** 按 PC 标识查规则:先精确匹配,再按 IP 等价匹配(Electron 上报名与 CMS 登记名可不同)。 */ + private Optional findPerAgentMapping(String agentSource) { + String key = EsignPrinterDeviceLinkService.normalizeAgentSource(agentSource); + Optional exact = mappingRepository.findFirstByScopeTypeAndAgentSource( + EsignPrinterPushRuleMapping.SCOPE_PER_AGENT, key); + if (exact.isPresent()) { + return exact; + } + for (EsignPrinterPushRuleMapping row : + mappingRepository.findByScopeTypeAndAgentSourceIsNotNullOrderByAgentSourceAsc( + EsignPrinterPushRuleMapping.SCOPE_PER_AGENT)) { + if (EsignPrinterDeviceLinkService.agentSourcesEquivalent(row.getAgentSource(), key)) { + return Optional.of(row); + } + } + return Optional.empty(); + } + + private void saveSingleton( + String scopeType, EsignPrinterPushRuleResponse.EsignPrinterPushRuleMappingItem item) { + List existing = + mappingRepository.findByScopeTypeOrderByUpdatedAtDesc(scopeType); + for (int i = 1; i < existing.size(); i++) { + mappingRepository.delete(existing.get(i)); + } + EsignPrinterPushRuleMapping row; + if (existing.isEmpty()) { + row = new EsignPrinterPushRuleMapping(); + row.setMappingId(UUID.randomUUID().toString()); + row.setScopeType(scopeType); + } else { + row = existing.get(0); + } + if (item == null) { + row.setUserIdsJson(null); + row.setGroupIdsJson(null); + } else { + writeTargets(row, item.getUserIds(), item.getGroupIds()); + } + mappingRepository.save(row); + } + + private void writeTargets(EsignPrinterPushRuleMapping row, List userIds, List groupIds) { + row.setUserIdsJson(writeJson(normalizeUserIds(userIds))); + row.setGroupIdsJson(writeJson(normalizeGroupIds(groupIds))); + } + + private List normalizeUserIds(List userIds) { + if (userIds == null) { + return List.of(); + } + return userIds.stream().filter(id -> id != null && id > 0).distinct().collect(Collectors.toList()); + } + + private List normalizeGroupIds(List groupIds) { + if (groupIds == null) { + return List.of(); + } + return groupIds.stream() + .filter(StringUtils::isNotBlank) + .map(String::trim) + .distinct() + .collect(Collectors.toList()); + } + + private EsignPrinterPushRuleResponse.EsignPrinterPushRuleMappingItem toMappingItem( + Optional rowOpt) { + EsignPrinterPushRuleResponse.EsignPrinterPushRuleMappingItem item = + new EsignPrinterPushRuleResponse.EsignPrinterPushRuleMappingItem(); + if (rowOpt.isEmpty()) { + item.setUserIds(List.of()); + item.setGroupIds(List.of()); + return item; + } + item.setUserIds(readUserIds(rowOpt.get())); + item.setGroupIds(readGroupIds(rowOpt.get())); + return item; + } + + private ResolvedPushTargets toResolved(EsignPrinterPushRuleMapping row) { + return new ResolvedPushTargets(readUserIds(row), readGroupIds(row)); + } + + private boolean hasTargets(EsignPrinterPushRuleMapping row) { + return !readUserIds(row).isEmpty() || !readGroupIds(row).isEmpty(); + } + + private List readUserIds(EsignPrinterPushRuleMapping row) { + return readLongList(row.getUserIdsJson()); + } + + private List readGroupIds(EsignPrinterPushRuleMapping row) { + return readStringList(row.getGroupIdsJson()); + } + + private List readLongList(String json) { + if (StringUtils.isBlank(json)) { + return List.of(); + } + try { + List list = objectMapper.readValue(json, new TypeReference>() {}); + return list != null ? normalizeUserIds(list) : List.of(); + } catch (Exception e) { + log.warn("Invalid userIdsJson: {}", json, e); + return List.of(); + } + } + + private List readStringList(String json) { + if (StringUtils.isBlank(json)) { + return List.of(); + } + try { + List list = objectMapper.readValue(json, new TypeReference>() {}); + return list != null ? normalizeGroupIds(list) : List.of(); + } catch (Exception e) { + log.warn("Invalid groupIdsJson: {}", json, e); + return List.of(); + } + } + + private String writeJson(List list) { + if (list == null || list.isEmpty()) { + return null; + } + try { + return objectMapper.writeValueAsString(list); + } catch (Exception e) { + log.warn("Failed to serialize json list", e); + return null; + } + } + + public static final class ResolvedPushTargets { + private final List userIds; + private final List groupIds; + + public ResolvedPushTargets(List userIds, List groupIds) { + this.userIds = userIds != null ? List.copyOf(userIds) : List.of(); + this.groupIds = groupIds != null ? List.copyOf(groupIds) : List.of(); + } + + public static ResolvedPushTargets empty() { + return new ResolvedPushTargets(Collections.emptyList(), Collections.emptyList()); + } + + public boolean isEmpty() { + return userIds.isEmpty() && groupIds.isEmpty(); + } + + public List getUserIds() { + return userIds; + } + + public List getGroupIds() { + return groupIds; + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPushService.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPushService.java new file mode 100644 index 0000000..102f0f1 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterPushService.java @@ -0,0 +1,688 @@ +package com.sjmresorts.opsapp.esign.printer.services; + +import com.sjmresorts.opsapp.esign.capture.dtos.EsignIosCaptureResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPushAssignRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPushCandidatesResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPushGroupListResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPushGroupSaveRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterQueueResponse; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterDeviceLink; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPdfTask; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPdfTaskPush; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPushGroup; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPushGroupMember; +import com.sjmresorts.opsapp.esign.auth.models.EsignUser; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterPdfTaskPushRepository; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterPdfTaskRepository; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterPushGroupMemberRepository; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterPushGroupRepository; +import com.sjmresorts.opsapp.esign.auth.repositories.EsignUserRepository; +import com.sjmresorts.opsapp.esign.auth.services.EsignUserPresenceService; +import com.sjmresorts.opsapp.esign.printer.support.EsignPrinterQueueNotifier; +import com.sjmresorts.opsapp.notifications.dtos.SendNotificationData; +import com.sjmresorts.opsapp.notifications.dtos.SendNotificationResponse; +import com.sjmresorts.opsapp.notifications.enums.NotificationTargetType; +import com.sjmresorts.opsapp.notifications.services.NotificationService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class EsignPrinterPushService { + + private static final String ACTIVE = "ACTIVE"; + private static final String TRIGGER_TASK = "PRINTER_PDF_PUSH"; + private static final String CLICK_ACTION = "OPEN_PRINTER_PDF"; + + @Resource + private EsignPrinterPdfTaskRepository printerPdfTaskRepository; + + @Resource + private EsignPrinterPdfTaskPushRepository taskPushRepository; + + @Resource + private EsignPrinterPushGroupRepository pushGroupRepository; + + @Resource + private EsignPrinterPushGroupMemberRepository pushGroupMemberRepository; + + @Resource + private EsignPrinterDeviceLinkService printerDeviceLinkService; + + @Resource + private EsignUserRepository esignUserRepository; + + @Resource + private NotificationService notificationService; + + @Resource + private EsignPrinterEventLogService printerEventLogService; + + @Resource + private EsignPrinterPushRuleService pushRuleService; + + @Resource + private com.sjmresorts.opsapp.esign.printer.websocket.EsignPrinterWebSocketHub printerWebSocketHub; + + @Resource + private EsignUserPresenceService userPresenceService; + + @Resource + private EsignPrinterTabletRegistryService tabletRegistryService; + + @Resource + private EsignPrinterQueueNotifier printerQueueNotifier; + + @Transactional(readOnly = true) + public EsignPrinterPushCandidatesResponse getPushCandidates(String taskId) { + if (StringUtils.isBlank(taskId)) { + return EsignPrinterPushCandidatesResponse.fail("400", "taskId required"); + } + Optional taskOpt = printerPdfTaskRepository.findById(taskId.trim()); + if (taskOpt.isEmpty()) { + return EsignPrinterPushCandidatesResponse.fail("404", "任务不存在"); + } + EsignPrinterPdfTask task = taskOpt.get(); + String agentSource = EsignPrinterDeviceLinkService.normalizeAgentSource(task.getAgentSource()); + + Set eligibleUserIds = EsignPrinterDeviceLinkService.isUnidentifiedAgent(agentSource) + ? printerDeviceLinkService.approvedUserIdsWithAnyApprovedLink() + : printerDeviceLinkService.approvedUserIdsForPushTarget(agentSource); + Map linkByUser = new HashMap<>(); + for (EsignPrinterDeviceLink link : printerDeviceLinkService.approvedLinksForPushTarget(agentSource)) { + if (link.getUserId() != null) { + linkByUser.putIfAbsent(link.getUserId(), link); + } + } + + List activeUsers = esignUserRepository.findByStatusOrderByLastLoginAtDesc(ACTIVE); + List devices = new ArrayList<>(); + for (Long userId : userPresenceService.dedupeUserIdsByClientIp(new ArrayList<>(eligibleUserIds))) { + EsignUser user = activeUsers.stream().filter(u -> userId.equals(u.getId())).findFirst().orElse(null); + if (user == null) { + continue; + } + EsignPrinterDeviceLink link = linkByUser.get(userId); + EsignPrinterPushCandidatesResponse.EsignPrinterPushDeviceOption opt = + new EsignPrinterPushCandidatesResponse.EsignPrinterPushDeviceOption(); + opt.setUserId(user.getId()); + opt.setUserName(user.getUserName()); + opt.setAppLabel(link != null ? link.getAppLabel() : null); + boolean pushEnabled = StringUtils.isNotBlank(user.getDeviceToken()); + opt.setPushEnabled(pushEnabled); + opt.setDeliveryMode(pushEnabled ? "FCM 推送" : "仅轮询"); + devices.add(opt); + } + + List groups = groupsForAgent(agentSource); + List groupOptions = new ArrayList<>(); + for (EsignPrinterPushGroup group : groups) { + List members = pushGroupMemberRepository.findByGroupId(group.getGroupId()); + List memberIds = members.stream().map(EsignPrinterPushGroupMember::getUserId).toList(); + EsignPrinterPushCandidatesResponse.EsignPrinterPushGroupOption g = + new EsignPrinterPushCandidatesResponse.EsignPrinterPushGroupOption(); + g.setGroupId(group.getGroupId()); + g.setGroupName(group.getGroupName()); + g.setMemberCount(memberIds.size()); + g.setMemberUserIds(memberIds); + groupOptions.add(g); + } + + List selectedUserIds = taskPushRepository.findByTaskId(task.getTaskId()).stream() + .map(EsignPrinterPdfTaskPush::getUserId) + .toList(); + List selectedGroupIds = List.of(); + if (selectedUserIds.isEmpty() && !pushRuleService.isAutoMode()) { + EsignPrinterPushRuleService.ResolvedPushTargets defaults = + pushRuleService.resolveManualPrefill(agentSource); + if (!defaults.isEmpty()) { + selectedUserIds = defaults.getUserIds(); + selectedGroupIds = defaults.getGroupIds(); + } + } + Set availableUserIds = devices.stream() + .map(EsignPrinterPushCandidatesResponse.EsignPrinterPushDeviceOption::getUserId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + selectedUserIds = selectedUserIds.stream() + .filter(availableUserIds::contains) + .toList(); + Set availableGroupIds = groupOptions.stream() + .map(EsignPrinterPushCandidatesResponse.EsignPrinterPushGroupOption::getGroupId) + .filter(StringUtils::isNotBlank) + .collect(Collectors.toSet()); + selectedGroupIds = selectedGroupIds.stream() + .filter(availableGroupIds::contains) + .toList(); + + EsignPrinterPushCandidatesResponse resp = EsignPrinterPushCandidatesResponse.ok("ok"); + EsignPrinterPushCandidatesResponse.EsignPrinterPushCandidatesData data = + new EsignPrinterPushCandidatesResponse.EsignPrinterPushCandidatesData(); + data.setTaskId(task.getTaskId()); + data.setAgentSource(agentSource); + data.setDevices(devices); + data.setGroups(groupOptions); + data.setSelectedUserIds(selectedUserIds); + data.setSelectedGroupIds(selectedGroupIds); + data.setPushMode(pushRuleService.getPushMode()); + resp.setData(data); + return resp; + } + + @Transactional(readOnly = true) + public String getPushMode() { + return pushRuleService.getPushMode(); + } + + @Transactional(readOnly = true) + public boolean isAutoMode() { + return pushRuleService.isAutoMode(); + } + + @Transactional + public void autoAssignOnSubmit(EsignPrinterPdfTask task) { + if (task == null || !pushRuleService.isAutoMode()) { + return; + } + if (!EsignPrinterPdfTaskService.STATUS_PENDING.equals(task.getStatus())) { + return; + } + if (taskPushRepository.existsByTaskId(task.getTaskId())) { + return; + } + EsignPrinterPushRuleService.ResolvedPushTargets targets = + pushRuleService.resolveAutoTargets(task.getAgentSource()); + if (targets.isEmpty()) { + log.info("Auto push skipped taskId={}: no rule mapping", task.getTaskId()); + return; + } + Set assigned = applyPushTargetsInternal( + task, + targets.getUserIds(), + targets.getGroupIds(), + "AUTO"); + if (assigned.isEmpty()) { + log.info("Auto push skipped taskId={}: no valid targets after filter", task.getTaskId()); + return; + } + printerEventLogService.append( + EsignPrinterEventLogService.EVENT_PDF_PUSH_ASSIGN, + task, + "自动推送 " + assigned.size() + " 台", + "AUTO"); + if (pushRuleService.isAutoSendFcm()) { + sendFcmForTask(task, new ArrayList<>(assigned)); + } + notifyPushTargetsViaWebSocket(task, assigned); + printerQueueNotifier.notifyChangedAfterCommit(); + } + + @Transactional(readOnly = true) + public boolean isTaskPushConfigured(String taskId) { + return taskPushRepository.existsByTaskId(taskId); + } + + @Transactional + public EsignIosCaptureResponse quickPushWithRules(String taskId, boolean sendFcm) { + EsignIosCaptureResponse resp = new EsignIosCaptureResponse(); + if (StringUtils.isBlank(taskId)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("taskId required"); + return resp; + } + Optional taskOpt = printerPdfTaskRepository.findById(taskId.trim()); + if (taskOpt.isEmpty()) { + resp.setSuccess(false); + resp.setCode("404"); + resp.setMessage("任务不存在"); + return resp; + } + EsignPrinterPdfTask task = taskOpt.get(); + if (!EsignPrinterPdfTaskService.STATUS_PENDING.equals(task.getStatus())) { + resp.setSuccess(false); + resp.setCode("409"); + resp.setMessage("仅待签收任务可推送"); + return resp; + } + + if (pushRuleService.isAutoMode() && !taskPushRepository.existsByTaskId(task.getTaskId())) { + autoAssignOnSubmit(task); + } + + if (taskPushRepository.existsByTaskId(task.getTaskId())) { + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("已推送"); + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(task.getTaskId()); + resp.setData(data); + return resp; + } + + String agentSource = EsignPrinterDeviceLinkService.normalizeAgentSource(task.getAgentSource()); + EsignPrinterPushRuleService.ResolvedPushTargets targets = + pushRuleService.resolveManualPrefill(agentSource); + if (targets.isEmpty()) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage( + "请先在推送规则中配置 PC「" + agentSource + "」的平板映射,或配置全局默认映射"); + return resp; + } + + Set targetUserIds = applyPushTargetsInternal( + task, targets.getUserIds(), targets.getGroupIds(), "CMS_QUICK"); + if (targetUserIds.isEmpty()) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage( + "推送规则中的平板与 PDF 上报 PC「" + agentSource + "」未建立已批准连接。" + + "常见原因:App 连接标识与 Electron :39201 上报标识不一致。" + + "请修正连接后重试,或使用「调整」手动选择设备。"); + return resp; + } + + printerEventLogService.append( + EsignPrinterEventLogService.EVENT_PDF_PUSH_ASSIGN, + task, + "快捷推送 " + targetUserIds.size() + " 台", + "CMS_QUICK"); + + if (shouldSendFcm(sendFcm)) { + sendFcmForTask(task, new ArrayList<>(targetUserIds)); + } + notifyPushTargetsViaWebSocket(task, targetUserIds); + printerQueueNotifier.notifyChangedAfterCommit(); + + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("已推送(" + targetUserIds.size() + " 台,平板将通过轮询拉取)"); + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(task.getTaskId()); + resp.setData(data); + return resp; + } + + @Transactional + public EsignIosCaptureResponse assignPushTargets(EsignPrinterPushAssignRequest request) { + EsignIosCaptureResponse resp = new EsignIosCaptureResponse(); + if (request == null || StringUtils.isBlank(request.getTaskId())) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("taskId required"); + return resp; + } + Optional taskOpt = printerPdfTaskRepository.findById(request.getTaskId().trim()); + if (taskOpt.isEmpty()) { + resp.setSuccess(false); + resp.setCode("404"); + resp.setMessage("任务不存在"); + return resp; + } + EsignPrinterPdfTask task = taskOpt.get(); + if (!EsignPrinterPdfTaskService.STATUS_PENDING.equals(task.getStatus())) { + resp.setSuccess(false); + resp.setCode("409"); + resp.setMessage("仅待签收任务可设置推送目标"); + return resp; + } + + String assignedBy = trim(request.getAssignedBy(), 64); + Set targetUserIds = applyPushTargetsInternal( + task, request.getUserIds(), request.getGroupIds(), assignedBy); + + if (targetUserIds.isEmpty()) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("请至少选择一个已连接的设备或组"); + return resp; + } + + printerEventLogService.append( + EsignPrinterEventLogService.EVENT_PDF_PUSH_ASSIGN, + task, + "CMS 设置推送目标 " + targetUserIds.size() + " 台", + assignedBy); + + if (shouldSendFcm(request.isSendFcm())) { + sendFcmForTask(task, new ArrayList<>(targetUserIds)); + } + notifyPushTargetsViaWebSocket(task, targetUserIds); + printerQueueNotifier.notifyChangedAfterCommit(); + + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage(pushRuleService.isAutoMode() + ? "已设置推送目标(" + targetUserIds.size() + " 台)" + : "已设置推送目标(" + targetUserIds.size() + " 台,平板将通过轮询拉取)"); + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(task.getTaskId()); + resp.setData(data); + return resp; + } + + private Set applyPushTargetsInternal( + EsignPrinterPdfTask task, + List requestUserIds, + List requestGroupIds, + String assignedBy) { + String agentSource = EsignPrinterDeviceLinkService.normalizeAgentSource(task.getAgentSource()); + Set allowedUserIds = pushEligibleUserIds(); + + Set targetUserIds = new LinkedHashSet<>(); + if (requestUserIds != null) { + for (Long uid : requestUserIds) { + if (uid != null && allowedUserIds.contains(uid)) { + targetUserIds.add(uid); + } + } + } + if (requestGroupIds != null && !requestGroupIds.isEmpty()) { + List fromGroups = pushGroupMemberRepository.findUserIdsByGroupIdIn(requestGroupIds); + for (Long uid : fromGroups) { + if (allowedUserIds.contains(uid)) { + targetUserIds.add(uid); + } + } + } + if (targetUserIds.isEmpty()) { + return targetUserIds; + } + targetUserIds = userPresenceService.dedupeUserIdsByClientIp(targetUserIds); + + taskPushRepository.deleteByTaskId(task.getTaskId()); + Instant now = Instant.now(); + String by = trim(assignedBy, 64); + for (Long userId : targetUserIds) { + Set clientIps = tabletRegistryService.resolvePushTargetClientIpsForUser(userId); + if (clientIps.isEmpty()) { + clientIps = Set.of(""); + } + for (String clientIp : clientIps) { + EsignPrinterPdfTaskPush row = new EsignPrinterPdfTaskPush(); + row.setTaskId(task.getTaskId()); + row.setUserId(userId); + row.setClientIp(clientIp != null ? clientIp : ""); + row.setAssignedAt(now); + row.setAssignedBy(by); + taskPushRepository.save(row); + } + } + return targetUserIds; + } + + @Transactional(readOnly = true) + public List resolveAssignedPushTargets(String taskId) { + List rows = taskPushRepository.findByTaskId(taskId); + if (rows.isEmpty()) { + return List.of(); + } + Optional taskOpt = printerPdfTaskRepository.findById(taskId); + String agentSource = taskOpt + .map(t -> EsignPrinterDeviceLinkService.normalizeAgentSource(t.getAgentSource())) + .orElse("printer-agent"); + + Set userIds = rows.stream().map(EsignPrinterPdfTaskPush::getUserId).collect(Collectors.toSet()); + Map users = new HashMap<>(); + for (Long uid : userIds) { + esignUserRepository.findById(uid).ifPresent(u -> users.put(uid, u)); + } + + List links = printerDeviceLinkService.approvedLinksForPushTarget(agentSource); + Map linkByUser = new HashMap<>(); + for (EsignPrinterDeviceLink link : links) { + linkByUser.put(link.getUserId(), link); + } + + List targets = new ArrayList<>(); + for (Long uid : userIds) { + EsignUser user = users.get(uid); + if (user == null || !ACTIVE.equalsIgnoreCase(user.getStatus())) { + continue; + } + EsignPrinterDeviceLink link = linkByUser.get(uid); + EsignPrinterQueueResponse.EsignPrinterPushTarget target = + new EsignPrinterQueueResponse.EsignPrinterPushTarget(); + target.setUserId(user.getId()); + target.setUserName(user.getUserName()); + target.setAgentSource(agentSource); + target.setAppLabel(link != null ? link.getAppLabel() : null); + target.setLinkStatus(EsignPrinterDeviceLinkService.STATUS_APPROVED); + boolean pushEnabled = StringUtils.isNotBlank(user.getDeviceToken()); + target.setPushEnabled(pushEnabled); + target.setDeliveryMode(pushEnabled ? "FCM 推送" : "仅轮询"); + targets.add(target); + } + return targets; + } + + @Transactional(readOnly = true) + public boolean isTaskVisibleToUser(String taskId, Long userId, String clientIp) { + if (userId == null) { + return false; + } + if (!taskPushRepository.existsByTaskId(taskId)) { + return false; + } + String ip = EsignUserPresenceService.normalizeClientIp(clientIp); + return taskPushRepository.findByTaskId(taskId).stream() + .anyMatch(p -> userId.equals(p.getUserId()) && clientIpMatchesPushTarget(p.getClientIp(), ip)); + } + + static boolean clientIpMatchesPushTarget(String storedClientIp, String requestClientIp) { + if (StringUtils.isBlank(storedClientIp)) { + return true; + } + return requestClientIp != null && storedClientIp.equals(requestClientIp); + } + + @Transactional(readOnly = true) + public Set assignedTaskIdsForUser(Long userId, String clientIp) { + if (userId == null) { + return Set.of(); + } + String ip = EsignUserPresenceService.normalizeClientIp(clientIp); + if (ip == null) { + return taskPushRepository.findTaskIdsByUserIdWildcardOnly(userId); + } + return taskPushRepository.findTaskIdsByUserIdAndClientIp(userId, ip); + } + + /** @deprecated 使用 {@link #assignedTaskIdsForUser(Long, String)} */ + @Deprecated + @Transactional(readOnly = true) + public Set assignedTaskIdsForUser(Long userId) { + if (userId == null) { + return Set.of(); + } + return taskPushRepository.findTaskIdsByUserId(userId); + } + + @Transactional(readOnly = true) + public EsignPrinterPushGroupListResponse listGroups(String agentSource) { + if (StringUtils.isBlank(agentSource)) { + return EsignPrinterPushGroupListResponse.fail("400", "agentSource required"); + } + String key = EsignPrinterDeviceLinkService.normalizeAgentSource(agentSource); + List groups = groupsForAgent(key); + List items = new ArrayList<>(); + for (EsignPrinterPushGroup group : groups) { + List memberIds = pushGroupMemberRepository.findByGroupId(group.getGroupId()).stream() + .map(EsignPrinterPushGroupMember::getUserId) + .toList(); + EsignPrinterPushGroupListResponse.EsignPrinterPushGroupItem item = + new EsignPrinterPushGroupListResponse.EsignPrinterPushGroupItem(); + item.setGroupId(group.getGroupId()); + item.setGroupName(group.getGroupName()); + item.setAgentSource(group.getAgentSource()); + item.setMemberCount(memberIds.size()); + item.setMemberUserIds(memberIds); + item.setCreatedAt(group.getCreatedAt() != null ? group.getCreatedAt().toString() : null); + item.setUpdatedAt(group.getUpdatedAt() != null ? group.getUpdatedAt().toString() : null); + items.add(item); + } + EsignPrinterPushGroupListResponse resp = EsignPrinterPushGroupListResponse.ok("ok"); + resp.setData(items); + return resp; + } + + @Transactional + public EsignPrinterPushGroupListResponse saveGroup(EsignPrinterPushGroupSaveRequest request) { + if (request == null || StringUtils.isBlank(request.getGroupName()) || StringUtils.isBlank(request.getAgentSource())) { + return EsignPrinterPushGroupListResponse.fail("400", "组名与 agentSource 必填"); + } + String agentSource = EsignPrinterDeviceLinkService.normalizeAgentSource(request.getAgentSource()); + String groupName = trim(request.getGroupName(), 128); + + Set allowedUserIds = pushEligibleUserIds(); + List memberIds = request.getMemberUserIds() != null ? request.getMemberUserIds() : List.of(); + List validMembers = memberIds.stream() + .filter(id -> id != null && allowedUserIds.contains(id)) + .distinct() + .toList(); + + EsignPrinterPushGroup group; + if (StringUtils.isNotBlank(request.getGroupId())) { + Optional opt = pushGroupRepository.findById(request.getGroupId().trim()); + if (opt.isEmpty()) { + return EsignPrinterPushGroupListResponse.fail("404", "推送组不存在"); + } + group = opt.get(); + group.setGroupName(groupName); + } else { + group = new EsignPrinterPushGroup(); + group.setGroupId(UUID.randomUUID().toString()); + group.setGroupName(groupName); + group.setAgentSource(agentSource); + } + pushGroupRepository.save(group); + + pushGroupMemberRepository.deleteByGroupId(group.getGroupId()); + for (Long uid : validMembers) { + EsignPrinterPushGroupMember member = new EsignPrinterPushGroupMember(); + member.setGroupId(group.getGroupId()); + member.setUserId(uid); + pushGroupMemberRepository.save(member); + } + + return listGroups(agentSource); + } + + @Transactional + public EsignPrinterPushGroupListResponse deleteGroup(String groupId, String agentSource) { + if (StringUtils.isBlank(groupId)) { + return EsignPrinterPushGroupListResponse.fail("400", "groupId required"); + } + pushGroupMemberRepository.deleteByGroupId(groupId.trim()); + pushGroupRepository.deleteById(groupId.trim()); + return listGroups(agentSource); + } + + /** + * 手动模式:仅写入推送目标,App 通过 WebSocket 即时通知 + 轮询兜底拉取,不发 FCM。 + * 自动模式:仅在规则开启 autoSendFcm 且调用方请求 sendFcm 时发送可选提醒。 + */ + private boolean shouldSendFcm(boolean requestedSend) { + if (!pushRuleService.isAutoMode()) { + return false; + } + return requestedSend && pushRuleService.isAutoSendFcm(); + } + + private void notifyPushTargetsViaWebSocket(EsignPrinterPdfTask task, Collection userIds) { + if (task == null || userIds == null || userIds.isEmpty()) { + return; + } + printerWebSocketHub.notifyPrinterPdfAssigned( + userIds, + task.getTaskId(), + task.getFileName(), + task.getAgentSource(), + tabletRegistryService.resolvePushClientIpFilters(userIds)); + } + + private void sendFcmForTask(EsignPrinterPdfTask task, List userIds) { + List targets = userIds.stream().map(String::valueOf).toList(); + if (targets.isEmpty()) { + return; + } + SendNotificationData data = new SendNotificationData(); + data.setTargetType(NotificationTargetType.ESIGN_USER); + data.setTargets(targets); + data.setTitle("新的登记卡 PDF"); + data.setBody(StringUtils.defaultIfBlank(task.getFileName(), "请打开 App 签收")); + data.setTriggerTask(TRIGGER_TASK); + data.setClickAction(CLICK_ACTION); + Map payload = new HashMap<>(); + payload.put("action", CLICK_ACTION); + payload.put("taskId", task.getTaskId()); + payload.put("taskType", EsignPrinterPdfTaskService.TASK_TYPE); + payload.put("fileName", task.getFileName()); + data.setData(payload); + try { + SendNotificationResponse response = notificationService.send(data, TRIGGER_TASK); + log.info( + "Printer PDF FCM taskId={}, targets={}, status={}", + task.getTaskId(), + targets.size(), + response.getData() != null ? response.getData().getResultStatus() : null); + } catch (Exception e) { + log.warn("Printer PDF FCM failed taskId={}", task.getTaskId(), e); + } + } + + /** 推送规则/手动推送可选的平板账号:启用中的 App 用户,不要求设备连接已审批。 */ + private Set pushEligibleUserIds() { + List ids = esignUserRepository.findByStatusOrderByLastLoginAtDesc(ACTIVE).stream() + .map(EsignUser::getId) + .filter(Objects::nonNull) + .toList(); + return new LinkedHashSet<>(userPresenceService.dedupeUserIdsByClientIp(ids)); + } + + private List groupsForAgent(String agentSource) { + String key = EsignPrinterDeviceLinkService.normalizeAgentSource(agentSource); + List direct = pushGroupRepository.findByAgentSourceOrderByGroupNameAsc(key); + if (!direct.isEmpty() || EsignPrinterDeviceLinkService.isUnidentifiedAgent(key)) { + return direct; + } + String ip = EsignPrinterDeviceLinkService.extractHostIp(key); + if (ip == null) { + return direct; + } + return pushGroupRepository.findAll().stream() + .filter(group -> EsignPrinterDeviceLinkService.sameHostIp(group.getAgentSource(), key)) + .sorted(Comparator.comparing(EsignPrinterPushGroup::getGroupName)) + .toList(); + } + + private static String trim(String s, int max) { + if (s == null) { + return null; + } + String t = s.trim(); + if (t.isEmpty()) { + return null; + } + return t.length() > max ? t.substring(0, max) : t; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterTabletRegistryService.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterTabletRegistryService.java new file mode 100644 index 0000000..9a81fa8 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/EsignPrinterTabletRegistryService.java @@ -0,0 +1,404 @@ +package com.sjmresorts.opsapp.esign.printer.services; + +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterTabletRegistryDeleteRequest; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterTabletRegistryResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterTabletRegistrySaveRequest; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterDeviceLink; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterTabletRegistry; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterTabletRegistryPc; +import com.sjmresorts.opsapp.esign.auth.models.EsignUser; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterDeviceLinkRepository; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterTabletRegistryPcRepository; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterTabletRegistryRepository; +import com.sjmresorts.opsapp.esign.auth.repositories.EsignUserRepository; +import com.sjmresorts.opsapp.esign.auth.services.EsignUserPresenceService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +@Slf4j +@Service +public class EsignPrinterTabletRegistryService { + + public static final String CMS_REVIEWED_BY = "cms-tablet-registry"; + + @Resource + private EsignPrinterTabletRegistryRepository registryRepository; + + @Resource + private EsignPrinterTabletRegistryPcRepository bindingRepository; + + @Resource + private EsignPrinterDeviceLinkRepository linkRepository; + + @Resource + private EsignUserRepository esignUserRepository; + + @Transactional(readOnly = true) + public EsignPrinterTabletRegistryResponse listAll() { + EsignPrinterTabletRegistryResponse resp = EsignPrinterTabletRegistryResponse.ok("ok"); + resp.setItems(toItems(registryRepository.findAllByOrderByUserNameAsc())); + return resp; + } + + @Transactional(readOnly = true) + public List listAllOrdered() { + return registryRepository.findAllByOrderByUserNameAsc(); + } + + @Transactional(readOnly = true) + public List findAllByUserId(Long userId) { + if (userId == null) { + return List.of(); + } + return registryRepository.findAllByUserIdOrderByClientIpAsc(userId); + } + + @Transactional(readOnly = true) + public Optional matchRegistry(Long userId, String clientIp) { + if (userId == null) { + return Optional.empty(); + } + String ip = normalizeBoundClientIp(clientIp); + List enabled = + registryRepository.findAllByUserIdAndEnabledTrueOrderByClientIpAsc(userId); + if (enabled.isEmpty()) { + return Optional.empty(); + } + if (ip != null) { + for (EsignPrinterTabletRegistry reg : enabled) { + String bound = normalizeBoundClientIp(reg.getClientIp()); + if (bound != null && bound.equals(ip)) { + return Optional.of(reg); + } + } + } + List wildcards = enabled.stream() + .filter(reg -> StringUtils.isBlank(normalizeBoundClientIp(reg.getClientIp()))) + .toList(); + if (wildcards.size() == 1) { + return Optional.of(wildcards.get(0)); + } + return Optional.empty(); + } + + @Transactional(readOnly = true) + public boolean isRegisteredAndEnabled(Long userId, String clientIp) { + return matchRegistry(userId, clientIp).isPresent(); + } + + /** 若账号登记了具体 IP,推送 WS 时仅通知匹配 IP 的会话 */ + @Transactional(readOnly = true) + public Map> resolvePushClientIpFilters(Collection userIds) { + Map> map = new HashMap<>(); + if (userIds == null) { + return map; + } + for (Long userId : userIds) { + if (userId == null) { + continue; + } + List regs = + registryRepository.findAllByUserIdAndEnabledTrueOrderByClientIpAsc(userId); + Set ips = new LinkedHashSet<>(); + boolean hasWildcard = false; + for (EsignPrinterTabletRegistry reg : regs) { + String bound = normalizeBoundClientIp(reg.getClientIp()); + if (StringUtils.isBlank(bound)) { + hasWildcard = true; + } else { + ips.add(bound); + } + } + if (!hasWildcard && !ips.isEmpty()) { + map.put(userId, ips); + } + } + return map; + } + + /** + * 写入推送记录时展开目标 IP:有绑定 IP 登记则逐 IP 写入;有「不限 IP」登记则仅 wildcard; + * 无登记时优先用用户最近登录 IP。 + */ + @Transactional(readOnly = true) + public Set resolvePushTargetClientIpsForUser(Long userId) { + if (userId == null) { + return Set.of(); + } + List regs = + registryRepository.findAllByUserIdAndEnabledTrueOrderByClientIpAsc(userId); + if (regs.isEmpty()) { + return esignUserRepository.findById(userId) + .map(user -> { + String ip = normalizeBoundClientIp(user.getLastLoginIp()); + return ip != null ? Set.of(ip) : Set.of(""); + }) + .orElse(Set.of("")); + } + boolean hasWildcard = false; + Set ips = new LinkedHashSet<>(); + for (EsignPrinterTabletRegistry reg : regs) { + String bound = normalizeBoundClientIp(reg.getClientIp()); + if (StringUtils.isBlank(bound)) { + hasWildcard = true; + } else { + ips.add(bound); + } + } + if (hasWildcard) { + return Set.of(""); + } + return ips.isEmpty() ? Set.of("") : ips; + } + + public static String normalizeBoundClientIp(String clientIp) { + return EsignUserPresenceService.normalizeClientIp(clientIp); + } + + public static String boundClientIpOrEmpty(String clientIp) { + String ip = normalizeBoundClientIp(clientIp); + return ip != null ? ip : ""; + } + + @Transactional(readOnly = true) + public List listAgentSourcesForRegistry(String registryId) { + if (StringUtils.isBlank(registryId)) { + return List.of(); + } + return bindingRepository.findByRegistryIdOrderByAgentSourceAsc(registryId.trim()).stream() + .map(EsignPrinterTabletRegistryPc::getAgentSource) + .toList(); + } + + @Transactional + public EsignPrinterTabletRegistryResponse save(EsignPrinterTabletRegistrySaveRequest request) { + if (request == null) { + return EsignPrinterTabletRegistryResponse.fail("400", "请求体不能为空"); + } + String userName = trim(request.getUserName(), 50); + if (StringUtils.isBlank(userName)) { + return EsignPrinterTabletRegistryResponse.fail("400", "请填写 App 登录账号"); + } + + Optional userOpt = esignUserRepository.findByUserName(userName); + if (userOpt.isEmpty()) { + return EsignPrinterTabletRegistryResponse.fail( + "400", "App 账号不存在,请先在系统中创建该用户后再登记"); + } + EsignUser user = userOpt.get(); + if (!"ACTIVE".equalsIgnoreCase(user.getStatus())) { + return EsignPrinterTabletRegistryResponse.fail("403", "App 账号未启用"); + } + + String clientIp = boundClientIpOrEmpty(request.getClientIp()); + List agentSources = normalizeAgentSources(request.getAgentSources()); + boolean creating = StringUtils.isBlank(request.getRegistryId()); + EsignPrinterTabletRegistry row; + if (creating) { + if (registryRepository.existsByUserIdAndClientIp(user.getId(), clientIp)) { + return EsignPrinterTabletRegistryResponse.fail( + "409", StringUtils.isBlank(clientIp) ? "该账号已存在「不限 IP」登记" : "该账号在此 IP 下已登记"); + } + row = new EsignPrinterTabletRegistry(); + row.setRegistryId(UUID.randomUUID().toString()); + row.setCreatedBy(trim(request.getCreatedBy(), 64)); + } else { + Optional opt = registryRepository.findById(request.getRegistryId().trim()); + if (opt.isEmpty()) { + return EsignPrinterTabletRegistryResponse.fail("404", "登记记录不存在"); + } + row = opt.get(); + if (registryRepository.existsByUserIdAndClientIpAndRegistryIdNot(user.getId(), clientIp, row.getRegistryId())) { + return EsignPrinterTabletRegistryResponse.fail("409", "该账号在此 IP 下已有其他登记"); + } + } + + row.setUserName(userName); + row.setUserId(user.getId()); + row.setClientIp(clientIp); + row.setDisplayName(trim(request.getDisplayName(), 128)); + if (request.getEnabled() != null) { + row.setEnabled(request.getEnabled()); + } else if (creating) { + row.setEnabled(true); + } + row.setNotes(trim(request.getNotes(), 256)); + registryRepository.save(row); + + bindingRepository.deleteByRegistryId(row.getRegistryId()); + for (String agentSource : agentSources) { + EsignPrinterTabletRegistryPc binding = new EsignPrinterTabletRegistryPc(); + binding.setBindingId(UUID.randomUUID().toString()); + binding.setRegistryId(row.getRegistryId()); + binding.setAgentSource(agentSource); + bindingRepository.save(binding); + } + + syncDeviceLinksForUser(user.getId()); + + log.info( + "Printer tablet registry {} registryId={}, userName={}, clientIp={}, pcs={}", + creating ? "created" : "updated", + row.getRegistryId(), + userName, + StringUtils.isBlank(clientIp) ? "*" : clientIp, + agentSources.size()); + + EsignPrinterTabletRegistryResponse resp = + EsignPrinterTabletRegistryResponse.ok(creating ? "已添加平板登记" : "已更新平板登记"); + resp.setData(toItem(row, agentSources)); + return resp; + } + + @Transactional + public EsignPrinterTabletRegistryResponse delete(EsignPrinterTabletRegistryDeleteRequest request) { + if (request == null || StringUtils.isBlank(request.getRegistryId())) { + return EsignPrinterTabletRegistryResponse.fail("400", "registryId required"); + } + Optional opt = registryRepository.findById(request.getRegistryId().trim()); + if (opt.isEmpty()) { + return EsignPrinterTabletRegistryResponse.fail("404", "登记记录不存在"); + } + EsignPrinterTabletRegistry row = opt.get(); + Long userId = row.getUserId(); + bindingRepository.deleteByRegistryId(row.getRegistryId()); + registryRepository.delete(row); + if (userId != null) { + syncDeviceLinksForUser(userId); + } + log.info( + "Printer tablet registry deleted registryId={}, userName={}, clientIp={}", + row.getRegistryId(), + row.getUserName(), + row.getClientIp()); + return EsignPrinterTabletRegistryResponse.ok("已删除平板登记"); + } + + private void syncDeviceLinksForUser(Long userId) { + if (userId == null) { + return; + } + Optional userOpt = esignUserRepository.findById(userId); + if (userOpt.isEmpty()) { + return; + } + EsignUser user = userOpt.get(); + Set desired = new LinkedHashSet<>(); + for (EsignPrinterTabletRegistry reg : + registryRepository.findAllByUserIdAndEnabledTrueOrderByClientIpAsc(userId)) { + desired.addAll(listAgentSourcesForRegistry(reg.getRegistryId())); + } + List approved = + linkRepository.findByUserIdAndStatus(userId, EsignPrinterDeviceLinkService.STATUS_APPROVED); + for (String agentSource : desired) { + Optional existing = linkRepository.findFirstByUserIdAndAgentSourceAndStatusIn( + userId, + agentSource, + List.of( + EsignPrinterDeviceLinkService.STATUS_PENDING, + EsignPrinterDeviceLinkService.STATUS_APPROVED)); + if (existing.isPresent()) { + EsignPrinterDeviceLink link = existing.get(); + if (EsignPrinterDeviceLinkService.STATUS_PENDING.equals(link.getStatus())) { + link.setStatus(EsignPrinterDeviceLinkService.STATUS_APPROVED); + link.setReviewedAt(Instant.now()); + link.setReviewedBy(CMS_REVIEWED_BY); + linkRepository.save(link); + } + continue; + } + EsignPrinterDeviceLink link = new EsignPrinterDeviceLink(); + link.setLinkId(UUID.randomUUID().toString()); + link.setUserId(userId); + link.setAgentSource(agentSource); + link.setAppLabel(user.getUserName()); + link.setStatus(EsignPrinterDeviceLinkService.STATUS_APPROVED); + link.setRequestedAt(Instant.now()); + link.setReviewedAt(Instant.now()); + link.setReviewedBy(CMS_REVIEWED_BY); + linkRepository.save(link); + } + for (EsignPrinterDeviceLink link : approved) { + if (CMS_REVIEWED_BY.equals(link.getReviewedBy()) && !desired.contains(link.getAgentSource())) { + link.setStatus(EsignPrinterDeviceLinkService.STATUS_REVOKED); + link.setReviewedAt(Instant.now()); + linkRepository.save(link); + } + } + } + + private void syncDeviceLinks( + EsignPrinterTabletRegistry registry, EsignUser user, List agentSources) { + syncDeviceLinksForUser(user != null ? user.getId() : null); + } + + private void revokeCmsManagedLinks(Long userId) { + syncDeviceLinksForUser(userId); + } + + private List normalizeAgentSources(List raw) { + if (raw == null) { + return List.of(); + } + Set set = new LinkedHashSet<>(); + for (String item : raw) { + if (StringUtils.isBlank(item)) { + continue; + } + set.add(EsignPrinterDeviceLinkService.normalizeAgentSource(item)); + } + return new ArrayList<>(set); + } + + private List toItems( + List rows) { + List items = new ArrayList<>(); + for (EsignPrinterTabletRegistry row : rows) { + items.add(toItem(row, listAgentSourcesForRegistry(row.getRegistryId()))); + } + return items; + } + + private EsignPrinterTabletRegistryResponse.EsignPrinterTabletRegistryItem toItem( + EsignPrinterTabletRegistry row, List agentSources) { + EsignPrinterTabletRegistryResponse.EsignPrinterTabletRegistryItem item = + new EsignPrinterTabletRegistryResponse.EsignPrinterTabletRegistryItem(); + item.setRegistryId(row.getRegistryId()); + item.setUserName(row.getUserName()); + item.setUserId(row.getUserId()); + item.setClientIp(StringUtils.isBlank(row.getClientIp()) ? null : row.getClientIp()); + item.setDisplayName(row.getDisplayName()); + item.setEnabled(row.isEnabled()); + item.setNotes(row.getNotes()); + item.setCreatedBy(row.getCreatedBy()); + item.setCreatedAt(row.getCreatedAt() != null ? row.getCreatedAt().toString() : null); + item.setUpdatedAt(row.getUpdatedAt() != null ? row.getUpdatedAt().toString() : null); + item.setAgentSources(agentSources); + return item; + } + + private static String trim(String s, int max) { + if (s == null) { + return null; + } + String t = s.trim(); + if (t.isEmpty()) { + return null; + } + return t.length() > max ? t.substring(0, max) : t; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/services/LocalExeLaunchService.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/LocalExeLaunchService.java new file mode 100644 index 0000000..5222585 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/LocalExeLaunchService.java @@ -0,0 +1,294 @@ +package com.sjmresorts.opsapp.esign.printer.services; + +import com.sjmresorts.opsapp.esign.printer.dtos.LocalExeLaunchResult; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 在本机(运行 Spring Boot 的 Windows 机器)启动 exe,并尝试将窗口拉到前台。 + */ +@Slf4j +@Service +public class LocalExeLaunchService { + + private static final String ACTIVATE_PS1 = """ + param( + [string]$ProcessName = "auto-check-in", + [int]$InitialWaitMs = 2000, + [int]$PollTimeoutMs = 15000 + ) + Start-Sleep -Milliseconds $InitialWaitMs + $deadline = (Get-Date).AddMilliseconds($PollTimeoutMs) + $proc = $null + while ((Get-Date) -lt $deadline) { + $proc = Get-Process -Name $ProcessName -ErrorAction SilentlyContinue | + Sort-Object StartTime -Descending | Select-Object -First 1 + if ($proc -and $proc.MainWindowHandle -ne [IntPtr]::Zero) { break } + Start-Sleep -Milliseconds 300 + } + if (-not $proc) { Write-Error "process not found: $ProcessName"; exit 1 } + if ($proc.MainWindowHandle -eq [IntPtr]::Zero) { Write-Error "no main window"; exit 2 } + Add-Type @' + using System; + using System.Runtime.InteropServices; + public class Win32Foreground { + [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool AllowSetForegroundWindow(int processId); + } + '@ + [void][Win32Foreground]::AllowSetForegroundWindow($proc.Id) + [void][Win32Foreground]::ShowWindow($proc.MainWindowHandle, 9) + [void][Win32Foreground]::BringWindowToTop($proc.MainWindowHandle) + [void][Win32Foreground]::SetForegroundWindow($proc.MainWindowHandle) + exit 0 + """; + + @Value("${ops-app.deeplink.local-exe.enabled:false}") + private boolean enabled; + + @Value("${ops-app.deeplink.local-exe.path:}") + private String exePath; + + @Value("${ops-app.deeplink.local-exe.working-dir:}") + private String workingDir; + + @Value("${ops-app.deeplink.local-exe.args:}") + private String extraArgs; + + @Value("${ops-app.deeplink.local-exe.launch-via:auto}") + private String launchVia; + + @Value("${ops-app.deeplink.local-exe.activate-window:true}") + private boolean activateWindow; + + @Value("${ops-app.deeplink.local-exe.activate-wait-ms:2000}") + private int activateWaitMs; + + @Value("${ops-app.deeplink.local-exe.activate-poll-ms:15000}") + private int activatePollMs; + + public LocalExeLaunchResult launchIfEnabled() { + if (!enabled) { + return LocalExeLaunchResult.skipped("local-exe.enabled=false,使用浏览器 myapp://"); + } + if (StringUtils.isBlank(exePath)) { + return LocalExeLaunchResult.failed("未配置 ops-app.deeplink.local-exe.path"); + } + + File exe = new File(exePath.trim()); + if (!exe.isFile()) { + return LocalExeLaunchResult.failed("exe 不存在: " + exe.getAbsolutePath()); + } + + File workDir = resolveWorkingDir(exe); + List args = parseArgs(); + String via = StringUtils.defaultIfBlank(launchVia, "auto").trim().toLowerCase(); + + try { + String detail = launchWithMode(via, exe, workDir, args); + if (activateWindow) { + activateWindowToForeground(exe); + detail = detail + "+activate"; + } + log.info("本机 exe 已启动 ({}): {}", detail, exe.getAbsolutePath()); + return LocalExeLaunchResult.success(exe.getAbsolutePath() + " (" + detail + ")"); + } catch (Exception e) { + log.error("本机 exe 启动失败: {}", exe.getAbsolutePath(), e); + return LocalExeLaunchResult.failed(e.getMessage()); + } + } + + private String launchWithMode(String via, File exe, File workDir, List args) throws Exception { + return switch (via) { + case "direct" -> { + startDirect(exe, workDir, args); + yield "direct"; + } + case "windows-start" -> { + startViaWindowsCmd(exe, workDir, args, false); + yield "windows-start"; + } + case "windows-start-max" -> { + startViaWindowsCmd(exe, workDir, args, true); + yield "windows-start-max"; + } + case "powershell" -> { + startViaPowerShell(exe, workDir, args); + yield "powershell"; + } + case "explorer" -> { + startViaExplorer(exe); + yield "explorer"; + } + default -> launchAuto(exe, workDir, args); + }; + } + + private String launchAuto(File exe, File workDir, List args) throws Exception { + Exception last = null; + for (String mode : List.of("explorer", "windows-start-max", "direct")) { + try { + return launchWithMode(mode, exe, workDir, args); + } catch (Exception e) { + last = e; + log.warn("launch-via={} 失败,尝试下一种: {}", mode, e.getMessage()); + } + } + throw last != null ? last : new IOException("auto 启动全部失败"); + } + + private void activateWindowToForeground(File exe) { + String processName = exe.getName().replaceAll("(?i)\\.exe$", ""); + Thread activator = new Thread(() -> { + File scriptFile = null; + try { + scriptFile = Files.createTempFile("auto-check-in-activate-", ".ps1").toFile(); + Files.writeString(scriptFile.toPath(), ACTIVATE_PS1, StandardCharsets.UTF_8); + ProcessBuilder builder = new ProcessBuilder( + "powershell.exe", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptFile.getAbsolutePath(), + "-ProcessName", + processName, + "-InitialWaitMs", + String.valueOf(activateWaitMs), + "-PollTimeoutMs", + String.valueOf(activatePollMs)); + builder.redirectErrorStream(true); + Process process = builder.start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (!process.waitFor(activatePollMs + activateWaitMs + 5000L, TimeUnit.MILLISECONDS)) { + process.destroyForcibly(); + log.warn("前台激活超时: {}", processName); + return; + } + if (process.exitValue() == 0) { + log.info("前台激活成功: {}", processName); + } else { + log.warn("前台激活失败 exit={} output={}", process.exitValue(), output.trim()); + } + } catch (Exception e) { + log.warn("前台激活异常: {}", e.getMessage()); + } finally { + if (scriptFile != null && scriptFile.exists() && !scriptFile.delete()) { + scriptFile.deleteOnExit(); + } + } + }, "exe-window-activator"); + activator.setDaemon(true); + activator.start(); + } + + private void startViaPowerShell(File exe, File workDir, List args) throws Exception { + StringBuilder ps = new StringBuilder(); + ps.append("$p=@{FilePath='").append(escapePowerShell(exe.getAbsolutePath())).append("';WindowStyle='Normal'};"); + if (workDir != null) { + ps.append("$p.WorkingDirectory='").append(escapePowerShell(workDir.getAbsolutePath())).append("';"); + } + if (!args.isEmpty()) { + ps.append("$p.ArgumentList=").append(toPowerShellStringArray(args)).append(";"); + } + ps.append("Start-Process @p"); + runAndCheck(List.of( + "powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps.toString())); + } + + private void startViaExplorer(File exe) throws IOException { + new ProcessBuilder("explorer.exe", exe.getAbsolutePath()).start(); + } + + private void startViaWindowsCmd(File exe, File workDir, List args, boolean maximized) throws IOException { + List command = new ArrayList<>(); + command.add("cmd.exe"); + command.add("/c"); + command.add("start"); + if (maximized) { + command.add("/MAX"); + } + command.add(""); + if (workDir != null) { + command.add("/D"); + command.add(workDir.getAbsolutePath()); + } + command.add(exe.getAbsolutePath()); + command.addAll(args); + new ProcessBuilder(command).start(); + } + + private void startDirect(File exe, File workDir, List args) throws IOException { + List command = new ArrayList<>(); + command.add(exe.getAbsolutePath()); + command.addAll(args); + ProcessBuilder builder = new ProcessBuilder(command); + if (workDir != null) { + builder.directory(workDir); + } + builder.start(); + } + + private void runAndCheck(List command) throws Exception { + ProcessBuilder builder = new ProcessBuilder(command); + builder.redirectErrorStream(true); + Process process = builder.start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + if (!process.waitFor(15, TimeUnit.SECONDS)) { + process.destroyForcibly(); + throw new IOException("启动命令超时: " + String.join(" ", command)); + } + if (process.exitValue() != 0) { + throw new IOException("启动命令失败 exit=" + process.exitValue() + " output=" + output.trim()); + } + } + + private File resolveWorkingDir(File exe) { + if (StringUtils.isNotBlank(workingDir)) { + File dir = new File(workingDir.trim()); + if (dir.isDirectory()) { + return dir; + } + log.warn("local-exe working-dir 无效,忽略: {}", workingDir); + } + File parent = exe.getParentFile(); + return parent != null && parent.isDirectory() ? parent : null; + } + + private List parseArgs() { + List args = new ArrayList<>(); + if (StringUtils.isNotBlank(extraArgs)) { + Arrays.stream(extraArgs.split("\\s+")) + .filter(StringUtils::isNotBlank) + .forEach(args::add); + } + return args; + } + + private static String escapePowerShell(String value) { + return String.valueOf(value).replace("'", "''"); + } + + private static String toPowerShellStringArray(List args) { + if (args.isEmpty()) { + return "@()"; + } + return "@(" + args.stream() + .map(a -> "'" + escapePowerShell(a) + "'") + .collect(Collectors.joining(",")) + ")"; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/services/RegistrationCardMetaExtractService.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/RegistrationCardMetaExtractService.java new file mode 100644 index 0000000..0fabdf9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/services/RegistrationCardMetaExtractService.java @@ -0,0 +1,88 @@ +package com.sjmresorts.opsapp.esign.printer.services; + +import com.sjmresorts.opsapp.esign.printer.dtos.RegistrationCardMeta; +import com.sjmresorts.opsapp.esign.printer.dtos.RegistrationCardMetaExtractor; +import com.sjmresorts.opsapp.esign.printer.support.RegistrationConfirmationOcrExtractor; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; +import org.springframework.stereotype.Service; + +import java.util.Base64; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RegistrationCardMetaExtractService { + + private final RegistrationConfirmationOcrExtractor ocrExtractor; + + public RegistrationCardMeta extractFromPdfBase64(String pdfBase64) { + if (StringUtils.isBlank(pdfBase64)) { + return RegistrationCardMeta.builder().build(); + } + try { + byte[] bytes = Base64.getDecoder().decode(normalizeBase64(pdfBase64)); + return extractFromPdfBytes(bytes); + } catch (IllegalArgumentException e) { + return RegistrationCardMeta.builder().build(); + } + } + + public RegistrationCardMeta extractFromPdfBytes(byte[] pdfBytes) { + if (pdfBytes == null || pdfBytes.length == 0) { + return RegistrationCardMeta.builder().build(); + } + RegistrationCardMeta textMeta = extractPdfTextLayer(pdfBytes) + .map(RegistrationCardMetaExtractor::extractFromText) + .orElseGet(() -> RegistrationCardMeta.builder().build()); + + if (!ocrExtractor.isOcrEnabled()) { + log.info("registration meta OCR disabled, textLayerFields={}", textMeta.hasAnyField()); + return textMeta; + } + if (!ocrExtractor.isOcrReady()) { + log.warn("registration meta OCR unavailable: {}", ocrExtractor.getUnavailableReason()); + return textMeta; + } + + Optional ocrText = ocrExtractor.extractFullTextFromPdfBytes(pdfBytes); + RegistrationCardMeta ocrMeta = ocrText + .map(RegistrationCardMetaExtractor::extractFromText) + .orElseGet(() -> RegistrationCardMeta.builder().build()); + RegistrationCardMeta merged = textMeta.mergedWith(ocrMeta); + log.info( + "registration meta merged guest={}, conf={}, room={}, email={}, checkIn={}, checkOut={}", + merged.getGuestName(), + merged.getConfirmationNo(), + merged.getRoomNo(), + merged.getEmail(), + merged.getCheckInDate(), + merged.getCheckOutDate()); + return merged; + } + + private static Optional extractPdfTextLayer(byte[] pdfBytes) { + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + PDFTextStripper stripper = new PDFTextStripper(); + stripper.setStartPage(1); + stripper.setEndPage(Math.min(doc.getNumberOfPages(), 2)); + return Optional.ofNullable(stripper.getText(doc)).filter(StringUtils::isNotBlank); + } catch (Exception e) { + return Optional.empty(); + } + } + + private static String normalizeBase64(String raw) { + String trimmed = raw.trim(); + int comma = trimmed.indexOf(','); + if (trimmed.startsWith("data:") && comma > 0) { + return trimmed.substring(comma + 1).trim(); + } + return trimmed.replaceAll("\\s", ""); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignOhipUploadRetryScheduler.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignOhipUploadRetryScheduler.java new file mode 100644 index 0000000..33cfefa --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignOhipUploadRetryScheduler.java @@ -0,0 +1,64 @@ +package com.sjmresorts.opsapp.esign.printer.support; + +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPdfTask; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterPdfTaskRepository; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPdfOhipUploadService; +import com.sjmresorts.opsapp.esign.reservation.dtos.SignedRegistrationCardUploadResult; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** 扫描 PENDING/FAILED 的已签名任务,从归档 signed.pdf 重试 OPERA 上传。 */ +@Slf4j +@Component +public class EsignOhipUploadRetryScheduler { + + private static final int BATCH_SIZE = 20; + + @Resource + private EsignPrinterPdfTaskRepository printerPdfTaskRepository; + + @Resource + private EsignPrinterPdfOhipUploadService ohipUploadService; + + @Scheduled(fixedDelayString = "${ops-app.esign.ohip-retry.fixed-delay-ms:300000}") + public void retryPendingUploads() { + List tasks = printerPdfTaskRepository.findOhipRetryCandidates( + PageRequest.of(0, BATCH_SIZE)); + if (tasks.isEmpty()) { + return; + } + log.info("OHIP retry scheduler processing {} task(s)", tasks.size()); + for (EsignPrinterPdfTask task : tasks) { + if (task == null || task.getTaskId() == null) { + continue; + } + try { + SignedRegistrationCardUploadResult result = ohipUploadService.uploadFromArchive( + task.getTaskId(), + task.getOcrConfirmationNo(), + task.getSignFieldsJson(), + null, + null, + true, + false); + log.info( + "OHIP retry taskId={}, success={}, statusCode={}, retryCount={}", + task.getTaskId(), + result.isSuccess(), + result.getStatusCode(), + task.getOhipRetryCount()); + if (!result.isSuccess() + && !EsignPrinterPdfOhipUploadService.isRetryable(result)) { + log.info("OHIP retry stop (non-retryable) taskId={}", task.getTaskId()); + } + } catch (Exception e) { + log.warn("OHIP retry failed taskId={}", task.getTaskId(), e); + } + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterOhipStatus.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterOhipStatus.java new file mode 100644 index 0000000..0ff4358 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterOhipStatus.java @@ -0,0 +1,14 @@ +package com.sjmresorts.opsapp.esign.printer.support; + +/** OPERA/OHIP 上传状态(持久化于 esign_printer_pdf_tasks.ohip_status)。 */ +public final class EsignPrinterOhipStatus { + + public static final String PENDING = "PENDING"; + public static final String SUCCESS = "SUCCESS"; + public static final String FAILED = "FAILED"; + public static final String SKIPPED = "SKIPPED"; + + public static final int MAX_RETRY = 5; + + private EsignPrinterOhipStatus() {} +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfArchiveListQuery.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfArchiveListQuery.java new file mode 100644 index 0000000..550de0f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfArchiveListQuery.java @@ -0,0 +1,130 @@ +package com.sjmresorts.opsapp.esign.printer.support; + +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfArchiveListRow; +import com.sjmresorts.opsapp.esign.printer.filters.EsignPrinterPdfArchiveFilter; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPdfTask; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfArchiveSpecs; +import jakarta.persistence.EntityManager; +import jakarta.persistence.Tuple; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.List; + +/** + * 归档列表查询:只 SELECT 元数据列,避免加载 pdf_base64 / signed_pdf_base64 等大字段。 + */ +@Component +@RequiredArgsConstructor +public class EsignPrinterPdfArchiveListQuery { + + private final EntityManager entityManager; + + public long count(EsignPrinterPdfArchiveFilter filter) { + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Long.class); + Root root = cq.from(EsignPrinterPdfTask.class); + cq.select(cb.count(root)); + applyWhere(filter, root, cq, cb); + return entityManager.createQuery(cq).getSingleResult(); + } + + public List findPage( + EsignPrinterPdfArchiveFilter filter, Pageable pageable) { + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + CriteriaQuery cq = cb.createTupleQuery(); + Root root = cq.from(EsignPrinterPdfTask.class); + cq.multiselect( + root.get("taskId").alias("taskId"), + root.get("externalId").alias("externalId"), + root.get("fileName").alias("fileName"), + root.get("fileSize").alias("fileSize"), + root.get("hotelId").alias("hotelId"), + root.get("status").alias("status"), + root.get("createdAt").alias("createdAt"), + root.get("completedAt").alias("completedAt"), + root.get("signedAt").alias("signedAt"), + root.get("archivedAt").alias("archivedAt"), + root.get("agentSource").alias("agentSource"), + root.get("signedByUserId").alias("signedByUserId"), + root.get("signedClientIp").alias("signedClientIp"), + root.get("signedDeviceId").alias("signedDeviceId"), + root.get("signedDeviceName").alias("signedDeviceName"), + root.get("ocrGuestName").alias("ocrGuestName"), + root.get("ocrConfirmationNo").alias("ocrConfirmationNo"), + root.get("ocrPhone").alias("ocrPhone"), + root.get("ocrEmail").alias("ocrEmail"), + root.get("ocrRoomNo").alias("ocrRoomNo"), + root.get("ocrCheckInDate").alias("ocrCheckInDate"), + root.get("ocrCheckOutDate").alias("ocrCheckOutDate"), + root.get("ocrStatus").alias("ocrStatus"), + root.get("ocrExtractedAt").alias("ocrExtractedAt"), + root.get("ohipStatus").alias("ohipStatus"), + root.get("ohipLastError").alias("ohipLastError"), + root.get("ohipUploadedAt").alias("ohipUploadedAt"), + root.get("ohipConfirmationNo").alias("ohipConfirmationNo"), + root.get("ohipReservationId").alias("ohipReservationId"), + root.get("ohipRetryCount").alias("ohipRetryCount")); + applyWhere(filter, root, cq, cb); + cq.orderBy(cb.desc(EsignPrinterPdfArchiveSpecs.archiveSortTime(root, cb))); + + TypedQuery query = entityManager.createQuery(cq); + query.setFirstResult((int) pageable.getOffset()); + query.setMaxResults(pageable.getPageSize()); + return query.getResultList().stream().map(this::mapRow).toList(); + } + + private static void applyWhere( + EsignPrinterPdfArchiveFilter filter, + Root root, + CriteriaQuery cq, + CriteriaBuilder cb) { + Specification spec = EsignPrinterPdfArchiveSpecs.archived(filter); + Predicate predicate = spec.toPredicate(root, cq, cb); + if (predicate != null) { + cq.where(predicate); + } + } + + private EsignPrinterPdfArchiveListRow mapRow(Tuple tuple) { + return new EsignPrinterPdfArchiveListRow( + tuple.get("taskId", String.class), + tuple.get("externalId", String.class), + tuple.get("fileName", String.class), + tuple.get("fileSize", Long.class), + tuple.get("hotelId", String.class), + tuple.get("status", String.class), + tuple.get("createdAt", Instant.class), + tuple.get("completedAt", Instant.class), + tuple.get("signedAt", Instant.class), + tuple.get("archivedAt", Instant.class), + tuple.get("agentSource", String.class), + tuple.get("signedByUserId", Long.class), + tuple.get("signedClientIp", String.class), + tuple.get("signedDeviceId", String.class), + tuple.get("signedDeviceName", String.class), + tuple.get("ocrGuestName", String.class), + tuple.get("ocrConfirmationNo", String.class), + tuple.get("ocrPhone", String.class), + tuple.get("ocrEmail", String.class), + tuple.get("ocrRoomNo", String.class), + tuple.get("ocrCheckInDate", String.class), + tuple.get("ocrCheckOutDate", String.class), + tuple.get("ocrStatus", String.class), + tuple.get("ocrExtractedAt", Instant.class), + tuple.get("ohipStatus", String.class), + tuple.get("ohipLastError", String.class), + tuple.get("ohipUploadedAt", Instant.class), + tuple.get("ohipConfirmationNo", String.class), + tuple.get("ohipReservationId", String.class), + tuple.get("ohipRetryCount", Integer.class)); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfArchiveOcrBatchQuery.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfArchiveOcrBatchQuery.java new file mode 100644 index 0000000..d52ae67 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfArchiveOcrBatchQuery.java @@ -0,0 +1,74 @@ +package com.sjmresorts.opsapp.esign.printer.support; + +import com.sjmresorts.opsapp.esign.printer.filters.EsignPrinterPdfArchiveFilter; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPdfTask; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfArchiveSpecs; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPdfArchiveOcrService; +import jakarta.persistence.EntityManager; +import jakarta.persistence.TypedQuery; +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; +import lombok.RequiredArgsConstructor; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +@RequiredArgsConstructor +public class EsignPrinterPdfArchiveOcrBatchQuery { + + private final EntityManager entityManager; + + public long countTaskIds(EsignPrinterPdfArchiveFilter filter, boolean failedOnly) { + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(Long.class); + Root root = cq.from(EsignPrinterPdfTask.class); + cq.select(cb.count(root)); + Predicate predicate = buildPredicate(filter, failedOnly, root, cq, cb); + if (predicate != null) { + cq.where(predicate); + } + return entityManager.createQuery(cq).getSingleResult(); + } + + public List findTaskIds( + EsignPrinterPdfArchiveFilter filter, boolean failedOnly, int page, int size) { + CriteriaBuilder cb = entityManager.getCriteriaBuilder(); + CriteriaQuery cq = cb.createQuery(String.class); + Root root = cq.from(EsignPrinterPdfTask.class); + cq.select(root.get("taskId")); + Predicate predicate = buildPredicate(filter, failedOnly, root, cq, cb); + if (predicate != null) { + cq.where(predicate); + } + cq.orderBy(cb.desc(EsignPrinterPdfArchiveSpecs.archiveSortTime(root, cb))); + TypedQuery query = entityManager.createQuery(cq); + query.setFirstResult(Math.max(page, 0) * size); + query.setMaxResults(size); + return query.getResultList(); + } + + private static Predicate buildPredicate( + EsignPrinterPdfArchiveFilter filter, + boolean failedOnly, + Root root, + CriteriaQuery cq, + CriteriaBuilder cb) { + Specification spec = EsignPrinterPdfArchiveSpecs.archived(filter); + if (failedOnly) { + spec = spec.and(ocrNeedsRetry()); + } + return spec.toPredicate(root, cq, cb); + } + + private static Specification ocrNeedsRetry() { + return (root, query, cb) -> cb.or( + cb.isNull(root.get("ocrStatus")), + cb.equal(root.get("ocrStatus"), EsignPrinterPdfArchiveOcrService.OCR_FAILED), + cb.equal(root.get("ocrStatus"), EsignPrinterPdfArchiveOcrService.OCR_SKIPPED), + cb.equal(root.get("ocrStatus"), EsignPrinterPdfArchiveOcrService.OCR_PENDING)); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfArchiveOcrBatchRunner.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfArchiveOcrBatchRunner.java new file mode 100644 index 0000000..882e55a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfArchiveOcrBatchRunner.java @@ -0,0 +1,19 @@ +package com.sjmresorts.opsapp.esign.printer.support; + +import com.sjmresorts.opsapp.esign.printer.filters.EsignPrinterPdfArchiveFilter; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPdfArchiveOcrBatchService; +import jakarta.annotation.Resource; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +@Component +public class EsignPrinterPdfArchiveOcrBatchRunner { + + @Resource + private EsignPrinterPdfArchiveOcrBatchService batchService; + + @Async + public void runAsync(String jobId, EsignPrinterPdfArchiveFilter filter, boolean failedOnly) { + batchService.executeBatch(jobId, filter, failedOnly); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfArchiveOcrEventListener.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfArchiveOcrEventListener.java new file mode 100644 index 0000000..b921aa0 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfArchiveOcrEventListener.java @@ -0,0 +1,35 @@ +package com.sjmresorts.opsapp.esign.printer.support; + +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfArchiveOcrEvent; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPdfArchiveOcrService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** + * 异步 OCR 监听器:独立 Bean,避免与 {@link EsignPrinterPdfArchiveOcrService} 循环依赖。 + */ +@Slf4j +@Component +public class EsignPrinterPdfArchiveOcrEventListener { + + @Resource + private EsignPrinterPdfArchiveOcrService archiveOcrService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onArchiveOcrRequested(EsignPrinterPdfArchiveOcrEvent event) { + if (event == null || StringUtils.isBlank(event.taskId())) { + return; + } + try { + archiveOcrService.extractAndPersist(event.taskId().trim()); + } catch (Exception e) { + log.warn("archive OCR async failed taskId={}", event.taskId(), e); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfFileStorage.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfFileStorage.java new file mode 100644 index 0000000..e071271 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfFileStorage.java @@ -0,0 +1,283 @@ +package com.sjmresorts.opsapp.esign.printer.support; + +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPdfTask; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Base64; +import java.util.Locale; +import java.util.Optional; + +/** + * Printer PDF 本地磁盘存储。DB 字段存 {@code stored://{taskId}/original|signed} 引用,兼容遗留 Base64。 + */ +@Slf4j +@Component +public class EsignPrinterPdfFileStorage { + + public static final String STORED_PREFIX = "stored://"; + private static final String ORIGINAL_SUFFIX = "/original"; + private static final String SIGNED_SUFFIX = "/signed"; + + @Value("${ops-app.esign.printer-pdf.storage-dir:C:/auto-check-in/printer-pdf}") + private String storageDir; + + @Value("${ops-app.esign.printer-pdf.store-on-disk:true}") + private boolean storeOnDisk; + + private Path storageRoot; + + @PostConstruct + void init() throws IOException { + storageRoot = Paths.get(storageDir).toAbsolutePath().normalize(); + Files.createDirectories(storageRoot); + log.info("Printer PDF storage root={}, onDisk={}", storageRoot, storeOnDisk); + } + + public Path getStorageRoot() { + return storageRoot; + } + + public boolean isStoreOnDisk() { + return storeOnDisk; + } + + public static boolean isStoredReference(String value) { + return StringUtils.isNotBlank(value) && value.trim().startsWith(STORED_PREFIX); + } + + public String originalRef(String taskId) { + return STORED_PREFIX + sanitizeTaskId(taskId) + ORIGINAL_SUFFIX; + } + + public String signedRef(String taskId) { + return STORED_PREFIX + sanitizeTaskId(taskId) + SIGNED_SUFFIX; + } + + /** 写入原始 PDF,返回 DB 引用;关闭落盘时退回 Base64 字符串。 */ + public String persistOriginal(String taskId, byte[] pdfBytes) throws IOException { + if (pdfBytes == null || pdfBytes.length == 0) { + throw new IOException("empty PDF"); + } + if (!storeOnDisk) { + return Base64.getEncoder().encodeToString(pdfBytes); + } + writeOriginal(sanitizeTaskId(taskId), pdfBytes); + return originalRef(taskId); + } + + /** 写入签名 PDF,返回 DB 引用。 */ + public String persistSigned(String taskId, byte[] pdfBytes) throws IOException { + if (pdfBytes == null || pdfBytes.length == 0) { + throw new IOException("empty signed PDF"); + } + if (!storeOnDisk) { + return Base64.getEncoder().encodeToString(pdfBytes); + } + writeSigned(sanitizeTaskId(taskId), pdfBytes); + return signedRef(taskId); + } + + /** 从 normalized Base64 落盘并返回引用(新任务提交用)。 */ + public String persistOriginalFromBase64(String taskId, String normalizedBase64) throws IOException { + byte[] bytes = decodeBase64(normalizedBase64); + if (bytes == null || bytes.length == 0) { + throw new IOException("invalid PDF base64"); + } + return persistOriginal(taskId, bytes); + } + + public String persistSignedFromBase64(String taskId, String normalizedBase64) throws IOException { + byte[] bytes = decodeBase64(normalizedBase64); + if (bytes == null || bytes.length == 0) { + throw new IOException("invalid signed PDF base64"); + } + return persistSigned(taskId, bytes); + } + + public Optional readOriginalBytes(EsignPrinterPdfTask task) { + if (task == null) { + return Optional.empty(); + } + return readPdfField(task.getTaskId(), task.getPdfBase64(), PdfKind.ORIGINAL); + } + + public Optional readSignedBytes(EsignPrinterPdfTask task) { + if (task == null) { + return Optional.empty(); + } + return readPdfField(task.getTaskId(), task.getSignedPdfBase64(), PdfKind.SIGNED); + } + + /** 供合成签名 PDF / OHIP 上传:优先读磁盘,兼容 DB 内 Base64。 */ + public String materializeOriginalBase64(EsignPrinterPdfTask task) { + return readOriginalBytes(task) + .map(bytes -> Base64.getEncoder().encodeToString(bytes)) + .orElse(null); + } + + public String materializeSignedBase64(EsignPrinterPdfTask task) { + return readSignedBytes(task) + .map(bytes -> Base64.getEncoder().encodeToString(bytes)) + .orElse(null); + } + + public boolean hasOriginal(EsignPrinterPdfTask task) { + if (task == null) { + return false; + } + if (StringUtils.isNotBlank(task.getPdfBase64())) { + if (isStoredReference(task.getPdfBase64())) { + return hasOriginalFile(task.getTaskId()); + } + return true; + } + return hasOriginalFile(task.getTaskId()); + } + + public boolean hasOriginalFile(String taskId) { + if (StringUtils.isBlank(taskId)) { + return false; + } + try { + return Files.isRegularFile(taskDir(sanitizeTaskId(taskId)).resolve("original.pdf")); + } catch (IllegalArgumentException e) { + return false; + } + } + + public boolean hasSignedFile(String taskId) { + if (StringUtils.isBlank(taskId)) { + return false; + } + try { + return Files.isRegularFile(taskDir(sanitizeTaskId(taskId)).resolve("signed.pdf")); + } catch (IllegalArgumentException e) { + return false; + } + } + + public boolean hasSigned(EsignPrinterPdfTask task) { + if (task == null) { + return false; + } + if (StringUtils.isNotBlank(task.getSignedPdfBase64())) { + if (isStoredReference(task.getSignedPdfBase64())) { + return hasSignedFile(task.getTaskId()); + } + return true; + } + // 仅有签名时间/图层元数据不算已归档 PDF(OPERA 必须能读到 signed.pdf) + return hasSignedFile(task.getTaskId()); + } + + /** OCR 用:优先签名 PDF,否则原始 PDF。 */ + public Optional readArchivePdfBytes(EsignPrinterPdfTask task) { + Optional signed = readSignedBytes(task); + if (signed.isPresent()) { + return signed; + } + return readOriginalBytes(task); + } + + private void writeOriginal(String taskId, byte[] bytes) throws IOException { + Path dir = taskDir(taskId); + Files.createDirectories(dir); + Path target = dir.resolve("original.pdf"); + Files.write(target, bytes); + } + + private void writeSigned(String taskId, byte[] bytes) throws IOException { + Path dir = taskDir(taskId); + Files.createDirectories(dir); + Path target = dir.resolve("signed.pdf"); + Files.write(target, bytes); + } + + private Optional readPdfField(String taskId, String fieldValue, PdfKind kind) { + if (StringUtils.isBlank(fieldValue)) { + return Optional.empty(); + } + String trimmed = fieldValue.trim(); + if (isStoredReference(trimmed)) { + Optional path = resolveStoredPath(trimmed); + if (path.isEmpty()) { + return Optional.empty(); + } + try { + byte[] bytes = Files.readAllBytes(path.get()); + return bytes.length > 0 ? Optional.of(bytes) : Optional.empty(); + } catch (IOException e) { + log.warn("read stored PDF failed taskId={}, ref={}", taskId, trimmed, e); + return Optional.empty(); + } + } + byte[] decoded = decodeBase64(trimmed); + return decoded != null && decoded.length > 0 ? Optional.of(decoded) : Optional.empty(); + } + + private Optional resolveStoredPath(String ref) { + if (!isStoredReference(ref)) { + return Optional.empty(); + } + String rest = ref.substring(STORED_PREFIX.length()); + int slash = rest.lastIndexOf('/'); + if (slash <= 0 || slash >= rest.length() - 1) { + return Optional.empty(); + } + String taskId = rest.substring(0, slash); + String kind = rest.substring(slash + 1).toLowerCase(Locale.ROOT); + try { + sanitizeTaskId(taskId); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + Path file = "signed".equals(kind) + ? taskDir(taskId).resolve("signed.pdf") + : taskDir(taskId).resolve("original.pdf"); + return Optional.of(file); + } + + private Path taskDir(String taskId) { + return storageRoot.resolve(sanitizeTaskId(taskId)); + } + + private static String sanitizeTaskId(String taskId) { + if (StringUtils.isBlank(taskId)) { + throw new IllegalArgumentException("taskId required"); + } + String id = taskId.trim(); + if (id.contains("..") || id.contains("/") || id.contains("\\")) { + throw new IllegalArgumentException("invalid taskId"); + } + return id; + } + + private static byte[] decodeBase64(String normalized) { + if (StringUtils.isBlank(normalized)) { + return null; + } + try { + String t = normalized.trim(); + int comma = t.indexOf(','); + if (t.regionMatches(true, 0, "data:", 0, 5) && comma > 0) { + t = t.substring(comma + 1).trim(); + } + return Base64.getDecoder().decode(t.replaceAll("\\s", "")); + } catch (IllegalArgumentException e) { + return null; + } + } + + private enum PdfKind { + ORIGINAL, + SIGNED + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfSignedOhipEventListener.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfSignedOhipEventListener.java new file mode 100644 index 0000000..8557c77 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfSignedOhipEventListener.java @@ -0,0 +1,58 @@ +package com.sjmresorts.opsapp.esign.printer.support; + +import com.sjmresorts.opsapp.common.config.AsyncConfig; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfSignedOhipEvent; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPdfOhipUploadService; +import com.sjmresorts.opsapp.esign.reservation.dtos.SignedRegistrationCardUploadResult; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** 平板签名提交后异步上传 OPERA(仅读归档 signed.pdf)。 */ +@Slf4j +@Component +public class EsignPrinterPdfSignedOhipEventListener { + + @Resource + private EsignPrinterPdfOhipUploadService ohipUploadService; + + @Async(AsyncConfig.OHIP_UPLOAD_EXECUTOR) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true) + public void onSignedOhipUpload(EsignPrinterPdfSignedOhipEvent event) { + if (event == null || StringUtils.isBlank(event.taskId())) { + return; + } + String taskId = event.taskId().trim(); + log.info( + "async OHIP upload triggered taskId={}, hotelId={}, reservationId={}", + taskId, + event.hotelId(), + event.reservationId()); + try { + SignedRegistrationCardUploadResult result = ohipUploadService.uploadFromArchive( + taskId, + event.confirmationNumberFromRequest(), + event.signFieldsJson(), + event.profileId(), + event.profileType(), + false, + false, + event.reservationId()); + log.info( + "async OHIP upload taskId={}, invoked={}, success={}, statusCode={}, confirmation={}, reservationId={}, error={}", + taskId, + result.isInvoked(), + result.isSuccess(), + result.getStatusCode(), + result.getConfirmationNumber(), + result.getReservationId(), + result.getErrorMessage()); + } catch (Exception e) { + log.warn("async OHIP upload failed taskId={}", taskId, e); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfSignedSubmitAsyncScheduler.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfSignedSubmitAsyncScheduler.java new file mode 100644 index 0000000..626fa4c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterPdfSignedSubmitAsyncScheduler.java @@ -0,0 +1,56 @@ +package com.sjmresorts.opsapp.esign.printer.support; + +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPdfSignedOhipEvent; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPdfArchiveOcrService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +/** 平板签名归档后的异步后处理(OCR / OPERA)。 */ +@Slf4j +@Component +public class EsignPrinterPdfSignedSubmitAsyncScheduler { + + @Resource + private ApplicationEventPublisher applicationEventPublisher; + + @Resource + private EsignPrinterPdfArchiveOcrService archiveOcrService; + + public void scheduleAfterSignedSubmit( + String taskId, + String confirmationNumberFromRequest, + String signFieldsJson, + String profileId, + String profileType, + String reservationId, + String hotelId, + boolean scheduleOcr) { + if (StringUtils.isBlank(taskId)) { + return; + } + String id = taskId.trim(); + if (scheduleOcr) { + archiveOcrService.scheduleAfterArchive(id); + } + EsignPrinterPdfSignedOhipEvent ohipEvent = new EsignPrinterPdfSignedOhipEvent( + id, + confirmationNumberFromRequest, + signFieldsJson, + profileId, + profileType, + reservationId, + hotelId); + // 在事务内发布,由 @TransactionalEventListener(AFTER_COMMIT) 在 commit 后触发 + applicationEventPublisher.publishEvent(ohipEvent); + log.info( + "signed submit async scheduled taskId={}, hotelId={}, reservationId={}, hasConfirmation={}, scheduleOcr={}", + id, + hotelId, + reservationId, + StringUtils.isNotBlank(confirmationNumberFromRequest), + scheduleOcr); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterQueueNotifier.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterQueueNotifier.java new file mode 100644 index 0000000..22bf998 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterQueueNotifier.java @@ -0,0 +1,47 @@ +package com.sjmresorts.opsapp.esign.printer.support; + +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterQueueWatchResponse; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPdfTaskService; +import com.sjmresorts.opsapp.esign.printer.support.EsignPrinterQueueSseHub; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +/** 在事务提交后向 CMS SSE 订阅者广播 PDF 队列变更。 */ +@Slf4j +@Component +public class EsignPrinterQueueNotifier { + + @Resource + private EsignPrinterQueueSseHub queueSseHub; + + @Resource + @Lazy + private EsignPrinterPdfTaskService printerPdfTaskService; + + public void notifyChangedAfterCommit() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + publishNow(); + } + }); + return; + } + publishNow(); + } + + private void publishNow() { + try { + EsignPrinterQueueWatchResponse watch = printerPdfTaskService.getQueueWatch(); + String revision = watch != null && watch.getData() != null ? watch.getData().getRevision() : null; + queueSseHub.publishQueueChanged(revision); + } catch (Exception e) { + log.warn("CMS queue SSE notify failed: {}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterQueueSseHub.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterQueueSseHub.java new file mode 100644 index 0000000..e5f2595 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/EsignPrinterQueueSseHub.java @@ -0,0 +1,103 @@ +package com.sjmresorts.opsapp.esign.printer.support; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.io.IOException; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** CMS PDF 队列 SSE:任务上报/推送/签收后即时通知前端刷新。 */ +@Slf4j +@Component +public class EsignPrinterQueueSseHub { + + private static final long EMITTER_TIMEOUT_MS = 30 * 60 * 1000L; + private static final long HEARTBEAT_MS = 25_000L; + + private final ObjectMapper objectMapper; + private final ConcurrentHashMap emitters = new ConcurrentHashMap<>(); + private final ScheduledExecutorService heartbeatScheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "printer-queue-sse-heartbeat"); + t.setDaemon(true); + return t; + }); + + public EsignPrinterQueueSseHub(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + heartbeatScheduler.scheduleAtFixedRate(this::sendHeartbeats, HEARTBEAT_MS, HEARTBEAT_MS, TimeUnit.MILLISECONDS); + } + + @PreDestroy + void shutdown() { + heartbeatScheduler.shutdownNow(); + emitters.values().forEach(SseEmitter::complete); + emitters.clear(); + } + + public SseEmitter subscribe() { + String id = UUID.randomUUID().toString(); + SseEmitter emitter = new SseEmitter(EMITTER_TIMEOUT_MS); + emitters.put(id, emitter); + Runnable remove = () -> emitters.remove(id, emitter); + emitter.onCompletion(remove); + emitter.onTimeout(() -> { + remove.run(); + emitter.complete(); + }); + emitter.onError(e -> remove.run()); + try { + emitter.send(SseEmitter.event().name("connected").data("{\"ok\":true}")); + } catch (IOException e) { + emitters.remove(id, emitter); + throw new IllegalStateException("SSE connect failed", e); + } + log.debug("CMS queue SSE subscribed id={}, active={}", id, emitters.size()); + return emitter; + } + + public void publishQueueChanged(String revision) { + if (emitters.isEmpty()) { + return; + } + String payload = toJson(Map.of("revision", StringUtils.defaultString(revision))); + emitters.forEach((id, emitter) -> { + try { + emitter.send(SseEmitter.event().name("queue-changed").data(payload)); + } catch (IOException e) { + emitters.remove(id, emitter); + } + }); + log.debug("CMS queue SSE published revision={}, active={}", revision, emitters.size()); + } + + private void sendHeartbeats() { + if (emitters.isEmpty()) { + return; + } + emitters.forEach((id, emitter) -> { + try { + emitter.send(SseEmitter.event().comment("keepalive")); + } catch (IOException e) { + emitters.remove(id, emitter); + } + }); + } + + private String toJson(Map payload) { + try { + return objectMapper.writeValueAsString(payload); + } catch (JsonProcessingException e) { + return "{}"; + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/support/PdfSignFieldTextExtractor.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/PdfSignFieldTextExtractor.java new file mode 100644 index 0000000..6c019c8 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/PdfSignFieldTextExtractor.java @@ -0,0 +1,366 @@ +package com.sjmresorts.opsapp.esign.printer.support; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDPage; +import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.text.PDFTextStripperByArea; +import org.springframework.stereotype.Component; + +import java.awt.Rectangle; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 从登记卡 PDF(含扫描件)按热区比例提取文字字段(PDF 文本层 → 热区 OCR)。 */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PdfSignFieldTextExtractor { + + private static final Pattern EMAIL = + Pattern.compile("[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}"); + private static final Pattern PHONE_STRICT = Pattern.compile( + "\\+(?:852|853|86|1|44|61|65|81|82|886)\\s*[\\d\\s().-]{5,}\\d" + + "|\\+[\\d\\s().-]{8,}\\d" + + "|\\b\\d{2,4}[\\s().-]?\\d{4}[\\s().-]?\\d{3,4}\\b"); + private static final Pattern PHONE_LETTER_GAP = + Pattern.compile("(?<=\\d)\\s+[A-Za-z]{2,6}\\s+(?=\\d)|\\s+[A-Za-z]{2,6}\\s+(?=\\d)"); + + private final RegistrationConfirmationOcrExtractor ocrExtractor; + + public Optional extractFromCropImage(String cropImageBase64, String inputFormat) { + if (StringUtils.isBlank(cropImageBase64)) { + return Optional.empty(); + } + // 裁剪图 OCR 返回原文,由平板端按 inputFormat 统一清洗(避免后端 phone 清洗失败导致 404 空弹窗) + return ocrExtractor + .ocrCropImageBase64(cropImageBase64, inputFormat) + .map(StringUtils::trimToEmpty) + .filter(StringUtils::isNotBlank); + } + + public Optional extract( + byte[] pdfBytes, + int pageIndex, + double left, + double top, + double width, + double height, + String inputFormat) { + if (pdfBytes == null || pdfBytes.length == 0) { + return Optional.empty(); + } + String fmt = normalizeInputFormat(inputFormat); + + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + if (pageIndex < 0 || pageIndex >= doc.getNumberOfPages()) { + return Optional.empty(); + } + PDPage page = doc.getPage(pageIndex); + PDRectangle media = page.getMediaBox(); + float pageW = media.getWidth(); + float pageH = media.getHeight(); + + String fromArea = extractTextInArea(page, pageW, pageH, left, top, width, height); + String cleaned = cleanFieldText(fromArea, fmt); + if (StringUtils.isNotBlank(cleaned)) { + return Optional.of(cleaned); + } + } catch (Exception e) { + log.warn("PdfSignFieldTextExtractor pdf text failed page={}", pageIndex, e); + } + + Optional ocrRegion = ocrExtractor.ocrFieldRegionFromPdfBytes( + pdfBytes, pageIndex, left, top, width, height); + if (ocrRegion.isPresent()) { + String cleaned = cleanFieldText(ocrRegion.get(), fmt); + if (StringUtils.isNotBlank(cleaned)) { + return Optional.of(cleaned); + } + } + return Optional.empty(); + } + + private static String extractTextInArea( + PDPage page, + float pageW, + float pageH, + double left, + double top, + double width, + double height) + throws Exception { + double l = clamp01(left); + double t = clamp01(top); + double w = clamp01(width); + double h = clamp01(height); + if (w <= 0 || h <= 0) { + return ""; + } + int x = (int) Math.max(0, Math.round(l * pageW) - 2); + int stripW = (int) Math.max(8, Math.round(w * pageW) + 4); + int stripH = (int) Math.max(8, Math.round(h * pageH) + 4); + int y = (int) Math.max(0, Math.round(pageH - (t + h) * pageH) - 2); + if (y + stripH > pageH) { + stripH = (int) pageH - y; + } + if (x + stripW > pageW) { + stripW = (int) pageW - x; + } + PDFTextStripperByArea stripper = new PDFTextStripperByArea(); + stripper.setSortByPosition(true); + stripper.addRegion("field", new Rectangle(x, y, stripW, stripH)); + stripper.extractRegions(page); + return StringUtils.trimToEmpty(stripper.getTextForRegion("field")); + } + + static String normalizeOcrEmail(String raw) { + if (StringUtils.isBlank(raw)) { + return ""; + } + String s = raw.trim() + .replaceAll("[\\(\\[\\{<「『【]+@", "@") + .replaceAll("@[\\)\\]\\}>」』】]+", "@") + .replaceAll("\\s+", ""); + if (!s.contains("@")) { + // PDF/OCR 常把 @ 识成 dz、at、©(画面仍是 @) + s = s.replaceAll("(?i)\\(at\\)", "@") + .replaceAll("(?i)\\[at\\]", "@") + .replaceAll("(?i)\\{at\\}", "@") + .replaceAll("(?i)([A-Za-z0-9._%+-])(?:dz|©|®|Ⓐ|@)([A-Za-z0-9.-]+\\.[A-Za-z]{2,})", "$1@$2") + .replaceAll("(?i)([A-Za-z0-9._%+-])at(?=[A-Za-z0-9.-]+\\.[A-Za-z]{2,})", "$1@"); + } + return s; + } + + static String extractEmailFromOcr(String raw) { + String normalized = normalizeOcrEmail(raw); + Matcher m = EMAIL.matcher(normalized); + return m.find() ? m.group() : ""; + } + + static String stripPhoneLabel(String raw) { + if (StringUtils.isBlank(raw)) { + return ""; + } + return raw.trim() + .replaceAll("(?i)^(phone\\s*電話|phone|電話|电话)\\s*[::]?\\s*", "") + .trim(); + } + + static String fixOcrPhoneChars(String raw) { + if (StringUtils.isBlank(raw)) { + return ""; + } + return raw + .replace('O', '0') + .replace('o', '0') + .replace('Q', '0') + .replace('q', '0') + .replace('D', '0') + .replace('I', '1') + .replace('l', '1') + .replace('|', '1') + .replace('!', '1') + .replace('Z', '2') + .replace('z', '2') + .replace('S', '5') + .replace('s', '5') + .replace('$', '5') + .replace('B', '8') + .replace('b', '8') + .replace('G', '9') + .replace('g', '9') + .replace('A', '4') + .replace('a', '4'); + } + + static int countDigits(String raw) { + if (StringUtils.isBlank(raw)) { + return 0; + } + return raw.replaceAll("\\D", "").length(); + } + + static boolean looksLikePhoneText(String raw) { + String s = stripPhoneLabel(raw); + if (StringUtils.isBlank(s) || s.contains("@")) { + return false; + } + int digits = countDigits(s); + return digits >= 7 && digits <= 15 && s.matches(".*[\\d+].*"); + } + + static String formatPhoneFromDigits(String digits, boolean preferPlus) { + String d = digits.replaceAll("\\D", ""); + if (d.length() < 7 || d.length() > 15) { + return ""; + } + if (d.length() == 11 && d.startsWith("86")) { + return "+86 " + d.substring(2, 5) + " " + d.substring(5, 9) + " " + d.substring(9); + } + if (d.length() == 11 && d.startsWith("852")) { + return "+852 " + d.substring(3, 7) + " " + d.substring(7); + } + if (d.length() == 11 && d.startsWith("853")) { + return "+853 " + d.substring(3, 7) + " " + d.substring(7); + } + if (d.length() == 11 && d.startsWith("1")) { + return "+1 " + d.substring(1, 4) + " " + d.substring(4, 7) + " " + d.substring(7); + } + if (d.length() == 10 && d.startsWith("1")) { + return "+1 " + d.substring(0, 3) + " " + d.substring(3, 6) + " " + d.substring(6); + } + if (d.length() == 8) { + return d.substring(0, 4) + " " + d.substring(4); + } + if (preferPlus || d.length() > 10) { + return "+" + d; + } + return d; + } + + static String extractPhoneFromOcr(String raw) { + String s = stripPhoneLabel(raw); + if (StringUtils.isBlank(s)) { + return ""; + } + // 常见 OCR:+1 误识为 +41,优先尝试修正后再提取 + if (s.matches("(?i).*\\+41\\s*.*")) { + String us = extractPhoneFromOcrOnce(s.replaceFirst("(?i)\\+41", "+1")); + if (StringUtils.isNotBlank(us)) { + return us; + } + } + return extractPhoneFromOcrOnce(s); + } + + private static String extractPhoneFromOcrOnce(String raw) { + String s = stripPhoneLabel(raw); + if (StringUtils.isBlank(s)) { + return ""; + } + s = PHONE_LETTER_GAP.matcher(s).replaceAll(" "); + s = fixOcrPhoneChars(s); + s = StringUtils.normalizeSpace(s); + + Matcher strict = PHONE_STRICT.matcher(s); + if (strict.find()) { + return strict.group().trim(); + } + + boolean hasPlus = s.contains("+"); + String digits = s.replaceAll("\\D", ""); + if (digits.length() >= 7 && digits.length() <= 15) { + return formatPhoneFromDigits(digits, hasPlus); + } + return ""; + } + + static String cleanFieldText(String raw, String inputFormat) { + String fmt = normalizeInputFormat(inputFormat); + String rawText = StringUtils.trimToEmpty(raw); + if ("email".equals(fmt) || rawText.contains("@")) { + String email = extractEmailFromOcr(rawText); + if (StringUtils.isNotBlank(email)) { + return email; + } + if ("email".equals(fmt)) { + return ""; + } + } + if ("phone".equals(fmt) || looksLikePhoneText(rawText)) { + String phone = extractPhoneFromOcr(rawText); + if (StringUtils.isNotBlank(phone)) { + return phone; + } + if ("phone".equals(fmt)) { + String fallback = stripPhoneLabel(rawText); + fallback = StringUtils.normalizeSpace(fallback); + return StringUtils.isNotBlank(fallback) ? fallback : ""; + } + } + String t = StringUtils.normalizeSpace(rawText); + if (StringUtils.isBlank(t)) { + t = pickFormatFromText(raw, fmt); + } else { + t = stripPhoneLabel(t); + t = t.replaceAll("(?i)^(email\\s*電子郵件|email)\\s*[::]?\\s*", "").trim(); + if (t.contains("@")) { + String email = extractEmailFromOcr(t); + t = StringUtils.isNotBlank(email) ? email : ""; + } else if (looksLikePhoneText(t)) { + String phone = extractPhoneFromOcr(t); + t = StringUtils.isNotBlank(phone) ? phone : ""; + } + } + if (!isPlausibleFieldText(t, fmt)) { + return ""; + } + return t; + } + + static String pickFormatFromText(String text, String inputFormat) { + String fmt = normalizeInputFormat(inputFormat); + String page = StringUtils.trimToEmpty(text); + if (StringUtils.isBlank(page)) { + return ""; + } + if ("email".equals(fmt) || page.contains("@")) { + String email = extractEmailFromOcr(page); + return StringUtils.isNotBlank(email) ? email : ""; + } + if ("phone".equals(fmt) || looksLikePhoneText(page)) { + String phone = extractPhoneFromOcr(page); + return StringUtils.isNotBlank(phone) ? phone : ""; + } + return isPlausibleFieldText(page, fmt) ? page : ""; + } + + static boolean isPlausibleFieldText(String text, String fmt) { + if (StringUtils.isBlank(text)) { + return false; + } + String t = text.trim(); + if ("email".equals(fmt) || t.contains("@")) { + return StringUtils.isNotBlank(extractEmailFromOcr(t)); + } + if ("phone".equals(fmt) || looksLikePhoneText(t)) { + int digits = countDigits(t); + return digits >= 7 && digits <= 15; + } + String compact = t.replaceAll("\\s", ""); + if (compact.length() <= 3 && compact.matches("[a-zA-Z]+")) { + return false; + } + if (!t.matches(".*[A-Za-z0-9\\u4e00-\\u9fff].*")) { + return false; + } + return compact.length() >= 2; + } + + private static double clamp01(double v) { + if (Double.isNaN(v) || Double.isInfinite(v)) { + return 0; + } + return Math.max(0, Math.min(1, v)); + } + + static String normalizeInputFormat(String raw) { + String s = StringUtils.trimToEmpty(raw).toLowerCase(); + if (s.isEmpty()) { + return "text"; + } + if (s.contains("mail") || s.contains("邮") || s.contains("郵") || "email".equals(s)) { + return "email"; + } + if (s.contains("phone") || s.contains("tel") || s.contains("电话") || s.contains("電話")) { + return "phone"; + } + return s; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/support/RegistrationConfirmationOcrExtractor.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/RegistrationConfirmationOcrExtractor.java new file mode 100644 index 0000000..5dc7823 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/RegistrationConfirmationOcrExtractor.java @@ -0,0 +1,628 @@ +package com.sjmresorts.opsapp.esign.printer.support; + +import com.sjmresorts.opsapp.esign.common.support.EsignLogMaskUtil; +import com.sjmresorts.opsapp.esign.reservation.support.RegistrationConfirmationExtractor; +import lombok.extern.slf4j.Slf4j; +import net.sourceforge.tess4j.ITesseract; +import net.sourceforge.tess4j.Tesseract; +import org.apache.commons.lang3.StringUtils; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.rendering.PDFRenderer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import jakarta.annotation.PostConstruct; +import javax.imageio.ImageIO; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.util.Base64; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** PDF 首页渲染 + Tesseract OCR,用于扫描件登记卡提取确认号。 */ +@Slf4j +@Component +public class RegistrationConfirmationOcrExtractor { + + private static final String[] WINDOWS_TESSDATA_CANDIDATES = { + "C:/Program Files/Tesseract-OCR/tessdata", + "C:/Program Files (x86)/Tesseract-OCR/tessdata" + }; + + private static final String[] LINUX_TESSDATA_CANDIDATES = { + "/usr/share/tesseract/tessdata", + "/usr/share/tessdata", + "/usr/local/share/tessdata" + }; + + @Value("${ops-app.esign.confirmation-extract.ocr-enabled:true}") + private boolean ocrEnabled; + + @Value("${ops-app.esign.confirmation-extract.tessdata-path:}") + private String tessdataPath; + + @Value("${ops-app.esign.confirmation-extract.ocr-language:eng}") + private String ocrLanguage; + + @Value("${ops-app.esign.confirmation-extract.ocr-dpi:260}") + private int ocrDpi; + + /** 热区字段 OCR 分辨率(低于整页 OCR,仅裁剪框内识别) */ + @Value("${ops-app.esign.confirmation-extract.field-ocr-dpi:180}") + private int fieldOcrDpi; + + @Value("${ops-app.esign.confirmation-extract.ocr-max-pages:2}") + private int ocrMaxPages; + + @Value("${ops-app.esign.confirmation-extract.ocr-header-ratio:0.38}") + private double ocrHeaderRatio; + + private volatile String resolvedTessdataPath; + private volatile boolean nativeLibAvailable; + private volatile String nativeLibError = ""; + private volatile String lastOcrHint = ""; + + private static final Pattern DIGIT_CONFIRMATION = Pattern.compile("(\\d{6,12})"); + private static final double[][] ROYAL_PARK_CONFIRMATION_REGIONS = { + {0.46, 0.10, 0.22, 0.12}, + {0.50, 0.12, 0.16, 0.08}, + {0.43, 0.08, 0.28, 0.16}, + {0.38, 0.08, 0.36, 0.18} + }; + + @PostConstruct + void init() { + resolvedTessdataPath = resolveTessdataPath(); + if (!ocrEnabled) { + return; + } + if (StringUtils.isBlank(resolvedTessdataPath)) { + log.warn( + "confirmation OCR enabled but tessdata not found; run scripts/download-tessdata.ps1 " + + "or install tesseract-ocr on server, or set ops-app.esign.confirmation-extract.tessdata-path"); + return; + } + nativeLibAvailable = probeNativeLibrary(); + if (nativeLibAvailable) { + log.info( + "confirmation OCR ready tessdata={}, language={}, dpi={}, maxPages={}, headerRatio={}", + resolvedTessdataPath, + ocrLanguage, + ocrDpi, + ocrMaxPages, + ocrHeaderRatio); + } else { + log.warn( + "confirmation OCR tessdata={} but native libtesseract unavailable: {}. " + + "Install tesseract-ocr on Linux or set ops-app.esign.confirmation-extract.ocr-enabled=false", + resolvedTessdataPath, + nativeLibError); + } + } + + public boolean isOcrEnabled() { + return ocrEnabled; + } + + public boolean isOcrReady() { + return ocrEnabled && StringUtils.isNotBlank(resolvedTessdataPath) && nativeLibAvailable; + } + + public String getUnavailableReason() { + if (!ocrEnabled) { + return "OCR 已关闭"; + } + if (StringUtils.isBlank(resolvedTessdataPath)) { + return "未找到 tessdata(请运行 scripts/download-tessdata.ps1 或安装 Tesseract)"; + } + if (!nativeLibAvailable) { + if (StringUtils.isNotBlank(nativeLibError)) { + return "未安装 Tesseract 原生库(libtesseract):" + nativeLibError; + } + return "未安装 Tesseract 原生库(libtesseract),请在服务器安装 tesseract-ocr 或关闭 OCR"; + } + return ""; + } + + public String getLastOcrHint() { + return lastOcrHint; + } + + public Optional extractFromPdfBytes(byte[] pdfBytes) { + lastOcrHint = ""; + if (!ocrEnabled || pdfBytes == null || pdfBytes.length == 0) { + return Optional.empty(); + } + if (!isOcrReady()) { + log.warn("confirmation OCR skip: {}", getUnavailableReason()); + return Optional.empty(); + } + Optional regionFound = extractConfirmationFromKnownRegions(pdfBytes); + if (regionFound.isPresent()) { + return regionFound; + } + Optional fullText = extractFullTextFromPdfBytes(pdfBytes); + if (fullText.isEmpty() || StringUtils.isBlank(fullText.get())) { + lastOcrHint = "OCR 未识别到文字"; + return Optional.empty(); + } + Optional found = RegistrationConfirmationExtractor.extractFromText(fullText.get()); + if (found.isPresent()) { + log.info( + "confirmation OCR hit via full text confirmation={} sample={}", + found.get(), + EsignLogMaskUtil.truncate(fullText.get())); + return found; + } + lastOcrHint = "OCR样例 " + EsignLogMaskUtil.truncate(fullText.get()); + return Optional.empty(); + } + + /** 渲染 PDF 前若干页并 OCR 合并为纯文本(供登记卡字段解析)。 */ + public Optional extractFullTextFromPdfBytes(byte[] pdfBytes) { + lastOcrHint = ""; + if (!ocrEnabled || pdfBytes == null || pdfBytes.length == 0) { + return Optional.empty(); + } + if (!isOcrReady()) { + return Optional.empty(); + } + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + int pages = Math.min(doc.getNumberOfPages(), Math.max(ocrMaxPages, 1)); + PDFRenderer renderer = new PDFRenderer(doc); + ITesseract tesseract = createTesseract(); + StringBuilder sb = new StringBuilder(); + for (int page = 0; page < pages; page++) { + BufferedImage full = renderer.renderImageWithDPI(page, ocrDpi); + String headerText = tesseract.doOCR(cropHeader(full)); + if (StringUtils.isNotBlank(headerText)) { + sb.append(headerText).append('\n'); + } + String pageText = tesseract.doOCR(full); + if (StringUtils.isNotBlank(pageText)) { + sb.append(pageText).append('\n'); + } + } + String merged = sb.toString().trim(); + return StringUtils.isNotBlank(merged) ? Optional.of(merged) : Optional.empty(); + } catch (Throwable e) { + lastOcrHint = "OCR 异常: " + e.getMessage(); + log.warn("registration card OCR full text failed", e); + return Optional.empty(); + } + } + + private Optional ocrImage(ITesseract tesseract, BufferedImage image, int page, String region) + throws Exception { + String pageText = tesseract.doOCR(image); + if (StringUtils.isBlank(pageText)) { + return Optional.empty(); + } + log.info( + "confirmation OCR page={} region={} textLen={}", + page + 1, + region, + pageText.length()); + Optional found = RegistrationConfirmationExtractor.extractFromText(pageText); + if (found.isPresent()) { + log.info( + "confirmation OCR hit page={} region={} confirmation={}", + page + 1, + region, + found.get()); + return found; + } + lastOcrHint = "OCR样例(" + region + ") " + EsignLogMaskUtil.truncate(pageText); + log.info("confirmation OCR page={} region={} sample={}", page + 1, region, lastOcrHint); + return Optional.empty(); + } + + /** + * 直接 OCR 平板 renderjs 裁剪的热区 PNG(与屏幕所见一致)。 + */ + public Optional ocrCropImageBase64(String base64) { + return ocrCropImageBase64(base64, null); + } + + public Optional ocrCropImageBase64(String base64, String inputFormat) { + lastOcrHint = ""; + String fmt = StringUtils.defaultIfBlank(inputFormat, "text"); + if (!ocrEnabled || !isOcrReady() || StringUtils.isBlank(base64)) { + lastOcrHint = getUnavailableReason(); + log.info( + "crop OCR skip format={} enabled={} ready={} hasImage={} reason={}", + fmt, + ocrEnabled, + isOcrReady(), + StringUtils.isNotBlank(base64), + lastOcrHint); + return Optional.empty(); + } + try { + String raw = base64.trim(); + int comma = raw.indexOf(','); + if (comma >= 0) { + raw = raw.substring(comma + 1); + } + byte[] bytes = Base64.getDecoder().decode(raw); + BufferedImage image = ImageIO.read(new ByteArrayInputStream(bytes)); + if (image == null) { + lastOcrHint = "crop image decode failed"; + log.warn("crop OCR decode failed format={} base64Len={}", fmt, raw.length()); + return Optional.empty(); + } + if (!cropHasMeaningfulInk(image)) { + lastOcrHint = "crop region empty"; + log.info( + "crop OCR empty ink format={} size={}x{}", + fmt, + image.getWidth(), + image.getHeight()); + return Optional.empty(); + } + image = prepareCropForOcr(image); + String text = ocrCropTextWithFallback(image, inputFormat); + if (StringUtils.isBlank(text)) { + lastOcrHint = "crop OCR empty"; + log.info( + "crop OCR no text format={} size={}x{}", + fmt, + image.getWidth(), + image.getHeight()); + return Optional.empty(); + } + log.info( + "crop OCR ok format={} size={}x{} textLen={} sample={}", + fmt, + image.getWidth(), + image.getHeight(), + text.length(), + EsignLogMaskUtil.truncate(text)); + return Optional.of(text.trim()); + } catch (Throwable e) { + markNativeUnavailable(e); + lastOcrHint = "crop OCR failed: " + summarizeNativeLibError(e); + log.warn("crop image OCR failed format={}", fmt, e); + return Optional.empty(); + } + } + + /** + * 按热区比例(页面左上原点,0~1)裁剪并 OCR,用于扫描件登记卡文字字段预填。 + */ + public Optional ocrFieldRegionFromPdfBytes( + byte[] pdfBytes, + int pageIndex, + double left, + double top, + double width, + double height) { + lastOcrHint = ""; + if (!ocrEnabled || pdfBytes == null || pdfBytes.length == 0 || !isOcrReady()) { + return Optional.empty(); + } + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + if (pageIndex < 0 || pageIndex >= doc.getNumberOfPages()) { + return Optional.empty(); + } + int dpi = Math.max(120, Math.min(fieldOcrDpi, 240)); + PDFRenderer renderer = new PDFRenderer(doc); + BufferedImage full = renderer.renderImageWithDPI(pageIndex, dpi); + BufferedImage crop = cropFieldRegion(full, left, top, width, height); + if (crop == null) { + return Optional.empty(); + } + ITesseract tesseract = createFieldTesseract(); + String text = tesseract.doOCR(crop); + if (StringUtils.isBlank(text)) { + lastOcrHint = "field OCR empty"; + return Optional.empty(); + } + log.info( + "field OCR page={} dpi={} crop={}x{} textLen={} sample={}", + pageIndex + 1, + dpi, + crop.getWidth(), + crop.getHeight(), + text.length(), + EsignLogMaskUtil.truncate(text)); + return Optional.of(text.trim()); + } catch (Throwable e) { + markNativeUnavailable(e); + lastOcrHint = "field OCR failed: " + summarizeNativeLibError(e); + log.warn("field region OCR failed page={}", pageIndex + 1, e); + return Optional.empty(); + } + } + + private static BufferedImage cropFieldRegion( + BufferedImage full, + double left, + double top, + double width, + double height) { + if (full == null) { + return null; + } + double l = clampRatio(left); + double t = clampRatio(top); + double w = clampRatio(width); + double h = clampRatio(height); + if (w <= 0 || h <= 0) { + return null; + } + int imgW = full.getWidth(); + int imgH = full.getHeight(); + int x = (int) Math.max(0, Math.round(l * imgW) - 4); + int y = (int) Math.max(0, Math.round(t * imgH) - 4); + int cropW = (int) Math.max(8, Math.round(w * imgW) + 8); + int cropH = (int) Math.max(8, Math.round(h * imgH) + 8); + if (x + cropW > imgW) { + cropW = imgW - x; + } + if (y + cropH > imgH) { + cropH = imgH - y; + } + if (cropW <= 0 || cropH <= 0) { + return null; + } + return full.getSubimage(x, y, cropW, cropH); + } + + private Optional extractConfirmationFromKnownRegions(byte[] pdfBytes) { + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + if (doc.getNumberOfPages() <= 0) { + return Optional.empty(); + } + PDFRenderer renderer = new PDFRenderer(doc); + BufferedImage full = renderer.renderImageWithDPI(0, Math.max(ocrDpi, 320)); + for (int i = 0; i < ROYAL_PARK_CONFIRMATION_REGIONS.length; i++) { + double[] region = ROYAL_PARK_CONFIRMATION_REGIONS[i]; + BufferedImage crop = cropFieldRegion(full, region[0], region[1], region[2], region[3]); + Optional found = ocrDigitsFromRegion(crop, i, region); + if (found.isPresent()) { + log.info("confirmation OCR hit known region index={} confirmation={}", i, found.get()); + return found; + } + } + return Optional.empty(); + } catch (Throwable e) { + lastOcrHint = "known region OCR failed: " + summarizeNativeLibError(e); + log.warn("confirmation known region OCR failed", e); + return Optional.empty(); + } + } + + private Optional ocrDigitsFromRegion(BufferedImage image, int index, double[] region) throws Exception { + if (image == null || !cropHasMeaningfulInk(image)) { + log.info("confirmation known region OCR skip index={} emptyInk={}", index, image != null); + return Optional.empty(); + } + BufferedImage prepared = prepareCropForOcr(image); + ITesseract tesseract = createTesseract(); + tesseract.setPageSegMode(7); + tesseract.setTessVariable("tessedit_char_whitelist", "0123456789"); + String text = StringUtils.trimToEmpty(tesseract.doOCR(prepared)); + String compact = text.replaceAll("\\s+", ""); + log.info( + "confirmation known region OCR index={} region=[{},{},{},{}] psm=7 crop={}x{} textRaw={} textCompact={}", + index, + region[0], + region[1], + region[2], + region[3], + prepared.getWidth(), + prepared.getHeight(), + EsignLogMaskUtil.truncate(text), + EsignLogMaskUtil.truncate(compact)); + Optional token = extractDigitToken(text); + if (token.isPresent()) { + return token; + } + ITesseract retry = createTesseract(); + retry.setPageSegMode(6); + retry.setTessVariable("tessedit_char_whitelist", "0123456789"); + text = StringUtils.trimToEmpty(retry.doOCR(prepared)); + compact = text.replaceAll("\\s+", ""); + log.info( + "confirmation known region OCR index={} region=[{},{},{},{}] psm=6 crop={}x{} textRaw={} textCompact={}", + index, + region[0], + region[1], + region[2], + region[3], + prepared.getWidth(), + prepared.getHeight(), + EsignLogMaskUtil.truncate(text), + EsignLogMaskUtil.truncate(compact)); + return extractDigitToken(text); + } + + private Optional extractDigitToken(String text) { + Matcher matcher = DIGIT_CONFIRMATION.matcher(StringUtils.defaultString(text)); + return matcher.find() ? Optional.of(matcher.group(1)) : Optional.empty(); + } + + private static double clampRatio(double v) { + if (Double.isNaN(v) || Double.isInfinite(v)) { + return 0; + } + return Math.max(0, Math.min(1, v)); + } + + private BufferedImage cropHeader(BufferedImage full) { + if (full == null || ocrHeaderRatio <= 0 || ocrHeaderRatio >= 1) { + return full; + } + int h = Math.max(1, (int) Math.round(full.getHeight() * ocrHeaderRatio)); + return full.getSubimage(0, 0, full.getWidth(), h); + } + + private ITesseract createFieldTesseract() { + return createFieldTesseract(null); + } + + private ITesseract createFieldTesseract(String inputFormat) { + ITesseract tesseract = createTesseract(); + tesseract.setPageSegMode(7); + return tesseract; + } + + private String ocrCropTextWithFallback(BufferedImage image, String inputFormat) throws Exception { + ITesseract tesseract = createFieldTesseract(inputFormat); + String text = StringUtils.trimToEmpty(tesseract.doOCR(image)); + if (StringUtils.isNotBlank(text)) { + return text; + } + String fmt = PdfSignFieldTextExtractor.normalizeInputFormat(inputFormat); + if (!"phone".equals(fmt)) { + return text; + } + ITesseract retry = createTesseract(); + retry.setPageSegMode(7); + return StringUtils.trimToEmpty(retry.doOCR(image)); + } + + /** 放大过小裁剪图并二值化,提升单行 email/phone OCR 准确率。 */ + private static BufferedImage prepareCropForOcr(BufferedImage source) { + if (source == null) { + return null; + } + BufferedImage image = source; + int minHeight = 96; + if (image.getHeight() < minHeight) { + double scale = (double) minHeight / Math.max(1, image.getHeight()); + int nw = Math.max(1, (int) Math.round(image.getWidth() * scale)); + int nh = Math.max(minHeight, (int) Math.round(image.getHeight() * scale)); + BufferedImage scaled = new BufferedImage(nw, nh, BufferedImage.TYPE_INT_RGB); + Graphics2D g = scaled.createGraphics(); + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); + g.drawImage(image, 0, 0, nw, nh, null); + g.dispose(); + image = scaled; + } + BufferedImage gray = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_BYTE_GRAY); + Graphics2D g2 = gray.createGraphics(); + g2.drawImage(image, 0, 0, null); + g2.dispose(); + for (int y = 0; y < gray.getHeight(); y++) { + for (int x = 0; x < gray.getWidth(); x++) { + int rgb = gray.getRGB(x, y) & 0xff; + int v = rgb >= 145 ? 255 : rgb <= 95 ? 0 : rgb >= 120 ? 255 : 0; + int out = (v << 16) | (v << 8) | v; + gray.setRGB(x, y, out); + } + } + return gray; + } + + private static boolean cropHasMeaningfulInk(BufferedImage image) { + if (image == null || image.getWidth() <= 0 || image.getHeight() <= 0) { + return false; + } + int dark = 0; + int total = image.getWidth() * image.getHeight(); + for (int y = 0; y < image.getHeight(); y++) { + for (int x = 0; x < image.getWidth(); x++) { + int argb = image.getRGB(x, y); + int r = (argb >> 16) & 0xff; + int g = (argb >> 8) & 0xff; + int b = argb & 0xff; + int gray = (int) (r * 0.299 + g * 0.587 + b * 0.114); + if (gray < 140) { + dark++; + } + } + } + return (double) dark / total >= 0.004; + } + + private ITesseract createTesseract() { + try { + Tesseract tesseract = new Tesseract(); + tesseract.setDatapath(resolvedTessdataPath); + tesseract.setLanguage(StringUtils.defaultIfBlank(ocrLanguage, "eng")); + tesseract.setPageSegMode(6); + return tesseract; + } catch (Throwable e) { + markNativeUnavailable(e); + throw new IllegalStateException( + "Tesseract native library unavailable: " + summarizeNativeLibError(e), e); + } + } + + private boolean probeNativeLibrary() { + try { + ITesseract tesseract = createTesseract(); + BufferedImage tiny = new BufferedImage(8, 8, BufferedImage.TYPE_INT_RGB); + tesseract.doOCR(tiny); + nativeLibError = ""; + return true; + } catch (Throwable e) { + markNativeUnavailable(e); + return false; + } + } + + private void markNativeUnavailable(Throwable e) { + nativeLibAvailable = false; + nativeLibError = summarizeNativeLibError(e); + } + + private static String summarizeNativeLibError(Throwable e) { + Throwable root = e; + while (root.getCause() != null && root.getCause() != root) { + root = root.getCause(); + } + String msg = StringUtils.defaultIfBlank(root.getMessage(), root.getClass().getSimpleName()); + String combined = msg + " " + e.getClass().getSimpleName(); + if (combined.contains("Could not initialize class") + || combined.contains("UnsatisfiedLinkError") + || combined.contains("no tessdata") + || combined.contains("libtesseract") + || combined.contains("Tesseract")) { + return "未安装 Tesseract 原生库(请在服务器安装 tesseract-ocr 后重启后端)"; + } + return StringUtils.defaultIfBlank(msg, "OCR 初始化失败"); + } + + private String resolveTessdataPath() { + List candidates = new ArrayList<>(); + if (StringUtils.isNotBlank(tessdataPath)) { + candidates.add(tessdataPath.trim()); + } + String userDir = System.getProperty("user.dir"); + if (StringUtils.isNotBlank(userDir)) { + candidates.add(Path.of(userDir, "tessdata").toString()); + } + for (String c : WINDOWS_TESSDATA_CANDIDATES) { + candidates.add(c); + } + for (String c : LINUX_TESSDATA_CANDIDATES) { + candidates.add(c); + } + candidates.add("tessdata"); + for (String candidate : candidates) { + if (isValidTessdataDir(candidate)) { + return Path.of(candidate).toAbsolutePath().normalize().toString(); + } + } + return null; + } + + private static boolean isValidTessdataDir(String path) { + if (StringUtils.isBlank(path)) { + return false; + } + Path dir = Path.of(path); + return Files.isDirectory(dir) && Files.exists(dir.resolve("eng.traineddata")); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/support/SignedPdfArchiveReader.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/SignedPdfArchiveReader.java new file mode 100644 index 0000000..0ac2cf6 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/SignedPdfArchiveReader.java @@ -0,0 +1,84 @@ +package com.sjmresorts.opsapp.esign.printer.support; + +import com.sjmresorts.opsapp.esign.printer.dtos.RegistrationCardSignFieldParser; +import com.sjmresorts.opsapp.esign.printer.dtos.SignedRegistrationPdfComposer; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPdfTask; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +import java.util.Base64; +import java.util.Optional; + +/** + * 读取已归档的签名 PDF。OPERA 上传优先读磁盘/DB 归档; + * 若归档丢失但仍有签名图层 + 原件,则按归档热区重合成并回写(与预览下载逻辑一致)。 + */ +@Slf4j +@Component +public class SignedPdfArchiveReader { + + @Resource + private EsignPrinterPdfFileStorage pdfFileStorage; + + public Optional readSignedBase64(EsignPrinterPdfTask task) { + if (task == null) { + return Optional.empty(); + } + String signedBase64 = pdfFileStorage.materializeSignedBase64(task); + if (StringUtils.isNotBlank(signedBase64)) { + return Optional.of(signedBase64); + } + log.warn( + "archived signed PDF missing taskId={}, hasSignedRef={}, hasSignedFile={}, hasMeta={}, hasOriginal={}", + task.getTaskId(), + StringUtils.isNotBlank(task.getSignedPdfBase64()), + pdfFileStorage.hasSignedFile(task.getTaskId()), + StringUtils.isNotBlank(task.getSignedMetaJson()), + pdfFileStorage.hasOriginal(task)); + return recoverFromMeta(task); + } + + /** + * 归档文件丢失时的兜底:用原件 + signedMetaJson + signFieldsJson 重合成并落盘。 + */ + private Optional recoverFromMeta(EsignPrinterPdfTask task) { + if (StringUtils.isBlank(task.getSignedMetaJson()) || !pdfFileStorage.hasOriginal(task)) { + return Optional.empty(); + } + try { + var signFields = RegistrationCardSignFieldParser.parse(task.getSignFieldsJson()); + String composed = SignedRegistrationPdfComposer.composeBase64( + pdfFileStorage.materializeOriginalBase64(task), + task.getSignedMetaJson(), + signFields); + if (StringUtils.isBlank(composed)) { + return Optional.empty(); + } + byte[] bytes = Base64.getDecoder().decode(composed); + if (bytes.length == 0) { + return Optional.empty(); + } + String ref = pdfFileStorage.persistSigned(task.getTaskId(), bytes); + task.setSignedPdfBase64(ref); + log.info( + "recovered signed PDF from meta taskId={}, signedPdfLen={}, ref={}", + task.getTaskId(), + composed.length(), + ref); + return Optional.of(composed); + } catch (Exception e) { + log.warn("recover signed PDF from meta failed taskId={}", task.getTaskId(), e); + return Optional.empty(); + } + } + + public String requireSignedBase64(EsignPrinterPdfTask task) { + return readSignedBase64(task) + .orElseThrow(() -> new IllegalStateException( + "归档签名 PDF 不存在,无法上传 OPERA(taskId=" + + (task != null ? task.getTaskId() : "null") + + ")")); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/support/SignedPdfComposeValidator.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/SignedPdfComposeValidator.java new file mode 100644 index 0000000..478a49b --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/support/SignedPdfComposeValidator.java @@ -0,0 +1,50 @@ +package com.sjmresorts.opsapp.esign.printer.support; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONObject; +import com.sjmresorts.opsapp.esign.printer.dtos.RegistrationCardSignFieldParser; +import com.sjmresorts.opsapp.esign.printer.dtos.SignedRegistrationPdfComposer; +import org.apache.commons.lang3.StringUtils; + +import java.util.List; +import java.util.Map; + +/** 归档前校验:必填热区均有签名图层。 */ +public final class SignedPdfComposeValidator { + + private SignedPdfComposeValidator() {} + + public static void assertRequiredSignaturesPresent(String signaturesJson, String signFieldsJson) { + if (StringUtils.isBlank(signaturesJson)) { + throw new IllegalStateException("signaturesJson required"); + } + Map signatures = parseSignatures(signaturesJson); + List fields = + RegistrationCardSignFieldParser.parse(signFieldsJson); + for (SignedRegistrationPdfComposer.SignFieldRect field : fields) { + if (field.isTextField()) { + continue; + } + String value = signatures.get(field.id()); + if (StringUtils.isBlank(value)) { + throw new IllegalStateException("必填签名热区未填写: " + field.id()); + } + if (!SignedRegistrationPdfComposer.isImageSignatureValue(value) + && StringUtils.length(StringUtils.trim(value)) < 1) { + throw new IllegalStateException("签名热区内容无效: " + field.id()); + } + } + } + + private static Map parseSignatures(String signaturesJson) { + try { + JSONObject obj = JSON.parseObject(signaturesJson); + if (obj == null) { + return Map.of(); + } + return obj.toJavaObject(Map.class); + } catch (Exception e) { + throw new IllegalStateException("signaturesJson 格式无效"); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/websocket/EsignPrinterWebSocketAuthInterceptor.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/websocket/EsignPrinterWebSocketAuthInterceptor.java new file mode 100644 index 0000000..b569e3a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/websocket/EsignPrinterWebSocketAuthInterceptor.java @@ -0,0 +1,116 @@ +package com.sjmresorts.opsapp.esign.printer.websocket; + +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import com.sjmresorts.opsapp.common.utils.NetworkUtil; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.http.server.ServletServerHttpRequest; +import org.springframework.http.server.ServletServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; + +import java.util.Map; + +@Slf4j +@Component +public class EsignPrinterWebSocketAuthInterceptor implements HandshakeInterceptor { + + @Override + public boolean beforeHandshake( + ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Map attributes) { + if (!(request instanceof ServletServerHttpRequest servletRequest)) { + reject(response, "not a servlet request"); + return false; + } + HttpServletRequest httpRequest = servletRequest.getServletRequest(); + String token = resolveToken(httpRequest); + if (StringUtils.isBlank(token)) { + reject(response, "missing token"); + return false; + } + try { + JwtTokenUtil.getJwtVerifier().verify(token); + } catch (JWTVerificationException e) { + reject(response, "invalid or expired token"); + return false; + } + LoginUserInfo user = JwtTokenUtil.parseUserFromToken(token); + if (user == null || StringUtils.isBlank(user.getEmployeeId())) { + reject(response, "no employeeId in token"); + return false; + } + try { + attributes.put( + EsignPrinterWebSocketHub.SESSION_USER_ID, + Long.parseLong(user.getEmployeeId().trim())); + attributes.put( + EsignPrinterWebSocketHub.SESSION_CLIENT_IP, + NetworkUtil.getClientIpByRequest(httpRequest)); + log.info("Printer WS handshake ok userId={}", user.getEmployeeId()); + return true; + } catch (NumberFormatException e) { + reject(response, "employeeId not numeric"); + return false; + } + } + + private static String resolveToken(HttpServletRequest request) { + String token = request.getParameter("token"); + if (StringUtils.isBlank(token)) { + token = extractBearerToken(request.getHeader("Authorization")); + } + if (StringUtils.isBlank(token)) { + return null; + } + token = token.trim(); + if (token.regionMatches(true, 0, "Bearer ", 0, 7)) { + token = token.substring(7).trim(); + } + return token; + } + + private static String extractBearerToken(String authorizationHeader) { + if (StringUtils.isBlank(authorizationHeader)) { + return null; + } + String h = authorizationHeader.trim(); + if (h.regionMatches(true, 0, "Bearer ", 0, 7)) { + String t = h.substring(7).trim(); + return StringUtils.isNotBlank(t) ? t : null; + } + return null; + } + + private void reject(ServerHttpResponse response, String reason) { + log.warn("Printer WS handshake rejected: {}", reason); + if (response instanceof ServletServerHttpResponse servletResponse) { + servletResponse.setStatusCode(HttpStatus.FORBIDDEN); + HttpServletResponse raw = servletResponse.getServletResponse(); + if (raw != null && !raw.isCommitted()) { + raw.setStatus(HttpServletResponse.SC_FORBIDDEN); + } + } + } + + @Override + public void afterHandshake( + ServerHttpRequest request, + ServerHttpResponse response, + WebSocketHandler wsHandler, + Exception exception) { + if (exception != null) { + log.warn("Printer WS afterHandshake error: {}", exception.getMessage()); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/websocket/EsignPrinterWebSocketHandler.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/websocket/EsignPrinterWebSocketHandler.java new file mode 100644 index 0000000..985e790 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/websocket/EsignPrinterWebSocketHandler.java @@ -0,0 +1,50 @@ +package com.sjmresorts.opsapp.esign.printer.websocket; + +import com.sjmresorts.opsapp.esign.auth.services.EsignUserPresenceService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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; + +@Slf4j +@Component +@RequiredArgsConstructor +public class EsignPrinterWebSocketHandler extends TextWebSocketHandler { + + private final EsignPrinterWebSocketHub hub; + private final EsignUserPresenceService userPresenceService; + + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + Long userId = hub.sessionUserId(session); + if (userId == null) { + session.close(CloseStatus.NOT_ACCEPTABLE); + return; + } + userPresenceService.touchClientIp(userId, hub.sessionClientIp(session)); + hub.register(userId, session); + session.sendMessage(new TextMessage("{\"type\":\"connected\"}")); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { + String payload = message.getPayload(); + if (payload != null && payload.trim().equalsIgnoreCase("ping")) { + session.sendMessage(new TextMessage("{\"type\":\"pong\"}")); + } + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + hub.unregister(session); + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) { + log.debug("Printer WS transport error sessionId={}", session.getId(), exception); + hub.unregister(session); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/printer/websocket/EsignPrinterWebSocketHub.java b/src/main/java/com/sjmresorts/opsapp/esign/printer/websocket/EsignPrinterWebSocketHub.java new file mode 100644 index 0000000..e860de1 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/printer/websocket/EsignPrinterWebSocketHub.java @@ -0,0 +1,227 @@ +package com.sjmresorts.opsapp.esign.printer.websocket; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sjmresorts.opsapp.esign.audit.services.EsignAuditLogService; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.apache.commons.lang3.StringUtils; + +import java.io.IOException; +import java.util.Collection; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArraySet; + +@Slf4j +@Component +public class EsignPrinterWebSocketHub { + + public static final String SESSION_USER_ID = "userId"; + public static final String SESSION_CLIENT_IP = "clientIp"; + + private final ObjectMapper objectMapper; + private final ConcurrentHashMap> sessionsByUser = new ConcurrentHashMap<>(); + + @Resource(name = "esignAuditLogService") + private EsignAuditLogService auditLogService; + + public EsignPrinterWebSocketHub(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + public void register(Long userId, WebSocketSession session) { + if (userId == null || session == null) { + return; + } + sessionsByUser.computeIfAbsent(userId, k -> new CopyOnWriteArraySet<>()).add(session); + log.info("Printer WS connected userId={}, sessionId={}", userId, session.getId()); + auditConnection(EsignAuditLogService.EVT_WS_CONNECTED, userId, session, "WebSocket 已連接"); + } + + public void unregister(WebSocketSession session) { + if (session == null) { + return; + } + Long userId = sessionUserId(session); + if (userId != null) { + Set set = sessionsByUser.get(userId); + if (set != null) { + set.remove(session); + if (set.isEmpty()) { + sessionsByUser.remove(userId, set); + } + } + } + log.info("Printer WS disconnected userId={}, sessionId={}", userId, session.getId()); + auditConnection(EsignAuditLogService.EVT_WS_DISCONNECTED, userId, session, "WebSocket 已斷開"); + } + + public Long sessionUserId(WebSocketSession session) { + Object v = session.getAttributes().get(SESSION_USER_ID); + if (v instanceof Long l) { + return l; + } + if (v instanceof Number n) { + return n.longValue(); + } + return null; + } + + public String sessionClientIp(WebSocketSession session) { + Object v = session != null ? session.getAttributes().get(SESSION_CLIENT_IP) : null; + return v != null ? String.valueOf(v) : null; + } + + public boolean isUserConnected(Long userId) { + if (userId == null) { + return false; + } + Set sessions = sessionsByUser.get(userId); + if (sessions == null || sessions.isEmpty()) { + return false; + } + for (WebSocketSession session : sessions) { + if (session != null && session.isOpen()) { + return true; + } + } + return false; + } + + public void notifyPrinterPdfAssigned( + Collection userIds, + String taskId, + String fileName, + String agentSource) { + notifyPrinterPdfAssigned(userIds, taskId, fileName, agentSource, null); + } + + public void notifyPrinterPdfAssigned( + Collection userIds, + String taskId, + String fileName, + String agentSource, + Map> allowedClientIpsByUser) { + if (userIds == null || userIds.isEmpty() || taskId == null || taskId.isBlank()) { + return; + } + String payload; + try { + payload = objectMapper.writeValueAsString(Map.of( + "type", "PRINTER_PDF", + "taskId", taskId.trim(), + "fileName", fileName != null ? fileName : "", + "agentSource", agentSource != null ? agentSource : "", + "taskType", "PRINTER_PDF")); + } catch (Exception e) { + log.warn("Printer WS notify serialize failed taskId={}", taskId, e); + return; + } + TextMessage message = new TextMessage(payload); + for (Long userId : userIds) { + if (userId == null) { + continue; + } + Set sessions = sessionsByUser.get(userId); + if (sessions == null || sessions.isEmpty()) { + continue; + } + for (WebSocketSession session : sessions) { + if (allowedClientIpsByUser != null) { + Set allowed = allowedClientIpsByUser.get(userId); + if (allowed != null && !allowed.isEmpty()) { + String sessionIp = + com.sjmresorts.opsapp.esign.auth.services.EsignUserPresenceService.normalizeClientIp( + sessionClientIp(session)); + if (sessionIp == null || !allowed.contains(sessionIp)) { + continue; + } + } + } + sendSafe(session, message); + } + } + } + + public void notifySurveyAssigned( + Collection userIds, + String surveyId, + String title, + Map> allowedClientIpsByUser) { + if (userIds == null || userIds.isEmpty() || StringUtils.isBlank(surveyId)) { + return; + } + String payload; + try { + payload = objectMapper.writeValueAsString(Map.of( + "type", "SURVEY", + "taskId", surveyId.trim(), + "surveyId", surveyId.trim(), + "fileName", title != null ? title : "", + "taskType", "SURVEY")); + } catch (Exception e) { + log.warn("Printer WS survey notify serialize failed surveyId={}", surveyId, e); + return; + } + TextMessage message = new TextMessage(payload); + for (Long userId : userIds) { + if (userId == null) { + continue; + } + Set sessions = sessionsByUser.get(userId); + if (sessions == null || sessions.isEmpty()) { + continue; + } + for (WebSocketSession session : sessions) { + if (allowedClientIpsByUser != null) { + Set allowed = allowedClientIpsByUser.get(userId); + if (allowed != null && !allowed.isEmpty()) { + String sessionIp = + com.sjmresorts.opsapp.esign.auth.services.EsignUserPresenceService.normalizeClientIp( + sessionClientIp(session)); + if (sessionIp == null || !allowed.contains(sessionIp)) { + continue; + } + } + } + sendSafe(session, message); + } + } + } + + private void sendSafe(WebSocketSession session, TextMessage message) { + if (session == null || !session.isOpen()) { + return; + } + try { + synchronized (session) { + if (session.isOpen()) { + session.sendMessage(message); + } + } + } catch (IOException e) { + log.debug("Printer WS send failed sessionId={}", session.getId(), e); + } + } + + private void auditConnection(String eventType, Long userId, WebSocketSession session, String message) { + if (auditLogService == null) { + return; + } + try { + auditLogService.appendConnection( + eventType, + userId, + message, + "websocket", + sessionClientIp(session), + session != null ? "{\"sessionId\":\"" + session.getId() + "\"}" : null); + } catch (Exception e) { + log.debug("audit connection log failed", e); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/controllers/EsignReservationController.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/controllers/EsignReservationController.java new file mode 100644 index 0000000..6ace206 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/controllers/EsignReservationController.java @@ -0,0 +1,204 @@ +package com.sjmresorts.opsapp.esign.reservation.controllers; + +import com.sjmresorts.opsapp.esign.reservation.dtos.GuestProfileIdResponse; +import com.sjmresorts.opsapp.esign.reservation.dtos.RegistrationCardResponse; +import com.sjmresorts.opsapp.esign.reservation.dtos.SignedRegistrationCardSubmitRequest; +import com.sjmresorts.opsapp.esign.capture.dtos.EsignIosCaptureResponse; +import com.sjmresorts.opsapp.esign.reservation.dtos.EsignReservationNotifyResponse; +import com.sjmresorts.opsapp.esign.reservation.services.EsignReservationService; +import com.sjmresorts.opsapp.esign.reservation.support.OperaFlexLinkPageBuilder; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +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; + +@Tag(name = "Esign Reservation", description = "预订相关(前端上报 reservationId + hotelId)") +@RestController +@RequestMapping({"/api/v1/esign/reservations", "/api/v1/sinosecu/reservations"}) +@CrossOrigin(origins = "*") +@RequiredArgsConstructor +@Slf4j +public class EsignReservationController { + + private final EsignReservationService reservationService; + + @Value("${ops-app.deeplink.opera.close-popup:true}") + private boolean operaClosePopup; + + @Value("${ops-app.deeplink.opera.parent-origin:}") + private String operaParentOrigin; + + @Value("${ops-app.deeplink.opera.flex-link-origin:http://127.0.0.1:8066}") + private String operaFlexLinkOrigin; + + @Value("${ops-app.deeplink.opera.close-delay-ms:100}") + private int operaCloseDelayMs; + + @Value("${ops-app.deeplink.opera.open-delay-ms:800}") + private int operaOpenDelayMs; + + @Value("${ops-app.deeplink.opera.ext-app-id:0}") + private long operaExtAppId; + + @Value("${ops-app.deeplink.opera.debug-page:true}") + private boolean operaDebugPage; + + @Value("${ops-app.deeplink.opera.popup-width:100%}") + private String operaPopupWidth; + + @Value("${ops-app.deeplink.opera.popup-height:800px}") + private String operaPopupHeight; + + @Value("${ops-app.deeplink.opera.launch-mode:new-window}") + private String operaLaunchMode; + + @Value("${ops-app.deeplink.opera.close-modal-message:true}") + private boolean operaCloseModalMessage; + + @Operation( + summary = "接收预订参数并转发第三方", + description = "免 JWT。GET 查询参数:reservationId、hotelId。先取 OHIP OAuth Token,再请求 registrationCard 接口;成功后向同酒店 Esign 用户推送 FCM(data 含 reservationId)。" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "已接收且第三方调用成功(或未配置第三方 URL)"), + @ApiResponse(responseCode = "400", description = "参数缺失"), + @ApiResponse(responseCode = "502", description = "第三方接口调用失败") + }) + @GetMapping(value = "/notify", produces = "application/json;charset=UTF-8") + public ResponseEntity notifyReservation( + @Parameter(description = "预订 ID", required = true) + @RequestParam("reservationId") String reservationId, + @Parameter(description = "酒店 ID", required = true) + @RequestParam("hotelId") String hotelId) { + EsignReservationNotifyResponse body = reservationService.acceptReservation(reservationId, hotelId); + HttpStatus status = HttpStatus.OK; + if (!body.isSuccess()) { + if ("502".equals(body.getCode())) { + status = HttpStatus.BAD_GATEWAY; + } else { + status = HttpStatus.BAD_REQUEST; + } + } + return ResponseEntity.status(status).body(body); + } + + @Operation( + summary = "Opera Flex Link 用:发通知并返回 HTML 页尝试关 Pop-up", + description = "免 JWT。服务端先执行 notify(FCM),再返回 HTML 页发送 DO_RETURN_EVENT。" + + " Destination 请用此路径,勿用 /launch。" + ) + @GetMapping(value = "/notify/page", produces = "text/html;charset=UTF-8") + public ResponseEntity notifyReservationPage( + @Parameter(description = "预订 ID", required = true) + @RequestParam("reservationId") String reservationId, + @Parameter(description = "酒店 ID", required = true) + @RequestParam("hotelId") String hotelId) { + EsignReservationNotifyResponse body = reservationService.acceptReservation(reservationId, hotelId); + String html = OperaFlexLinkPageBuilder.buildNotifyPage( + body.isSuccess(), + body.getMessage(), + body.getReservationId(), + operaLaunchMode, + operaClosePopup, + operaCloseModalMessage, + operaParentOrigin, + operaFlexLinkOrigin, + operaExtAppId, + operaOpenDelayMs, + operaCloseDelayMs, + operaDebugPage, + operaPopupWidth, + operaPopupHeight); + HttpStatus status = body.isSuccess() ? HttpStatus.OK : HttpStatus.BAD_REQUEST; + return ResponseEntity.status(status) + .contentType(MediaType.parseMediaType("text/html;charset=UTF-8")) + .header(HttpHeaders.CACHE_CONTROL, "no-cache, no-store, must-revalidate") + .body(html); + } + + @Operation( + summary = "获取预订登记卡 PDF", + description = "免 JWT。点击推送通知后调用:先取 OHIP OAuth Token,再请求 registrationCard,返回 PDF Base64。" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "获取成功"), + @ApiResponse(responseCode = "400", description = "参数缺失"), + @ApiResponse(responseCode = "502", description = "第三方接口失败或未解析到 PDF"), + @ApiResponse(responseCode = "503", description = "第三方未配置") + }) + @GetMapping(value = "/registration-card", produces = "application/json;charset=UTF-8") + public ResponseEntity getRegistrationCard( + @Parameter(description = "预订 ID", required = true) + @RequestParam("reservationId") String reservationId, + @Parameter(description = "酒店 ID", required = true) + @RequestParam("hotelId") String hotelId) { + RegistrationCardResponse body = reservationService.fetchRegistrationCard(reservationId, hotelId); + HttpStatus status = HttpStatus.OK; + if (!body.isSuccess()) { + status = switch (body.getCode()) { + case "502" -> HttpStatus.BAD_GATEWAY; + case "503" -> HttpStatus.SERVICE_UNAVAILABLE; + default -> HttpStatus.BAD_REQUEST; + }; + } + return ResponseEntity.status(status).body(body); + } + + @Operation( + summary = "获取预订关联的 Guest Profile ID", + description = "免 JWT。代理 OHIP GET /rsv/v1/hotels/{hotelId}/reservations/{reservationId}?fetchInstructions=Reservation," + + "从 reservationGuests.profileInfo.profileIdList 解析 type=Profile 的 id。") + @GetMapping(value = "/guest-profile-id", produces = "application/json;charset=UTF-8") + public ResponseEntity getGuestProfileId( + @Parameter(description = "预订 ID", required = true) + @RequestParam("reservationId") String reservationId, + @Parameter(description = "酒店 ID", required = true) + @RequestParam("hotelId") String hotelId) { + GuestProfileIdResponse body = reservationService.fetchGuestProfileId(reservationId, hotelId); + HttpStatus status = body.isSuccess() ? HttpStatus.OK : HttpStatus.BAD_REQUEST; + if (!body.isSuccess() && "404".equals(body.getCode())) { + status = HttpStatus.NOT_FOUND; + } + return ResponseEntity.status(status).body(body); + } + + @Operation( + summary = "提交签名登记卡至 OPERA", + description = "免 JWT。通知/预订登记卡页签名后:合成 PDF → fileAttachments。" + ) + @PostMapping(value = "/signed-registration-card", consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") + public ResponseEntity submitSignedRegistrationCard( + @RequestBody SignedRegistrationCardSubmitRequest request) { + log.info( + "POST /reservations/signed-registration-card reservationId={}, hotelId={}, signatureCount={}", + request != null ? request.getReservationId() : null, + request != null ? request.getHotelId() : null, + request != null ? request.getSignatureCount() : null); + EsignIosCaptureResponse body = reservationService.submitSignedRegistrationCard(request); + log.info( + "POST /reservations/signed-registration-card done success={} code={} message={}", + body.isSuccess(), + body.getCode(), + body.getMessage()); + HttpStatus status = body.isSuccess() ? HttpStatus.OK : HttpStatus.BAD_REQUEST; + if (!body.isSuccess() && "502".equals(body.getCode())) { + status = HttpStatus.BAD_GATEWAY; + } + return ResponseEntity.status(status).body(body); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/controllers/EsignSignFieldOhipMappingController.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/controllers/EsignSignFieldOhipMappingController.java new file mode 100644 index 0000000..2329398 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/controllers/EsignSignFieldOhipMappingController.java @@ -0,0 +1,64 @@ +package com.sjmresorts.opsapp.esign.reservation.controllers; + +import com.sjmresorts.opsapp.esign.common.dtos.OhipProfileFieldCatalogResponse; +import com.sjmresorts.opsapp.esign.reservation.dtos.EsignSignFieldOhipMappingResponse; +import com.sjmresorts.opsapp.esign.reservation.dtos.EsignSignFieldOhipMappingSaveRequest; +import com.sjmresorts.opsapp.esign.reservation.dtos.EsignSignFieldOhipPreviewRequest; +import com.sjmresorts.opsapp.esign.reservation.dtos.EsignSignFieldOhipPreviewResponse; +import com.sjmresorts.opsapp.esign.reservation.services.EsignSignFieldOhipMappingService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Esign Sign Field OHIP Mapping", description = "签名字段 → OHIP 字段映射(CMS 配置)") +@RestController +@RequestMapping({"/api/v1/esign/sign-field-ohip-mapping", "/api/v1/sinosecu/sign-field-ohip-mapping"}) +@CrossOrigin(origins = "*") +public class EsignSignFieldOhipMappingController { + + @Resource + private EsignSignFieldOhipMappingService mappingService; + + @Operation(summary = "管理端:读取签名字段 OHIP 映射") + @GetMapping(produces = "application/json;charset=UTF-8") + public ResponseEntity getForAdmin( + @RequestParam(value = "hotelId", required = false) String hotelId) { + if (hotelId != null && !hotelId.isBlank()) { + return ResponseEntity.ok(mappingService.getForAdmin(hotelId)); + } + return ResponseEntity.ok(mappingService.getForAdmin()); + } + + @Operation(summary = "管理端:OHIP profileDetails 固定字段目录") + @GetMapping(value = "/fields", produces = "application/json;charset=UTF-8") + public ResponseEntity listFields() { + return ResponseEntity.ok(mappingService.listProfileFields()); + } + + @Operation(summary = "管理端:保存签名字段 OHIP 映射") + @PutMapping(produces = "application/json;charset=UTF-8") + public ResponseEntity save( + @RequestBody EsignSignFieldOhipMappingSaveRequest request) { + EsignSignFieldOhipMappingResponse body = mappingService.save(request); + if (!body.isSuccess()) { + return ResponseEntity.badRequest().body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "管理端:预览映射组装后的 profileDetails JSON(与提交 OHIP 时结构一致)") + @PostMapping(value = "/preview", produces = "application/json;charset=UTF-8") + public ResponseEntity preview( + @RequestBody EsignSignFieldOhipPreviewRequest request) { + return ResponseEntity.ok(mappingService.previewProfileBody(request)); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignReservationNotifyResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignReservationNotifyResponse.java new file mode 100644 index 0000000..395d70b --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignReservationNotifyResponse.java @@ -0,0 +1,121 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "预订参数接收响应") +public class EsignReservationNotifyResponse { + + private boolean success; + private String code; + private String message; + + @Schema(description = "回显预订 ID") + private String reservationId; + + @Schema(description = "回显酒店 ID") + private String hotelId; + + @Schema(description = "是否已调用第三方接口") + private Boolean thirdPartyInvoked; + + @Schema(description = "第三方 HTTP 状态码(未调用时为 null)") + private Integer thirdPartyStatusCode; + + @Schema(description = "第三方响应摘要(可能截断)") + private String thirdPartyResponse; + + @Schema(description = "是否已尝试发送 FCM 通知") + private Boolean notificationInvoked; + + @Schema(description = "FCM 通知目标用户数") + private Integer notificationTargetCount; + + @Schema(description = "FCM 通知成功数") + private Integer notificationSuccessCount; + + @Schema(description = "FCM 通知失败数") + private Integer notificationFailureCount; + + @Schema(description = "FCM 通知结果状态") + private String notificationResultStatus; + + @Schema(description = "FCM 通知说明(含跳过原因)") + private String notificationMessage; + + public static EsignReservationNotifyResponse ok( + String reservationId, + String hotelId, + String message, + ReservationThirdPartyResult thirdParty) { + return ok(reservationId, hotelId, message, thirdParty, null); + } + + public static EsignReservationNotifyResponse ok( + String reservationId, + String hotelId, + String message, + ReservationThirdPartyResult thirdParty, + ReservationNotificationResult notification) { + EsignReservationNotifyResponse r = new EsignReservationNotifyResponse(); + r.setSuccess(true); + r.setCode("200"); + r.setMessage(message != null ? message : "Reservation accepted"); + r.setReservationId(reservationId); + r.setHotelId(hotelId); + applyThirdParty(r, thirdParty); + applyNotification(r, notification); + return r; + } + + public static EsignReservationNotifyResponse fail(String code, String message) { + EsignReservationNotifyResponse r = new EsignReservationNotifyResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } + + public static EsignReservationNotifyResponse fail( + String code, + String message, + String reservationId, + String hotelId, + ReservationThirdPartyResult thirdParty) { + EsignReservationNotifyResponse r = fail(code, message); + r.setReservationId(reservationId); + r.setHotelId(hotelId); + applyThirdParty(r, thirdParty); + return r; + } + + private static void applyThirdParty(EsignReservationNotifyResponse r, ReservationThirdPartyResult thirdParty) { + if (thirdParty == null) { + return; + } + r.setThirdPartyInvoked(thirdParty.isInvoked()); + if (thirdParty.isInvoked()) { + r.setThirdPartyStatusCode(thirdParty.getStatusCode()); + r.setThirdPartyResponse(thirdParty.getResponseBody()); + } + } + + private static void applyNotification( + EsignReservationNotifyResponse r, + ReservationNotificationResult notification) { + if (notification == null) { + return; + } + r.setNotificationInvoked(notification.isInvoked()); + r.setNotificationMessage(notification.getMessage()); + if (notification.isInvoked()) { + r.setNotificationTargetCount(notification.getTargetCount()); + r.setNotificationSuccessCount(notification.getSuccessCount()); + r.setNotificationFailureCount(notification.getFailureCount()); + r.setNotificationResultStatus(notification.getResultStatus()); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignReservationTaskCompleteRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignReservationTaskCompleteRequest.java new file mode 100644 index 0000000..33d2b53 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignReservationTaskCompleteRequest.java @@ -0,0 +1,20 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "预订待处理任务完成(从待签列表移除)") +public class EsignReservationTaskCompleteRequest { + + @Schema(description = "任务 ID(与 reservationId+hotelId 二选一)") + private String taskId; + + @Schema(description = "预订 ID") + private String reservationId; + + @Schema(description = "酒店 ID") + private String hotelId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignSignFieldOhipMappingResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignSignFieldOhipMappingResponse.java new file mode 100644 index 0000000..128b005 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignSignFieldOhipMappingResponse.java @@ -0,0 +1,48 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.sjmresorts.opsapp.esign.common.dtos.SignFieldOhipMappingItem; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignSignFieldOhipMappingResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private SignFieldOhipMappingData data; + + @Data + @NoArgsConstructor + public static class SignFieldOhipMappingData { + private SignFieldOhipMappingConfigItem config; + } + + @Data + @NoArgsConstructor + public static class SignFieldOhipMappingConfigItem { + private String configId; + private String hotelId; + private List mappings; + private String updatedAt; + } + + public static EsignSignFieldOhipMappingResponse ok(String message) { + EsignSignFieldOhipMappingResponse r = new EsignSignFieldOhipMappingResponse(); + r.setMessage(message); + return r; + } + + public static EsignSignFieldOhipMappingResponse fail(String code, String message) { + EsignSignFieldOhipMappingResponse r = new EsignSignFieldOhipMappingResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignSignFieldOhipMappingSaveRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignSignFieldOhipMappingSaveRequest.java new file mode 100644 index 0000000..62b1c37 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignSignFieldOhipMappingSaveRequest.java @@ -0,0 +1,16 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import com.sjmresorts.opsapp.esign.common.dtos.SignFieldOhipMappingItem; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +public class EsignSignFieldOhipMappingSaveRequest { + + /** 留空表示全酒店默认 */ + private String hotelId; + private List mappings; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignSignFieldOhipPreviewRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignSignFieldOhipPreviewRequest.java new file mode 100644 index 0000000..7e0e60c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignSignFieldOhipPreviewRequest.java @@ -0,0 +1,20 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import com.sjmresorts.opsapp.esign.common.dtos.SignFieldOhipMappingItem; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@Schema(description = "签名字段 OHIP 映射 → profileDetails JSON 预览请求") +public class EsignSignFieldOhipPreviewRequest { + + @Schema(description = "当前编辑中的映射行(未保存也可预览)") + private List mappings; + + @Schema(description = "预览用 profileType,默认 Guest") + private String profileType; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignSignFieldOhipPreviewResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignSignFieldOhipPreviewResponse.java new file mode 100644 index 0000000..f28e293 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/EsignSignFieldOhipPreviewResponse.java @@ -0,0 +1,48 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "profileDetails JSON 预览响应") +public class EsignSignFieldOhipPreviewResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private PreviewData data; + + @Data + @NoArgsConstructor + public static class PreviewData { + @Schema(description = "PUT /crm/v1/profiles/{profileId} 请求体(含 profileDetails)") + private Map profileBody; + + @Schema(description = "格式化后的 JSON 字符串,便于 CMS 展示") + private String jsonPreview; + + @Schema(description = "参与组装的映射字段数量(仅 enabled 且 text)") + private Integer mappedFieldCount; + } + + public static EsignSignFieldOhipPreviewResponse ok(PreviewData data) { + EsignSignFieldOhipPreviewResponse r = new EsignSignFieldOhipPreviewResponse(); + r.setMessage("ok"); + r.setData(data); + return r; + } + + public static EsignSignFieldOhipPreviewResponse fail(String code, String message) { + EsignSignFieldOhipPreviewResponse r = new EsignSignFieldOhipPreviewResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/GuestProfileIdResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/GuestProfileIdResponse.java new file mode 100644 index 0000000..43d550f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/GuestProfileIdResponse.java @@ -0,0 +1,50 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "预订关联的 OHIP Guest Profile ID") +public class GuestProfileIdResponse { + + private boolean success; + private String code; + private String message; + private String reservationId; + private String hotelId; + + @Schema(description = "reservationGuests.profileInfo.profileIdList 中 type=Profile 的 id") + private String profileId; + + @Schema(description = "reservationGuests.profileInfo.profile.profileType,例如 Guest") + private String profileType; + + public static GuestProfileIdResponse ok( + String reservationId, String hotelId, String profileId, String profileType) { + GuestProfileIdResponse r = new GuestProfileIdResponse(); + r.setSuccess(true); + r.setCode("200"); + r.setMessage("ok"); + r.setReservationId(reservationId); + r.setHotelId(hotelId); + r.setProfileId(profileId); + r.setProfileType(profileType); + return r; + } + + public static GuestProfileIdResponse ok(String reservationId, String hotelId, String profileId) { + return ok(reservationId, hotelId, profileId, null); + } + + public static GuestProfileIdResponse fail(String code, String message, String reservationId, String hotelId) { + GuestProfileIdResponse r = new GuestProfileIdResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + r.setReservationId(reservationId); + r.setHotelId(hotelId); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/GuestProfileRef.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/GuestProfileRef.java new file mode 100644 index 0000000..c27ec3d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/GuestProfileRef.java @@ -0,0 +1,9 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +/** OHIP 预订旅客 Profile 引用(id + type)。 */ +public record GuestProfileRef(String profileId, String profileType) { + + public boolean hasProfileId() { + return profileId != null && !profileId.isBlank(); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/HospitalityOAuthSettings.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/HospitalityOAuthSettings.java new file mode 100644 index 0000000..049952b --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/HospitalityOAuthSettings.java @@ -0,0 +1,32 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import lombok.Builder; +import lombok.Getter; +import org.apache.commons.lang3.StringUtils; + +@Getter +@Builder +public class HospitalityOAuthSettings { + + private final boolean enabled; + private final String baseUrl; + private final String tokenPath; + private final String clientId; + private final String clientSecret; + private final String appKey; + private final String enterpriseId; + private final String hotelId; + private final String scope; + private final String grantType; + private final int timeoutMs; + + public boolean isComplete() { + return enabled + && StringUtils.isNotBlank(baseUrl) + && StringUtils.isNotBlank(clientId) + && StringUtils.isNotBlank(clientSecret) + && StringUtils.isNotBlank(appKey) + && StringUtils.isNotBlank(enterpriseId) + && StringUtils.isNotBlank(hotelId); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/OAuthTokenResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/OAuthTokenResponse.java new file mode 100644 index 0000000..54a1e25 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/OAuthTokenResponse.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class OAuthTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("expires_in") + private Long expiresIn; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/RegistrationCardResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/RegistrationCardResponse.java new file mode 100644 index 0000000..ec8d7b2 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/RegistrationCardResponse.java @@ -0,0 +1,99 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import com.sjmresorts.opsapp.esign.printer.dtos.RegistrationCardResult; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "预订登记卡 PDF 响应") +public class RegistrationCardResponse { + + private boolean success; + private String code; + private String message; + + @Schema(description = "预订 ID") + private String reservationId; + + @Schema(description = "酒店 ID") + private String hotelId; + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "文件类型,通常为 PDF") + private String fileType; + + @Schema(description = "PDF Base64(不含 data: 前缀)") + private String pdfBase64; + + @Schema(description = "旅客 Profile ID(OHIP reservationGuests)") + private String profileId; + + @Schema(description = "旅客 Profile Type(OHIP profile.profileType)") + private String profileType; + + @Schema(description = "第三方 HTTP 状态码") + private Integer thirdPartyStatusCode; + + public static RegistrationCardResponse ok( + String reservationId, + String hotelId, + RegistrationCardResult result) { + return ok(reservationId, hotelId, result, null, null); + } + + public static RegistrationCardResponse ok( + String reservationId, + String hotelId, + RegistrationCardResult result, + String profileId) { + return ok(reservationId, hotelId, result, profileId, null); + } + + public static RegistrationCardResponse ok( + String reservationId, + String hotelId, + RegistrationCardResult result, + String profileId, + String profileType) { + RegistrationCardResponse r = new RegistrationCardResponse(); + r.setSuccess(true); + r.setCode("200"); + r.setMessage("Registration card fetched"); + r.setReservationId(reservationId); + r.setHotelId(hotelId); + r.setFileName(result.getFileName()); + r.setFileType(result.getFileType()); + r.setPdfBase64(result.getPdfBase64()); + r.setProfileId(profileId); + r.setProfileType(profileType); + r.setThirdPartyStatusCode(result.getStatusCode()); + return r; + } + + public static RegistrationCardResponse fail(String code, String message) { + RegistrationCardResponse r = new RegistrationCardResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } + + public static RegistrationCardResponse fail( + String code, + String message, + String reservationId, + String hotelId, + RegistrationCardResult result) { + RegistrationCardResponse r = fail(code, message); + r.setReservationId(reservationId); + r.setHotelId(hotelId); + if (result != null) { + r.setThirdPartyStatusCode(result.getStatusCode()); + } + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/ReservationLookupHit.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/ReservationLookupHit.java new file mode 100644 index 0000000..79b6005 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/ReservationLookupHit.java @@ -0,0 +1,5 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +/** OHIP 按确认号查预订时的 reservationId + 可选 Guest Profile。 */ +public record ReservationLookupHit(String reservationId, GuestProfileRef guestProfile) { +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/ReservationNotificationResult.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/ReservationNotificationResult.java new file mode 100644 index 0000000..a2fa68e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/ReservationNotificationResult.java @@ -0,0 +1,52 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import com.sjmresorts.opsapp.notifications.dtos.SendNotificationResponse; +import com.sjmresorts.opsapp.notifications.dtos.SendNotificationResultData; +import lombok.Getter; + +@Getter +public class ReservationNotificationResult { + + private final boolean invoked; + private final boolean success; + private final int targetCount; + private final int successCount; + private final int failureCount; + private final String resultStatus; + private final String message; + + private ReservationNotificationResult( + boolean invoked, + boolean success, + int targetCount, + int successCount, + int failureCount, + String resultStatus, + String message) { + this.invoked = invoked; + this.success = success; + this.targetCount = targetCount; + this.successCount = successCount; + this.failureCount = failureCount; + this.resultStatus = resultStatus; + this.message = message; + } + + public static ReservationNotificationResult skipped(String message) { + return new ReservationNotificationResult(false, false, 0, 0, 0, null, message); + } + + public static ReservationNotificationResult from(SendNotificationResponse response, int targetCount) { + SendNotificationResultData data = response != null ? response.getData() : null; + int successCount = data != null ? data.getSuccessCount() : 0; + int failureCount = data != null ? data.getFailureCount() : 0; + String resultStatus = data != null ? data.getResultStatus() : null; + boolean success = response != null && response.isSuccess() && successCount > 0; + String message = response != null ? response.getMessage() : null; + return new ReservationNotificationResult(true, success, targetCount, successCount, failureCount, resultStatus, message); + } + + public static ReservationNotificationResult failure(String message) { + return new ReservationNotificationResult(true, false, 0, 0, 0, "FAILED", message); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/ReservationThirdPartyResult.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/ReservationThirdPartyResult.java new file mode 100644 index 0000000..c6679a6 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/ReservationThirdPartyResult.java @@ -0,0 +1,45 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class ReservationThirdPartyResult { + + private boolean invoked; + private int statusCode; + private String responseBody; + private String errorMessage; + + public static ReservationThirdPartyResult skipped(String reason) { + ReservationThirdPartyResult r = new ReservationThirdPartyResult(); + r.setInvoked(false); + r.setStatusCode(0); + r.setErrorMessage(reason); + return r; + } + + public static ReservationThirdPartyResult success(int statusCode, String responseBody) { + ReservationThirdPartyResult r = new ReservationThirdPartyResult(); + r.setInvoked(true); + r.setStatusCode(statusCode); + r.setResponseBody(responseBody); + return r; + } + + public static ReservationThirdPartyResult failure(int statusCode, String responseBody, String errorMessage) { + ReservationThirdPartyResult r = new ReservationThirdPartyResult(); + r.setInvoked(true); + r.setStatusCode(statusCode); + r.setResponseBody(responseBody); + r.setErrorMessage(errorMessage); + return r; + } + + public boolean isHttpSuccess() { + return invoked && statusCode >= 200 && statusCode < 300; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/SignedRegistrationCardSubmitRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/SignedRegistrationCardSubmitRequest.java new file mode 100644 index 0000000..5809e03 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/SignedRegistrationCardSubmitRequest.java @@ -0,0 +1,44 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "登记卡签名提交 OPERA(通知/预订流程,无需打印机任务 ID)") +public class SignedRegistrationCardSubmitRequest { + + @Schema(description = "OHIP reservation id(linkId)", requiredMode = Schema.RequiredMode.REQUIRED) + private String reservationId; + + @Schema(description = "酒店 ID") + private String hotelId; + + @Schema(description = "文件名") + private String fileName; + + @Schema(description = "原始登记卡 PDF Base64(用于合成签名或与 signaturesJson 配合)") + private String pdfBase64; + + @Schema(description = "签名后完整 PDF Base64(可选,优先使用)") + private String base64; + + @Schema(description = "签名图层 JSON") + private String signaturesJson; + + @Schema(description = "签名数量") + private Integer signatureCount; + + @Schema(description = "确认号(可选,未传则从 PDF 解析)") + private String confirmationNumber; + + @Schema(description = "签名热区 JSON(与 App loadSignFields 一致)") + private String signFieldsJson; + + @Schema(description = "OHIP Guest Profile ID(可选,未传则服务端从预订详情解析)") + private String profileId; + + @Schema(description = "OHIP profile.profileType(可选,例如 Guest)") + private String profileType; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/SignedRegistrationCardUploadResult.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/SignedRegistrationCardUploadResult.java new file mode 100644 index 0000000..eccadaa --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/dtos/SignedRegistrationCardUploadResult.java @@ -0,0 +1,72 @@ +package com.sjmresorts.opsapp.esign.reservation.dtos; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +public class SignedRegistrationCardUploadResult { + + private boolean invoked; + private boolean success; + private int statusCode; + private String confirmationNumber; + private String reservationId; + private String fileName; + /** 从预订查询/详情解析的 OHIP Profile ID(用于签名字段回写)。 */ + private String profileId; + private String profileType; + private String errorMessage; + private String responseSummary; + + public static SignedRegistrationCardUploadResult skipped(String reason) { + SignedRegistrationCardUploadResult r = new SignedRegistrationCardUploadResult(); + r.setInvoked(false); + r.setSuccess(false); + r.setErrorMessage(reason); + return r; + } + + public static SignedRegistrationCardUploadResult failure( + int statusCode, String summary, String errorMessage) { + return failure(statusCode, summary, errorMessage, null, null, null); + } + + public static SignedRegistrationCardUploadResult failure( + int statusCode, + String summary, + String errorMessage, + String confirmationNumber, + String reservationId, + String fileName) { + SignedRegistrationCardUploadResult r = new SignedRegistrationCardUploadResult(); + r.setInvoked(true); + r.setSuccess(false); + r.setStatusCode(statusCode); + r.setResponseSummary(summary); + r.setErrorMessage(errorMessage); + r.setConfirmationNumber(confirmationNumber); + r.setReservationId(reservationId); + r.setFileName(fileName); + return r; + } + + public static SignedRegistrationCardUploadResult success( + int statusCode, + String confirmationNumber, + String reservationId, + String fileName, + String responseSummary) { + SignedRegistrationCardUploadResult r = new SignedRegistrationCardUploadResult(); + r.setInvoked(true); + r.setSuccess(true); + r.setStatusCode(statusCode); + r.setConfirmationNumber(confirmationNumber); + r.setReservationId(reservationId); + r.setFileName(fileName); + r.setResponseSummary(responseSummary); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/models/EsignReservationTask.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/models/EsignReservationTask.java new file mode 100644 index 0000000..e2402ed --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/models/EsignReservationTask.java @@ -0,0 +1,45 @@ +package com.sjmresorts.opsapp.esign.reservation.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_reservation_tasks") +public class EsignReservationTask { + + @Id + @Column(name = "task_id", nullable = false, length = 36) + private String taskId; + + @Column(name = "reservation_id", nullable = false, length = 64) + private String reservationId; + + @Column(name = "hotel_id", nullable = false, length = 64) + private String hotelId; + + @Column(name = "status", nullable = false, length = 32) + private String status; + + @Column(name = "title", length = 256) + private String title; + + @Column(name = "body", length = 512) + private String body; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + /** 用户打开登记卡并完成处理的时间 */ + @Column(name = "completed_at") + private Instant completedAt; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/models/EsignSignFieldOhipConfig.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/models/EsignSignFieldOhipConfig.java new file mode 100644 index 0000000..093809d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/models/EsignSignFieldOhipConfig.java @@ -0,0 +1,34 @@ +package com.sjmresorts.opsapp.esign.reservation.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_sign_field_ohip_config") +public class EsignSignFieldOhipConfig { + + @Id + @Column(name = "config_id", nullable = false, length = 36) + private String configId; + + @Column(name = "hotel_id", length = 64) + private String hotelId; + + @Lob + @Column(name = "mappings_json", nullable = false) + private String mappingsJson; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/repositories/EsignReservationTaskRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/repositories/EsignReservationTaskRepository.java new file mode 100644 index 0000000..05c02f2 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/repositories/EsignReservationTaskRepository.java @@ -0,0 +1,24 @@ +package com.sjmresorts.opsapp.esign.reservation.repositories; + +import com.sjmresorts.opsapp.esign.reservation.models.EsignReservationTask; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +@Repository +public interface EsignReservationTaskRepository extends JpaRepository { + + Optional findFirstByReservationIdAndHotelIdAndStatusOrderByCreatedAtDesc( + String reservationId, String hotelId, String status); + + List findTop100ByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc( + String status, Instant createdAtMinInclusive); + + List findTop100ByStatusAndCreatedAtLessThanOrderByCreatedAtDesc( + String status, Instant createdAtMaxExclusive); + + List findTop100ByStatusOrderByCompletedAtDesc(String status); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/repositories/EsignSignFieldOhipConfigRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/repositories/EsignSignFieldOhipConfigRepository.java new file mode 100644 index 0000000..e7d2bd6 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/repositories/EsignSignFieldOhipConfigRepository.java @@ -0,0 +1,13 @@ +package com.sjmresorts.opsapp.esign.reservation.repositories; + +import com.sjmresorts.opsapp.esign.reservation.models.EsignSignFieldOhipConfig; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface EsignSignFieldOhipConfigRepository extends JpaRepository { + + Optional findFirstByHotelIdOrderByUpdatedAtDesc(String hotelId); + + Optional findFirstByHotelIdIsNullOrderByUpdatedAtDesc(); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/EsignReservationNotificationService.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/EsignReservationNotificationService.java new file mode 100644 index 0000000..fcbb5c0 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/EsignReservationNotificationService.java @@ -0,0 +1,170 @@ +package com.sjmresorts.opsapp.esign.reservation.services; + +import com.sjmresorts.opsapp.esign.common.support.EsignReservationParamUtil; +import com.sjmresorts.opsapp.esign.reservation.dtos.ReservationNotificationResult; +import com.sjmresorts.opsapp.esign.auth.models.EsignUser; +import com.sjmresorts.opsapp.esign.auth.repositories.EsignUserRepository; +import com.sjmresorts.opsapp.notifications.dtos.SendNotificationData; +import com.sjmresorts.opsapp.notifications.dtos.SendNotificationResponse; +import com.sjmresorts.opsapp.notifications.dtos.SendNotificationResultData; +import com.sjmresorts.opsapp.notifications.enums.NotificationTargetType; +import com.sjmresorts.opsapp.notifications.services.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EsignReservationNotificationService { + + private static final String ACTIVE_STATUS = "ACTIVE"; + + private final NotificationService notificationService; + private final EsignUserRepository esignUserRepository; + private final EsignReservationTaskService reservationTaskService; + + @Value("${ops-app.esign.reservation.notification.enabled:true}") + private boolean enabled; + + @Value("${ops-app.esign.reservation.notification.broadcast-if-no-hotel-match:true}") + private boolean broadcastIfNoHotelMatch; + + @Value("${ops-app.esign.reservation.notification.title:新预订待处理}") + private String title; + + @Value("${ops-app.esign.reservation.notification.body-template:预订号 {reservationId} 已就绪,请点击查看}") + private String bodyTemplate; + + @Value("${ops-app.esign.reservation.notification.trigger-task:RESERVATION_NOTIFY}") + private String triggerTask; + + @Value("${ops-app.esign.reservation.notification.action:OPEN_RESERVATION}") + private String action; + + public ReservationNotificationResult notifyReservationReady(String reservationId, String hotelId) { + log.info( + "Reservation notification start reservationId={}, hotelId={}, enabled={}, broadcastIfNoHotelMatch={}", + reservationId, hotelId, enabled, broadcastIfNoHotelMatch); + + if (StringUtils.isAnyBlank(reservationId, hotelId)) { + return ReservationNotificationResult.skipped("reservationId or hotelId is blank"); + } + + String rid = reservationId.trim(); + String hid = hotelId.trim(); + String notifyTitle = title; + String notifyBody = formatBody(rid, hid); + String taskId = reservationTaskService.createOrRefreshPendingTask(rid, hid, notifyTitle, notifyBody); + if (taskId == null) { + log.warn("Reservation task persist failed reservationId={}, hotelId={}", rid, hid); + } + + if (!enabled) { + return ReservationNotificationResult.skipped("Reservation notification disabled"); + } + + List recipients = resolveRecipients(hid); + if (recipients.isEmpty()) { + log.warn( + "No active Esign users with device token for hotelId={} (broadcastIfNoHotelMatch={}); task persisted for offline pull taskId={}", + hid, + broadcastIfNoHotelMatch, + taskId); + return ReservationNotificationResult.skipped("No push recipients with device token"); + } + + List targets = recipients.stream() + .map(user -> String.valueOf(user.getId())) + .collect(Collectors.toList()); + + log.info( + "Reservation notification recipients reservationId={}, hotelId={}, userIds={}", + reservationId, + hotelId, + targets); + + SendNotificationData data = new SendNotificationData(); + data.setTargetType(NotificationTargetType.ESIGN_USER); + data.setTargets(targets); + data.setTitle(notifyTitle); + data.setBody(notifyBody); + data.setTriggerTask(triggerTask); + data.setClickAction(action); + + Map payload = new HashMap<>(); + payload.put("action", action); + payload.put("reservationId", rid); + payload.put("hotelId", hid); + if (StringUtils.isNotBlank(taskId)) { + payload.put("taskId", taskId); + payload.put("taskType", EsignReservationTaskService.TASK_TYPE); + } + data.setData(payload); + + try { + SendNotificationResponse response = notificationService.send(data, triggerTask); + SendNotificationResultData resultData = response.getData(); + log.info( + "Reservation notification finished reservationId={}, hotelId={}, targets={}, success={}, failure={}, status={}", + reservationId, + hotelId, + targets.size(), + resultData != null ? resultData.getSuccessCount() : 0, + resultData != null ? resultData.getFailureCount() : 0, + resultData != null ? resultData.getResultStatus() : null); + return ReservationNotificationResult.from(response, targets.size()); + } catch (Exception e) { + log.error("Reservation notification failed reservationId={}, hotelId={}", reservationId, hotelId, e); + return ReservationNotificationResult.failure(e.getMessage()); + } + } + + private List resolveRecipients(String hotelId) { + String normalizedHotelId = EsignReservationParamUtil.normalizeParam(hotelId); + List byHotel = esignUserRepository.findPushRecipientsByHotel(normalizedHotelId, ACTIVE_STATUS); + if (!byHotel.isEmpty()) { + log.info("Found {} push recipient(s) for hotelId={}", byHotel.size(), normalizedHotelId); + return byHotel; + } + + List allActive = esignUserRepository.findActivePushRecipients(ACTIVE_STATUS); + if (!allActive.isEmpty()) { + log.warn( + "No esign_user matched hotelId={} (query uses esign_user.hotel_id, not esign_hotels). " + + "Active users with device token:", + normalizedHotelId); + for (EsignUser user : allActive) { + log.warn( + " userId={}, hotelId=[{}], userName={}", + user.getId(), + user.getHotelId(), + user.getUserName()); + } + } + + if (!broadcastIfNoHotelMatch) { + return List.of(); + } + + if (!allActive.isEmpty()) { + log.warn( + "Fallback broadcast to all {} active user(s) with device token", + allActive.size()); + } + return allActive; + } + + private String formatBody(String reservationId, String hotelId) { + return bodyTemplate + .replace("{reservationId}", reservationId) + .replace("{hotelId}", hotelId); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/EsignReservationService.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/EsignReservationService.java new file mode 100644 index 0000000..2924637 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/EsignReservationService.java @@ -0,0 +1,221 @@ +package com.sjmresorts.opsapp.esign.reservation.services; + +import com.sjmresorts.opsapp.esign.common.support.EsignReservationParamUtil; +import com.sjmresorts.opsapp.esign.reservation.dtos.GuestProfileIdResponse; +import com.sjmresorts.opsapp.esign.reservation.dtos.RegistrationCardResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.RegistrationCardResult; +import com.sjmresorts.opsapp.esign.reservation.dtos.ReservationNotificationResult; +import com.sjmresorts.opsapp.esign.reservation.dtos.ReservationThirdPartyResult; +import com.sjmresorts.opsapp.esign.reservation.dtos.SignedRegistrationCardSubmitRequest; +import com.sjmresorts.opsapp.esign.reservation.dtos.SignedRegistrationCardUploadResult; +import com.sjmresorts.opsapp.esign.capture.dtos.EsignIosCaptureResponse; +import com.sjmresorts.opsapp.esign.reservation.dtos.EsignReservationNotifyResponse; +import com.sjmresorts.opsapp.esign.reservation.dtos.GuestProfileRef; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EsignReservationService { + + private final ReservationThirdPartyClient thirdPartyClient; + private final EsignReservationNotificationService reservationNotificationService; + private final SignedRegistrationCardOhipService signedRegistrationCardOhipService; + + public EsignReservationNotifyResponse acceptReservation(String reservationId, String hotelId) { + if (StringUtils.isBlank(reservationId)) { + return EsignReservationNotifyResponse.fail("400", "reservationId must not be blank"); + } + if (StringUtils.isBlank(hotelId)) { + return EsignReservationNotifyResponse.fail("400", "hotelId must not be blank"); + } + + String rid = EsignReservationParamUtil.normalizeParam(reservationId); + String hid = EsignReservationParamUtil.normalizeParam(hotelId); + log.info("Reservation accepted reservationId={}, hotelId={}", rid, hid); + + // TODO: 临时关闭 OHIP 第三方调用(① OAuth Token ② registrationCard),仅保留推送通知 + /* + ReservationThirdPartyResult thirdPartyResult = thirdPartyClient.notifyReservation(rid, hid); + if (!thirdPartyResult.isInvoked()) { + return EsignReservationNotifyResponse.ok( + rid, + hid, + thirdPartyResult.getErrorMessage(), + thirdPartyResult); + } + if (thirdPartyResult.isHttpSuccess()) { + log.info("Registration card OK, triggering push notification reservationId={}, hotelId={}", rid, hid); + ReservationNotificationResult notificationResult = + reservationNotificationService.notifyReservationReady(rid, hid); + return EsignReservationNotifyResponse.ok( + rid, + hid, + "Registration card fetched", + thirdPartyResult, + notificationResult); + } + + return EsignReservationNotifyResponse.fail( + "502", + StringUtils.defaultIfBlank(thirdPartyResult.getErrorMessage(), "Third party request failed"), + rid, + hid, + thirdPartyResult); + */ + + ReservationThirdPartyResult thirdPartyResult = + ReservationThirdPartyResult.skipped("Third party temporarily disabled"); + log.info("Third party skipped, triggering push notification reservationId={}, hotelId={}", rid, hid); + ReservationNotificationResult notificationResult = + reservationNotificationService.notifyReservationReady(rid, hid); + return EsignReservationNotifyResponse.ok( + rid, + hid, + "Notification sent (third party disabled)", + thirdPartyResult, + notificationResult); + } + + public RegistrationCardResponse fetchRegistrationCard(String reservationId, String hotelId) { + if (StringUtils.isBlank(reservationId)) { + return RegistrationCardResponse.fail("400", "reservationId must not be blank"); + } + if (StringUtils.isBlank(hotelId)) { + return RegistrationCardResponse.fail("400", "hotelId must not be blank"); + } + + String rid = EsignReservationParamUtil.normalizeParam(reservationId); + String hid = EsignReservationParamUtil.normalizeParam(hotelId); + log.info("Fetch registration card PDF reservationId={}, hotelId={}", rid, hid); + + RegistrationCardResult result = thirdPartyClient.fetchRegistrationCard(rid, hid); + if (!result.isInvoked()) { + return RegistrationCardResponse.fail("503", result.getErrorMessage(), rid, hid, result); + } + if (result.isSuccess()) { + GuestProfileRef profile = thirdPartyClient.fetchGuestProfile(hid, rid).orElse(null); + return RegistrationCardResponse.ok( + rid, + hid, + result, + profile != null ? profile.profileId() : null, + profile != null ? profile.profileType() : null); + } + return RegistrationCardResponse.fail( + "502", + StringUtils.defaultIfBlank(result.getErrorMessage(), "Third party request failed"), + rid, + hid, + result); + } + + public GuestProfileIdResponse fetchGuestProfileId(String reservationId, String hotelId) { + if (StringUtils.isBlank(reservationId)) { + return GuestProfileIdResponse.fail("400", "reservationId must not be blank", null, hotelId); + } + if (StringUtils.isBlank(hotelId)) { + return GuestProfileIdResponse.fail("400", "hotelId must not be blank", reservationId, null); + } + String rid = EsignReservationParamUtil.normalizeParam(reservationId); + String hid = EsignReservationParamUtil.normalizeParam(hotelId); + return thirdPartyClient.fetchGuestProfile(hid, rid) + .map(profile -> GuestProfileIdResponse.ok( + rid, hid, profile.profileId(), profile.profileType())) + .orElseGet(() -> GuestProfileIdResponse.fail( + "404", "Profile not found in reservation", rid, hid)); + } + + public EsignIosCaptureResponse submitSignedRegistrationCard(SignedRegistrationCardSubmitRequest request) { + EsignIosCaptureResponse resp = new EsignIosCaptureResponse(); + if (request == null) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("request body required"); + return resp; + } + String rid = EsignReservationParamUtil.normalizeParam(request.getReservationId()); + String hid = EsignReservationParamUtil.normalizeParam(request.getHotelId()); + String signaturesJson = StringUtils.trimToEmpty(request.getSignaturesJson()); + String signedBase64 = StringUtils.trimToEmpty(request.getBase64()); + String originalPdf = StringUtils.trimToEmpty(request.getPdfBase64()); + if (StringUtils.isBlank(rid)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("reservationId required"); + return resp; + } + if (StringUtils.isAllBlank(signedBase64, signaturesJson, originalPdf)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("pdfBase64 or signaturesJson required"); + return resp; + } + + log.info( + "Submit signed registration card reservationId={}, hotelId={}, signatureCount={}", + rid, + hid, + request.getSignatureCount()); + + GuestProfileRef profile = resolveGuestProfile( + request.getProfileId(), request.getProfileType(), hid, rid); + + SignedRegistrationCardUploadResult ohipResult = signedRegistrationCardOhipService.uploadForReservation( + rid, + hid, + originalPdf, + signedBase64, + signaturesJson, + request.getFileName(), + request.getConfirmationNumber(), + request.getSignFieldsJson(), + profile.profileId(), + profile.profileType()); + + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setReservationId(rid); + if (ohipResult.isInvoked()) { + data.setConfirmationNumber(ohipResult.getConfirmationNumber()); + data.setOhipUploaded(ohipResult.isSuccess()); + if (!ohipResult.isSuccess()) { + resp.setSuccess(false); + resp.setCode(ohipResult.getStatusCode() > 0 ? String.valueOf(ohipResult.getStatusCode()) : "502"); + resp.setMessage(StringUtils.defaultIfBlank( + ohipResult.getErrorMessage(), "提交 OPERA 失败")); + resp.setData(data); + return resp; + } + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("签名已提交至 OPERA(reservation " + rid + ")"); + resp.setData(data); + return resp; + } + + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage(StringUtils.defaultIfBlank( + ohipResult.getErrorMessage(), + "签名已处理(未上传 OPERA,请检查 signed-upload / OAuth 配置)")); + resp.setData(data); + return resp; + } + + private GuestProfileRef resolveGuestProfile( + String profileIdFromRequest, String profileTypeFromRequest, String hotelId, String reservationId) { + String profileId = StringUtils.isNotBlank(profileIdFromRequest) + ? EsignReservationParamUtil.normalizeParam(profileIdFromRequest) + : null; + String profileType = StringUtils.isNotBlank(profileTypeFromRequest) + ? EsignReservationParamUtil.normalizeParam(profileTypeFromRequest) + : null; + if (StringUtils.isNotBlank(profileId)) { + return new GuestProfileRef(profileId, profileType); + } + return thirdPartyClient.fetchGuestProfile(hotelId, reservationId).orElse(new GuestProfileRef(null, null)); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/EsignReservationTaskService.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/EsignReservationTaskService.java new file mode 100644 index 0000000..0899782 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/EsignReservationTaskService.java @@ -0,0 +1,207 @@ +package com.sjmresorts.opsapp.esign.reservation.services; + +import com.sjmresorts.opsapp.esign.capture.dtos.EsignIosCaptureResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPendingListResponse; +import com.sjmresorts.opsapp.esign.common.support.EsignReservationParamUtil; +import com.sjmresorts.opsapp.esign.reservation.models.EsignReservationTask; +import com.sjmresorts.opsapp.esign.reservation.repositories.EsignReservationTaskRepository; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Service +public class EsignReservationTaskService { + + public static final String TASK_TYPE = "RESERVATION"; + public static final String STATUS_PENDING = "PENDING"; + public static final String STATUS_COMPLETED = "COMPLETED"; + + private static final int TASK_WINDOW_HOURS = 24; + + @Resource + private EsignReservationTaskRepository reservationTaskRepository; + + private Instant taskWindowCutoff() { + return Instant.now().minus(TASK_WINDOW_HOURS, ChronoUnit.HOURS); + } + + /** + * 预订通知触发时落库;同 reservationId+hotelId 若已有待处理则刷新 created_at。 + * + * @return 任务 ID(供 FCM data 携带) + */ + @Transactional + public String createOrRefreshPendingTask(String reservationId, String hotelId, String title, String body) { + String rid = EsignReservationParamUtil.normalizeParam(reservationId); + String hid = EsignReservationParamUtil.normalizeParam(hotelId); + if (StringUtils.isAnyBlank(rid, hid)) { + return null; + } + + Instant now = Instant.now(); + Optional existing = + reservationTaskRepository.findFirstByReservationIdAndHotelIdAndStatusOrderByCreatedAtDesc( + rid, hid, STATUS_PENDING); + if (existing.isPresent()) { + EsignReservationTask t = existing.get(); + t.setCreatedAt(now); + if (StringUtils.isNotBlank(title)) { + t.setTitle(trim(title, 256)); + } + if (StringUtils.isNotBlank(body)) { + t.setBody(trim(body, 512)); + } + reservationTaskRepository.save(t); + log.info("Reservation task refreshed taskId={}, reservationId={}, hotelId={}", t.getTaskId(), rid, hid); + return t.getTaskId(); + } + + EsignReservationTask task = new EsignReservationTask(); + task.setTaskId(UUID.randomUUID().toString()); + task.setReservationId(rid); + task.setHotelId(hid); + task.setStatus(STATUS_PENDING); + task.setTitle(trim(title, 256)); + task.setBody(trim(body, 512)); + task.setCreatedAt(now); + reservationTaskRepository.save(task); + log.info("Reservation task created taskId={}, reservationId={}, hotelId={}", task.getTaskId(), rid, hid); + return task.getTaskId(); + } + + @Transactional(readOnly = true) + public List listItemsForBucket(String bucket, String hotelId) { + String b = StringUtils.isBlank(bucket) ? "pending" : bucket.trim().toLowerCase(); + Instant cutoff = taskWindowCutoff(); + List rows; + if ("signed".equals(b)) { + rows = reservationTaskRepository.findTop100ByStatusOrderByCompletedAtDesc(STATUS_COMPLETED); + } else if ("voided".equals(b)) { + rows = reservationTaskRepository.findTop100ByStatusAndCreatedAtLessThanOrderByCreatedAtDesc( + STATUS_PENDING, cutoff); + } else { + rows = reservationTaskRepository.findTop100ByStatusAndCreatedAtGreaterThanEqualOrderByCreatedAtDesc( + STATUS_PENDING, cutoff); + } + + boolean filterByHotel = StringUtils.isNotBlank(hotelId); + String normalizedHotelId = filterByHotel ? EsignReservationParamUtil.normalizeParam(hotelId) : null; + List items = new ArrayList<>(); + for (EsignReservationTask row : rows) { + if (filterByHotel && !normalizedHotelId.equals(row.getHotelId())) { + continue; + } + items.add(toPendingItem(row, "signed".equals(b))); + } + return items; + } + + @Transactional + public EsignIosCaptureResponse completeTask(String taskId, String reservationId, String hotelId) { + EsignIosCaptureResponse resp = new EsignIosCaptureResponse(); + Optional opt = resolveTask(taskId, reservationId, hotelId); + if (opt.isEmpty()) { + resp.setSuccess(false); + resp.setCode("404"); + resp.setMessage("Reservation task not found"); + return resp; + } + EsignReservationTask t = opt.get(); + if (STATUS_COMPLETED.equals(t.getStatus())) { + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("Already completed"); + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(t.getTaskId()); + resp.setData(data); + return resp; + } + t.setStatus(STATUS_COMPLETED); + t.setCompletedAt(Instant.now()); + reservationTaskRepository.save(t); + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("Completed"); + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(t.getTaskId()); + resp.setData(data); + return resp; + } + + private Optional resolveTask(String taskId, String reservationId, String hotelId) { + if (StringUtils.isNotBlank(taskId)) { + return reservationTaskRepository.findById(taskId.trim()); + } + String rid = EsignReservationParamUtil.normalizeParam(reservationId); + String hid = EsignReservationParamUtil.normalizeParam(hotelId); + if (StringUtils.isAnyBlank(rid, hid)) { + return Optional.empty(); + } + return reservationTaskRepository.findFirstByReservationIdAndHotelIdAndStatusOrderByCreatedAtDesc( + rid, hid, STATUS_PENDING); + } + + private EsignPendingListResponse.EsignPendingItem toPendingItem(EsignReservationTask row, boolean completedBucket) { + EsignPendingListResponse.EsignPendingItem it = new EsignPendingListResponse.EsignPendingItem(); + it.setTaskType(TASK_TYPE); + it.setTaskId(row.getTaskId()); + it.setReservationId(row.getReservationId()); + it.setHotelId(row.getHotelId()); + it.setCreatedAt(row.getCreatedAt() != null ? row.getCreatedAt().toString() : null); + it.setDocTitle(StringUtils.defaultIfBlank(row.getTitle(), "新预订待处理")); + it.setNotificationTitle(row.getTitle()); + it.setNotificationBody(row.getBody()); + if (completedBucket && row.getCompletedAt() != null) { + it.setSignedAt(row.getCompletedAt().toString()); + } + return it; + } + + public static void mergeSortedByCreatedAt( + List target, + List extra) { + if (extra == null || extra.isEmpty()) { + return; + } + target.addAll(extra); + target.sort(Comparator.comparing( + EsignReservationTaskService::readCreatedInstant, + Comparator.nullsLast(Comparator.reverseOrder()))); + if (target.size() > 100) { + target.subList(100, target.size()).clear(); + } + } + + private static Instant readCreatedInstant(EsignPendingListResponse.EsignPendingItem item) { + if (item == null || StringUtils.isBlank(item.getCreatedAt())) { + return null; + } + try { + return Instant.parse(item.getCreatedAt()); + } catch (Exception e) { + return null; + } + } + + private static String trim(String s, int max) { + if (s == null) { + return null; + } + String t = s.trim(); + if (t.isEmpty()) { + return null; + } + return t.length() > max ? t.substring(0, max) : t; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/EsignSignFieldOhipMappingService.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/EsignSignFieldOhipMappingService.java new file mode 100644 index 0000000..a353138 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/EsignSignFieldOhipMappingService.java @@ -0,0 +1,194 @@ +package com.sjmresorts.opsapp.esign.reservation.services; + +import com.alibaba.fastjson2.JSON; +import com.sjmresorts.opsapp.esign.common.dtos.OhipProfileFieldCatalogResponse; +import com.sjmresorts.opsapp.esign.common.dtos.SignFieldOhipMappingItem; +import com.sjmresorts.opsapp.esign.reservation.dtos.EsignSignFieldOhipMappingResponse; +import com.sjmresorts.opsapp.esign.reservation.dtos.EsignSignFieldOhipMappingSaveRequest; +import com.sjmresorts.opsapp.esign.reservation.dtos.EsignSignFieldOhipPreviewRequest; +import com.sjmresorts.opsapp.esign.reservation.dtos.EsignSignFieldOhipPreviewResponse; +import com.sjmresorts.opsapp.esign.reservation.models.EsignSignFieldOhipConfig; +import com.sjmresorts.opsapp.esign.reservation.repositories.EsignSignFieldOhipConfigRepository; +import com.sjmresorts.opsapp.esign.common.support.EsignReservationParamUtil; +import com.sjmresorts.opsapp.esign.common.support.OhipProfileFieldCatalog; +import com.sjmresorts.opsapp.esign.reservation.support.ProfileDetailsJsonBuilder; +import com.sjmresorts.opsapp.esign.common.mappers.SignFieldOhipValueMapper; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +public class EsignSignFieldOhipMappingService { + + public static final String DEFAULT_CONFIG_ID = "DEFAULT"; + + @Resource + private EsignSignFieldOhipConfigRepository configRepository; + + public EsignSignFieldOhipMappingResponse getForAdmin() { + return buildOk(ensureDefaultRow()); + } + + public EsignSignFieldOhipMappingResponse getForAdmin(String hotelId) { + return buildOk(resolveConfig(hotelId)); + } + + public List getMappingsForHotel(String hotelId) { + return parseMappings(resolveConfig(hotelId).getMappingsJson()); + } + + public Map mapSignatureValues( + String hotelId, String signaturesJson, String signFieldsJson) { + List mappings = getMappingsForHotel(hotelId); + return SignFieldOhipValueMapper.mapValues(signaturesJson, signFieldsJson, mappings); + } + + public OhipProfileFieldCatalogResponse listProfileFields() { + return OhipProfileFieldCatalogResponse.ok( + OhipProfileFieldCatalog.groupOrder(), + OhipProfileFieldCatalog.all(), + OhipProfileFieldCatalog.commonFields()); + } + + /** + * 根据当前映射配置生成 PUT /crm/v1/profiles/{profileId} 请求体预览。 + * 示例值优先取备注 label,其次 fieldName。 + */ + public EsignSignFieldOhipPreviewResponse previewProfileBody(EsignSignFieldOhipPreviewRequest request) { + List mappings = request != null && request.getMappings() != null + ? SignFieldOhipValueMapper.normalizeMappings(request.getMappings()) + : Collections.emptyList(); + Map sampleFields = new LinkedHashMap<>(); + for (SignFieldOhipMappingItem item : mappings) { + if (item == null || Boolean.FALSE.equals(item.getEnabled())) { + continue; + } + String valueType = StringUtils.trimToEmpty(item.getValueType()).toLowerCase(); + if ("signature".equals(valueType)) { + continue; + } + String path = OhipProfileFieldCatalog.resolvePath(item.getOhipFieldCode()).orElse(null); + if (path == null) { + continue; + } + String sample = StringUtils.firstNonBlank( + StringUtils.trimToNull(item.getLabel()), + StringUtils.trimToNull(item.getFieldName()), + "示例值"); + sampleFields.put(path, sample); + } + String profileType = StringUtils.defaultIfBlank( + request != null ? StringUtils.trimToNull(request.getProfileType()) : null, + "Guest"); + var body = ProfileDetailsJsonBuilder.buildBody(sampleFields, profileType); + String jsonPreview = JSON.toJSONString(body, com.alibaba.fastjson2.JSONWriter.Feature.PrettyFormat); + + EsignSignFieldOhipPreviewResponse.PreviewData data = + new EsignSignFieldOhipPreviewResponse.PreviewData(); + data.setProfileBody(JSON.parseObject(body.toJSONString(), Map.class)); + data.setJsonPreview(jsonPreview); + data.setMappedFieldCount(sampleFields.size()); + return EsignSignFieldOhipPreviewResponse.ok(data); + } + + @Transactional + public EsignSignFieldOhipMappingResponse save(EsignSignFieldOhipMappingSaveRequest request) { + if (request == null) { + return EsignSignFieldOhipMappingResponse.fail("400", "request required"); + } + String hotelId = normalizeHotelId(request.getHotelId()); + List mappings = + SignFieldOhipValueMapper.normalizeMappings(request.getMappings()); + + EsignSignFieldOhipConfig row = hotelId == null + ? ensureDefaultRow() + : configRepository + .findFirstByHotelIdOrderByUpdatedAtDesc(hotelId) + .orElseGet(() -> { + EsignSignFieldOhipConfig created = new EsignSignFieldOhipConfig(); + created.setConfigId(generateConfigId(hotelId)); + created.setHotelId(hotelId); + return created; + }); + + row.setMappingsJson(JSON.toJSONString(mappings)); + row.setUpdatedAt(Instant.now()); + configRepository.save(row); + return buildOk(row); + } + + private EsignSignFieldOhipConfig resolveConfig(String hotelId) { + String normalized = normalizeHotelId(hotelId); + if (normalized != null) { + EsignSignFieldOhipConfig hotelRow = + configRepository.findFirstByHotelIdOrderByUpdatedAtDesc(normalized).orElse(null); + if (hotelRow != null) { + return hotelRow; + } + } + return ensureDefaultRow(); + } + + private EsignSignFieldOhipConfig ensureDefaultRow() { + return configRepository + .findById(DEFAULT_CONFIG_ID) + .orElseGet(() -> { + EsignSignFieldOhipConfig row = new EsignSignFieldOhipConfig(); + row.setConfigId(DEFAULT_CONFIG_ID); + row.setHotelId(null); + row.setMappingsJson("[]"); + row.setUpdatedAt(Instant.now()); + return configRepository.save(row); + }); + } + + private List parseMappings(String json) { + if (StringUtils.isBlank(json)) { + return Collections.emptyList(); + } + try { + List list = + JSON.parseArray(json.trim(), SignFieldOhipMappingItem.class); + return SignFieldOhipValueMapper.normalizeMappings(list); + } catch (Exception e) { + return Collections.emptyList(); + } + } + + private EsignSignFieldOhipMappingResponse buildOk(EsignSignFieldOhipConfig row) { + EsignSignFieldOhipMappingResponse resp = EsignSignFieldOhipMappingResponse.ok("ok"); + EsignSignFieldOhipMappingResponse.SignFieldOhipMappingData data = + new EsignSignFieldOhipMappingResponse.SignFieldOhipMappingData(); + EsignSignFieldOhipMappingResponse.SignFieldOhipMappingConfigItem item = + new EsignSignFieldOhipMappingResponse.SignFieldOhipMappingConfigItem(); + item.setConfigId(row.getConfigId()); + item.setHotelId(row.getHotelId()); + item.setMappings(parseMappings(row.getMappingsJson())); + item.setUpdatedAt(row.getUpdatedAt() != null ? row.getUpdatedAt().toString() : null); + data.setConfig(item); + resp.setData(data); + return resp; + } + + private static String normalizeHotelId(String hotelId) { + if (StringUtils.isBlank(hotelId)) { + return null; + } + try { + return EsignReservationParamUtil.normalizeParam(hotelId); + } catch (Exception e) { + return StringUtils.trimToNull(hotelId); + } + } + + private static String generateConfigId(String hotelId) { + return "HOTEL_" + StringUtils.defaultString(hotelId).replaceAll("[^A-Za-z0-9]", "_"); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/RegistrationConfirmationExtractService.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/RegistrationConfirmationExtractService.java new file mode 100644 index 0000000..e7df52a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/RegistrationConfirmationExtractService.java @@ -0,0 +1,96 @@ +package com.sjmresorts.opsapp.esign.reservation.services; + +import com.sjmresorts.opsapp.esign.reservation.support.RegistrationConfirmationExtractor; +import com.sjmresorts.opsapp.esign.printer.support.RegistrationConfirmationOcrExtractor; +import com.sjmresorts.opsapp.esign.common.support.EsignLogMaskUtil; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.Base64; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RegistrationConfirmationExtractService { + + private final RegistrationConfirmationOcrExtractor ocrExtractor; + + @Getter + private volatile String lastFailureDetail = ""; + + public Optional extractFromPdfBase64(String pdfBase64) { + if (StringUtils.isBlank(pdfBase64)) { + lastFailureDetail = "PDF Base64 为空"; + return Optional.empty(); + } + byte[] bytes = decodePdf(pdfBase64); + if (bytes == null) { + lastFailureDetail = "PDF Base64 解码失败"; + return Optional.empty(); + } + return extractFromPdfBytes(bytes); + } + + public Optional extractFromPdfBytes(byte[] pdfBytes) { + lastFailureDetail = ""; + if (pdfBytes == null || pdfBytes.length == 0) { + lastFailureDetail = "PDF 字节为空"; + return Optional.empty(); + } + Optional fromText = RegistrationConfirmationExtractor.extractFromPdfBytes(pdfBytes); + if (fromText.isPresent()) { + log.info("confirmation extract hit via PDF text: {}", fromText.get()); + return fromText; + } + log.info("confirmation extract PDF text miss, trying OCR pdfBytes={}", pdfBytes.length); + if (!ocrExtractor.isOcrEnabled()) { + lastFailureDetail = "PDF 无文本层且 OCR 已关闭"; + return Optional.empty(); + } + if (!ocrExtractor.isOcrReady()) { + lastFailureDetail = ocrExtractor.getUnavailableReason(); + log.warn("confirmation extract OCR unavailable: {}", lastFailureDetail); + return Optional.empty(); + } + Optional fromOcr = ocrExtractor.extractFromPdfBytes(pdfBytes); + if (fromOcr.isPresent()) { + log.info("confirmation extract hit via OCR: {}", fromOcr.get()); + return fromOcr; + } + String ocrHint = ocrExtractor.getLastOcrHint(); + lastFailureDetail = StringUtils.isNotBlank(ocrHint) + ? "PDF 无文本层,OCR 已执行但未匹配确认号;" + ocrHint + : "PDF 无文本层,OCR 已执行但未匹配确认号"; + log.warn("confirmation extract miss after PDF text + OCR: {}", lastFailureDetail); + return Optional.empty(); + } + + public String buildUserFacingFailureMessage() { + if (StringUtils.isNotBlank(lastFailureDetail)) { + return "无法在 PDF 中找到确认号(Conf. No / 確認號碼)。" + lastFailureDetail + + "。可安装 Tesseract 或执行 scripts/download-tessdata.ps1 后重启后端。"; + } + return "无法在 PDF 中找到确认号(Conf. No / 確認號碼)"; + } + + private static byte[] decodePdf(String pdfBase64) { + try { + return Base64.getDecoder().decode(normalizeBase64(pdfBase64)); + } catch (IllegalArgumentException e) { + return null; + } + } + + private static String normalizeBase64(String raw) { + String trimmed = raw.trim(); + int comma = trimmed.indexOf(','); + if (trimmed.startsWith("data:") && comma > 0) { + return trimmed.substring(comma + 1).trim(); + } + return trimmed.replaceAll("\\s", ""); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/ReservationThirdPartyClient.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/ReservationThirdPartyClient.java new file mode 100644 index 0000000..a43a4ae --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/ReservationThirdPartyClient.java @@ -0,0 +1,824 @@ +package com.sjmresorts.opsapp.esign.reservation.services; + +import com.sjmresorts.opsapp.common.dtos.httpclient.HttpResponseResult; +import com.sjmresorts.opsapp.common.utils.SimpleHttpClient; +import com.sjmresorts.opsapp.esign.audit.services.EsignAuditLogService; +import com.sjmresorts.opsapp.esign.audit.support.ThirdPartyAuditDetailBuilder; +import com.sjmresorts.opsapp.esign.printer.dtos.RegistrationCardResponseParser; +import com.sjmresorts.opsapp.esign.printer.dtos.RegistrationCardResult; +import com.sjmresorts.opsapp.esign.reservation.dtos.ReservationThirdPartyResult; +import com.sjmresorts.opsapp.esign.reservation.dtos.SignedRegistrationCardUploadResult; +import com.sjmresorts.opsapp.esign.reservation.support.HospitalityOAuthTokenSingleton; +import com.sjmresorts.opsapp.esign.reservation.dtos.GuestProfileRef; +import com.sjmresorts.opsapp.esign.reservation.support.ProfileDetailsJsonBuilder; +import com.sjmresorts.opsapp.esign.common.support.ProfileDetailsJsonReader; +import com.sjmresorts.opsapp.esign.reservation.support.ReservationDetailResponseParser; +import com.sjmresorts.opsapp.esign.reservation.dtos.ReservationLookupHit; +import com.sjmresorts.opsapp.esign.reservation.support.ReservationLookupResponseParser; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Base64; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +public class ReservationThirdPartyClient { + + private static final int RESPONSE_BODY_LOG_MAX = 2000; + + private final HospitalityOAuthTokenSingleton oauthTokenSingleton = HospitalityOAuthTokenSingleton.getInstance(); + + @Value("${ops-app.esign.reservation.third-party.base-url:}") + private String baseUrl; + + @Value("${ops-app.esign.reservation.third-party.timeout-ms:30000}") + private int timeoutMs; + + @Value("${ops-app.esign.reservation.third-party.registration-card.regenerate:false}") + private boolean regenerate; + + @Value("${ops-app.esign.reservation.third-party.registration-card.signed-only:false}") + private boolean signedOnly; + + @Value("${ops-app.esign.reservation.third-party.registration-card.reservation-id-context:OPERA}") + private String reservationIdContext; + + @Value("${ops-app.esign.reservation.third-party.registration-card.reservation-id-type:Reservation}") + private String reservationIdType; + + @Value("${ops-app.esign.reservation.third-party.registration-card.language:EN}") + private String language; + + @Value("${ops-app.esign.reservation.third-party.signed-upload.enabled:true}") + private boolean signedUploadEnabled; + + @Value("${ops-app.esign.reservation.third-party.reservation-lookup.path-template:/rsv/v1/hotels/{hotelId}/reservations}") + private String reservationLookupPathTemplate; + + @Value("${ops-app.esign.reservation.third-party.reservation-lookup.fetch-instructions:Reservation}") + private String reservationLookupFetchInstructions; + + @Value("${ops-app.esign.reservation.third-party.reservation-detail.path-template:/rsv/v1/hotels/{hotelId}/reservations/{reservationId}}") + private String reservationDetailPathTemplate; + + @Value("${ops-app.esign.reservation.third-party.file-attachment.path:/med/config/v1/fileAttachments}") + private String fileAttachmentPath; + + @Value("${ops-app.esign.reservation.third-party.file-attachment.user-name:USER@HOTEL}") + private String fileAttachmentUserName; + + @Value("${ops-app.esign.reservation.third-party.file-attachment.description:E}") + private String fileAttachmentDescription; + + @Value("${ops-app.esign.reservation.third-party.file-attachment.link-type:Reservation}") + private String fileAttachmentLinkType; + + @Value("${ops-app.esign.reservation.third-party.file-attachment.overwrite-existing:Y}") + private String fileAttachmentOverwrite; + + @Value("${ops-app.esign.reservation.third-party.file-attachment.global-yn:N}") + private String fileAttachmentGlobalYn; + + @Value("${ops-app.esign.reservation.third-party.profile-fields.enabled:false}") + private boolean profileFieldsEnabled; + + @Value("${ops-app.esign.reservation.third-party.profile-fields.path-template:/crm/v1/profiles/{profileId}}") + private String profileFieldsPathTemplate; + + @Resource(name = "esignAuditLogService") + private EsignAuditLogService auditLogService; + + /** notify 流程仍跳过第三方,仅发推送 */ + public ReservationThirdPartyResult notifyReservation(String reservationId, String hotelId) { + log.debug("notifyReservation skips third party, reservationId={}, hotelId={}", reservationId, hotelId); + return ReservationThirdPartyResult.skipped("Third party disabled in notify flow"); + } + + /** 点击通知后:OAuth + registrationCard,解析 PDF */ + public RegistrationCardResult fetchRegistrationCard(String reservationId, String hotelId) { + if (StringUtils.isBlank(baseUrl)) { + return RegistrationCardResult.skipped("Third party base-url not configured"); + } + if (!oauthTokenSingleton.isConfigured()) { + return RegistrationCardResult.skipped("OHIP OAuth is not configured"); + } + + String ohipHotelId = resolveOhipHotelId(hotelId); + String path = buildRegistrationCardPath(ohipHotelId, reservationId.trim()); + String url = SimpleHttpClient.joinUrl(baseUrl, path); + + try { + SimpleHttpClient.RequestBuilder request = SimpleHttpClient.get(url) + .timeoutMs(timeoutMs) + .queryParam("regenerate", String.valueOf(regenerate)) + .queryParam("signedOnly", String.valueOf(signedOnly)) + .queryParam("reservationIdContext", reservationIdContext) + .queryParam("reservationIdType", reservationIdType) + .queryParam("language", language); + + applyBusinessAuth(request, ohipHotelId); + + log.info( + "RegistrationCard fetch: method=GET, url={}, pathHotelId={}, reservationId={}, requestHotelId={}", + request.getRequestUrlWithQuery(), ohipHotelId, reservationId, hotelId); + log.info( + "RegistrationCard fetch headers={}", + SimpleHttpClient.maskHeadersForLog(request.getHeaders())); + + long start = System.currentTimeMillis(); + HttpResponseResult response = request.execute(); + auditThirdParty( + EsignAuditLogService.EVT_OHIP_GET_REGISTRATION, + request, + response, + start, + ohipHotelId, + "GET registrationCard reservationId=" + reservationId); + return toRegistrationCardResult(response); + } catch (IllegalStateException e) { + log.error("RegistrationCard fetch failed", e); + return RegistrationCardResult.failure(-1, null, e.getMessage()); + } + } + + /** + * 签名登记卡提交 OPERA:确认号 → reservation id → fileAttachments。 + */ + public SignedRegistrationCardUploadResult uploadSignedRegistrationCard( + String hotelId, + String confirmationNumber, + String signedPdfBase64, + String preferredFileName) { + log.info( + "OHIP uploadSignedRegistrationCard start hotelId={}, confirmation={}, signedPdfLen={}, fileName={}", + hotelId, + confirmationNumber, + StringUtils.length(signedPdfBase64), + preferredFileName); + if (!signedUploadEnabled) { + log.info("OHIP uploadSignedRegistrationCard skip: signed-upload disabled"); + return SignedRegistrationCardUploadResult.skipped("Signed upload disabled"); + } + if (StringUtils.isBlank(baseUrl)) { + log.info("OHIP uploadSignedRegistrationCard skip: base-url not configured"); + return SignedRegistrationCardUploadResult.skipped("Third party base-url not configured"); + } + if (!oauthTokenSingleton.isConfigured()) { + log.info("OHIP uploadSignedRegistrationCard skip: OAuth not configured"); + return SignedRegistrationCardUploadResult.skipped("OHIP OAuth is not configured"); + } + if (StringUtils.isBlank(confirmationNumber)) { + return SignedRegistrationCardUploadResult.failure( + 400, null, "confirmationNumber required"); + } + if (StringUtils.isBlank(signedPdfBase64)) { + log.warn("OHIP uploadSignedRegistrationCard fail: signed PDF empty"); + return SignedRegistrationCardUploadResult.failure( + 400, null, "signed PDF base64 required"); + } + + String ohipHotelId = resolveOhipHotelId(hotelId); + String confNo = confirmationNumber.trim(); + log.info("OHIP step1 reservation lookup hotelId={} (resolved={}), confirmation={}", hotelId, ohipHotelId, confNo); + + Optional lookupHit = lookupReservationByConfirmation(ohipHotelId, confNo); + if (lookupHit.isEmpty()) { + log.warn("OHIP step1 reservation lookup miss confirmation={}", confNo); + return SignedRegistrationCardUploadResult.failure( + 404, null, "Reservation not found for confirmation " + confNo); + } + ReservationLookupHit hit = lookupHit.get(); + String reservationId = hit.reservationId(); + log.info("OHIP step1 reservation lookup hit confirmation={}, reservationId={}", confNo, reservationId); + SignedRegistrationCardUploadResult uploaded = uploadFileAttachment( + ohipHotelId, reservationId, confNo, signedPdfBase64, preferredFileName); + attachGuestProfile(uploaded, hit.guestProfile()); + return uploaded; + } + + private static void attachGuestProfile( + SignedRegistrationCardUploadResult result, GuestProfileRef guestProfile) { + if (result == null || guestProfile == null || !result.isSuccess()) { + return; + } + if (StringUtils.isNotBlank(guestProfile.profileId())) { + result.setProfileId(guestProfile.profileId()); + } + if (StringUtils.isNotBlank(guestProfile.profileType())) { + result.setProfileType(guestProfile.profileType()); + } + } + + /** + * 已知 reservation id 时直接上传(登记卡通知流程),跳过确认号查询。 + */ + public SignedRegistrationCardUploadResult uploadSignedRegistrationCardByReservationId( + String hotelId, + String reservationId, + String confirmationNumber, + String signedPdfBase64, + String preferredFileName) { + if (!signedUploadEnabled) { + return SignedRegistrationCardUploadResult.skipped("Signed upload disabled"); + } + if (StringUtils.isBlank(baseUrl)) { + return SignedRegistrationCardUploadResult.skipped("Third party base-url not configured"); + } + if (!oauthTokenSingleton.isConfigured()) { + return SignedRegistrationCardUploadResult.skipped("OHIP OAuth is not configured"); + } + if (StringUtils.isAnyBlank(reservationId, signedPdfBase64)) { + return SignedRegistrationCardUploadResult.failure( + 400, null, "reservationId and signed PDF base64 required"); + } + String ohipHotelId = resolveOhipHotelId(hotelId); + String confNo = StringUtils.defaultIfBlank(confirmationNumber, reservationId.trim()); + return uploadFileAttachment( + ohipHotelId, reservationId.trim(), confNo, signedPdfBase64, preferredFileName); + } + + public Optional lookupReservationIdByConfirmation(String hotelId, String confirmationNumber) { + return lookupReservationByConfirmation(hotelId, confirmationNumber).map(ReservationLookupHit::reservationId); + } + + public Optional lookupReservationByConfirmation(String hotelId, String confirmationNumber) { + String ohipHotelId = resolveOhipHotelId(hotelId); + if (StringUtils.isAnyBlank(baseUrl, ohipHotelId, confirmationNumber)) { + log.warn( + "OHIP reservation lookup skip: baseUrl={}, hotelId={}, resolvedHotelId={}, confirmation={}", + StringUtils.isNotBlank(baseUrl), + hotelId, + ohipHotelId, + confirmationNumber); + return Optional.empty(); + } + if (!oauthTokenSingleton.isConfigured()) { + log.warn("OHIP reservation lookup skip: OAuth not configured"); + return Optional.empty(); + } + String path = reservationLookupPathTemplate.replace("{hotelId}", urlEncodePathSegment(ohipHotelId)); + String url = SimpleHttpClient.joinUrl(baseUrl, path); + try { + SimpleHttpClient.RequestBuilder request = SimpleHttpClient.get(url) + .timeoutMs(timeoutMs) + .queryParam("confirmationNumberList", confirmationNumber.trim()) + .queryParam("fetchInstructions", reservationLookupFetchInstructions); + applyBusinessAuth(request, ohipHotelId); + log.info( + "OHIP GET reservations request url={}, hotelId={}, confirmationNumber={}, headers={}", + request.getRequestUrlWithQuery(), + ohipHotelId, + confirmationNumber, + SimpleHttpClient.maskHeadersForLog(request.getHeaders())); + long start = System.currentTimeMillis(); + HttpResponseResult response = request.execute(); + auditThirdParty( + EsignAuditLogService.EVT_OHIP_GET_RESERVATIONS, + request, + response, + start, + ohipHotelId, + "GET reservations confirmation=" + confirmationNumber); + String body = response.getResponseContent(); + log.info( + "OHIP GET reservations response http={}, body={}", + response.getStatusCode(), + truncate(body)); + if (response.isTransportError()) { + log.error("OHIP GET reservations transport error: {}", response.errorOrEmpty()); + return Optional.empty(); + } + if (!response.isSuccessful()) { + return Optional.empty(); + } + Optional reservationId = + ReservationLookupResponseParser.extractReservationId(body, confirmationNumber.trim()); + Optional guestProfile = ReservationDetailResponseParser.extractGuestProfile(body); + if (reservationId.isPresent()) { + log.info( + "OHIP GET reservations parsed reservationId={} profileId={} for confirmation={}", + reservationId.get(), + guestProfile.map(GuestProfileRef::profileId).orElse(null), + confirmationNumber); + return Optional.of(new ReservationLookupHit(reservationId.get(), guestProfile.orElse(null))); + } else { + log.warn( + "OHIP GET reservations HTTP 200 but no reservationId for confirmation={}, body={}", + confirmationNumber, + truncate(body)); + } + return Optional.empty(); + } catch (Exception e) { + log.error("OHIP GET reservations failed", e); + return Optional.empty(); + } + } + + public Optional fetchGuestProfileId(String hotelId, String reservationId) { + return fetchGuestProfile(hotelId, reservationId).map(GuestProfileRef::profileId); + } + + /** + * GET /rsv/v1/hotels/{hotelId}/reservations/{reservationId}?fetchInstructions=Reservation + * 解析 reservationGuests[].profileInfo 中的 profileId 与 profile.profileType。 + */ + public Optional fetchGuestProfile(String hotelId, String reservationId) { + String reservationKey = StringUtils.trimToNull(reservationId); + if (StringUtils.isNotBlank(reservationKey) && "printer-import".equalsIgnoreCase(reservationKey)) { + reservationKey = null; + } + String ohipHotelId = resolveOhipHotelId(hotelId); + if (StringUtils.isAnyBlank(baseUrl, ohipHotelId, reservationKey)) { + log.warn( + "OHIP reservation detail skip: baseUrl={}, hotelId={}, resolvedHotelId={}, reservationId={}", + StringUtils.isNotBlank(baseUrl), + hotelId, + ohipHotelId, + reservationId); + return Optional.empty(); + } + if (!oauthTokenSingleton.isConfigured()) { + log.warn("OHIP reservation detail skip: OAuth not configured"); + return Optional.empty(); + } + String path = reservationDetailPathTemplate + .replace("{hotelId}", urlEncodePathSegment(ohipHotelId)) + .replace("{reservationId}", urlEncodePathSegment(reservationKey)); + String url = SimpleHttpClient.joinUrl(baseUrl, path); + try { + SimpleHttpClient.RequestBuilder request = SimpleHttpClient.get(url) + .timeoutMs(timeoutMs) + .queryParam("fetchInstructions", reservationLookupFetchInstructions); + applyBusinessAuth(request, ohipHotelId); + log.info( + "OHIP GET reservation detail request url={}, hotelId={}, reservationId={}, headers={}", + request.getRequestUrlWithQuery(), + ohipHotelId, + reservationKey, + SimpleHttpClient.maskHeadersForLog(request.getHeaders())); + long start = System.currentTimeMillis(); + HttpResponseResult response = request.execute(); + auditThirdParty( + EsignAuditLogService.EVT_OHIP_GET_RESERVATIONS, + request, + response, + start, + ohipHotelId, + "GET reservation detail reservationId=" + reservationId); + String body = response.getResponseContent(); + log.info( + "OHIP GET reservation detail response http={}, body={}", + response.getStatusCode(), + truncate(body)); + if (response.isTransportError()) { + log.error("OHIP GET reservation detail transport error: {}", response.errorOrEmpty()); + return Optional.empty(); + } + if (!response.isSuccessful()) { + return Optional.empty(); + } + Optional profile = ReservationDetailResponseParser.extractGuestProfile(body); + if (profile.isPresent()) { + log.info( + "OHIP GET reservation detail profileId={}, profileType={}, reservationId={}", + profile.get().profileId(), + profile.get().profileType(), + reservationKey); + } else { + log.warn( + "OHIP GET reservation detail HTTP 200 but no profile reservationId={}, body={}", + reservationId, + truncate(body)); + } + return profile; + } catch (Exception e) { + log.error("OHIP GET reservation detail failed reservationId={}", reservationId, e); + return Optional.empty(); + } + } + + /** + * GET /crm/v1/profiles/{profileId},返回 profileDetails 对象(用于签名字段预填)。 + */ + public Optional fetchGuestProfileDetails(String hotelId, String profileId) { + if (StringUtils.isAnyBlank(baseUrl, profileId)) { + return Optional.empty(); + } + if (!oauthTokenSingleton.isConfigured()) { + log.warn("OHIP GET profile skip: OAuth not configured"); + return Optional.empty(); + } + String ohipHotelId = StringUtils.isNotBlank(hotelId) ? resolveOhipHotelId(hotelId) : null; + String path = profileFieldsPathTemplate.replace( + "{profileId}", urlEncodePathSegment(profileId.trim())); + String url = SimpleHttpClient.joinUrl(baseUrl, path); + try { + SimpleHttpClient.RequestBuilder request = SimpleHttpClient.get(url).timeoutMs(timeoutMs); + if (StringUtils.isNotBlank(ohipHotelId)) { + applyBusinessAuth(request, ohipHotelId); + } + log.info( + "OHIP GET profile request url={}, profileId={}, headers={}", + url, + profileId, + SimpleHttpClient.maskHeadersForLog(request.getHeaders())); + long start = System.currentTimeMillis(); + HttpResponseResult response = request.execute(); + auditThirdParty( + EsignAuditLogService.EVT_OHIP_PUT_PROFILE, + request, + response, + start, + ohipHotelId, + "GET profile profileId=" + profileId); + String body = response.getResponseContent(); + log.info( + "OHIP GET profile response http={}, body={}", + response.getStatusCode(), + truncate(body)); + if (response.isTransportError()) { + log.error("OHIP GET profile transport error: {}", response.errorOrEmpty()); + return Optional.empty(); + } + if (!response.isSuccessful()) { + return Optional.empty(); + } + JSONObject details = ProfileDetailsJsonReader.unwrapProfileDetails(body); + if (details == null || details.isEmpty()) { + log.warn("OHIP GET profile empty profileId={}, body={}", profileId, truncate(body)); + return Optional.empty(); + } + return Optional.of(details); + } catch (Exception e) { + log.error("OHIP GET profile failed profileId={}", profileId, e); + return Optional.empty(); + } + } + + private SignedRegistrationCardUploadResult uploadFileAttachment( + String hotelId, + String reservationId, + String confirmationNumber, + String signedPdfBase64, + String preferredFileName) { + String url = SimpleHttpClient.joinUrl(baseUrl, fileAttachmentPath); + String fileName = buildSignedFileName(preferredFileName, confirmationNumber); + JSONObject body = new JSONObject(); + body.put("fileName", fileName); + body.put("linkId", reservationId); + body.put("overwriteExistingFileYN", fileAttachmentOverwrite); + body.put("description", fileAttachmentDescription); + body.put("linkType", fileAttachmentLinkType); + body.put("hotelId", hotelId); + body.put("userName", fileAttachmentUserName); + body.put("globalYN", fileAttachmentGlobalYn); + String attachmentBase64 = normalizePdfBase64(signedPdfBase64); + body.put("fileAttachment", attachmentBase64); + logAttachmentPreview(attachmentBase64); + + try { + SimpleHttpClient.RequestBuilder request = SimpleHttpClient.post(url) + .timeoutMs(timeoutMs) + .jsonBody(body.toJSONString()); + applyBusinessAuth(request, hotelId); + log.info( + "OHIP POST fileAttachments request url={}, hotelId={}, reservationId={}, confirmation={}, fileName={}, attachmentLen={}, headers={}", + url, + hotelId, + reservationId, + confirmationNumber, + fileName, + StringUtils.length(signedPdfBase64), + SimpleHttpClient.maskHeadersForLog(request.getHeaders())); + long start = System.currentTimeMillis(); + HttpResponseResult response = request.execute(); + auditThirdParty( + EsignAuditLogService.EVT_OHIP_POST_ATTACHMENT, + request, + response, + start, + hotelId, + "POST fileAttachments confirmation=" + confirmationNumber); + String summary = truncate(response.getResponseContent()); + log.info( + "OHIP POST fileAttachments response http={}, body={}", + response.getStatusCode(), + summary); + if (response.isTransportError()) { + return SignedRegistrationCardUploadResult.failure( + response.getStatusCode(), + summary, + StringUtils.defaultIfBlank(response.getErrorMessage(), "fileAttachments transport error"), + confirmationNumber, + reservationId, + fileName); + } + if (!response.isSuccessful()) { + return SignedRegistrationCardUploadResult.failure( + response.getStatusCode(), + summary, + "fileAttachments returned HTTP " + response.getStatusCode(), + confirmationNumber, + reservationId, + fileName); + } + return SignedRegistrationCardUploadResult.success( + response.getStatusCode(), + confirmationNumber, + reservationId, + fileName, + summary); + } catch (IllegalStateException e) { + log.error("FileAttachment upload failed", e); + return SignedRegistrationCardUploadResult.failure(-1, null, e.getMessage()); + } + } + + /** + * 将 CMS 映射后的文字字段通过 CRM PUT /profiles/{profileId} 更新旅客 Profile(需 profile-fields.enabled=true)。 + */ + public SignedRegistrationCardUploadResult submitMappedProfileFields( + String hotelId, String reservationId, Map fields) { + return submitMappedProfileFields(hotelId, reservationId, fields, null); + } + + public SignedRegistrationCardUploadResult submitMappedProfileFields( + String hotelId, String reservationId, Map fields, String profileId) { + return submitMappedProfileFields(hotelId, reservationId, fields, profileId, null); + } + + public SignedRegistrationCardUploadResult submitMappedProfileFields( + String hotelId, + String reservationId, + Map fields, + String profileId, + String profileType) { + if (fields == null || fields.isEmpty()) { + return SignedRegistrationCardUploadResult.skipped("No mapped profile fields"); + } + if (StringUtils.isBlank(baseUrl)) { + return SignedRegistrationCardUploadResult.skipped("Third party base-url not configured"); + } + if (!oauthTokenSingleton.isConfigured()) { + return SignedRegistrationCardUploadResult.skipped("OHIP OAuth is not configured"); + } + if (!profileFieldsEnabled) { + return SignedRegistrationCardUploadResult.skipped( + "Profile update mapped but disabled (profile-fields.enabled=false)"); + } + + String resolvedProfileId = StringUtils.trimToNull(profileId); + String resolvedProfileType = StringUtils.trimToNull(profileType); + String reservationKey = StringUtils.trimToNull(reservationId); + if (StringUtils.isNotBlank(reservationKey) && "printer-import".equalsIgnoreCase(reservationKey)) { + reservationKey = null; + } + String effectiveHotelId = resolveOhipHotelId(hotelId); + if (StringUtils.isBlank(resolvedProfileId) && StringUtils.isNotBlank(reservationKey)) { + Optional guest = fetchGuestProfile(effectiveHotelId, reservationKey); + if (guest.isPresent()) { + resolvedProfileId = guest.get().profileId(); + if (StringUtils.isBlank(resolvedProfileType)) { + resolvedProfileType = guest.get().profileType(); + } + } else { + log.warn( + "OHIP CRM profile lookup miss hotelId={}, resolvedHotelId={}, reservationId={}", + hotelId, + effectiveHotelId, + reservationKey); + } + } + if (StringUtils.isBlank(resolvedProfileId)) { + log.warn( + "OHIP CRM profile update skipped: profileId unresolved hotelId={}, reservationId={}", + effectiveHotelId, + reservationKey); + return SignedRegistrationCardUploadResult.skipped( + "profileId not resolved for CRM profile update"); + } + + String ohipHotelId = effectiveHotelId; + log.info( + "OHIP CRM profile update prepared reservationId={}, profileId={}, profileType={}, keys={}", + reservationId, + resolvedProfileId, + resolvedProfileType, + fields.keySet()); + + String path = profileFieldsPathTemplate.replace( + "{profileId}", urlEncodePathSegment(resolvedProfileId)); + String url = SimpleHttpClient.joinUrl(baseUrl, path); + JSONObject body = ProfileDetailsJsonBuilder.buildBody(fields, resolvedProfileType); + String bodyJson = body.toJSONString(); + + try { + SimpleHttpClient.RequestBuilder request = SimpleHttpClient.put(url) + .timeoutMs(timeoutMs) + .jsonBody(bodyJson); + applyBusinessAuth(request, ohipHotelId); + log.info( + "OHIP PUT profile request url={}, profileId={}, reservationId={}, fieldCount={}, body={}, headers={}", + url, + resolvedProfileId, + reservationId, + fields.size(), + truncate(bodyJson), + SimpleHttpClient.maskHeadersForLog(request.getHeaders())); + long start = System.currentTimeMillis(); + HttpResponseResult response = request.execute(); + auditThirdParty( + EsignAuditLogService.EVT_OHIP_PUT_PROFILE, + request, + response, + start, + ohipHotelId, + "PUT profile profileId=" + resolvedProfileId + " reservationId=" + reservationId); + String summary = truncate(response.getResponseContent()); + log.info("OHIP PUT profile response http={}, body={}", response.getStatusCode(), summary); + if (response.isTransportError()) { + return SignedRegistrationCardUploadResult.failure( + response.getStatusCode(), + summary, + StringUtils.defaultIfBlank(response.getErrorMessage(), "profile update transport error"), + null, + reservationId, + null); + } + if (!response.isSuccessful()) { + return SignedRegistrationCardUploadResult.failure( + response.getStatusCode(), + summary, + "profile update returned HTTP " + response.getStatusCode(), + null, + reservationId, + null); + } + return SignedRegistrationCardUploadResult.success( + response.getStatusCode(), null, reservationId, null, summary); + } catch (IllegalStateException e) { + log.error("Profile update failed", e); + return SignedRegistrationCardUploadResult.failure(-1, null, e.getMessage()); + } + } + + private static String buildSignedFileName(String preferredFileName, String confirmationNumber) { + String attachmentExt = ".pdf"; + if (StringUtils.isNotBlank(preferredFileName)) { + String trimmed = preferredFileName.trim(); + int dot = trimmed.lastIndexOf('.'); + if (dot > 0 && dot < trimmed.length() - 1) { + attachmentExt = trimmed.substring(dot).toLowerCase(); + } + } + // OPERA fileAttachments:.regcard 需 HTML 内容;我们上传的是签名 PDF,必须用 .pdf + if (".regcard".equals(attachmentExt)) { + attachmentExt = ".pdf"; + } + String ts = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd-HH-mm")); + return "e-SignedRegCard-" + confirmationNumber + "-" + ts + attachmentExt; + } + + private static void logAttachmentPreview(String attachmentBase64) { + if (StringUtils.isBlank(attachmentBase64)) { + log.warn("OHIP fileAttachments attachment base64 empty"); + return; + } + try { + byte[] bytes = Base64.getDecoder().decode(attachmentBase64); + String magic = bytes.length >= 4 + ? new String(bytes, 0, Math.min(16, bytes.length), java.nio.charset.StandardCharsets.ISO_8859_1) + : ""; + log.info( + "OHIP fileAttachments payload decodedBytes={}, magic={}", + bytes.length, + magic.replaceAll("[\\r\\n]", "")); + } catch (IllegalArgumentException e) { + log.warn("OHIP fileAttachments attachment base64 invalid: {}", e.getMessage()); + } + } + + private static String normalizePdfBase64(String raw) { + if (StringUtils.isBlank(raw)) { + return ""; + } + String trimmed = raw.trim(); + int comma = trimmed.indexOf(','); + if (trimmed.startsWith("data:") && comma > 0) { + return trimmed.substring(comma + 1).trim(); + } + return trimmed.replaceAll("\\s", ""); + } + + private RegistrationCardResult toRegistrationCardResult(HttpResponseResult httpResult) { + int statusCode = httpResult.getStatusCode(); + String body = httpResult.getResponseContent(); + String summary = truncate(body); + + if (httpResult.isTransportError()) { + String error = StringUtils.defaultIfBlank(httpResult.getErrorMessage(), "Third party request failed"); + log.error("RegistrationCard API failed: {}", error); + return RegistrationCardResult.failure(statusCode, summary, error); + } + + if (!httpResult.isSuccessful()) { + String error = "Third party returned HTTP " + statusCode; + log.warn("RegistrationCard API non-success status={}, body={}", statusCode, summary); + return RegistrationCardResult.failure(statusCode, summary, error); + } + + Optional pdf = + RegistrationCardResponseParser.extract(body); + if (pdf.isEmpty()) { + log.warn("RegistrationCard API success but PDF not found in response, status={}", statusCode); + return RegistrationCardResult.failure( + statusCode, + summary, + "PDF not found in registrationCard response"); + } + + RegistrationCardResponseParser.PdfPayload payload = pdf.get(); + log.info( + "RegistrationCard PDF extracted status={}, fileName={}, fileType={}, base64Len={}", + statusCode, + payload.fileName(), + payload.fileType(), + payload.pdfBase64() != null ? payload.pdfBase64().length() : 0); + return RegistrationCardResult.success( + statusCode, + payload.fileName(), + payload.fileType(), + payload.pdfBase64(), + summary); + } + + private String resolveOhipHotelId(String requestHotelId) { + String configured = oauthTokenSingleton.getHotelId(); + if (StringUtils.isNotBlank(configured)) { + return configured.trim(); + } + return StringUtils.defaultString(requestHotelId).trim(); + } + + private static String buildRegistrationCardPath(String hotelId, String reservationId) { + return "/med/config/v1/hotels/" + urlEncodePathSegment(hotelId) + + "/reservations/" + urlEncodePathSegment(reservationId) + + "/registrationCard"; + } + + private static String urlEncodePathSegment(String value) { + return StringUtils.defaultString(value).trim(); + } + + private void applyBusinessAuth(SimpleHttpClient.RequestBuilder request, String hotelId) { + String accessToken = oauthTokenSingleton.getAccessToken(); + Map headers = oauthTokenSingleton.buildBusinessHeaders(hotelId); + request.bearerToken(accessToken).headers(headers); + } + + private static String truncate(String value) { + if (value == null) { + return null; + } + if (value.length() <= RESPONSE_BODY_LOG_MAX) { + return value; + } + return value.substring(0, RESPONSE_BODY_LOG_MAX) + "...(truncated)"; + } + + private void auditThirdParty( + String eventType, + SimpleHttpClient.RequestBuilder request, + HttpResponseResult response, + long startMs, + String hotelId, + String message) { + if (auditLogService == null) { + return; + } + try { + Integer status = response != null ? response.getStatusCode() : null; + String detail = ThirdPartyAuditDetailBuilder.build(request, response); + auditLogService.appendThirdParty( + eventType, + request != null ? request.getMethod() : null, + request != null ? request.getRequestUrlWithQuery() : null, + status, + System.currentTimeMillis() - startMs, + hotelId, + message, + detail); + } catch (Exception e) { + log.debug("audit third party log failed", e); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/SignedRegistrationCardOhipService.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/SignedRegistrationCardOhipService.java new file mode 100644 index 0000000..efee2e3 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/services/SignedRegistrationCardOhipService.java @@ -0,0 +1,258 @@ +package com.sjmresorts.opsapp.esign.reservation.services; + +import com.sjmresorts.opsapp.esign.reservation.dtos.SignedRegistrationCardUploadResult; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPdfTask; +import com.sjmresorts.opsapp.esign.printer.support.EsignPrinterPdfFileStorage; +import com.sjmresorts.opsapp.esign.printer.dtos.RegistrationCardSignFieldParser; +import com.sjmresorts.opsapp.esign.printer.dtos.SignedRegistrationPdfComposer; +import com.sjmresorts.opsapp.esign.reservation.services.EsignSignFieldOhipMappingService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SignedRegistrationCardOhipService { + + private final ReservationThirdPartyClient thirdPartyClient; + private final RegistrationConfirmationExtractService confirmationExtractService; + private final EsignSignFieldOhipMappingService signFieldOhipMappingService; + private final EsignPrinterPdfFileStorage pdfFileStorage; + + public SignedRegistrationCardUploadResult uploadAfterSign( + EsignPrinterPdfTask task, + String signedPdfBase64FromRequest, + String signaturesJson, + String confirmationNumberFromRequest, + String signFieldsJson) { + return uploadAfterSign( + task, + signedPdfBase64FromRequest, + signaturesJson, + confirmationNumberFromRequest, + signFieldsJson, + null, + null); + } + + public SignedRegistrationCardUploadResult uploadAfterSign( + EsignPrinterPdfTask task, + String signedPdfBase64FromRequest, + String signaturesJson, + String confirmationNumberFromRequest, + String signFieldsJson, + String profileId) { + return uploadAfterSign( + task, + signedPdfBase64FromRequest, + signaturesJson, + confirmationNumberFromRequest, + signFieldsJson, + profileId, + null); + } + + public SignedRegistrationCardUploadResult uploadAfterSign( + EsignPrinterPdfTask task, + String signedPdfBase64FromRequest, + String signaturesJson, + String confirmationNumberFromRequest, + String signFieldsJson, + String profileId, + String profileType) { + String taskId = task != null ? task.getTaskId() : null; + String originalPdf = task != null ? pdfFileStorage.materializeOriginalBase64(task) : null; + log.info( + "OHIP uploadAfterSign start taskId={}, hasOriginalPdf={}, hasSignedFromRequest={}, signaturesJsonLen={}, confirmationFromRequest={}", + taskId, + task != null && pdfFileStorage.hasOriginal(task), + StringUtils.isNotBlank(signedPdfBase64FromRequest), + StringUtils.length(signaturesJson), + StringUtils.isNotBlank(confirmationNumberFromRequest)); + if ((task == null || !pdfFileStorage.hasOriginal(task)) && StringUtils.isBlank(signedPdfBase64FromRequest)) { + log.warn("OHIP uploadAfterSign skip taskId={}: no PDF", taskId); + return SignedRegistrationCardUploadResult.skipped("No PDF available for OHIP upload"); + } + + String signedPdfBase64 = StringUtils.isNotBlank(signedPdfBase64FromRequest) + ? signedPdfBase64FromRequest + : composeSignedPdf(originalPdf, signaturesJson, signFieldsJson); + log.info("OHIP uploadAfterSign composed taskId={}, signedPdfLen={}", taskId, StringUtils.length(signedPdfBase64)); + + String confirmationNumber = StringUtils.isNotBlank(confirmationNumberFromRequest) + ? confirmationNumberFromRequest.trim() + : confirmationExtractService.extractFromPdfBase64(signedPdfBase64) + .or(() -> confirmationExtractService.extractFromPdfBase64(originalPdf)) + .orElse(null); + if (StringUtils.isBlank(confirmationNumber)) { + log.warn("OHIP uploadAfterSign fail taskId={}: confirmation number not found in PDF", taskId); + return SignedRegistrationCardUploadResult.failure( + 400, null, confirmationExtractService.buildUserFacingFailureMessage()); + } + + String hotelId = task != null ? task.getHotelId() : null; + String fileName = task != null ? task.getFileName() : null; + log.info( + "OHIP uploadAfterSign lookup OPERA confirmation={}, hotelId={}, taskId={}, fileName={}", + confirmationNumber, + hotelId, + taskId, + fileName); + + return submitWithMappedFields( + thirdPartyClient.uploadSignedRegistrationCard( + hotelId, + confirmationNumber, + signedPdfBase64, + fileName), + hotelId, + signaturesJson, + signFieldsJson, + profileId, + profileType); + } + + /** + * 登记卡通知页:已有 reservationId,合成 PDF 后直接上传 OPERA。 + */ + public SignedRegistrationCardUploadResult uploadForReservation( + String reservationId, + String hotelId, + String originalPdfBase64, + String signedPdfBase64FromRequest, + String signaturesJson, + String fileName, + String confirmationNumberFromRequest, + String signFieldsJson, + String profileId, + String profileType) { + if (StringUtils.isBlank(reservationId)) { + return SignedRegistrationCardUploadResult.failure(400, null, "reservationId required"); + } + String originalPdf = originalPdfBase64; + if (StringUtils.isBlank(originalPdf) && StringUtils.isBlank(signedPdfBase64FromRequest)) { + return SignedRegistrationCardUploadResult.failure( + 400, null, "pdfBase64 or signed base64 required"); + } + + String signedPdfBase64 = StringUtils.isNotBlank(signedPdfBase64FromRequest) + ? signedPdfBase64FromRequest + : composeSignedPdf(originalPdf, signaturesJson, signFieldsJson); + + String confirmationNumber = StringUtils.isNotBlank(confirmationNumberFromRequest) + ? confirmationNumberFromRequest.trim() + : confirmationExtractService.extractFromPdfBase64(signedPdfBase64) + .or(() -> confirmationExtractService.extractFromPdfBase64(originalPdf)) + .orElse(reservationId.trim()); + + log.info( + "OHIP signed upload (reservation flow) reservationId={}, hotelId={}, confirmation={}, profileId={}, profileType={}", + reservationId, + hotelId, + confirmationNumber, + profileId, + profileType); + + return submitWithMappedFields( + thirdPartyClient.uploadSignedRegistrationCardByReservationId( + hotelId, + reservationId.trim(), + confirmationNumber, + signedPdfBase64, + fileName), + hotelId, + signaturesJson, + signFieldsJson, + profileId, + profileType); + } + + private SignedRegistrationCardUploadResult submitWithMappedFields( + SignedRegistrationCardUploadResult pdfResult, + String hotelId, + String signaturesJson, + String signFieldsJson) { + return submitWithMappedFields(pdfResult, hotelId, signaturesJson, signFieldsJson, null, null); + } + + private SignedRegistrationCardUploadResult submitWithMappedFields( + SignedRegistrationCardUploadResult pdfResult, + String hotelId, + String signaturesJson, + String signFieldsJson, + String profileId) { + return submitWithMappedFields(pdfResult, hotelId, signaturesJson, signFieldsJson, profileId, null); + } + + private SignedRegistrationCardUploadResult submitWithMappedFields( + SignedRegistrationCardUploadResult pdfResult, + String hotelId, + String signaturesJson, + String signFieldsJson, + String profileId, + String profileType) { + if (pdfResult == null || !pdfResult.isSuccess() || StringUtils.isBlank(pdfResult.getReservationId())) { + return pdfResult; + } + Map mapped = + signFieldOhipMappingService.mapSignatureValues(hotelId, signaturesJson, signFieldsJson); + if (mapped.isEmpty()) { + log.warn( + "OHIP profile update skipped: no mapped text fields hotelId={}, reservationId={}, profileId={}, signFieldsJsonLen={}", + hotelId, + pdfResult.getReservationId(), + profileId, + StringUtils.length(signFieldsJson)); + return pdfResult; + } + log.info( + "OHIP CRM profile update reservationId={}, profileId={}, profileType={}, count={}, keys={}", + pdfResult.getReservationId(), + StringUtils.firstNonBlank(profileId, pdfResult.getProfileId()), + StringUtils.firstNonBlank(profileType, pdfResult.getProfileType()), + mapped.size(), + mapped.keySet()); + SignedRegistrationCardUploadResult profileResult = thirdPartyClient.submitMappedProfileFields( + hotelId, + pdfResult.getReservationId(), + mapped, + StringUtils.firstNonBlank(profileId, pdfResult.getProfileId()), + StringUtils.firstNonBlank(profileType, pdfResult.getProfileType())); + if (!profileResult.isInvoked()) { + log.warn( + "OHIP profile update not invoked reservationId={}, reason={}", + pdfResult.getReservationId(), + profileResult.getErrorMessage()); + return pdfResult; + } + if (!profileResult.isSuccess()) { + log.error( + "OHIP profile update failed reservationId={}, statusCode={}, error={}", + pdfResult.getReservationId(), + profileResult.getStatusCode(), + profileResult.getErrorMessage()); + // PDF 已上传 OPERA,不因 Profile 字段更新失败把整体签收标为失败 + return pdfResult; + } + return pdfResult; + } + + private String composeSignedPdf(String originalPdfBase64, String signaturesJson, String signFieldsJson) { + if (StringUtils.isBlank(signaturesJson)) { + return originalPdfBase64; + } + try { + return SignedRegistrationPdfComposer.composeBase64( + originalPdfBase64, + signaturesJson, + RegistrationCardSignFieldParser.parse(signFieldsJson)); + } catch (Exception e) { + log.warn("Compose signed PDF failed, fallback to original PDF", e); + return originalPdfBase64; + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/HospitalityOAuthTokenConfigurer.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/HospitalityOAuthTokenConfigurer.java new file mode 100644 index 0000000..1a5226d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/HospitalityOAuthTokenConfigurer.java @@ -0,0 +1,91 @@ +package com.sjmresorts.opsapp.esign.reservation.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sjmresorts.opsapp.esign.reservation.dtos.HospitalityOAuthSettings; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class HospitalityOAuthTokenConfigurer { + + private final ObjectMapper objectMapper; + + @Value("${ops-app.esign.reservation.third-party.base-url:}") + private String baseUrl; + + @Value("${ops-app.esign.reservation.third-party.oauth.token-path:/oauth/v1/tokens}") + private String tokenPath; + + /** Postman Basic Auth 用户名,对应 CLIENT_ID */ + @Value("${ops-app.esign.reservation.third-party.oauth.client-id:}") + private String clientId; + + /** Postman Basic Auth 密码,对应 CLIENT_SECRET */ + @Value("${ops-app.esign.reservation.third-party.oauth.client-secret:}") + private String clientSecret; + + @Value("${ops-app.esign.reservation.third-party.oauth.app-key:}") + private String appKey; + + @Value("${ops-app.esign.reservation.third-party.oauth.enterprise-id:}") + private String enterpriseId; + + @Value("${ops-app.esign.reservation.third-party.oauth.hotel-id:}") + private String hotelId; + + @Value("${ops-app.esign.reservation.third-party.oauth.scope:}") + private String scope; + + @Value("${ops-app.esign.reservation.third-party.oauth.grant-type:client_credentials}") + private String grantType; + + @Value("${ops-app.esign.reservation.third-party.oauth.timeout-ms:30000}") + private int timeoutMs; + + @Value("${ops-app.esign.reservation.third-party.oauth.enabled:true}") + private boolean oauthEnabled; + + @PostConstruct + public void configureSingleton() { + HospitalityOAuthSettings settings = HospitalityOAuthSettings.builder() + .enabled(oauthEnabled) + .baseUrl(baseUrl) + .tokenPath(tokenPath) + .clientId(clientId) + .clientSecret(clientSecret) + .appKey(appKey) + .enterpriseId(enterpriseId) + .hotelId(hotelId) + .scope(scope) + .grantType(grantType) + .timeoutMs(timeoutMs) + .build(); + HospitalityOAuthTokenSingleton.getInstance().configure(settings, objectMapper); + log.info( + "OHIP OAuth singleton loaded: baseUrl={}, clientId={}, enterpriseId={}, hotelId={}, appKeyPrefix={}, scopeConfigured={}", + baseUrl, + clientId, + enterpriseId, + hotelId, + maskPrefix(appKey), + StringUtils.isNotBlank(scope)); + if (!settings.isComplete()) { + log.warn( + "OHIP OAuth 未完整配置:签名 PDF 归档后不会请求 OPERA(无 THIRD_PARTY 审计日志)。" + + " 请检查 ops-app.esign.reservation.third-party.oauth.* 与 base-url"); + } + } + + private static String maskPrefix(String value) { + if (StringUtils.isBlank(value)) { + return "(empty)"; + } + return value.length() <= 8 ? value : value.substring(0, 8) + "..."; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/HospitalityOAuthTokenSingleton.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/HospitalityOAuthTokenSingleton.java new file mode 100644 index 0000000..a54f059 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/HospitalityOAuthTokenSingleton.java @@ -0,0 +1,240 @@ +package com.sjmresorts.opsapp.esign.reservation.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sjmresorts.opsapp.common.dtos.httpclient.HttpResponseResult; +import com.sjmresorts.opsapp.common.utils.SimpleHttpClient; +import com.sjmresorts.opsapp.esign.reservation.dtos.HospitalityOAuthSettings; +import com.sjmresorts.opsapp.esign.reservation.dtos.OAuthTokenResponse; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.UUID; + +/** + * OHIP OAuth Token 单例:全局仅维护一份 access token,过期前自动刷新。 + */ +public final class HospitalityOAuthTokenSingleton { + + private static final Logger log = LoggerFactory.getLogger(HospitalityOAuthTokenSingleton.class); + private static final long DEFAULT_EXPIRES_SECONDS = 3600; + private static final long REFRESH_BUFFER_SECONDS = 120; + + private static volatile HospitalityOAuthTokenSingleton instance; + private static final Object INSTANCE_LOCK = new Object(); + + private final Object refreshLock = new Object(); + + private volatile HospitalityOAuthSettings settings; + private volatile ObjectMapper objectMapper; + private volatile CachedToken cachedToken; + + private HospitalityOAuthTokenSingleton() { + } + + public static HospitalityOAuthTokenSingleton getInstance() { + if (instance == null) { + synchronized (INSTANCE_LOCK) { + if (instance == null) { + instance = new HospitalityOAuthTokenSingleton(); + } + } + } + return instance; + } + + public void configure(HospitalityOAuthSettings newSettings, ObjectMapper mapper) { + synchronized (INSTANCE_LOCK) { + this.settings = newSettings; + this.objectMapper = mapper; + this.cachedToken = null; + } + log.info("Hospitality OAuth token singleton configured, enabled={}", + newSettings != null && newSettings.isEnabled()); + } + + public boolean isConfigured() { + HospitalityOAuthSettings current = settings; + return current != null && current.isComplete(); + } + + public String getHotelId() { + HospitalityOAuthSettings current = settings; + return current != null ? current.getHotelId() : null; + } + + public String getAppKey() { + HospitalityOAuthSettings current = settings; + return current != null ? current.getAppKey() : null; + } + + public String getAccessToken() { + if (!isConfigured()) { + throw new IllegalStateException("OHIP OAuth is not configured"); + } + + CachedToken current = cachedToken; + if (current != null && current.isValid()) { + return current.accessToken; + } + + synchronized (refreshLock) { + current = cachedToken; + if (current != null && current.isValid()) { + return current.accessToken; + } + return refreshAccessToken(); + } + } + + /** 业务 API Header(如 registrationCard):x-hotelid + x-app-key */ + public Map buildBusinessHeaders(String hotelId) { + HospitalityOAuthSettings current = settings; + if (current == null) { + throw new IllegalStateException("OHIP OAuth is not configured"); + } + Map headers = new LinkedHashMap<>(); + headers.put("x-hotelid", StringUtils.defaultIfBlank(hotelId, current.getHotelId())); + headers.put("x-app-key", current.getAppKey()); + headers.put("Accept", "application/json"); + headers.put("X-Request-Id", UUID.randomUUID().toString()); + return headers; + } + + public Map buildCommonHeaders() { + HospitalityOAuthSettings current = settings; + if (current == null) { + throw new IllegalStateException("OHIP OAuth is not configured"); + } + Map headers = new LinkedHashMap<>(); + headers.put("x-app-key", current.getAppKey()); + headers.put("enterpriseId", current.getEnterpriseId()); + headers.put("X-Request-Id", UUID.randomUUID().toString()); + return headers; + } + + /** 仅用于测试或强制续期。 */ + public void invalidate() { + cachedToken = null; + } + + private static Map buildOAuthTokenHeaders(HospitalityOAuthSettings current, String requestId) { + // 与 Postman /oauth/v1/tokens 一致:仅 x-app-key、enterpriseId、X-Request-Id(不含 hotelId) + Map headers = new LinkedHashMap<>(); + headers.put("x-app-key", current.getAppKey()); + headers.put("enterpriseId", current.getEnterpriseId()); + headers.put("X-Request-Id", requestId); + return headers; + } + + private String refreshAccessToken() { + HospitalityOAuthSettings current = settings; + ObjectMapper mapper = objectMapper; + if (current == null || mapper == null) { + throw new IllegalStateException("OHIP OAuth is not configured"); + } + + String url = SimpleHttpClient.joinUrl(current.getBaseUrl(), current.getTokenPath()); + String requestId = UUID.randomUUID().toString(); + Map form = new LinkedHashMap<>(); + form.put("grant_type", StringUtils.defaultIfBlank(current.getGrantType(), "client_credentials")); + if (StringUtils.isNotBlank(current.getScope())) { + form.put("scope", current.getScope().trim()); + } + + SimpleHttpClient.RequestBuilder requestBuilder = SimpleHttpClient.post(url) + .basicAuth(current.getClientId(), current.getClientSecret()) + .headers(buildOAuthTokenHeaders(current, requestId)) + .formBody(form) + .timeoutMs(current.getTimeoutMs()); + + String basicAuthBase64 = SimpleHttpClient.buildBasicAuthBase64( + current.getClientId(), current.getClientSecret()); + + log.info( + "OHIP OAuth token request: method={}, url={}, headers={}, basicAuth={{clientId={}, clientSecret={}}}, body={}", + requestBuilder.getMethod(), + requestBuilder.getRequestUrlWithQuery(), + SimpleHttpClient.maskHeadersForLog(requestBuilder.getHeaders()), + current.getClientId(), + maskSecret(current.getClientSecret()), + form); + log.info( + "OHIP OAuth Authorization compare with Postman: Authorization: Basic {}", + basicAuthBase64); + + HttpResponseResult response = requestBuilder.execute(); + + if (response.isTransportError()) { + log.error("OHIP OAuth token request failed: error={}", response.errorOrEmpty()); + throw new IllegalStateException("OAuth token request failed: " + response.errorOrEmpty()); + } + if (!response.isSuccessful()) { + log.warn("OHIP OAuth token request rejected: status={}, body={}", + response.getStatusCode(), truncate(response.bodyOrEmpty())); + throw new IllegalStateException( + "OAuth token request returned HTTP " + response.getStatusCode() + ": " + response.bodyOrEmpty()); + } + + try { + OAuthTokenResponse tokenResponse = mapper.readValue(response.bodyOrEmpty(), OAuthTokenResponse.class); + if (tokenResponse == null || StringUtils.isBlank(tokenResponse.getAccessToken())) { + throw new IllegalStateException("OAuth token response missing access_token"); + } + + long expiresIn = tokenResponse.getExpiresIn() != null && tokenResponse.getExpiresIn() > 0 + ? tokenResponse.getExpiresIn() + : DEFAULT_EXPIRES_SECONDS; + long effectiveSeconds = Math.max(60, expiresIn - REFRESH_BUFFER_SECONDS); + cachedToken = new CachedToken( + tokenResponse.getAccessToken(), + Instant.now().plusSeconds(effectiveSeconds)); + + log.info("OHIP OAuth token obtained, expiresIn={}s, accessTokenPrefix={}", + expiresIn, maskSecret(tokenResponse.getAccessToken())); + return cachedToken.accessToken; + } catch (IllegalStateException e) { + throw e; + } catch (Exception e) { + throw new IllegalStateException("Failed to parse OAuth token response: " + e.getMessage(), e); + } + } + + private static final class CachedToken { + private final String accessToken; + private final Instant expiresAt; + + private CachedToken(String accessToken, Instant expiresAt) { + this.accessToken = accessToken; + this.expiresAt = expiresAt; + } + + private boolean isValid() { + return Instant.now().isBefore(expiresAt); + } + } + + private static String maskSecret(String value) { + if (StringUtils.isBlank(value)) { + return "(empty)"; + } + if (value.length() <= 8) { + return "****"; + } + return value.substring(0, 4) + "****" + value.substring(value.length() - 4); + } + + private static String truncate(String value) { + if (value == null) { + return null; + } + int max = 500; + if (value.length() <= max) { + return value; + } + return value.substring(0, max) + "...(truncated)"; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/OperaFlexLinkPageBuilder.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/OperaFlexLinkPageBuilder.java new file mode 100644 index 0000000..e5231e5 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/OperaFlexLinkPageBuilder.java @@ -0,0 +1,797 @@ +package com.sjmresorts.opsapp.esign.reservation.support; + +import org.apache.commons.lang3.StringUtils; + +/** + * Opera Flex Link 页面。 + * Opera iframe(内层):只 postMessage 发信号,不操作外层 DOM。 + * Opera 主页面(外层代理):注入 opera-parent-close-listener.js 负责销毁 Pop-up 外壳。 + * 独立浏览器标签:myapp:// + window.close()。 + */ +public final class OperaFlexLinkPageBuilder { + + private OperaFlexLinkPageBuilder() { + } + + public static String buildLaunchPage( + String deepLinkUrl, + String launchMode, + boolean useBrowserDeepLink, + String serverLaunchMessage, + boolean closePopupEnabled, + boolean closeModalMessage, + String configuredParentOrigin, + String flexLinkOrigin, + long fallbackExtAppId, + int openDelayMs, + int closeDelayMs, + boolean debugPage, + String popupWidth, + String popupHeight) { + String link = escapeJsString(deepLinkUrl); + String launchMsg = escapeJsString(StringUtils.defaultString(serverLaunchMessage)); + String mode = normalizeLaunchMode(launchMode); + int openWaitMs = useBrowserDeepLink ? Math.max(600, openDelayMs) : Math.max(150, openDelayMs); + int closeRetryMs = Math.max(80, closeDelayMs); + String closeScript = buildCloseScript( + mode, closePopupEnabled, closeModalMessage, configuredParentOrigin, flexLinkOrigin, + fallbackExtAppId, openWaitMs, closeRetryMs, debugPage, popupWidth, popupHeight); + boolean newWindow = "new-window".equals(mode); + + if (newWindow) { + return """ + + + + + + 登记程序 + + + +
%s
+ + + + %s + + + + """.formatted( + useBrowserDeepLink + ? "正在唤起登记程序…
若浏览器询问是否打开应用,请先点击「打开」。登记程序启动后本标签将自动关闭。" + : escapeHtml("登记程序已由本机服务启动。" + (StringUtils.isNotBlank(serverLaunchMessage) ? " " + serverLaunchMessage : "") + " 本页将自动关闭。"), + useBrowserDeepLink ? "再次打开登记程序" : "再次启动", + debugPanelHtml(debugPage), + closeScript, + link, + useBrowserDeepLink, + launchMsg, + openWaitMs, + closeRetryMs); + } + + String hintHtml = debugPage + ? (useBrowserDeepLink ? "正在唤起登记程序并关闭 Pop-up…" : escapeHtml("登记程序已由本机服务启动。")) + : ""; + String bodyClass = debugPage ? "" : " stealth"; + String extraStyles = debugPage ? "" : """ + html,body{margin:0!important;padding:0!important;background:transparent!important;overflow:hidden!important} + body.stealth .warn,body.stealth .ok,body.stealth .btn,body.stealth .btn2{display:none!important} + """; + String manualButtonsHtml = debugPage + ? """ + + + """ + : ""; + + return """ + + + + + + 登记程序 + + + + %s +
%s
+
+ %s + %s + + + + """.formatted( + extraStyles, + bodyClass.trim(), + debugPage ? "" : "", + hintHtml, + manualButtonsHtml, + debugPanelHtml(debugPage), + closeScript, + link, + useBrowserDeepLink, + openWaitMs, + closeRetryMs, + debugPage); + } + + public static String buildNotifyPage( + boolean success, + String message, + String reservationId, + String launchMode, + boolean closePopupEnabled, + boolean closeModalMessage, + String configuredParentOrigin, + String flexLinkOrigin, + long fallbackExtAppId, + int openDelayMs, + int closeDelayMs, + boolean debugPage, + String popupWidth, + String popupHeight) { + String statusText = success ? "已通知前台" : "通知失败"; + String detail = escapeHtml(StringUtils.defaultIfBlank(message, statusText)); + String rid = escapeHtml(StringUtils.defaultIfBlank(reservationId, "—")); + String mode = normalizeLaunchMode(launchMode); + int openWaitMs = Math.max(600, openDelayMs); + int closeRetryMs = Math.max(80, closeDelayMs); + String closeScript = buildCloseScript( + mode, closePopupEnabled, closeModalMessage, configuredParentOrigin, flexLinkOrigin, + fallbackExtAppId, openWaitMs, closeRetryMs, debugPage, popupWidth, popupHeight); + String boxClass = success ? "ok" : "err"; + int notifyCloseDelay = "new-window".equals(mode) ? openWaitMs : Math.max(50, closeRetryMs); + String initScript = "new-window".equals(mode) + ? "setTimeout(function () { var r = closeLaunchWindow(); renderLaunchDebug(r); }, " + notifyCloseDelay + ");" + : "initOperaPopupSession(); setTimeout(function () { var r = closeLaunchWindow(); renderLaunchDebug(r); }, " + + notifyCloseDelay + ");"; + + return """ + + + + + + %s + + + +
+ %s
预订号:%s
%s +
+ %s + + + + """.formatted( + statusText, + boxClass, + statusText, + rid, + detail, + debugPanelHtml(debugPage), + closeScript, + initScript); + } + + private static String normalizeLaunchMode(String launchMode) { + return "popup".equalsIgnoreCase(StringUtils.trimToEmpty(launchMode)) ? "popup" : "new-window"; + } + + private static String debugPanelHtml(boolean debugPage) { + if (!debugPage) { + return ""; + } + return "
调试信息
"; + } + + /** + * 内层脚本:Opera iframe 内只 emit 信号;独立标签页才 window.close()。 + * 外层脚本见 {@link #buildOperaParentCloseListenerScript(String)}。 + */ + private static String buildCloseScript( + String launchMode, + boolean closeEnabled, + boolean closeModalMessage, + String configuredParentOrigin, + String flexLinkOrigin, + long fallbackExtAppId, + int openDelayMs, + int closeRetryMs, + boolean debugPage, + String popupWidth, + String popupHeight) { + String origin = escapeJsString(StringUtils.defaultString(configuredParentOrigin)); + String flexOrigin = escapeJsString(StringUtils.defaultIfBlank(flexLinkOrigin, "http://127.0.0.1:8066")); + String proxyInstallUrl = escapeJsString(StringUtils.defaultIfBlank(flexLinkOrigin, "http://127.0.0.1:8066") + + "/api/v1/esign/app/opera-parent-close-listener.user.js"); + String mode = escapeJsString(normalizeLaunchMode(launchMode)); + int popupCloseDelayMs = Math.max(50, closeRetryMs); + return """ + (function () { + var launchMode = "{{LAUNCH_MODE}}"; + var closeEnabled = {{CLOSE_ENABLED}}; + var closeModalMessage = {{CLOSE_MODAL_MESSAGE}}; + var operaOrigin = "{{OPERA_ORIGIN}}"; + var flexLinkOrigin = "{{FLEX_LINK_ORIGIN}}"; + var proxyInstallUrl = "{{PROXY_INSTALL_URL}}"; + var fallbackExtAppId = {{FALLBACK_EXT_APP_ID}}; + var openDelayMs = {{OPEN_DELAY_MS}}; + var closeDelayMs = {{CLOSE_DELAY_MS}}; + var debugPage = {{DEBUG_PAGE}}; + var lastDetail = ""; + var shellProxyAck = false; + + function isOperaPopupIframe() { + try { + if (window.self !== window.top) return true; + } catch (e) { + return true; + } + return false; + } + + function resolveExtAppId() { + try { + var q = new URLSearchParams(location.search).get("ocextappid"); + if (q) return parseInt(q, 10); + } catch (e) {} + return fallbackExtAppId > 0 ? fallbackExtAppId : null; + } + + function resolveOperaOrigin() { + if (operaOrigin) return operaOrigin; + try { + if (document.referrer) return new URL(document.referrer).origin; + } catch (e) {} + return null; + } + + function postToWindow(win, payload, targetOrigin, label) { + try { + win.postMessage(payload, targetOrigin); + return label + "@" + targetOrigin; + } catch (e1) { + try { + win.postMessage(payload, "*"); + return label + "@*"; + } catch (e2) { + return label + ":fail"; + } + } + } + + /** 向内层→外层:纯 postMessage,不触碰 parent DOM */ + function emitShellSignal(action, extra) { + if (!closeModalMessage && (action === "CLOSE_SHELL" || action === "closeModal")) { + return action + ":disabled"; + } + var targetOrigin = resolveOperaOrigin(); + if (!targetOrigin) return action + ":skip no origin"; + var payload = { action: action, source: "auto-check-in-launch", ts: Date.now() }; + if (extra) { + for (var k in extra) { + if (Object.prototype.hasOwnProperty.call(extra, k)) payload[k] = extra[k]; + } + } + var sent = []; + var wins = []; + try { if (window.parent && window.parent !== window) wins.push(["parent", window.parent]); } catch (e) {} + try { if (window.top && window.top !== window) wins.push(["top", window.top]); } catch (e2) {} + for (var i = 0; i < wins.length; i++) { + var r = postToWindow(wins[i][1], payload, targetOrigin, wins[i][0]); + if (r.indexOf(":fail") < 0) sent.push(r); + } + return sent.length ? (action + "->" + sent.join(",")) : (action + ":skip"); + } + + function emitCloseShellBurst() { + var delays = [0, 40, 90, 180]; + delays.forEach(function (ms) { + setTimeout(function () { + emitShellSignal("CLOSE_SHELL"); + emitShellSignal("closeModal"); + }, ms); + }); + return "CLOSE_SHELL burst x" + delays.length; + } + + function emitDoReturnSignal() { + var appId = resolveExtAppId(); + var targetOrigin = resolveOperaOrigin(); + if (!appId || !targetOrigin) return "DO_RETURN:skip"; + var payload = { ocextappid: appId, eventId: "DO_RETURN_EVENT" }; + var sent = false; + var wins = []; + try { if (window.parent && window.parent !== window) wins.push(window.parent); } catch (e) {} + try { if (window.top && window.top !== window) wins.push(window.top); } catch (e2) {} + for (var i = 0; i < wins.length; i++) { + var r = postToWindow(wins[i], payload, targetOrigin, "doReturn"); + if (r.indexOf(":fail") < 0) sent = true; + } + return sent ? "DO_RETURN sent" : "DO_RETURN:skip"; + } + + function emitOperaSetupSignal(eventId, args) { + var appId = resolveExtAppId(); + var targetOrigin = resolveOperaOrigin(); + if (!appId || !targetOrigin) return null; + var payload = { ocextappid: appId, eventId: eventId }; + if (args) payload.args = args; + try { + return postToWindow(window.top, payload, targetOrigin, eventId); + } catch (e) { + return eventId + " error"; + } + } + + function showProxyMissingHelp() { + if (shellProxyAck || !isOperaPopupIframe()) return; + var el = document.getElementById("proxyHelp"); + if (!el) { + el = document.createElement("div"); + el.id = "proxyHelp"; + el.style.cssText = "margin:12px 0;padding:14px;background:#ffebee;border:2px solid #e57373;border-radius:8px;text-align:left;font-size:13px;color:#b71c1c;line-height:1.6"; + var anchor = document.getElementById("hintBox") || document.body.firstChild; + if (anchor && anchor.parentNode) anchor.parentNode.insertBefore(el, anchor.nextSibling); + else document.body.insertBefore(el, document.body.firstChild); + } + el.innerHTML = + "Pop-up 未关闭:Opera 主页面缺少外层代理

" + + "内层已发 CLOSE_SHELL,但未收到 SHELL_CLOSE_ACK。

" + + "一次性安装(推荐 Tampermonkey):
" + + "1. 安装 Tampermonkey 扩展
" + + "2. 打开 " + proxyInstallUrl + " 并「安装此脚本」
" + + "3. 刷新 Opera 主页面后再点 Flex Link

" + + "或手动注入: 在 Opera 主页面(非本 Pop-up)按 F12 → Console,执行:
" + + "" + + "fetch(\\"" + flexLinkOrigin + "/api/v1/esign/app/opera-parent-close-listener.js\\").then(r=>r.text()).then(eval)" + + ""; + } + + window.initOperaPopupSession = function () { + var h = emitOperaSetupSignal("SET_HEADER_EVENT", { title: "登记程序" }); + lastDetail = "init: " + (h || "skip"); + renderLaunchDebug("内层初始化:SET_HEADER(关窗由延迟 CLOSE_SHELL 触发)"); + return lastDetail; + }; + + function closeStandaloneBrowserTab() { + if (!closeEnabled) return "tab-close:disabled"; + try { + window.close(); + return "tab-close:window.close()"; + } catch (e) { + return "tab-close:error"; + } + } + + /** Opera iframe:只发信号,由外层代理销毁外壳 */ + function requestShellCloseViaProxy() { + if (!closeEnabled) return "signal:disabled"; + shellProxyAck = false; + var steps = []; + steps.push(emitCloseShellBurst()); + steps.push(emitDoReturnSignal()); + lastDetail = steps.join(" | "); + renderLaunchDebug("内层已发 CLOSE_SHELL + DO_RETURN,等待外层代理…"); + setTimeout(function () { + if (!shellProxyAck) { + steps.push(emitShellSignal("CLOSE_SHELL")); + steps.push(emitShellSignal("closeModal")); + steps.push(emitDoReturnSignal()); + lastDetail = steps.join(" | "); + renderLaunchDebug("内层第 2 轮信号已发"); + } + }, Math.max(80, closeDelayMs)); + setTimeout(showProxyMissingHelp, 2500); + return lastDetail; + } + + window.closeLaunchWindow = function () { + if (isOperaPopupIframe()) { + return requestShellCloseViaProxy(); + } + return emitShellSignal("CLOSE_SHELL") + " | " + closeStandaloneBrowserTab(); + }; + + window.addEventListener("message", function (event) { + if (!event.data) return; + if (event.data.action === "SHELL_CLOSE_ACK") { + shellProxyAck = true; + var help = document.getElementById("proxyHelp"); + if (help) help.remove(); + lastDetail = (lastDetail || "") + " | outer ACK ok=" + event.data.ok + + (event.data.frame ? (" frame=" + event.data.frame) : ""); + renderLaunchDebug("外层代理回执 ✓"); + } + }); + + window.renderLaunchDebug = function (note) { + if (!debugPage) return; + var box = document.getElementById("debugBox"); + if (!box) return; + var inIframe = isOperaPopupIframe(); + var appId = resolveExtAppId(); + var targetOrigin = resolveOperaOrigin(); + box.textContent = + (note ? ("[" + note + "]\\n\\n") : "") + + "mode: " + (inIframe ? "iframe-proxy (signal only)" : "standalone-tab") + "\\n" + + "launchMode config: " + launchMode + "\\n" + + "URL: " + location.href + "\\n" + + "shellProxyAck: " + shellProxyAck + "\\n" + + (inIframe ? ("ocextappid: " + (appId || "null") + "\\npostMessage target: " + (targetOrigin || "null") + "\\nproxy: " + proxyInstallUrl + "\\n") : "") + + "lastDetail: " + lastDetail + "\\n\\n" + + (inIframe + ? "内层只发 CLOSE_SHELL / DO_RETURN。外层须装 opera-parent-close-listener.user.js" + : "独立标签:myapp:// 后 window.close()"); + }; + + window.renderOperaDebug = window.renderLaunchDebug; + })(); + """ + .replace("{{LAUNCH_MODE}}", mode) + .replace("{{CLOSE_ENABLED}}", String.valueOf(closeEnabled)) + .replace("{{CLOSE_MODAL_MESSAGE}}", String.valueOf(closeModalMessage)) + .replace("{{OPERA_ORIGIN}}", origin) + .replace("{{FLEX_LINK_ORIGIN}}", flexOrigin) + .replace("{{PROXY_INSTALL_URL}}", proxyInstallUrl) + .replace("{{FALLBACK_EXT_APP_ID}}", String.valueOf(fallbackExtAppId)) + .replace("{{OPEN_DELAY_MS}}", String.valueOf(openDelayMs)) + .replace("{{CLOSE_DELAY_MS}}", String.valueOf(popupCloseDelayMs)) + .replace("{{DEBUG_PAGE}}", String.valueOf(debugPage)); + } + + /** + * 供 Opera 主页面注入(Tampermonkey / Console 一次粘贴)。 + * Flex Link iframe 会 postMessage({ action: 'CLOSE_SHELL' | 'closeModal' }) 触发此逻辑。 + */ + public static String buildOperaParentCloseListenerScript(String flexLinkOrigin) { + String origin = escapeJsString(StringUtils.defaultIfBlank(flexLinkOrigin, "http://127.0.0.1:8066")); + return """ + (function () { + if (window.__autoCheckInShellProxy) return; + window.__autoCheckInShellProxy = true; + var flexOrigin = "{{FLEX_ORIGIN}}"; + + function isFlexOrigin(origin) { + if (!origin) return false; + if (flexOrigin && origin === flexOrigin) return true; + try { + var u = new URL(origin); + if (u.hostname === "127.0.0.1" || u.hostname === "localhost") return true; + } catch (e) {} + return false; + } + + function isCloseSignal(data) { + if (!data) return false; + if (data.source && data.source !== "auto-check-in-launch") return false; + return data.action === "CLOSE_SHELL" || data.action === "closeModal"; + } + + function clickEl(el) { + if (!el) return false; + try { el.focus(); } catch (e1) {} + try { el.click(); return true; } catch (e2) {} + try { + el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true, view: window })); + return true; + } catch (e3) {} + return false; + } + + function destroyAdfPopupShell() { + var closeSelectors = [ + 'a[id$="::close"][aria-label="Close"][title="Close"]', + 'a.x2c5[title="Close"][id$="::close"]', + '.af_panelWindow_header-close-icon-wrapper a[title="Close"]', + 'a[id$="::close"]' + ]; + var clicked = false; + for (var i = 0; i < closeSelectors.length; i++) { + var el = document.querySelector(closeSelectors[i]); + if (clickEl(el)) { + console.log("[auto-check-in proxy] 点击 Close:", el.id); + clicked = true; + break; + } + } + document.querySelectorAll('div[id*="ocExtWrpBack"] a, div[id*="ocExtWrpPp"] a').forEach(function (a) { + if (clickEl(a)) { + console.log("[auto-check-in proxy] 点击 ocExtWrp 隐藏链:", a.id || a.className); + clicked = true; + } + }); + var removed = 0; + document.querySelectorAll([ + 'div[id$="::popup-container"][role="dialog"]', + 'div[id$="::popup-container"]', + 'div[data-afr-popupid]' + ].join(",")).forEach(function (el) { + el.remove(); + removed++; + console.log("[auto-check-in proxy] 移除 popup-container"); + }); + document.querySelectorAll([ + 'div.x2px[data-afr-panelwindowbackground="1"]', + 'div.AFPopupTail', + '.AFModalGlassPane', + '.oj-dialog-layer', + '.oj-component-layer' + ].join(",")).forEach(function (el) { el.remove(); }); + document.body.style.overflow = "auto"; + document.documentElement.style.overflow = "auto"; + document.body.classList.remove("p_OraModalGlass"); + return { clicked: clicked, removed: removed }; + } + + function ackShellClose(event, ok, detail) { + if (!event || !event.source) return; + try { + event.source.postMessage({ + action: "SHELL_CLOSE_ACK", + ok: ok, + detail: detail, + frame: location.pathname, + source: "auto-check-in-proxy" + }, event.origin); + } catch (e) { + console.warn("[auto-check-in proxy] ACK 失败", e); + } + } + + window.addEventListener("message", function (event) { + if (!isFlexOrigin(event.origin)) return; + if (!isCloseSignal(event.data)) return; + console.log("[auto-check-in proxy] 收到内层信号:", event.data.action, + "from", event.origin, "frame:", location.href); + var last = null; + [0, 30, 80, 150, 300].forEach(function (ms, idx, arr) { + setTimeout(function () { + last = destroyAdfPopupShell(); + if (idx === arr.length - 1) { + ackShellClose(event, true, JSON.stringify(last)); + } + }, ms); + }); + }); + + window.__autoCheckInDestroyAdfPopupShell = destroyAdfPopupShell; + window.__autoCheckInCloseAdfPopup = function () { return destroyAdfPopupShell(); }; + console.log("[auto-check-in proxy] 外层代理已就绪 @", location.href, + "flexOrigin=", flexOrigin, "手动: __autoCheckInDestroyAdfPopupShell()"); + })(); + """.replace("{{FLEX_ORIGIN}}", origin); + } + + public static String buildOperaParentCloseListenerUserScript(String flexLinkOrigin) { + String origin = StringUtils.defaultIfBlank(flexLinkOrigin, "http://127.0.0.1:8066"); + String userJsUrl = origin + "/api/v1/esign/app/opera-parent-close-listener.user.js"; + return """ + // ==UserScript== + // @name Opera Auto Check-in Close Popup + // @namespace auto-check-in + // @version 1.2 + // @description 外层代理:接收 CLOSE_SHELL 信号并销毁 Flex Link Pop-up 外壳(装一次,永久自动) + // @match *://*.oraclehospitality.* + // @match *://*.oraclecloud.com/* + // @match *://*.ocs.oraclecloud.com/* + // @updateURL %s + // @downloadURL %s + // @run-at document-idle + // @allFrames true + // @grant none + // ==/UserScript== + """ + .formatted(userJsUrl, userJsUrl) + + buildOperaParentCloseListenerScript(origin); + } + + public static String buildOperaProxyInstallPage(String flexLinkOrigin) { + String base = StringUtils.defaultIfBlank(flexLinkOrigin, "http://127.0.0.1:8066"); + String userJs = escapeHtml(base + "/api/v1/esign/app/opera-parent-close-listener.user.js"); + String installPage = escapeHtml(base + "/api/v1/esign/app/opera-proxy-install"); + String extPath = escapeHtml("scripts/opera-close-extension(项目内文件夹,需「加载已解压的扩展程序」)"); + return """ + + + + + Opera 关窗代理 — 安装向导 + + + +

Opera Flex Link 自动关 Pop-up — 安装向导

+
+ 为什么 Chrome 扩展不能「一条链接自动装上」?
+ 浏览器安全策略禁止从任意网址一键安装扩展(防恶意软件)。 + 只有 应用商店「添加到 Chrome」 或 IT 组策略强制下发 才能接近「一点安装」。 +
+
+ ✓ 最接近「一条链接自动装」:Tampermonkey 脚本
+ 先装一次 Tampermonkey 扩展(应用商店),之后点下面链接即可一键安装关窗脚本,无需加载文件夹。 +
一键安装 Tampermonkey 脚本 + 安装 Tampermonkey(首次) +
+

方式对比

+ + + + + + +
方式能否一条链接说明
Tampermonkey + .user.js✓ 脚本能首次需装 Tampermonkey,之后点链接即装脚本
自研 Chrome 扩展(加载已解压)✗ 不能须 chrome://extensions → 加载文件夹(约 4 步)
上架 Chrome 网上应用店✓ 能需 $5 开发者账号 + 审核,适合全公司统一下载
IT 组策略强制安装✓ 能用户无感,适合企业批量部署
+
+ 方式 A:Tampermonkey(推荐,一条链接装脚本) +
    +
  1. 安装 Tampermonkey(只做一次)
  2. +
  3. 打开 %s → 点「安装」
  4. +
  5. 刷新 Opera Cloud 主页面
  6. +
+
+
+ 方式 B:自研 Chrome 扩展(不能一条链接,但无需 Tampermonkey) +
    +
  1. 打开 chrome://extensions,开启「开发者模式」
  2. +
  3. 「加载已解压的扩展程序」→ 选项目内 %s
  4. +
  5. 刷新 Opera Cloud 主页面
  6. +
+

Console 应出现:[auto-check-in ext] 外层代理已就绪

+
+
+ 验证 +

点 Flex Link → Pop-up 应能自动关闭;调试框可见 shellProxyAck: true

+

主页面 Console:__autoCheckInDestroyAdfPopupShell()

+
+

本页地址:%s

+ + + """.formatted(userJs, userJs, userJs, extPath, installPage, installPage); + } + + private static String escapeHtml(String value) { + return String.valueOf(value) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } + + private static String escapeJsString(String value) { + return String.valueOf(value) + .replace("\\", "\\\\") + .replace("\"", "\\\""); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/ProfileDetailsJsonBuilder.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/ProfileDetailsJsonBuilder.java new file mode 100644 index 0000000..0271737 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/ProfileDetailsJsonBuilder.java @@ -0,0 +1,353 @@ +package com.sjmresorts.opsapp.esign.reservation.support; + +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 将 CMS 配置的扁平 OHIP 字段路径(相对 profileDetails)构造成 CRM PUT /profiles/{id} 请求体。 + *

+ * 示例路径:{@code customer.personName[Primary].givenName}、 + * {@code customer.identifications.identificationInfo[0].identification.idNumber}、 + * {@code addresses.addressInfo[0].address.cityName}、{@code profileType} + */ +public final class ProfileDetailsJsonBuilder { + + private static final Pattern SEGMENT = + Pattern.compile("([^.\\[]+)(?:\\[([^\\]]+)\\])?"); + + /** OHIP telephone.phoneUseType 未映射时,更新 phoneNumber 的默认值。 */ + private static final String DEFAULT_PHONE_USE_TYPE = "HOME"; + private static final String PHONE_NUMBER_PATH = + "telephones.telephoneInfo[0].telephone.phoneNumber"; + private static final String PHONE_USE_TYPE_PATH = + "telephones.telephoneInfo[0].telephone.phoneUseType"; + private static final String PHONE_PRIMARY_IND_PATH = + "telephones.telephoneInfo[0].telephone.primaryInd"; + + /** OHIP email.type 未映射时,更新 emailAddress 的默认值。 */ + private static final String DEFAULT_EMAIL_TYPE = "EMAIL"; + private static final String EMAIL_ADDRESS_PATH = + "emails.emailInfo[0].email.emailAddress"; + private static final String EMAIL_TYPE_PATH = + "emails.emailInfo[0].email.type"; + private static final String EMAIL_PRIMARY_IND_PATH = + "emails.emailInfo[0].email.primaryInd"; + + private ProfileDetailsJsonBuilder() { + } + + public static JSONObject buildBody(Map mappedFields, String profileType) { + JSONObject profileDetails = new JSONObject(); + if (mappedFields != null) { + for (Map.Entry entry : mappedFields.entrySet()) { + String path = normalizePath(entry.getKey()); + String value = StringUtils.trimToNull(entry.getValue()); + if (path == null || value == null) { + continue; + } + setValue(profileDetails, path, value); + } + } + ensureTelephoneDefaults(profileDetails); + ensureEmailDefaults(profileDetails); + if (StringUtils.isNotBlank(profileType)) { + profileDetails.put("profileType", profileType.trim()); + } + JSONObject root = new JSONObject(); + root.put("profileDetails", profileDetails); + return root; + } + + private static void ensureTelephoneDefaults(JSONObject profileDetails) { + if (StringUtils.isBlank(getStringAtPath(profileDetails, PHONE_NUMBER_PATH))) { + return; + } + if (StringUtils.isBlank(getStringAtPath(profileDetails, PHONE_USE_TYPE_PATH))) { + setValue(profileDetails, PHONE_USE_TYPE_PATH, DEFAULT_PHONE_USE_TYPE); + } + if (!hasLeafAtPath(profileDetails, PHONE_PRIMARY_IND_PATH)) { + setBooleanAtPath(profileDetails, PHONE_PRIMARY_IND_PATH, true); + } + } + + private static void ensureEmailDefaults(JSONObject profileDetails) { + if (StringUtils.isBlank(getStringAtPath(profileDetails, EMAIL_ADDRESS_PATH))) { + return; + } + if (StringUtils.isBlank(getStringAtPath(profileDetails, EMAIL_TYPE_PATH))) { + setValue(profileDetails, EMAIL_TYPE_PATH, DEFAULT_EMAIL_TYPE); + } + if (!hasLeafAtPath(profileDetails, EMAIL_PRIMARY_IND_PATH)) { + setBooleanAtPath(profileDetails, EMAIL_PRIMARY_IND_PATH, true); + } + } + + private static String normalizePath(String raw) { + String path = StringUtils.trimToEmpty(raw); + if (path.startsWith("profileDetails.")) { + path = path.substring("profileDetails.".length()); + } + return StringUtils.trimToNull(path); + } + + private static void setValue(JSONObject root, String path, String value) { + List segments = parsePath(path); + if (segments.isEmpty()) { + return; + } + Object current = root; + for (int i = 0; i < segments.size() - 1; i++) { + current = navigate(current, segments.get(i), true); + if (current == null) { + return; + } + } + Segment leaf = segments.get(segments.size() - 1); + if (!(current instanceof JSONObject obj)) { + return; + } + Selector leafSelector = leaf.selector(); + if (leafSelector != null && leafSelector.kind == SelectorKind.INDEX) { + JSONArray arr = obj.getJSONArray(leaf.name()); + if (arr == null) { + arr = new JSONArray(); + obj.put(leaf.name(), arr); + } + while (arr.size() <= leafSelector.index) { + arr.add(defaultArrayElement(leaf.name())); + } + arr.set(leafSelector.index, value); + return; + } + obj.put(leaf.name(), value); + } + + private static String getStringAtPath(JSONObject root, String path) { + List segments = parsePath(path); + if (segments.isEmpty()) { + return null; + } + Object current = root; + for (int i = 0; i < segments.size() - 1; i++) { + current = navigate(current, segments.get(i), false); + if (current == null) { + return null; + } + } + Segment leaf = segments.get(segments.size() - 1); + if (!(current instanceof JSONObject obj)) { + return null; + } + Selector leafSelector = leaf.selector(); + if (leafSelector != null && leafSelector.kind == SelectorKind.INDEX) { + JSONArray arr = obj.getJSONArray(leaf.name()); + if (arr == null || arr.size() <= leafSelector.index) { + return null; + } + Object value = arr.get(leafSelector.index); + return value != null ? StringUtils.trimToNull(String.valueOf(value)) : null; + } + return StringUtils.trimToNull(obj.getString(leaf.name())); + } + + private static boolean hasLeafAtPath(JSONObject root, String path) { + List segments = parsePath(path); + if (segments.isEmpty()) { + return false; + } + Object current = root; + for (int i = 0; i < segments.size() - 1; i++) { + current = navigate(current, segments.get(i), false); + if (current == null) { + return false; + } + } + Segment leaf = segments.get(segments.size() - 1); + if (!(current instanceof JSONObject obj)) { + return false; + } + Selector leafSelector = leaf.selector(); + if (leafSelector != null && leafSelector.kind == SelectorKind.INDEX) { + JSONArray arr = obj.getJSONArray(leaf.name()); + return arr != null && arr.size() > leafSelector.index && arr.get(leafSelector.index) != null; + } + return obj.containsKey(leaf.name()); + } + + private static void setBooleanAtPath(JSONObject root, String path, boolean value) { + List segments = parsePath(path); + if (segments.isEmpty()) { + return; + } + Object current = root; + for (int i = 0; i < segments.size() - 1; i++) { + current = navigate(current, segments.get(i), true); + if (current == null) { + return; + } + } + Segment leaf = segments.get(segments.size() - 1); + if (!(current instanceof JSONObject obj)) { + return; + } + Selector leafSelector = leaf.selector(); + if (leafSelector != null && leafSelector.kind == SelectorKind.INDEX) { + JSONArray arr = obj.getJSONArray(leaf.name()); + if (arr == null) { + arr = new JSONArray(); + obj.put(leaf.name(), arr); + } + while (arr.size() <= leafSelector.index) { + arr.add(defaultArrayElement(leaf.name())); + } + arr.set(leafSelector.index, value); + return; + } + obj.put(leaf.name(), value); + } + + private static Object defaultArrayElement(String segmentName) { + return "addressLine".equals(segmentName) ? "" : new JSONObject(); + } + + private static List parsePath(String path) { + List out = new ArrayList<>(); + Matcher matcher = SEGMENT.matcher(path); + int pos = 0; + while (matcher.find(pos)) { + if (matcher.start() != pos) { + break; + } + String name = matcher.group(1); + String selector = StringUtils.trimToNull(matcher.group(2)); + out.add(new Segment(name, parseSelector(name, selector))); + pos = matcher.end(); + if (pos < path.length() && path.charAt(pos) == '.') { + pos++; + } + } + return out; + } + + private static Selector parseSelector(String segmentName, String raw) { + if (raw == null) { + return null; + } + if (raw.contains("=")) { + int eq = raw.indexOf('='); + return Selector.match(raw.substring(0, eq).trim(), raw.substring(eq + 1).trim()); + } + if (NumberUtils.isDigits(raw)) { + return Selector.index(Integer.parseInt(raw)); + } + if ("personName".equals(segmentName)) { + return Selector.match("nameType", raw); + } + if ("keyword".equals(segmentName)) { + return Selector.index(0); + } + return Selector.match("type", raw); + } + + private static Object navigate(Object current, Segment segment, boolean create) { + if (!(current instanceof JSONObject obj)) { + return null; + } + Selector selector = segment.selector(); + if (selector == null) { + if (!obj.containsKey(segment.name())) { + if (!create) { + return null; + } + obj.put(segment.name(), new JSONObject()); + } + return obj.get(segment.name()); + } + if (!obj.containsKey(segment.name())) { + if (!create) { + return null; + } + obj.put(segment.name(), new JSONArray()); + } + Object node = obj.get(segment.name()); + final JSONArray arr; + if (node instanceof JSONArray existing) { + arr = existing; + } else { + if (!create) { + return null; + } + arr = new JSONArray(); + obj.put(segment.name(), arr); + } + if (selector.kind == SelectorKind.INDEX) { + while (arr.size() <= selector.index) { + arr.add(defaultArrayElement(segment.name())); + } + return arr.get(selector.index); + } + for (int i = 0; i < arr.size(); i++) { + Object item = arr.get(i); + if (item instanceof JSONObject itemObj && matches(itemObj, selector)) { + return itemObj; + } + } + if (!create) { + return null; + } + JSONObject created = new JSONObject(); + created.put(selector.matchKey, selector.matchValue); + arr.add(created); + return created; + } + + private static boolean matches(JSONObject obj, Selector selector) { + if (selector.kind != SelectorKind.MATCH) { + return false; + } + String actual = StringUtils.trimToEmpty(obj.getString(selector.matchKey)); + return actual.equalsIgnoreCase(selector.matchValue); + } + + private record Segment(String name, Selector selector) { + } + + private enum SelectorKind { + INDEX, + MATCH + } + + private static final class Selector { + private final SelectorKind kind; + private final int index; + private final String matchKey; + private final String matchValue; + + private Selector(SelectorKind kind, int index, String matchKey, String matchValue) { + this.kind = kind; + this.index = index; + this.matchKey = matchKey; + this.matchValue = matchValue; + } + + static Selector index(int index) { + return new Selector(SelectorKind.INDEX, index, null, null); + } + + static Selector match(String key, String value) { + return new Selector( + SelectorKind.MATCH, + -1, + StringUtils.trimToEmpty(key), + StringUtils.trimToEmpty(value)); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/RegistrationConfirmationExtractor.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/RegistrationConfirmationExtractor.java new file mode 100644 index 0000000..fe5af1e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/RegistrationConfirmationExtractor.java @@ -0,0 +1,94 @@ +package com.sjmresorts.opsapp.esign.reservation.support; + +import org.apache.commons.lang3.StringUtils; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.text.PDFTextStripper; + +import java.util.Base64; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 从登记卡 PDF 文本中提取 OPERA 确认号(Conf. No / 確認號碼)。 */ +public final class RegistrationConfirmationExtractor { + + private static final Pattern[] TEXT_PATTERNS = { + Pattern.compile("(?is)conf\\.?\\s*no\\s*確認號碼\\s*[::]?\\s*(\\d{4,12})"), + Pattern.compile("(?is)confirmation\\s*no\\.?[^0-9]{0,48}(\\d{4,12})"), + Pattern.compile("(?is)(?:訂房編號|订房编号|訂房號碼|订房号码)\\s*[::]?\\s*(\\d{4,12})"), + Pattern.compile("(?is)確認號碼\\s*[::]?\\s*(\\d{4,12})"), + Pattern.compile("(?is)confirmation\\s*(?:no\\.?|number)\\s*[::]?\\s*(\\d{4,12})"), + Pattern.compile("(?is)conf\\.?\\s*no\\.?\\s*[::]?\\s*(\\d{4,12})"), + // OCR 常见误识别:Conf. Nof#aa sk: 3041736(No 与数字间可有字母/符号) + Pattern.compile("(?is)conf[\\s.]*no[^0-9]{0,48}(\\d{6,12})"), + Pattern.compile("(?is)conf\\W{0,20}(?:no|n[o0])[^0-9]{0,48}(\\d{6,12})"), + Pattern.compile("(?is)(?:確認號碼|确认号码|確認号码)\\W{0,16}(\\d{4,12})"), + Pattern.compile("(?is)(?:conf|corn|conl)[\\s.]*(?:no|n[o0])[^0-9]{0,48}(\\d{6,12})") + }; + + private RegistrationConfirmationExtractor() { + } + + public static Optional extractFromPdfBase64(String pdfBase64) { + if (StringUtils.isBlank(pdfBase64)) { + return Optional.empty(); + } + try { + byte[] bytes = Base64.getDecoder().decode(normalizeBase64(pdfBase64)); + return extractFromPdfBytes(bytes); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } + + public static Optional extractFromPdfBytes(byte[] pdfBytes) { + if (pdfBytes == null || pdfBytes.length == 0) { + return Optional.empty(); + } + try (PDDocument doc = Loader.loadPDF(pdfBytes)) { + PDFTextStripper stripper = new PDFTextStripper(); + String text = stripper.getText(doc); + return extractFromText(text); + } catch (Exception e) { + return Optional.empty(); + } + } + + public static Optional extractFromText(String text) { + if (StringUtils.isBlank(text)) { + return Optional.empty(); + } + String normalized = text.replace('\u00a0', ' '); + for (Pattern pattern : TEXT_PATTERNS) { + Matcher matcher = pattern.matcher(normalized); + if (matcher.find()) { + return Optional.of(matcher.group(1).trim()); + } + } + return extractLooseConfNo(normalized); + } + + /** OCR 兜底:Conf / No 附近 48 字符内的 6–12 位数字 */ + private static Optional extractLooseConfNo(String text) { + Matcher label = Pattern.compile("(?is)conf[\\s.]*no").matcher(text); + while (label.find()) { + int start = label.end(); + int end = Math.min(text.length(), start + 48); + Matcher digits = Pattern.compile("(\\d{6,12})").matcher(text.substring(start, end)); + if (digits.find()) { + return Optional.of(digits.group(1).trim()); + } + } + return Optional.empty(); + } + + private static String normalizeBase64(String raw) { + String trimmed = raw.trim(); + int comma = trimmed.indexOf(','); + if (trimmed.startsWith("data:") && comma > 0) { + return trimmed.substring(comma + 1).trim(); + } + return trimmed.replaceAll("\\s", ""); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/ReservationDetailResponseParser.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/ReservationDetailResponseParser.java new file mode 100644 index 0000000..d8bdacf --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/ReservationDetailResponseParser.java @@ -0,0 +1,138 @@ +package com.sjmresorts.opsapp.esign.reservation.support; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import com.sjmresorts.opsapp.esign.reservation.dtos.GuestProfileRef; +import org.apache.commons.lang3.StringUtils; + +import java.util.Optional; + +/** 解析 OHIP GET /hotels/{hotelId}/reservations/{reservationId} 响应中的 Guest Profile。 */ +public final class ReservationDetailResponseParser { + + private ReservationDetailResponseParser() { + } + + public static Optional extractProfileId(String body) { + return extractGuestProfile(body).map(GuestProfileRef::profileId); + } + + public static Optional extractGuestProfile(String body) { + if (StringUtils.isBlank(body)) { + return Optional.empty(); + } + try { + Object parsed = JSON.parse(body.trim()); + return extractGuestProfile(parsed); + } catch (Exception e) { + return Optional.empty(); + } + } + + private static Optional extractGuestProfile(Object node) { + if (node instanceof JSONObject obj) { + Optional fromGuests = extractFromReservationGuests(obj.get("reservationGuests")); + if (fromGuests.isPresent()) { + return fromGuests; + } + Object reservations = obj.get("reservations"); + if (reservations instanceof JSONObject reservationsObj) { + Optional nested = extractFromReservationsContainer(reservationsObj); + if (nested.isPresent()) { + return nested; + } + } + for (String key : obj.keySet()) { + Optional nested = extractGuestProfile(obj.get(key)); + if (nested.isPresent()) { + return nested; + } + } + } else if (node instanceof JSONArray arr) { + for (int i = 0; i < arr.size(); i++) { + Optional nested = extractGuestProfile(arr.get(i)); + if (nested.isPresent()) { + return nested; + } + } + } + return Optional.empty(); + } + + private static Optional extractFromReservationsContainer(JSONObject reservationsObj) { + Object reservation = reservationsObj.get("reservation"); + if (reservation instanceof JSONArray arr) { + for (int i = 0; i < arr.size(); i++) { + if (arr.get(i) instanceof JSONObject resObj) { + Optional profile = extractFromReservationGuests(resObj.get("reservationGuests")); + if (profile.isPresent()) { + return profile; + } + } + } + } else if (reservation instanceof JSONObject resObj) { + return extractFromReservationGuests(resObj.get("reservationGuests")); + } + return Optional.empty(); + } + + private static Optional extractFromReservationGuests(Object guestsNode) { + if (!(guestsNode instanceof JSONArray guests)) { + return Optional.empty(); + } + Optional primary = Optional.empty(); + for (int i = 0; i < guests.size(); i++) { + if (!(guests.get(i) instanceof JSONObject guest)) { + continue; + } + Optional profile = extractFromProfileInfo(guest.getJSONObject("profileInfo")); + if (profile.isEmpty()) { + continue; + } + if (Boolean.TRUE.equals(guest.getBoolean("primary"))) { + return profile; + } + if (primary.isEmpty()) { + primary = profile; + } + } + return primary; + } + + private static Optional extractFromProfileInfo(JSONObject profileInfo) { + if (profileInfo == null) { + return Optional.empty(); + } + String profileType = readProfileType(profileInfo.getJSONObject("profile")); + Object list = profileInfo.get("profileIdList"); + if (!(list instanceof JSONArray arr)) { + return Optional.empty(); + } + Optional fallbackId = Optional.empty(); + for (int i = 0; i < arr.size(); i++) { + if (!(arr.get(i) instanceof JSONObject idObj)) { + continue; + } + String id = StringUtils.trimToNull(idObj.getString("id")); + if (id == null) { + continue; + } + String type = StringUtils.defaultString(idObj.getString("type")); + if ("Profile".equalsIgnoreCase(type)) { + return Optional.of(new GuestProfileRef(id, profileType)); + } + if (fallbackId.isEmpty()) { + fallbackId = Optional.of(id); + } + } + return fallbackId.map(id -> new GuestProfileRef(id, profileType)); + } + + private static String readProfileType(JSONObject profile) { + if (profile == null) { + return null; + } + return StringUtils.trimToNull(profile.getString("profileType")); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/ReservationLookupResponseParser.java b/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/ReservationLookupResponseParser.java new file mode 100644 index 0000000..a1f25ee --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/reservation/support/ReservationLookupResponseParser.java @@ -0,0 +1,103 @@ +package com.sjmresorts.opsapp.esign.reservation.support; + +import com.alibaba.fastjson2.JSON; +import com.alibaba.fastjson2.JSONArray; +import com.alibaba.fastjson2.JSONObject; +import org.apache.commons.lang3.StringUtils; + +import java.util.Optional; + +/** 解析 OHIP GET /reservations 响应中的 reservation id。 */ +public final class ReservationLookupResponseParser { + + private ReservationLookupResponseParser() { + } + + public static Optional extractReservationId(String body, String confirmationNumber) { + if (StringUtils.isBlank(body)) { + return Optional.empty(); + } + try { + Object parsed = JSON.parse(body.trim()); + Optional matched = findReservationId(parsed, confirmationNumber); + if (matched.isPresent()) { + return matched; + } + return findReservationId(parsed, null); + } catch (Exception e) { + return Optional.empty(); + } + } + + private static Optional findReservationId(Object node, String confirmationNumber) { + if (node instanceof JSONObject obj) { + String confInNode = firstNonBlank( + obj.getString("confirmationNumber"), + obj.getString("confirmationNo"), + obj.getString("confirmationId")); + String reservationId = firstReservationId(obj); + if (StringUtils.isNotBlank(reservationId)) { + if (StringUtils.isBlank(confirmationNumber) + || StringUtils.isBlank(confInNode) + || confirmationNumber.equals(confInNode.trim())) { + return Optional.of(reservationId.trim()); + } + } + for (String key : obj.keySet()) { + Optional nested = findReservationId(obj.get(key), confirmationNumber); + if (nested.isPresent()) { + return nested; + } + } + } else if (node instanceof JSONArray arr) { + for (int i = 0; i < arr.size(); i++) { + Optional nested = findReservationId(arr.get(i), confirmationNumber); + if (nested.isPresent()) { + return nested; + } + } + } + return Optional.empty(); + } + + private static String firstReservationId(JSONObject obj) { + String direct = firstNonBlank( + obj.getString("reservationId"), + obj.getString("reservationID"), + obj.getString("id")); + if (StringUtils.isNotBlank(direct) && looksLikeReservationId(direct)) { + return direct; + } + Object list = obj.get("reservationIdList"); + if (list instanceof JSONArray arr) { + for (int i = 0; i < arr.size(); i++) { + Object item = arr.get(i); + if (item instanceof JSONObject idObj) { + String type = StringUtils.defaultString(idObj.getString("type")); + String id = firstNonBlank(idObj.getString("id"), idObj.getString("reservationId")); + if (StringUtils.isNotBlank(id) + && (StringUtils.isBlank(type) || type.toLowerCase().contains("reservation"))) { + return id; + } + } else if (item != null && StringUtils.isNotBlank(String.valueOf(item))) { + return String.valueOf(item); + } + } + } + return null; + } + + private static boolean looksLikeReservationId(String value) { + String trimmed = value.trim(); + return trimmed.matches("\\d{3,12}"); + } + + private static String firstNonBlank(String... values) { + for (String value : values) { + if (StringUtils.isNotBlank(value)) { + return value.trim(); + } + } + return null; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/controllers/EsignSurveyController.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/controllers/EsignSurveyController.java new file mode 100644 index 0000000..4cbf2e9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/controllers/EsignSurveyController.java @@ -0,0 +1,309 @@ +package com.sjmresorts.opsapp.esign.survey.controllers; + +import com.auth0.jwt.JWTVerifier; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import com.sjmresorts.opsapp.common.utils.NetworkUtil; +import com.sjmresorts.opsapp.esign.survey.dtos.ExternalSurveyUrlParseResponse; +import com.sjmresorts.opsapp.esign.capture.dtos.EsignIosCaptureResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPushCandidatesResponse; +import com.sjmresorts.opsapp.esign.survey.dtos.EsignSurveyExternalWebhookRequest; +import com.sjmresorts.opsapp.esign.survey.dtos.EsignSurveyPushAssignRequest; +import com.sjmresorts.opsapp.esign.survey.dtos.EsignSurveyResponse; +import com.sjmresorts.opsapp.esign.survey.dtos.EsignSurveySaveRequest; +import com.sjmresorts.opsapp.esign.survey.dtos.EsignSurveySubmitRequest; +import com.sjmresorts.opsapp.esign.survey.services.EsignSurveyMediaService; +import com.sjmresorts.opsapp.esign.survey.services.EsignSurveyPushService; +import com.sjmresorts.opsapp.esign.survey.services.EsignSurveyService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import org.apache.commons.lang3.StringUtils; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.CacheControl; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.DeleteMapping; +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 org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@Tag(name = "Esign Survey", description = "调查问卷(Electron 配置 + App 填写)") +@RestController +@RequestMapping({"/api/v1/esign/surveys", "/api/v1/sinosecu/surveys"}) +@CrossOrigin(origins = "*") +public class EsignSurveyController { + + @Resource + private EsignSurveyService surveyService; + + @Resource + private EsignSurveyPushService surveyPushService; + + @Resource + private EsignSurveyMediaService surveyMediaService; + + @Operation(summary = "管理端:问卷列表", description = "免 JWT。Electron 问卷配置管理。") + @GetMapping(produces = "application/json;charset=UTF-8") + public ResponseEntity listAll() { + return ResponseEntity.ok(surveyService.listAllForAdmin()); + } + + @Operation(summary = "App:已发布问卷列表") + @GetMapping(value = "/published", produces = "application/json;charset=UTF-8") + public ResponseEntity listPublished( + @RequestParam(value = "hotelId", required = false) String hotelId) { + return ResponseEntity.ok(surveyService.listPublished(hotelId)); + } + + @Operation(summary = "问卷详情(含题目)") + @GetMapping(value = "/{surveyId}", produces = "application/json;charset=UTF-8") + public ResponseEntity getDetail(@PathVariable("surveyId") String surveyId) { + EsignSurveyResponse body = surveyService.getDetail(surveyId, true); + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "问卷 Logo 图片(顶栏固定样式)") + @GetMapping(value = "/{surveyId}/media") + public ResponseEntity getMedia(@PathVariable("surveyId") String surveyId) { + Optional path = surveyMediaService.resolveMediaFile(surveyId); + if (path.isEmpty()) { + return ResponseEntity.notFound().build(); + } + try { + Path file = path.get(); + String contentType = surveyMediaService.probeMimeForPath(file); + org.springframework.core.io.FileSystemResource body = new org.springframework.core.io.FileSystemResource(file); + return ResponseEntity.ok() + .contentType(MediaType.parseMediaType(contentType)) + .cacheControl(CacheControl.maxAge(7, TimeUnit.DAYS).cachePublic()) + .contentLength(Files.size(file)) + .body(body); + } catch (IOException e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @Operation(summary = "上传问卷顶栏 Logo(multipart,推荐)") + @PostMapping(value = "/{surveyId}/media", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity uploadMedia( + @PathVariable("surveyId") String surveyId, + @RequestParam("file") MultipartFile file) { + if (file == null || file.isEmpty()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(EsignSurveyResponse.fail("400", "file required")); + } + try { + EsignSurveyResponse body = surveyService.uploadLogo( + surveyId, + file.getBytes(), + StringUtils.defaultIfBlank(file.getContentType(), MediaType.APPLICATION_OCTET_STREAM_VALUE)); + if (!body.isSuccess()) { + HttpStatus status = "404".equals(body.getCode()) ? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST; + return ResponseEntity.status(status).body(body); + } + return ResponseEntity.ok(body); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(EsignSurveyResponse.fail("500", "upload failed")); + } + } + + @Operation(summary = "创建或更新问卷") + @PostMapping(consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") + public ResponseEntity save(@RequestBody EsignSurveySaveRequest request) { + EsignSurveyResponse body = surveyService.save(request); + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "更新问卷") + @PutMapping(value = "/{surveyId}", consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") + public ResponseEntity update( + @PathVariable("surveyId") String surveyId, + @RequestBody EsignSurveySaveRequest request) { + if (request != null) { + request.setSurveyId(surveyId); + } + EsignSurveyResponse body = surveyService.save(request); + if (!body.isSuccess()) { + HttpStatus status = "404".equals(body.getCode()) ? HttpStatus.NOT_FOUND : HttpStatus.BAD_REQUEST; + return ResponseEntity.status(status).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "发布问卷") + @PostMapping(value = "/{surveyId}/publish", produces = "application/json;charset=UTF-8") + public ResponseEntity publish(@PathVariable("surveyId") String surveyId) { + EsignSurveyResponse body = surveyService.publish(surveyId); + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "归档问卷") + @PostMapping(value = "/{surveyId}/archive", produces = "application/json;charset=UTF-8") + public ResponseEntity archive(@PathVariable("surveyId") String surveyId) { + EsignSurveyResponse body = surveyService.archive(surveyId); + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "删除问卷") + @DeleteMapping(value = "/{surveyId}", produces = "application/json;charset=UTF-8") + public ResponseEntity delete(@PathVariable("surveyId") String surveyId) { + EsignSurveyResponse body = surveyService.delete(surveyId); + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "提交问卷答案") + @PostMapping(value = "/{surveyId}/submit", consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") + public ResponseEntity submit( + HttpServletRequest request, + @PathVariable("surveyId") String surveyId, + @RequestBody EsignSurveySubmitRequest body) { + EsignSurveyResponse resp = surveyService.submit(surveyId, body); + if (!resp.isSuccess()) { + HttpStatus status = switch (resp.getCode()) { + case "404" -> HttpStatus.NOT_FOUND; + case "409" -> HttpStatus.CONFLICT; + default -> HttpStatus.BAD_REQUEST; + }; + return ResponseEntity.status(status).body(resp); + } + Long userId = resolveUserIdIfVerifiedJwt(request); + if (userId != null) { + surveyPushService.completePushForUser( + surveyId, userId, NetworkUtil.getClientIpByRequest(request)); + } + return ResponseEntity.ok(resp); + } + + @Operation(summary = "App:完成问卷推送(从待签列表移除)") + @PostMapping(value = "/{surveyId}/push/complete", produces = "application/json;charset=UTF-8") + public ResponseEntity completeSurveyPush( + HttpServletRequest request, + @PathVariable("surveyId") String surveyId) { + Long userId = resolveUserIdIfVerifiedJwt(request); + EsignIosCaptureResponse body = surveyPushService.completePushForUser( + surveyId, userId, NetworkUtil.getClientIpByRequest(request)); + return ResponseEntity.status(httpStatus(body.isSuccess(), body.getCode())).body(body); + } + + private static Long resolveUserIdIfVerifiedJwt(HttpServletRequest request) { + String auth = request.getHeader("Authorization"); + if (StringUtils.isBlank(auth) || !auth.startsWith("Bearer ")) { + return null; + } + String token = auth.substring(7).trim(); + if (StringUtils.isBlank(token)) { + return null; + } + try { + JWTVerifier jwtVerifier = JwtTokenUtil.getJwtVerifier(); + jwtVerifier.verify(token); + LoginUserInfo user = JwtTokenUtil.parseUserFromToken(token); + if (user == null || StringUtils.isBlank(user.getEmployeeId())) { + return null; + } + return Long.parseLong(user.getEmployeeId()); + } catch (Exception e) { + return null; + } + } + + @Operation(summary = "解析第三方问卷链接(Microsoft Forms 等)") + @GetMapping(value = "/external/parse-url", produces = "application/json;charset=UTF-8") + public ResponseEntity parseExternalUrl( + @RequestParam(value = "url", required = false) String url) { + if (StringUtils.isBlank(url)) { + return ResponseEntity.badRequest() + .body(ExternalSurveyUrlParseResponse.fail("400", "url query parameter required")); + } + ExternalSurveyUrlParseResponse body = surveyService.parseExternalUrl(url); + HttpStatus status = body.isSuccess() ? HttpStatus.OK : HttpStatus.BAD_REQUEST; + return ResponseEntity.status(status).body(body); + } + + @Operation(summary = "第三方问卷 Webhook 回传(Power Automate 等)") + @PostMapping(value = "/external/webhook", consumes = "application/json;charset=UTF-8", produces = "application/json;charset=UTF-8") + public ResponseEntity externalWebhook( + @RequestBody EsignSurveyExternalWebhookRequest request) { + EsignSurveyResponse body = surveyService.ingestExternalWebhook(request); + if (!body.isSuccess()) { + HttpStatus status = switch (body.getCode()) { + case "404" -> HttpStatus.NOT_FOUND; + case "403" -> HttpStatus.FORBIDDEN; + case "409" -> HttpStatus.CONFLICT; + default -> HttpStatus.BAD_REQUEST; + }; + return ResponseEntity.status(status).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "管理端:问卷提交记录") + @GetMapping(value = "/{surveyId}/submissions", produces = "application/json;charset=UTF-8") + public ResponseEntity listSubmissions(@PathVariable("surveyId") String surveyId) { + EsignSurveyResponse body = surveyService.listSubmissions(surveyId); + if (!body.isSuccess()) { + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body); + } + return ResponseEntity.ok(body); + } + + @Operation(summary = "问卷推送候选(设备+组)", description = "免 JWT。仅 MANUAL_PUSH 模式可用。") + @GetMapping(value = "/survey-push-candidates", produces = "application/json;charset=UTF-8") + public ResponseEntity getSurveyPushCandidates( + @RequestParam("surveyId") String surveyId) { + EsignPrinterPushCandidatesResponse body = surveyPushService.getPushCandidates(surveyId); + return ResponseEntity.status(httpStatus(body.isSuccess(), body.getCode())).body(body); + } + + @Operation(summary = "设置问卷推送目标", description = "免 JWT。选择设备 userIds 和/或 groupIds。") + @PostMapping(value = "/survey-push", produces = "application/json;charset=UTF-8") + public ResponseEntity assignSurveyPush( + @RequestBody EsignSurveyPushAssignRequest body) { + EsignIosCaptureResponse resp = surveyPushService.assignPushTargets(body); + return ResponseEntity.status(httpStatus(resp.isSuccess(), resp.getCode())).body(resp); + } + + private static HttpStatus httpStatus(boolean success, String code) { + if (success) { + return HttpStatus.OK; + } + return switch (StringUtils.defaultString(code)) { + case "400" -> HttpStatus.BAD_REQUEST; + case "401" -> HttpStatus.UNAUTHORIZED; + case "404" -> HttpStatus.NOT_FOUND; + case "409" -> HttpStatus.CONFLICT; + default -> HttpStatus.INTERNAL_SERVER_ERROR; + }; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveyExternalWebhookRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveyExternalWebhookRequest.java new file mode 100644 index 0000000..83572cb --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveyExternalWebhookRequest.java @@ -0,0 +1,32 @@ +package com.sjmresorts.opsapp.esign.survey.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "第三方问卷 Webhook 回传(Power Automate / 自定义集成)") +public class EsignSurveyExternalWebhookRequest { + + /** 可选;与 webhook secret 二选一用于定位问卷 */ + private String surveyId; + /** Microsoft Forms form id;可与 surveyId 二选一 */ + private String externalFormId; + /** 与 esign_surveys.webhook_secret 匹配 */ + private String secret; + /** 外部平台响应 ID,用于去重 */ + private String externalResponseId; + private String respondentEmail; + private String respondentName; + private String deviceId; + private String hotelId; + /** 题目 → 答案 */ + private Map answers; + /** 原始 payload(answers 为空时使用) */ + private Map raw; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveyPushAssignRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveyPushAssignRequest.java new file mode 100644 index 0000000..902bdae --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveyPushAssignRequest.java @@ -0,0 +1,21 @@ +package com.sjmresorts.opsapp.esign.survey.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EsignSurveyPushAssignRequest { + + private String surveyId; + private List userIds; + private List groupIds; + /** 按平板登记推送(与 CMS 设备页 hotel-1 等条目一一对应) */ + private List registryIds; + private boolean sendFcm = false; + private String assignedBy; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveyResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveyResponse.java new file mode 100644 index 0000000..a0374fb --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveyResponse.java @@ -0,0 +1,93 @@ +package com.sjmresorts.opsapp.esign.survey.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "问卷 API 通用响应") +public class EsignSurveyResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private EsignSurveyData data; + + @Data + @NoArgsConstructor + public static class EsignSurveyData { + private String surveyId; + private String submissionId; + private List surveys; + private EsignSurveyItem survey; + private List submissions; + } + + @Data + @NoArgsConstructor + public static class EsignSurveyItem { + private String surveyId; + private String title; + private String description; + private String rewardText; + private String displayType; + private String imageUrl; + private String headerHotelName; + private String hotelId; + private String status; + private Integer sortOrder; + private Integer questionCount; + private String createdAt; + private String updatedAt; + private String publishedAt; + /** NATIVE | EXTERNAL */ + private String sourceType; + /** MANUAL_PUSH | AUTO_AFTER_PDF_SIGN */ + private String deliveryMode; + private String externalUrl; + private String externalProvider; + private String externalFormId; + /** App WebView 打开地址 */ + private String externalEmbedUrl; + /** CMS 展示 Webhook 地址用(只读) */ + private String webhookUrl; + /** 管理端保存后展示,用于配置 Power Automate */ + private String webhookSecret; + private Integer submissionCount; + private List> questions; + } + + @Data + @NoArgsConstructor + public static class EsignSurveySubmissionItem { + private String submissionId; + private String surveyId; + private String sourceType; + private String externalResponseId; + private String deviceId; + private String hotelId; + private String submittedAt; + private Map answers; + private Map respondentMeta; + } + + public static EsignSurveyResponse ok(String message) { + EsignSurveyResponse r = new EsignSurveyResponse(); + r.setMessage(message); + return r; + } + + public static EsignSurveyResponse fail(String code, String message) { + EsignSurveyResponse r = new EsignSurveyResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveySaveRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveySaveRequest.java new file mode 100644 index 0000000..98b07ed --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveySaveRequest.java @@ -0,0 +1,36 @@ +package com.sjmresorts.opsapp.esign.survey.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "问卷保存请求(Electron 配置)") +public class EsignSurveySaveRequest { + + private String surveyId; + private String title; + private String description; + private String rewardText; + /** CARD | IMAGE */ + private String displayType; + private String imageUrl; + /** Forms 顶栏酒店名称 */ + private String headerHotelName; + private String hotelId; + private Integer sortOrder; + /** NATIVE | EXTERNAL */ + private String sourceType; + /** MANUAL_PUSH | AUTO_AFTER_PDF_SIGN */ + private String deliveryMode; + /** 第三方问卷链接(Microsoft Forms 等) */ + private String externalUrl; + /** 题目列表,见文档:SINGLE | MULTI | TEXT | RATING */ + private List> questions; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveySubmitRequest.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveySubmitRequest.java new file mode 100644 index 0000000..03a1773 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/EsignSurveySubmitRequest.java @@ -0,0 +1,22 @@ +package com.sjmresorts.opsapp.esign.survey.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "问卷提交答案") +public class EsignSurveySubmitRequest { + + private String deviceId; + private String hotelId; + /** questionId -> answer(单选为 string,多选为 array,文本为 string,评分为 number) */ + private Map answers; + private List> answerList; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/ExternalSurveyUrlParseResponse.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/ExternalSurveyUrlParseResponse.java new file mode 100644 index 0000000..24b3d6c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/ExternalSurveyUrlParseResponse.java @@ -0,0 +1,41 @@ +package com.sjmresorts.opsapp.esign.survey.dtos; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "第三方问卷链接解析结果") +public class ExternalSurveyUrlParseResponse { + + private boolean success = true; + private String code = "200"; + private String message; + private ParseData data; + + @Data + @NoArgsConstructor + public static class ParseData { + private String provider; + private String formId; + private String normalizedUrl; + private String embedUrl; + } + + public static ExternalSurveyUrlParseResponse ok(ParseData data) { + ExternalSurveyUrlParseResponse r = new ExternalSurveyUrlParseResponse(); + r.setData(data); + return r; + } + + public static ExternalSurveyUrlParseResponse fail(String code, String message) { + ExternalSurveyUrlParseResponse r = new ExternalSurveyUrlParseResponse(); + r.setSuccess(false); + r.setCode(code); + r.setMessage(message); + return r; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/ExternalSurveyUrlParser.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/ExternalSurveyUrlParser.java new file mode 100644 index 0000000..4e89437 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/dtos/ExternalSurveyUrlParser.java @@ -0,0 +1,123 @@ +package com.sjmresorts.opsapp.esign.survey.dtos; + +import org.apache.commons.lang3.StringUtils; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** 识别第三方问卷链接(Microsoft Forms 等)并提取 formId、规范化 embed URL。 */ +public final class ExternalSurveyUrlParser { + + public static final String PROVIDER_MICROSOFT_FORMS = "MICROSOFT_FORMS"; + public static final String PROVIDER_GOOGLE_FORMS = "GOOGLE_FORMS"; + public static final String PROVIDER_GENERIC = "GENERIC"; + + private static final Pattern MS_FORMS_OFFICE_R = + Pattern.compile("forms\\.office\\.com/r/([A-Za-z0-9_-]+)", Pattern.CASE_INSENSITIVE); + private static final Pattern GOOGLE_FORMS = + Pattern.compile("docs\\.google\\.com/forms/d/(?:e/)?([A-Za-z0-9_-]+)", Pattern.CASE_INSENSITIVE); + + private ExternalSurveyUrlParser() { + } + + public record ParseResult( + String provider, + String formId, + String normalizedUrl, + String embedUrl) { + } + + public static Optional parse(String rawUrl) { + String trimmed = StringUtils.trimToNull(rawUrl); + if (trimmed == null) { + return Optional.empty(); + } + String url = trimmed; + if (!url.contains("://")) { + url = "https://" + url; + } + URI uri; + try { + uri = new URI(url); + } catch (URISyntaxException e) { + return Optional.empty(); + } + String host = StringUtils.defaultString(uri.getHost()).toLowerCase(Locale.ROOT); + String path = StringUtils.defaultString(uri.getPath()); + String query = StringUtils.defaultString(uri.getRawQuery()); + + if (host.contains("forms.cloud.microsoft") || host.contains("forms.office.com")) { + return parseMicrosoftForms(url, host, path, query); + } + if (host.contains("docs.google.com")) { + return parseGoogleForms(url); + } + return Optional.of(new ParseResult(PROVIDER_GENERIC, null, url, url)); + } + + private static Optional parseMicrosoftForms( + String url, String host, String path, String query) { + String formId = extractQueryParam(query, "id"); + if (StringUtils.isBlank(formId)) { + Matcher m = MS_FORMS_OFFICE_R.matcher(url); + if (m.find()) { + formId = m.group(1); + } + } + if (StringUtils.isBlank(formId)) { + return Optional.empty(); + } + String embedUrl = buildMicrosoftFormsEmbedUrl(formId, host, path, query, url); + return Optional.of(new ParseResult(PROVIDER_MICROSOFT_FORMS, formId.trim(), url.trim(), embedUrl)); + } + + private static String buildMicrosoftFormsEmbedUrl( + String formId, String host, String path, String query, String fallbackUrl) { + if (host.contains("forms.cloud.microsoft")) { + return "https://forms.cloud.microsoft/pages/responsepage.aspx?id=" + + formId + + "&origin=lprLink&route=shorturl"; + } + if (path.toLowerCase(Locale.ROOT).contains("/r/")) { + return fallbackUrl; + } + if (StringUtils.isNotBlank(query) && query.contains("id=")) { + return "https://forms.office.com/Pages/ResponsePage.aspx?id=" + formId; + } + return fallbackUrl; + } + + private static Optional parseGoogleForms(String url) { + Matcher m = GOOGLE_FORMS.matcher(url); + if (!m.find()) { + return Optional.empty(); + } + String formId = m.group(1); + String embedUrl = url.contains("/viewform") + ? url + : "https://docs.google.com/forms/d/" + formId + "/viewform"; + return Optional.of(new ParseResult(PROVIDER_GOOGLE_FORMS, formId, url.trim(), embedUrl)); + } + + private static String extractQueryParam(String query, String name) { + if (StringUtils.isBlank(query) || StringUtils.isBlank(name)) { + return null; + } + for (String part : query.split("&")) { + int eq = part.indexOf('='); + if (eq <= 0) { + continue; + } + String key = part.substring(0, eq); + if (!name.equalsIgnoreCase(key)) { + continue; + } + return part.substring(eq + 1); + } + return null; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/models/EsignSurvey.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/models/EsignSurvey.java new file mode 100644 index 0000000..3fba9ff --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/models/EsignSurvey.java @@ -0,0 +1,89 @@ +package com.sjmresorts.opsapp.esign.survey.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_surveys") +public class EsignSurvey { + + @Id + @Column(name = "survey_id", nullable = false, length = 36) + private String surveyId; + + @Column(name = "title", nullable = false, length = 256) + private String title; + + @Column(name = "description", length = 1000) + private String description; + + @Column(name = "reward_text", length = 256) + private String rewardText; + + @Column(name = "display_type", nullable = false, length = 16) + private String displayType; + + @Lob + @Column(name = "image_url") + private String imageUrl; + + /** Forms 顶栏酒店名称(Logo 使用 imageUrl) */ + @Column(name = "header_hotel_name", length = 256) + private String headerHotelName; + + @Column(name = "hotel_id", length = 64) + private String hotelId; + + @Column(name = "status", nullable = false, length = 32) + private String status; + + @Lob + @Column(name = "questions_json", nullable = false) + private String questionsJson; + + @Column(name = "sort_order", nullable = false) + private Integer sortOrder; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + @Column(name = "published_at") + private Instant publishedAt; + + /** NATIVE | EXTERNAL */ + @Column(name = "source_type", nullable = false, length = 16) + private String sourceType; + + @Column(name = "external_url", length = 2000) + private String externalUrl; + + @Column(name = "external_provider", length = 32) + private String externalProvider; + + @Column(name = "external_form_id", length = 512) + private String externalFormId; + + @Column(name = "external_embed_url", length = 2000) + private String externalEmbedUrl; + + @Column(name = "webhook_secret", length = 64) + private String webhookSecret; + + /** MANUAL_PUSH | AUTO_AFTER_PDF_SIGN */ + @Column(name = "delivery_mode", nullable = false, length = 32) + private String deliveryMode; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/models/EsignSurveyPush.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/models/EsignSurveyPush.java new file mode 100644 index 0000000..cc6c70f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/models/EsignSurveyPush.java @@ -0,0 +1,52 @@ +package com.sjmresorts.opsapp.esign.survey.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.IdClass; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_survey_push") +@IdClass(EsignSurveyPush.SurveyPushId.class) +public class EsignSurveyPush { + + @Id + @Column(name = "survey_id", nullable = false, length = 36) + private String surveyId; + + @Id + @Column(name = "user_id", nullable = false) + private Long userId; + + @Id + @Column(name = "client_ip", nullable = false, length = 64) + private String clientIp = ""; + + @Column(name = "assigned_at", nullable = false) + private Instant assignedAt; + + @Column(name = "assigned_by", length = 64) + private String assignedBy; + + @Column(name = "group_id", length = 36) + private String groupId; + + @Getter + @Setter + @NoArgsConstructor + public static class SurveyPushId implements Serializable { + private String surveyId; + private Long userId; + private String clientIp; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/models/EsignSurveySubmission.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/models/EsignSurveySubmission.java new file mode 100644 index 0000000..395c67a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/models/EsignSurveySubmission.java @@ -0,0 +1,51 @@ +package com.sjmresorts.opsapp.esign.survey.models; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "esign_survey_submissions") +public class EsignSurveySubmission { + + @Id + @Column(name = "submission_id", nullable = false, length = 36) + private String submissionId; + + @Column(name = "survey_id", nullable = false, length = 36) + private String surveyId; + + @Lob + @Column(name = "answers_json", nullable = false) + private String answersJson; + + @Column(name = "device_id", length = 128) + private String deviceId; + + @Column(name = "hotel_id", length = 64) + private String hotelId; + + @Column(name = "submitted_at", nullable = false) + private Instant submittedAt; + + /** NATIVE | EXTERNAL */ + @Column(name = "source_type", nullable = false, length = 16) + private String sourceType; + + @Column(name = "external_response_id", length = 256) + private String externalResponseId; + + @Lob + @Column(name = "respondent_meta_json") + private String respondentMetaJson; +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/repositories/EsignSurveyPushRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/repositories/EsignSurveyPushRepository.java new file mode 100644 index 0000000..5c65262 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/repositories/EsignSurveyPushRepository.java @@ -0,0 +1,31 @@ +package com.sjmresorts.opsapp.esign.survey.repositories; + +import com.sjmresorts.opsapp.esign.survey.models.EsignSurveyPush; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Collection; +import java.util.List; + +@Repository +public interface EsignSurveyPushRepository extends JpaRepository { + + List findBySurveyId(String surveyId); + + void deleteBySurveyId(String surveyId); + + @Query("SELECT p.surveyId FROM EsignSurveyPush p WHERE p.userId = :userId") + List findSurveyIdsByUserId(Long userId); + + @Query( + """ + SELECT p FROM EsignSurveyPush p + WHERE p.userId = :userId + AND p.surveyId IN :surveyIds + """) + List findByUserIdAndSurveyIdIn(Long userId, Collection surveyIds); + + @Query("SELECT COUNT(p) > 0 FROM EsignSurveyPush p WHERE p.surveyId = :surveyId") + boolean existsBySurveyId(String surveyId); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/repositories/EsignSurveyRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/repositories/EsignSurveyRepository.java new file mode 100644 index 0000000..fec5f4a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/repositories/EsignSurveyRepository.java @@ -0,0 +1,15 @@ +package com.sjmresorts.opsapp.esign.survey.repositories; + +import com.sjmresorts.opsapp.esign.survey.models.EsignSurvey; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface EsignSurveyRepository extends JpaRepository { + + List findAllByOrderBySortOrderAscUpdatedAtDesc(); + + List findByStatusOrderBySortOrderAscUpdatedAtDesc(String status); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/repositories/EsignSurveySubmissionRepository.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/repositories/EsignSurveySubmissionRepository.java new file mode 100644 index 0000000..393cfed --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/repositories/EsignSurveySubmissionRepository.java @@ -0,0 +1,19 @@ +package com.sjmresorts.opsapp.esign.survey.repositories; + +import com.sjmresorts.opsapp.esign.survey.models.EsignSurveySubmission; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface EsignSurveySubmissionRepository extends JpaRepository { + + List findBySurveyIdOrderBySubmittedAtDesc(String surveyId); + + long countBySurveyId(String surveyId); + + Optional findFirstBySurveyIdAndExternalResponseId( + String surveyId, String externalResponseId); +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/services/EsignSurveyMediaService.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/services/EsignSurveyMediaService.java new file mode 100644 index 0000000..43d2448 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/services/EsignSurveyMediaService.java @@ -0,0 +1,227 @@ +package com.sjmresorts.opsapp.esign.survey.services; + +import com.sjmresorts.opsapp.esign.survey.models.EsignSurvey; +import com.sjmresorts.opsapp.esign.survey.repositories.EsignSurveyRepository; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Base64; +import java.util.Locale; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +public class EsignSurveyMediaService { + + public static final String STORED_PREFIX = "stored://"; + private static final Pattern DATA_URL = + Pattern.compile("^data:([^;]+);base64,(.+)$", Pattern.CASE_INSENSITIVE | Pattern.DOTALL); + + @Value("${ops-app.survey.media-dir:C:/auto-check-in/survey-logos}") + private String mediaDir; + + @Resource + private EsignSurveyRepository surveyRepository; + + private Path mediaRoot; + + @PostConstruct + void init() throws IOException { + mediaRoot = Paths.get(mediaDir).toAbsolutePath().normalize(); + Files.createDirectories(mediaRoot); + } + + public String saveBytes(String surveyId, byte[] bytes, String contentType) throws IOException { + if (StringUtils.isBlank(surveyId) || bytes == null || bytes.length == 0) { + throw new IOException("empty logo"); + } + String ext = extensionForMime(StringUtils.defaultString(contentType)); + Path target = mediaRoot.resolve(surveyId.trim() + ext); + Files.write(target, bytes); + deleteSiblingFiles(surveyId.trim(), target); + return STORED_PREFIX + surveyId.trim(); + } + + public String persistIfNeeded(String surveyId, String imageUrl) throws IOException { + if (StringUtils.isBlank(imageUrl)) { + deleteStoredFile(surveyId); + return null; + } + String trimmed = imageUrl.trim(); + if (trimmed.startsWith(STORED_PREFIX)) { + return trimmed; + } + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + deleteStoredFile(surveyId); + return trimmed; + } + if (isSurveyMediaPath(trimmed)) { + return STORED_PREFIX + surveyId; + } + if (!trimmed.startsWith("data:")) { + return trimmed; + } + ParsedDataUrl parsed = parseDataUrl(trimmed); + if (parsed == null) { + throw new IOException("invalid data URL"); + } + String ext = extensionForMime(parsed.mimeType()); + Path target = mediaRoot.resolve(surveyId + ext); + Files.write(target, parsed.bytes()); + deleteSiblingFiles(surveyId, target); + return STORED_PREFIX + surveyId; + } + + @Transactional + public Optional resolveMediaFile(String surveyId) { + if (StringUtils.isBlank(surveyId)) { + return Optional.empty(); + } + Optional opt = surveyRepository.findById(surveyId.trim()); + return opt.flatMap(this::resolveMediaFile); + } + + @Transactional + public Optional resolveMediaFile(EsignSurvey row) { + if (row == null) { + return Optional.empty(); + } + String imageUrl = StringUtils.trimToEmpty(row.getImageUrl()); + if (imageUrl.startsWith("data:")) { + try { + String stored = persistIfNeeded(row.getSurveyId(), imageUrl); + row.setImageUrl(stored); + surveyRepository.save(row); + imageUrl = StringUtils.trimToEmpty(stored); + } catch (IOException ignored) { + return Optional.empty(); + } + } + String fileId = resolveStoredFileId(row.getSurveyId(), imageUrl); + return findStoredFilePath(fileId); + } + + public String probeMimeForPath(Path path) throws IOException { + String probed = Files.probeContentType(path); + if (StringUtils.isNotBlank(probed)) { + return probed; + } + String name = path.getFileName().toString().toLowerCase(Locale.ROOT); + if (name.endsWith(".png")) return "image/png"; + if (name.endsWith(".webp")) return "image/webp"; + if (name.endsWith(".gif")) return "image/gif"; + return "image/jpeg"; + } + + public boolean shouldUseMediaEndpoint(EsignSurvey row) { + if (row == null || StringUtils.isBlank(row.getImageUrl())) { + return false; + } + String imageUrl = row.getImageUrl().trim(); + return imageUrl.startsWith(STORED_PREFIX) + || imageUrl.startsWith("data:") + || isSurveyMediaPath(imageUrl); + } + + public String toRelativeMediaPath(String surveyId) { + return "/esign/surveys/" + surveyId + "/media"; + } + + private static String resolveStoredFileId(String surveyId, String imageUrl) { + if (StringUtils.isBlank(imageUrl)) { + return surveyId; + } + if (imageUrl.startsWith(STORED_PREFIX)) { + return imageUrl.substring(STORED_PREFIX.length()); + } + if (isSurveyMediaPath(imageUrl)) { + return surveyId; + } + return surveyId; + } + + static boolean isSurveyMediaPath(String url) { + return StringUtils.isNotBlank(url) + && url.contains("/esign/surveys/") + && url.endsWith("/media"); + } + + private Optional findStoredFilePath(String surveyId) { + if (StringUtils.isBlank(surveyId)) { + return Optional.empty(); + } + for (Path path : candidatePaths(surveyId.trim())) { + if (Files.isRegularFile(path)) { + return Optional.of(path); + } + } + return Optional.empty(); + } + + private Path[] candidatePaths(String surveyId) { + return new Path[] { + mediaRoot.resolve(surveyId + ".jpg"), + mediaRoot.resolve(surveyId + ".jpeg"), + mediaRoot.resolve(surveyId + ".png"), + mediaRoot.resolve(surveyId + ".webp"), + mediaRoot.resolve(surveyId + ".gif") + }; + } + + private void deleteStoredFile(String surveyId) throws IOException { + if (StringUtils.isBlank(surveyId)) { + return; + } + deleteSiblingFiles(surveyId, null); + } + + private void deleteSiblingFiles(String surveyId, Path keep) throws IOException { + String prefix = surveyId + "."; + if (!Files.isDirectory(mediaRoot)) { + return; + } + try (var stream = Files.list(mediaRoot)) { + stream.filter(p -> p.getFileName().toString().startsWith(prefix)) + .filter(p -> keep == null || !p.equals(keep)) + .forEach(p -> { + try { + Files.deleteIfExists(p); + } catch (IOException ignored) { + } + }); + } + } + + private static String extensionForMime(String mimeType) { + String mime = StringUtils.defaultString(mimeType).toLowerCase(Locale.ROOT); + if (mime.contains("png")) return ".png"; + if (mime.contains("webp")) return ".webp"; + if (mime.contains("gif")) return ".gif"; + return ".jpg"; + } + + private static ParsedDataUrl parseDataUrl(String dataUrl) { + Matcher m = DATA_URL.matcher(dataUrl); + if (!m.matches()) { + return null; + } + try { + byte[] bytes = Base64.getDecoder().decode(m.group(2).replaceAll("\\s", "")); + return new ParsedDataUrl(StringUtils.defaultString(m.group(1)), bytes); + } catch (IllegalArgumentException e) { + return null; + } + } + + private record ParsedDataUrl(String mimeType, byte[] bytes) { + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/services/EsignSurveyPushService.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/services/EsignSurveyPushService.java new file mode 100644 index 0000000..6930a18 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/services/EsignSurveyPushService.java @@ -0,0 +1,487 @@ +package com.sjmresorts.opsapp.esign.survey.services; + +import com.sjmresorts.opsapp.esign.capture.dtos.EsignIosCaptureResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPendingListResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterDevicesResponse; +import com.sjmresorts.opsapp.esign.printer.dtos.EsignPrinterPushCandidatesResponse; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterPdfTaskService; +import com.sjmresorts.opsapp.esign.printer.services.EsignPrinterTabletRegistryService; +import com.sjmresorts.opsapp.esign.printer.support.EsignPrinterQueueNotifier; +import com.sjmresorts.opsapp.esign.survey.dtos.EsignSurveyPushAssignRequest; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPushGroup; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterPushGroupMember; +import com.sjmresorts.opsapp.esign.printer.models.EsignPrinterTabletRegistry; +import com.sjmresorts.opsapp.esign.survey.models.EsignSurvey; +import com.sjmresorts.opsapp.esign.survey.models.EsignSurveyPush; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterPushGroupMemberRepository; +import com.sjmresorts.opsapp.esign.printer.repositories.EsignPrinterPushGroupRepository; +import com.sjmresorts.opsapp.esign.survey.repositories.EsignSurveyPushRepository; +import com.sjmresorts.opsapp.esign.survey.repositories.EsignSurveyRepository; +import com.sjmresorts.opsapp.esign.auth.repositories.EsignUserRepository; +import com.sjmresorts.opsapp.esign.common.support.EsignReservationParamUtil; +import com.sjmresorts.opsapp.esign.auth.services.EsignUserPresenceService; +import com.sjmresorts.opsapp.esign.printer.websocket.EsignPrinterWebSocketHub; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class EsignSurveyPushService { + + public static final String DELIVERY_MANUAL_PUSH = "MANUAL_PUSH"; + public static final String DELIVERY_AUTO_AFTER_PDF_SIGN = "AUTO_AFTER_PDF_SIGN"; + private static final String DEFAULT_AGENT_SOURCE = "printer-agent"; + + @Resource + private EsignSurveyRepository surveyRepository; + + @Resource + private EsignSurveyPushRepository surveyPushRepository; + + @Resource + private EsignUserRepository esignUserRepository; + + @Resource + private EsignPrinterPushGroupRepository pushGroupRepository; + + @Resource + private EsignPrinterPushGroupMemberRepository pushGroupMemberRepository; + + @Resource + private EsignPrinterTabletRegistryService tabletRegistryService; + + @Resource + private EsignPrinterWebSocketHub printerWebSocketHub; + + @Resource + private EsignPrinterQueueNotifier printerQueueNotifier; + + @Resource + @Lazy + private EsignPrinterPdfTaskService printerPdfTaskService; + + @Transactional(readOnly = true) + public EsignPrinterPushCandidatesResponse getPushCandidates(String surveyId) { + if (StringUtils.isBlank(surveyId)) { + return EsignPrinterPushCandidatesResponse.fail("400", "surveyId required"); + } + Optional surveyOpt = surveyRepository.findById(surveyId.trim()); + if (surveyOpt.isEmpty()) { + return EsignPrinterPushCandidatesResponse.fail("404", "Survey not found"); + } + EsignSurvey survey = surveyOpt.get(); + if (!DELIVERY_MANUAL_PUSH.equals(normalizeDeliveryMode(survey.getDeliveryMode()))) { + return EsignPrinterPushCandidatesResponse.fail("409", "Survey delivery mode is not MANUAL_PUSH"); + } + String agentSource = DEFAULT_AGENT_SOURCE; + List devices = + buildPushDeviceOptionsFromDevicesApi(); + List groups = pushGroupRepository.findByAgentSourceOrderByGroupNameAsc(agentSource); + List groupOptions = new ArrayList<>(); + for (EsignPrinterPushGroup group : groups) { + List members = pushGroupMemberRepository.findByGroupId(group.getGroupId()); + EsignPrinterPushCandidatesResponse.EsignPrinterPushGroupOption g = + new EsignPrinterPushCandidatesResponse.EsignPrinterPushGroupOption(); + g.setGroupId(group.getGroupId()); + g.setGroupName(group.getGroupName()); + g.setMemberCount(members.size()); + g.setMemberUserIds(members.stream().map(EsignPrinterPushGroupMember::getUserId).toList()); + groupOptions.add(g); + } + List selectedUserIds = surveyPushRepository.findBySurveyId(survey.getSurveyId()).stream() + .map(EsignSurveyPush::getUserId) + .distinct() + .toList(); + List selectedRegistryIds = resolveSelectedRegistryIds(survey.getSurveyId(), devices); + + EsignPrinterPushCandidatesResponse resp = EsignPrinterPushCandidatesResponse.ok("ok"); + EsignPrinterPushCandidatesResponse.EsignPrinterPushCandidatesData data = + new EsignPrinterPushCandidatesResponse.EsignPrinterPushCandidatesData(); + data.setTaskId(survey.getSurveyId()); + data.setAgentSource(agentSource); + data.setDevices(devices); + data.setGroups(groupOptions); + data.setSelectedUserIds(selectedUserIds); + data.setSelectedRegistryIds(selectedRegistryIds); + data.setSelectedGroupIds(List.of()); + data.setPushMode("MANUAL"); + resp.setData(data); + return resp; + } + + @Transactional + public EsignIosCaptureResponse assignPushTargets(EsignSurveyPushAssignRequest request) { + EsignIosCaptureResponse resp = new EsignIosCaptureResponse(); + if (request == null || StringUtils.isBlank(request.getSurveyId())) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("surveyId required"); + return resp; + } + Optional surveyOpt = surveyRepository.findById(request.getSurveyId().trim()); + if (surveyOpt.isEmpty()) { + resp.setSuccess(false); + resp.setCode("404"); + resp.setMessage("Survey not found"); + return resp; + } + EsignSurvey survey = surveyOpt.get(); + if (!EsignSurveyService.STATUS_PUBLISHED.equals(survey.getStatus())) { + resp.setSuccess(false); + resp.setCode("409"); + resp.setMessage("Survey not published"); + return resp; + } + if (!DELIVERY_MANUAL_PUSH.equals(normalizeDeliveryMode(survey.getDeliveryMode()))) { + resp.setSuccess(false); + resp.setCode("409"); + resp.setMessage("Survey delivery mode is not MANUAL_PUSH"); + return resp; + } + Set targetUserIds = resolveTargetUserIds(request); + List pushTargets = resolvePushTargetRows(request, targetUserIds); + if (pushTargets.isEmpty()) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("No push targets selected"); + return resp; + } + surveyPushRepository.deleteBySurveyId(survey.getSurveyId()); + Instant now = Instant.now(); + String by = trim(request.getAssignedBy(), 64); + Set notifyUserIds = new LinkedHashSet<>(); + for (PushTargetRow target : pushTargets) { + EsignSurveyPush row = new EsignSurveyPush(); + row.setSurveyId(survey.getSurveyId()); + row.setUserId(target.userId()); + row.setClientIp(target.clientIp() != null ? target.clientIp() : ""); + row.setAssignedAt(now); + row.setAssignedBy(by); + surveyPushRepository.save(row); + notifyUserIds.add(target.userId()); + } + notifyPushTargets(survey, notifyUserIds); + printerQueueNotifier.notifyChangedAfterCommit(); + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("Survey push assigned"); + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(survey.getSurveyId()); + resp.setData(data); + log.info("Survey push assigned surveyId={}, targets={}", survey.getSurveyId(), targetUserIds.size()); + return resp; + } + + @Transactional + public EsignIosCaptureResponse completePushForUser(String surveyId, Long userId, String clientIp) { + EsignIosCaptureResponse resp = new EsignIosCaptureResponse(); + if (StringUtils.isBlank(surveyId)) { + resp.setSuccess(false); + resp.setCode("400"); + resp.setMessage("surveyId required"); + return resp; + } + if (userId == null) { + resp.setSuccess(false); + resp.setCode("401"); + resp.setMessage("login required"); + return resp; + } + String normalizedIp = EsignUserPresenceService.normalizeClientIp(clientIp); + List rows = surveyPushRepository.findBySurveyId(surveyId.trim()); + boolean removed = false; + for (EsignSurveyPush row : rows) { + if (!userId.equals(row.getUserId())) { + continue; + } + String rowIp = StringUtils.defaultString(row.getClientIp()); + if (rowIp.isEmpty() || normalizedIp == null || rowIp.equals(normalizedIp)) { + surveyPushRepository.delete(row); + removed = true; + } + } + if (!removed) { + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("Already completed"); + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(surveyId.trim()); + resp.setData(data); + return resp; + } + printerQueueNotifier.notifyChangedAfterCommit(); + resp.setSuccess(true); + resp.setCode("200"); + resp.setMessage("Survey push completed"); + EsignIosCaptureResponse.EsignIosCaptureData data = new EsignIosCaptureResponse.EsignIosCaptureData(); + data.setTaskId(surveyId.trim()); + resp.setData(data); + log.info("Survey push completed surveyId={}, userId={}", surveyId.trim(), userId); + return resp; + } + + @Transactional(readOnly = true) + public List listPendingItemsForUser( + Long userId, String clientIp, String hotelId) { + if (userId == null) { + return List.of(); + } + String normalizedHotelId = StringUtils.isNotBlank(hotelId) + ? EsignReservationParamUtil.normalizeParam(hotelId) + : null; + String normalizedClientIp = EsignUserPresenceService.normalizeClientIp(clientIp); + List pushedSurveyIds = surveyPushRepository.findSurveyIdsByUserId(userId); + if (pushedSurveyIds.isEmpty()) { + return List.of(); + } + List surveys = surveyRepository.findAllById(pushedSurveyIds).stream() + .filter(s -> EsignSurveyService.STATUS_PUBLISHED.equals(s.getStatus())) + .filter(s -> DELIVERY_MANUAL_PUSH.equals(normalizeDeliveryMode(s.getDeliveryMode()))) + .filter(s -> normalizedHotelId == null + || StringUtils.isBlank(s.getHotelId()) + || normalizedHotelId.equals(s.getHotelId())) + .sorted(Comparator.comparing(EsignSurvey::getSortOrder, Comparator.nullsLast(Integer::compareTo)) + .thenComparing(EsignSurvey::getUpdatedAt, Comparator.nullsLast(Comparator.reverseOrder()))) + .toList(); + List items = new ArrayList<>(); + for (EsignSurvey survey : surveys) { + if (!isVisibleToUser(survey.getSurveyId(), userId, normalizedClientIp)) { + continue; + } + EsignPendingListResponse.EsignPendingItem item = + new EsignPendingListResponse.EsignPendingItem(); + item.setTaskId(survey.getSurveyId()); + item.setTaskType("SURVEY"); + item.setDocTitle(survey.getTitle()); + item.setGuestName(survey.getRewardText()); + item.setCreatedAt(survey.getPublishedAt() != null ? survey.getPublishedAt().toString() : null); + items.add(item); + } + return items; + } + + @Transactional(readOnly = true) + public Optional resolveAutoSurveyAfterPdfSign(String hotelId) { + String normalizedHotelId = StringUtils.isNotBlank(hotelId) + ? EsignReservationParamUtil.normalizeParam(hotelId) + : null; + return surveyRepository.findByStatusOrderBySortOrderAscUpdatedAtDesc(EsignSurveyService.STATUS_PUBLISHED) + .stream() + .filter(s -> DELIVERY_AUTO_AFTER_PDF_SIGN.equals(normalizeDeliveryMode(s.getDeliveryMode()))) + .filter(s -> normalizedHotelId == null + || StringUtils.isBlank(s.getHotelId()) + || normalizedHotelId.equals(s.getHotelId())) + .findFirst(); + } + + @Transactional(readOnly = true) + public boolean isVisibleToUser(String surveyId, Long userId, String clientIp) { + if (userId == null || StringUtils.isBlank(surveyId)) { + return false; + } + List rows = surveyPushRepository.findBySurveyId(surveyId.trim()); + if (rows.isEmpty()) { + return false; + } + String ip = EsignUserPresenceService.normalizeClientIp(clientIp); + for (EsignSurveyPush row : rows) { + if (!userId.equals(row.getUserId())) { + continue; + } + String rowIp = StringUtils.defaultString(row.getClientIp()); + if (rowIp.isEmpty() || ip == null || rowIp.equals(ip)) { + return true; + } + } + return false; + } + + public static String normalizeDeliveryMode(String mode) { + if (StringUtils.isBlank(mode)) { + return DELIVERY_MANUAL_PUSH; + } + String t = mode.trim().toUpperCase(); + if (DELIVERY_AUTO_AFTER_PDF_SIGN.equals(t)) { + return DELIVERY_AUTO_AFTER_PDF_SIGN; + } + return DELIVERY_MANUAL_PUSH; + } + + private Set resolveTargetUserIds(EsignSurveyPushAssignRequest request) { + Set targetUserIds = new LinkedHashSet<>(); + if (request.getRegistryIds() != null && !request.getRegistryIds().isEmpty()) { + for (String registryId : request.getRegistryIds()) { + if (StringUtils.isBlank(registryId)) { + continue; + } + tabletRegistryService.listAllOrdered().stream() + .filter(reg -> registryId.trim().equals(reg.getRegistryId())) + .findFirst() + .ifPresent(reg -> { + if (reg.getUserId() != null && isPushableUserId(reg.getUserId())) { + targetUserIds.add(reg.getUserId()); + } + }); + } + } + if (request.getUserIds() != null) { + for (Long uid : request.getUserIds()) { + if (uid != null && isPushableUserId(uid)) { + targetUserIds.add(uid); + } + } + } + if (request.getGroupIds() != null && !request.getGroupIds().isEmpty()) { + for (Long uid : pushGroupMemberRepository.findUserIdsByGroupIdIn(request.getGroupIds())) { + if (isPushableUserId(uid)) { + targetUserIds.add(uid); + } + } + } + return targetUserIds; + } + + private List resolvePushTargetRows( + EsignSurveyPushAssignRequest request, Set targetUserIds) { + List rows = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + if (request.getRegistryIds() != null && !request.getRegistryIds().isEmpty()) { + Map registryById = tabletRegistryService.listAllOrdered().stream() + .collect(Collectors.toMap(EsignPrinterTabletRegistry::getRegistryId, r -> r, (a, b) -> a)); + for (String registryId : request.getRegistryIds()) { + if (StringUtils.isBlank(registryId)) { + continue; + } + EsignPrinterTabletRegistry reg = registryById.get(registryId.trim()); + if (reg == null || reg.getUserId() == null || !isPushableUserId(reg.getUserId())) { + continue; + } + String ip = EsignPrinterTabletRegistryService.boundClientIpOrEmpty(reg.getClientIp()); + String key = reg.getUserId() + "#" + ip; + if (seen.add(key)) { + rows.add(new PushTargetRow(reg.getUserId(), ip)); + } + } + if (!rows.isEmpty()) { + return rows; + } + } + for (Long userId : targetUserIds) { + Set clientIps = tabletRegistryService.resolvePushTargetClientIpsForUser(userId); + if (clientIps.isEmpty()) { + clientIps = Set.of(""); + } + for (String clientIp : clientIps) { + String ip = clientIp != null ? clientIp : ""; + String key = userId + "#" + ip; + if (seen.add(key)) { + rows.add(new PushTargetRow(userId, ip)); + } + } + } + return rows; + } + + private boolean isPushableUserId(Long userId) { + if (userId == null) { + return false; + } + return esignUserRepository.findById(userId).isPresent(); + } + + /** 与 CMS「设备管理」平板列表同源,展示全部平板(不要求设备连接审批)。 */ + private List buildPushDeviceOptionsFromDevicesApi() { + EsignPrinterDevicesResponse devicesResponse = printerPdfTaskService.getDevices(); + List tablets = + devicesResponse.getData() != null && devicesResponse.getData().getTabletDevices() != null + ? devicesResponse.getData().getTabletDevices() + : List.of(); + List devices = new ArrayList<>(); + for (EsignPrinterDevicesResponse.EsignPrinterTabletDevice tab : tablets) { + if (tab.getUserId() == null) { + continue; + } + EsignPrinterPushCandidatesResponse.EsignPrinterPushDeviceOption opt = + new EsignPrinterPushCandidatesResponse.EsignPrinterPushDeviceOption(); + opt.setUserId(tab.getUserId()); + opt.setUserName(tab.getUserName()); + opt.setDisplayName(tab.getDisplayName()); + opt.setRegistryId(tab.getRegistryId()); + String boundIp = StringUtils.isNotBlank(tab.getBoundClientIp()) + ? tab.getBoundClientIp() + : EsignUserPresenceService.normalizeClientIp(tab.getLastLoginIp()); + opt.setClientIp(boundIp != null ? boundIp : ""); + opt.setPushEnabled(tab.isPushEnabled()); + opt.setDeliveryMode( + StringUtils.defaultIfBlank(tab.getDeliveryMode(), tab.isPushEnabled() ? "FCM 推送" : "仅轮询")); + devices.add(opt); + } + return devices; + } + + private List resolveSelectedRegistryIds( + String surveyId, + List devices) { + List pushes = surveyPushRepository.findBySurveyId(surveyId); + if (pushes.isEmpty() || devices == null || devices.isEmpty()) { + return List.of(); + } + Map registryIdByKey = new HashMap<>(); + for (EsignPrinterPushCandidatesResponse.EsignPrinterPushDeviceOption device : devices) { + if (StringUtils.isBlank(device.getRegistryId()) || device.getUserId() == null) { + continue; + } + String ip = device.getClientIp() != null ? device.getClientIp() : ""; + registryIdByKey.put(device.getUserId() + "#" + ip, device.getRegistryId()); + } + List selected = new ArrayList<>(); + for (EsignSurveyPush push : pushes) { + String ip = push.getClientIp() != null ? push.getClientIp() : ""; + String registryId = registryIdByKey.get(push.getUserId() + "#" + ip); + if (registryId != null) { + selected.add(registryId); + } + } + return selected.stream().distinct().toList(); + } + + private record PushTargetRow(Long userId, String clientIp) {} + + private void notifyPushTargets(EsignSurvey survey, Collection userIds) { + if (survey == null || userIds == null || userIds.isEmpty()) { + return; + } + printerWebSocketHub.notifySurveyAssigned( + userIds, + survey.getSurveyId(), + survey.getTitle(), + tabletRegistryService.resolvePushClientIpFilters(userIds)); + } + + private static String trim(String s, int max) { + if (s == null) { + return null; + } + String t = s.trim(); + if (t.isEmpty()) { + return null; + } + return t.length() > max ? t.substring(0, max) : t; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/esign/survey/services/EsignSurveyService.java b/src/main/java/com/sjmresorts/opsapp/esign/survey/services/EsignSurveyService.java new file mode 100644 index 0000000..5eb6df4 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/esign/survey/services/EsignSurveyService.java @@ -0,0 +1,668 @@ +package com.sjmresorts.opsapp.esign.survey.services; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sjmresorts.opsapp.esign.survey.dtos.ExternalSurveyUrlParseResponse; +import com.sjmresorts.opsapp.esign.survey.dtos.EsignSurveyExternalWebhookRequest; +import com.sjmresorts.opsapp.esign.survey.dtos.EsignSurveyResponse; +import com.sjmresorts.opsapp.esign.survey.dtos.EsignSurveySaveRequest; +import com.sjmresorts.opsapp.esign.survey.dtos.EsignSurveySubmitRequest; +import com.sjmresorts.opsapp.esign.survey.models.EsignSurvey; +import com.sjmresorts.opsapp.esign.survey.models.EsignSurveySubmission; +import com.sjmresorts.opsapp.esign.survey.repositories.EsignSurveyRepository; +import com.sjmresorts.opsapp.esign.survey.repositories.EsignSurveySubmissionRepository; +import com.sjmresorts.opsapp.esign.common.support.EsignReservationParamUtil; +import com.sjmresorts.opsapp.esign.survey.dtos.ExternalSurveyUrlParser; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class EsignSurveyService { + + public static final String STATUS_DRAFT = "DRAFT"; + public static final String STATUS_PUBLISHED = "PUBLISHED"; + public static final String STATUS_ARCHIVED = "ARCHIVED"; + public static final String SOURCE_NATIVE = "NATIVE"; + public static final String SOURCE_EXTERNAL = "EXTERNAL"; + + private static final Set ALLOWED_SOURCE_TYPES = Set.of(SOURCE_NATIVE, SOURCE_EXTERNAL); + private static final Set ALLOWED_DISPLAY_TYPES = Set.of("CARD", "IMAGE"); + private static final int MAX_IMAGE_URL_LENGTH = 2_000_000; + + @Resource + private EsignSurveyRepository surveyRepository; + + @Resource + private EsignSurveySubmissionRepository submissionRepository; + + @Resource + private ObjectMapper objectMapper; + + @Resource + private EsignSurveyMediaService mediaService; + + public EsignSurveyResponse listAllForAdmin() { + List rows = surveyRepository.findAllByOrderBySortOrderAscUpdatedAtDesc(); + EsignSurveyResponse r = EsignSurveyResponse.ok("ok"); + EsignSurveyResponse.EsignSurveyData data = new EsignSurveyResponse.EsignSurveyData(); + data.setSurveys(rows.stream().map(this::toListItem).collect(Collectors.toList())); + r.setData(data); + return r; + } + + public EsignSurveyResponse listPublished(String hotelId) { + List rows = surveyRepository.findByStatusOrderBySortOrderAscUpdatedAtDesc(STATUS_PUBLISHED); + String normalizedHotelId = StringUtils.isNotBlank(hotelId) + ? EsignReservationParamUtil.normalizeParam(hotelId) + : null; + List filtered = new ArrayList<>(); + for (EsignSurvey row : rows) { + if (normalizedHotelId != null && StringUtils.isNotBlank(row.getHotelId()) + && !normalizedHotelId.equals(row.getHotelId())) { + continue; + } + filtered.add(toListItem(row)); + } + EsignSurveyResponse r = EsignSurveyResponse.ok("ok"); + EsignSurveyResponse.EsignSurveyData data = new EsignSurveyResponse.EsignSurveyData(); + data.setSurveys(filtered); + r.setData(data); + return r; + } + + public EsignSurveyResponse getDetail(String surveyId, boolean includeQuestions) { + Optional opt = findSurvey(surveyId); + if (opt.isEmpty()) { + return EsignSurveyResponse.fail("404", "Survey not found"); + } + EsignSurveyResponse r = EsignSurveyResponse.ok("ok"); + EsignSurveyResponse.EsignSurveyData data = new EsignSurveyResponse.EsignSurveyData(); + data.setSurvey(toClientItem(opt.get(), includeQuestions)); + r.setData(data); + return r; + } + + @Transactional + public EsignSurveyResponse save(EsignSurveySaveRequest request) { + if (request == null || StringUtils.isBlank(request.getTitle())) { + return EsignSurveyResponse.fail("400", "title required"); + } + String sourceType = normalizeSourceType(request.getSourceType()); + boolean external = SOURCE_EXTERNAL.equals(sourceType); + + String questionsJson; + try { + questionsJson = objectMapper.writeValueAsString( + request.getQuestions() != null ? request.getQuestions() : List.of()); + } catch (JsonProcessingException e) { + return EsignSurveyResponse.fail("400", "invalid questions"); + } + + Instant now = Instant.now(); + EsignSurvey entity; + if (StringUtils.isNotBlank(request.getSurveyId())) { + Optional opt = findSurvey(request.getSurveyId()); + if (opt.isEmpty()) { + return EsignSurveyResponse.fail("404", "Survey not found"); + } + entity = opt.get(); + } else { + entity = new EsignSurvey(); + entity.setSurveyId(UUID.randomUUID().toString()); + entity.setStatus(STATUS_DRAFT); + entity.setCreatedAt(now); + entity.setWebhookSecret(UUID.randomUUID().toString().replace("-", "")); + } + + entity.setTitle(trim(request.getTitle(), 256)); + entity.setDescription(trim(request.getDescription(), 1000)); + entity.setRewardText(trim(request.getRewardText(), 256)); + entity.setDisplayType(normalizeDisplayType(request.getDisplayType())); + String imageUrl = resolveImageUrlForSave(entity, request.getImageUrl()); + if ("IMAGE".equals(entity.getDisplayType()) && StringUtils.isBlank(imageUrl)) { + return EsignSurveyResponse.fail("400", "imageUrl required for IMAGE display"); + } + entity.setImageUrl(imageUrl); + entity.setHeaderHotelName(trim(request.getHeaderHotelName(), 256)); + String hotelId = trim(request.getHotelId(), 64); + entity.setHotelId(StringUtils.isNotBlank(hotelId) ? EsignReservationParamUtil.normalizeParam(hotelId) : null); + entity.setSortOrder(request.getSortOrder() != null ? request.getSortOrder() : 0); + entity.setSourceType(sourceType); + entity.setDeliveryMode( + EsignSurveyPushService.normalizeDeliveryMode(request.getDeliveryMode())); + if (external) { + String externalUrl = trim(request.getExternalUrl(), 2000); + if (StringUtils.isBlank(externalUrl)) { + return EsignSurveyResponse.fail("400", "externalUrl required for EXTERNAL survey"); + } + ExternalSurveyUrlParser.ParseResult parsed = + ExternalSurveyUrlParser.parse(externalUrl).orElse(null); + if (parsed == null) { + return EsignSurveyResponse.fail("400", "unsupported or invalid external survey URL"); + } + entity.setExternalUrl(parsed.normalizedUrl()); + entity.setExternalProvider(parsed.provider()); + entity.setExternalFormId(parsed.formId()); + entity.setExternalEmbedUrl(parsed.embedUrl()); + entity.setQuestionsJson("[]"); + if (StringUtils.isBlank(entity.getWebhookSecret())) { + entity.setWebhookSecret(UUID.randomUUID().toString().replace("-", "")); + } + } else { + entity.setExternalUrl(null); + entity.setExternalProvider(null); + entity.setExternalFormId(null); + entity.setExternalEmbedUrl(null); + entity.setQuestionsJson(questionsJson); + } + entity.setUpdatedAt(now); + surveyRepository.save(entity); + + EsignSurveyResponse r = EsignSurveyResponse.ok("saved"); + EsignSurveyResponse.EsignSurveyData data = new EsignSurveyResponse.EsignSurveyData(); + data.setSurveyId(entity.getSurveyId()); + data.setSurvey(toClientItem(entity, true)); + r.setData(data); + return r; + } + + @Transactional + public EsignSurveyResponse uploadLogo(String surveyId, byte[] bytes, String contentType) { + if (StringUtils.isBlank(surveyId) || bytes == null || bytes.length == 0) { + return EsignSurveyResponse.fail("400", "file required"); + } + Optional opt = findSurvey(surveyId); + if (opt.isEmpty()) { + return EsignSurveyResponse.fail("404", "Survey not found"); + } + EsignSurvey entity = opt.get(); + try { + String stored = mediaService.saveBytes(entity.getSurveyId(), bytes, contentType); + entity.setImageUrl(stored); + entity.setUpdatedAt(Instant.now()); + surveyRepository.save(entity); + } catch (Exception e) { + return EsignSurveyResponse.fail("500", "failed to store survey logo"); + } + EsignSurveyResponse r = EsignSurveyResponse.ok("logo uploaded"); + EsignSurveyResponse.EsignSurveyData data = new EsignSurveyResponse.EsignSurveyData(); + data.setSurveyId(entity.getSurveyId()); + data.setSurvey(toClientItem(entity, true)); + r.setData(data); + return r; + } + + public ExternalSurveyUrlParseResponse parseExternalUrl(String url) { + return ExternalSurveyUrlParser.parse(url) + .map(parsed -> { + ExternalSurveyUrlParseResponse.ParseData data = + new ExternalSurveyUrlParseResponse.ParseData(); + data.setProvider(parsed.provider()); + data.setFormId(parsed.formId()); + data.setNormalizedUrl(parsed.normalizedUrl()); + data.setEmbedUrl(parsed.embedUrl()); + return ExternalSurveyUrlParseResponse.ok(data); + }) + .orElse(ExternalSurveyUrlParseResponse.fail("400", "unsupported or invalid external survey URL")); + } + + public EsignSurveyResponse listSubmissions(String surveyId) { + Optional opt = findSurvey(surveyId); + if (opt.isEmpty()) { + return EsignSurveyResponse.fail("404", "Survey not found"); + } + List rows = + submissionRepository.findBySurveyIdOrderBySubmittedAtDesc(surveyId.trim()); + EsignSurveyResponse r = EsignSurveyResponse.ok("ok"); + EsignSurveyResponse.EsignSurveyData data = new EsignSurveyResponse.EsignSurveyData(); + data.setSurveyId(surveyId.trim()); + data.setSubmissions(rows.stream().map(this::toSubmissionItem).collect(Collectors.toList())); + r.setData(data); + return r; + } + + @Transactional + public EsignSurveyResponse ingestExternalWebhook(EsignSurveyExternalWebhookRequest request) { + if (request == null) { + return EsignSurveyResponse.fail("400", "request required"); + } + Optional surveyOpt = resolveSurveyForWebhook(request); + if (surveyOpt.isEmpty()) { + return EsignSurveyResponse.fail("404", "Survey not found for webhook"); + } + EsignSurvey survey = surveyOpt.get(); + if (!SOURCE_EXTERNAL.equals(normalizeSourceType(survey.getSourceType()))) { + return EsignSurveyResponse.fail("409", "Survey is not external"); + } + if (!matchesWebhookSecret(survey, request.getSecret())) { + return EsignSurveyResponse.fail("403", "invalid webhook secret"); + } + String externalResponseId = trim(request.getExternalResponseId(), 256); + if (StringUtils.isNotBlank(externalResponseId)) { + Optional dup = + submissionRepository.findFirstBySurveyIdAndExternalResponseId( + survey.getSurveyId(), externalResponseId); + if (dup.isPresent()) { + EsignSurveyResponse r = EsignSurveyResponse.ok("duplicate ignored"); + EsignSurveyResponse.EsignSurveyData data = new EsignSurveyResponse.EsignSurveyData(); + data.setSubmissionId(dup.get().getSubmissionId()); + r.setData(data); + return r; + } + } + Map answers = resolveExternalAnswers(request); + if (answers.isEmpty()) { + return EsignSurveyResponse.fail("400", "answers or raw payload required"); + } + Map meta = new LinkedHashMap<>(); + if (StringUtils.isNotBlank(request.getRespondentEmail())) { + meta.put("email", request.getRespondentEmail().trim()); + } + if (StringUtils.isNotBlank(request.getRespondentName())) { + meta.put("name", request.getRespondentName().trim()); + } + if (StringUtils.isNotBlank(request.getExternalFormId())) { + meta.put("externalFormId", request.getExternalFormId().trim()); + } + return persistExternalSubmission( + survey, + answers, + meta, + externalResponseId, + trim(request.getDeviceId(), 128), + request.getHotelId()); + } + + @Transactional + public EsignSurveyResponse publish(String surveyId) { + Optional opt = findSurvey(surveyId); + if (opt.isEmpty()) { + return EsignSurveyResponse.fail("404", "Survey not found"); + } + EsignSurvey entity = opt.get(); + if (SOURCE_EXTERNAL.equals(normalizeSourceType(entity.getSourceType())) + && StringUtils.isBlank(entity.getExternalEmbedUrl())) { + return EsignSurveyResponse.fail("400", "external survey URL not configured"); + } + entity.setStatus(STATUS_PUBLISHED); + entity.setPublishedAt(Instant.now()); + entity.setUpdatedAt(Instant.now()); + surveyRepository.save(entity); + return EsignSurveyResponse.ok("published"); + } + + @Transactional + public EsignSurveyResponse archive(String surveyId) { + Optional opt = findSurvey(surveyId); + if (opt.isEmpty()) { + return EsignSurveyResponse.fail("404", "Survey not found"); + } + EsignSurvey entity = opt.get(); + entity.setStatus(STATUS_ARCHIVED); + entity.setUpdatedAt(Instant.now()); + surveyRepository.save(entity); + return EsignSurveyResponse.ok("archived"); + } + + @Transactional + public EsignSurveyResponse delete(String surveyId) { + Optional opt = findSurvey(surveyId); + if (opt.isEmpty()) { + return EsignSurveyResponse.fail("404", "Survey not found"); + } + surveyRepository.delete(opt.get()); + try { + mediaService.persistIfNeeded(surveyId, null); + } catch (Exception ignored) { + } + return EsignSurveyResponse.ok("deleted"); + } + + @Transactional + public EsignSurveyResponse submit(String surveyId, EsignSurveySubmitRequest request) { + Optional opt = findSurvey(surveyId); + if (opt.isEmpty()) { + return EsignSurveyResponse.fail("404", "Survey not found"); + } + EsignSurvey survey = opt.get(); + if (!STATUS_PUBLISHED.equals(survey.getStatus())) { + return EsignSurveyResponse.fail("409", "Survey not published"); + } + if (SOURCE_EXTERNAL.equals(normalizeSourceType(survey.getSourceType()))) { + return EsignSurveyResponse.fail("409", "External survey answers must be sent via webhook"); + } + Map answers = resolveAnswers(request); + if (answers.isEmpty()) { + return EsignSurveyResponse.fail("400", "answers required"); + } + String answersJson; + try { + answersJson = objectMapper.writeValueAsString(answers); + } catch (JsonProcessingException e) { + return EsignSurveyResponse.fail("500", "failed to serialize answers"); + } + + EsignSurveySubmission sub = new EsignSurveySubmission(); + sub.setSubmissionId(UUID.randomUUID().toString()); + sub.setSurveyId(survey.getSurveyId()); + sub.setAnswersJson(answersJson); + sub.setDeviceId(trim(request != null ? request.getDeviceId() : null, 128)); + String hotelId = request != null ? request.getHotelId() : null; + sub.setHotelId(StringUtils.isNotBlank(hotelId) ? EsignReservationParamUtil.normalizeParam(hotelId) : null); + sub.setSourceType(SOURCE_NATIVE); + sub.setSubmittedAt(Instant.now()); + submissionRepository.save(sub); + + EsignSurveyResponse r = EsignSurveyResponse.ok("submitted"); + EsignSurveyResponse.EsignSurveyData data = new EsignSurveyResponse.EsignSurveyData(); + data.setSubmissionId(sub.getSubmissionId()); + r.setData(data); + return r; + } + + private Map resolveAnswers(EsignSurveySubmitRequest request) { + if (request == null) { + return Map.of(); + } + if (request.getAnswers() != null && !request.getAnswers().isEmpty()) { + return request.getAnswers(); + } + if (request.getAnswerList() == null || request.getAnswerList().isEmpty()) { + return Map.of(); + } + Map out = new HashMap<>(); + for (Map row : request.getAnswerList()) { + if (row == null) { + continue; + } + Object qid = row.get("questionId"); + if (qid == null) { + qid = row.get("id"); + } + if (qid == null) { + continue; + } + Object ans = row.containsKey("answer") ? row.get("answer") : row.get("value"); + out.put(String.valueOf(qid), ans); + } + return out; + } + + private Optional findSurvey(String surveyId) { + if (StringUtils.isBlank(surveyId)) { + return Optional.empty(); + } + return surveyRepository.findById(surveyId.trim()); + } + + private EsignSurveyResponse.EsignSurveyItem toListItem(EsignSurvey row) { + return toClientItem(row, false); + } + + private EsignSurveyResponse.EsignSurveyItem toClientItem(EsignSurvey row, boolean includeQuestions) { + EsignSurveyResponse.EsignSurveyItem item = toDetailItem(row, includeQuestions); + item.setImageUrl(resolveClientImageUrl(row)); + return item; + } + + private EsignSurveyResponse.EsignSurveyItem toDetailItem(EsignSurvey row, boolean includeQuestions) { + EsignSurveyResponse.EsignSurveyItem item = new EsignSurveyResponse.EsignSurveyItem(); + item.setSurveyId(row.getSurveyId()); + item.setTitle(row.getTitle()); + item.setDescription(row.getDescription()); + item.setRewardText(row.getRewardText()); + item.setDisplayType(StringUtils.defaultIfBlank(row.getDisplayType(), "CARD")); + item.setImageUrl(row.getImageUrl()); + item.setHeaderHotelName(row.getHeaderHotelName()); + item.setHotelId(row.getHotelId()); + item.setStatus(row.getStatus()); + item.setSortOrder(row.getSortOrder()); + item.setSourceType(normalizeSourceType(row.getSourceType())); + item.setDeliveryMode(EsignSurveyPushService.normalizeDeliveryMode(row.getDeliveryMode())); + item.setExternalUrl(row.getExternalUrl()); + item.setExternalProvider(row.getExternalProvider()); + item.setExternalFormId(row.getExternalFormId()); + item.setExternalEmbedUrl(row.getExternalEmbedUrl()); + item.setSubmissionCount((int) submissionRepository.countBySurveyId(row.getSurveyId())); + if (SOURCE_EXTERNAL.equals(item.getSourceType()) && StringUtils.isNotBlank(row.getWebhookSecret())) { + item.setWebhookUrl("/api/v1/esign/surveys/external/webhook"); + item.setWebhookSecret(row.getWebhookSecret()); + } + item.setQuestionCount( + SOURCE_EXTERNAL.equals(item.getSourceType()) + ? 0 + : parseQuestions(row.getQuestionsJson()).size()); + item.setCreatedAt(row.getCreatedAt() != null ? row.getCreatedAt().toString() : null); + item.setUpdatedAt(row.getUpdatedAt() != null ? row.getUpdatedAt().toString() : null); + item.setPublishedAt(row.getPublishedAt() != null ? row.getPublishedAt().toString() : null); + if (includeQuestions && !SOURCE_EXTERNAL.equals(item.getSourceType())) { + item.setQuestions(parseQuestions(row.getQuestionsJson())); + } + return item; + } + + private List> parseQuestions(String json) { + if (StringUtils.isBlank(json)) { + return List.of(); + } + try { + return objectMapper.readValue(json, new TypeReference>>() {}); + } catch (Exception e) { + return List.of(); + } + } + + private static String normalizeSourceType(String sourceType) { + if (StringUtils.isBlank(sourceType)) { + return SOURCE_NATIVE; + } + String t = sourceType.trim().toUpperCase(); + return ALLOWED_SOURCE_TYPES.contains(t) ? t : SOURCE_NATIVE; + } + + private Optional resolveSurveyForWebhook(EsignSurveyExternalWebhookRequest request) { + if (StringUtils.isNotBlank(request.getSurveyId())) { + return findSurvey(request.getSurveyId()); + } + String formId = trim(request.getExternalFormId(), 512); + if (StringUtils.isBlank(formId)) { + return Optional.empty(); + } + return surveyRepository.findAllByOrderBySortOrderAscUpdatedAtDesc().stream() + .filter(row -> formId.equals(row.getExternalFormId())) + .findFirst(); + } + + private static boolean matchesWebhookSecret(EsignSurvey survey, String secret) { + if (survey == null || StringUtils.isBlank(survey.getWebhookSecret())) { + return false; + } + return survey.getWebhookSecret().equals(StringUtils.trimToEmpty(secret)); + } + + private Map resolveExternalAnswers(EsignSurveyExternalWebhookRequest request) { + if (request.getAnswers() != null && !request.getAnswers().isEmpty()) { + return request.getAnswers(); + } + if (request.getRaw() != null && !request.getRaw().isEmpty()) { + return request.getRaw(); + } + return Map.of(); + } + + private EsignSurveyResponse persistExternalSubmission( + EsignSurvey survey, + Map answers, + Map meta, + String externalResponseId, + String deviceId, + String hotelId) { + String answersJson; + String metaJson = null; + try { + answersJson = objectMapper.writeValueAsString(answers); + if (!meta.isEmpty()) { + metaJson = objectMapper.writeValueAsString(meta); + } + } catch (JsonProcessingException e) { + return EsignSurveyResponse.fail("500", "failed to serialize webhook payload"); + } + EsignSurveySubmission sub = new EsignSurveySubmission(); + sub.setSubmissionId(UUID.randomUUID().toString()); + sub.setSurveyId(survey.getSurveyId()); + sub.setAnswersJson(answersJson); + sub.setRespondentMetaJson(metaJson); + sub.setExternalResponseId(externalResponseId); + sub.setDeviceId(deviceId); + sub.setHotelId(StringUtils.isNotBlank(hotelId) ? EsignReservationParamUtil.normalizeParam(hotelId) : null); + sub.setSourceType(SOURCE_EXTERNAL); + sub.setSubmittedAt(Instant.now()); + submissionRepository.save(sub); + log.info( + "External survey webhook saved surveyId={}, submissionId={}, externalResponseId={}", + survey.getSurveyId(), + sub.getSubmissionId(), + externalResponseId); + EsignSurveyResponse r = EsignSurveyResponse.ok("webhook accepted"); + EsignSurveyResponse.EsignSurveyData data = new EsignSurveyResponse.EsignSurveyData(); + data.setSubmissionId(sub.getSubmissionId()); + r.setData(data); + return r; + } + + private EsignSurveyResponse.EsignSurveySubmissionItem toSubmissionItem(EsignSurveySubmission row) { + EsignSurveyResponse.EsignSurveySubmissionItem item = + new EsignSurveyResponse.EsignSurveySubmissionItem(); + item.setSubmissionId(row.getSubmissionId()); + item.setSurveyId(row.getSurveyId()); + item.setSourceType(StringUtils.defaultIfBlank(row.getSourceType(), SOURCE_NATIVE)); + item.setExternalResponseId(row.getExternalResponseId()); + item.setDeviceId(row.getDeviceId()); + item.setHotelId(row.getHotelId()); + item.setSubmittedAt(row.getSubmittedAt() != null ? row.getSubmittedAt().toString() : null); + item.setAnswers(parseJsonMap(row.getAnswersJson())); + item.setRespondentMeta(parseJsonMap(row.getRespondentMetaJson())); + return item; + } + + private Map parseJsonMap(String json) { + if (StringUtils.isBlank(json)) { + return Map.of(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + return Map.of(); + } + } + + private static String normalizeDisplayType(String displayType) { + if (StringUtils.isBlank(displayType)) { + return "CARD"; + } + String t = displayType.trim().toUpperCase(); + return ALLOWED_DISPLAY_TYPES.contains(t) ? t : "CARD"; + } + + private static String normalizeImageUrl(String imageUrl) { + if (StringUtils.isBlank(imageUrl)) { + return null; + } + String t = imageUrl.trim(); + if (t.length() > MAX_IMAGE_URL_LENGTH) { + return t.substring(0, MAX_IMAGE_URL_LENGTH); + } + return t; + } + + private String resolveClientImageUrl(EsignSurvey row) { + if (row == null || StringUtils.isBlank(row.getImageUrl())) { + return null; + } + String imageUrl = row.getImageUrl().trim(); + if (imageUrl.startsWith("http://") || imageUrl.startsWith("https://")) { + return imageUrl; + } + if (imageUrl.startsWith("data:") || mediaService.shouldUseMediaEndpoint(row)) { + return mediaService.toRelativeMediaPath(row.getSurveyId()); + } + return imageUrl; + } + + private String resolveImageUrlForSave(EsignSurvey entity, String requestImageUrl) { + String surveyId = entity.getSurveyId(); + if (requestImageUrl == null) { + return normalizeExistingImageRef(entity.getImageUrl(), surveyId); + } + if (requestImageUrl.isEmpty()) { + try { + mediaService.persistIfNeeded(surveyId, null); + } catch (Exception ignored) { + } + return null; + } + return prepareImageUrl(surveyId, requestImageUrl); + } + + private String normalizeExistingImageRef(String existing, String surveyId) { + if (StringUtils.isBlank(existing)) { + return null; + } + String trimmed = existing.trim(); + if (trimmed.startsWith(EsignSurveyMediaService.STORED_PREFIX)) { + return trimmed; + } + if (trimmed.startsWith("data:")) { + return trimmed; + } + if (EsignSurveyMediaService.isSurveyMediaPath(trimmed)) { + return EsignSurveyMediaService.STORED_PREFIX + surveyId; + } + return trimmed; + } + + private String prepareImageUrl(String surveyId, String rawImageUrl) { + if (StringUtils.isBlank(rawImageUrl)) { + return null; + } + String trimmed = rawImageUrl.trim(); + if (trimmed.startsWith("data:") || trimmed.startsWith(EsignSurveyMediaService.STORED_PREFIX)) { + try { + return mediaService.persistIfNeeded(surveyId, trimmed); + } catch (Exception e) { + throw new IllegalStateException("failed to store survey logo", e); + } + } + if (EsignSurveyMediaService.isSurveyMediaPath(trimmed)) { + return EsignSurveyMediaService.STORED_PREFIX + surveyId; + } + return normalizeImageUrl(trimmed); + } + + private static String trim(String s, int max) { + if (s == null) { + return null; + } + String t = s.trim(); + if (t.isEmpty()) { + return null; + } + return t.length() > max ? t.substring(0, max) : t; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/controllers/ApiLogController.java b/src/main/java/com/sjmresorts/opsapp/logging/controllers/ApiLogController.java new file mode 100644 index 0000000..6cca5ec --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/controllers/ApiLogController.java @@ -0,0 +1,73 @@ +package com.sjmresorts.opsapp.logging.controllers; + +import com.sjmresorts.opsapp.common.dtos.DataTableResult; +import com.sjmresorts.opsapp.logging.dtos.ApiLogDto; +import com.sjmresorts.opsapp.logging.dtos.ApiLogPageRequest; +import com.sjmresorts.opsapp.logging.dtos.ApiLogPageResponse; +import com.sjmresorts.opsapp.logging.dtos.GetApiLogResponse; +import com.sjmresorts.opsapp.logging.services.ApiLogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.NotNull; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Logging", description = "APIs for Api Log functions") +@RestController +@RequestMapping("/api/v1/logging/apiLog") +public class ApiLogController { + + @Resource + private ApiLogService apiLogService; + + @Operation( + summary = "Query api log page", + description = "API for query api log" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Api log queried successful"), + @ApiResponse(responseCode = "400", description = "Invalid request data") + }) + @PostMapping("/queryPage") + public ResponseEntity queryPage( + HttpServletRequest request, + @RequestBody ApiLogPageRequest pageRequest) { + + DataTableResult apiLogList = apiLogService.getApiLogListByPage(pageRequest); + + ApiLogPageResponse response = new ApiLogPageResponse(); + response.setCode("200"); + response.setSuccess(true); + response.setMessage("Query Successfully"); + response.setData(apiLogList); + + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Get one api log data", + description = "API for get one api log data" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Api log data got successful"), + @ApiResponse(responseCode = "400", description = "Invalid request data") + }) + @GetMapping("/{apiLogId}") + public ResponseEntity getApiLogById( + @PathVariable @NotNull Long apiLogId) { + + GetApiLogResponse responseData = new GetApiLogResponse(); + + ApiLogDto apiLogDto = apiLogService.queryApiLogById(apiLogId); + responseData.setCode("200"); + responseData.setSuccess(apiLogDto != null); + responseData.setMessage(apiLogDto != null ? "Query Successfully" : "Api log does not exists"); + responseData.setData(apiLogDto); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/controllers/AppLogController.java b/src/main/java/com/sjmresorts/opsapp/logging/controllers/AppLogController.java new file mode 100644 index 0000000..40ab920 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/controllers/AppLogController.java @@ -0,0 +1,58 @@ +package com.sjmresorts.opsapp.logging.controllers; + +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import com.sjmresorts.opsapp.logging.dtos.CreateAppLogRequest; +import com.sjmresorts.opsapp.logging.dtos.CreateAppLogResponse; +import com.sjmresorts.opsapp.logging.mappers.AppLogMapper; +import com.sjmresorts.opsapp.logging.models.AppLog; +import com.sjmresorts.opsapp.logging.services.AppLogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Logging", description = "APIs to store app frond-end logging ") +@RestController +@RequestMapping("/api/v1/logging") +@CrossOrigin(origins = "*") +public class AppLogController { + + @Resource + private AppLogService appLogService; + @Resource + private AppLogMapper appLogMapper; + + @Operation( + summary = "Create App log", + description = "API for App frond-end log creation" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "App log created successful"), + @ApiResponse(responseCode = "400", description = "Invalid request data") + }) + @PostMapping(value="/createAppLog",produces = "application/json;charset=UTF-8") + public ResponseEntity createAppLog( + HttpServletRequest request, + @Parameter(description = "App log request info", required = true) @Valid @RequestBody CreateAppLogRequest requestData) { + CreateAppLogResponse responseData = new CreateAppLogResponse(); + LoginUserInfo requestor = JwtTokenUtil.parseUserFromRequest(request); + AppLog appLog = appLogMapper.requestToEntity(requestData); + if(requestor!=null){ + appLog.setUserId(requestor.getEmployeeId()); + } + appLogService.saveAppLog(appLog); + responseData.setSuccess(true); + responseData.setCode("200"); + responseData.setMessage("App log created success"); + + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/controllers/AuditLogController.java b/src/main/java/com/sjmresorts/opsapp/logging/controllers/AuditLogController.java new file mode 100644 index 0000000..926a60e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/controllers/AuditLogController.java @@ -0,0 +1,74 @@ +package com.sjmresorts.opsapp.logging.controllers; + + +import com.sjmresorts.opsapp.common.dtos.DataTableResult; +import com.sjmresorts.opsapp.logging.dtos.AuditLogDto; +import com.sjmresorts.opsapp.logging.dtos.AuditLogPageRequest; +import com.sjmresorts.opsapp.logging.dtos.AuditLogPageResponse; +import com.sjmresorts.opsapp.logging.dtos.GetAuditLogResponse; +import com.sjmresorts.opsapp.logging.services.AuditLogService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.constraints.NotNull; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Logging", description = "APIs for Audit Log functions") +@RestController +@RequestMapping("/api/v1/logging/auditLog") +public class AuditLogController { + + @Resource + private AuditLogService auditLogService; + + @Operation( + summary = "Query audit log page", + description = "API for query audit log" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Audit log queried successful"), + @ApiResponse(responseCode = "400", description = "Invalid request data") + }) + @PostMapping("/queryPage") + public ResponseEntity queryPage( + HttpServletRequest request, + @RequestBody AuditLogPageRequest pageRequest) { + + DataTableResult apiLogList = auditLogService.getAuditLogListByPage(pageRequest); + + AuditLogPageResponse response = new AuditLogPageResponse(); + response.setCode("200"); + response.setSuccess(true); + response.setMessage("Query Successfully"); + response.setData(apiLogList); + + return ResponseEntity.ok(response); + } + + @Operation( + summary = "Get one audit log data", + description = "API for get one audit log data" + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Audit log data got successful"), + @ApiResponse(responseCode = "400", description = "Invalid request data") + }) + @GetMapping("/{auditLogId}") + public ResponseEntity getAuditLogById( + @PathVariable @NotNull Long auditLogId) { + + GetAuditLogResponse responseData = new GetAuditLogResponse(); + + AuditLogDto auditLogDto = auditLogService.queryAuditLogById(auditLogId); + responseData.setCode("200"); + responseData.setSuccess(auditLogDto != null); + responseData.setMessage(auditLogDto != null ? "Query Successfully" : "Audit log does not exists"); + responseData.setData(auditLogDto); + return ResponseEntity.status(HttpStatus.OK).body(responseData); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/dtos/ApiLogDto.java b/src/main/java/com/sjmresorts/opsapp/logging/dtos/ApiLogDto.java new file mode 100644 index 0000000..2701bb8 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/dtos/ApiLogDto.java @@ -0,0 +1,31 @@ +package com.sjmresorts.opsapp.logging.dtos; + +import lombok.Data; + +import java.time.LocalDateTime; + +@Data +public class ApiLogDto { + + private Long apiLogId; + + private String url; + + private String type; + + private LocalDateTime requestTime; + + private LocalDateTime responseTime; + + private String header = ""; + + private String requestBody = ""; + + private String requestBy = ""; + + private String responseBody = ""; + + private String debugLevel = ""; + + private String statusCode = ""; +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/dtos/ApiLogPageRequest.java b/src/main/java/com/sjmresorts/opsapp/logging/dtos/ApiLogPageRequest.java new file mode 100644 index 0000000..74b224c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/dtos/ApiLogPageRequest.java @@ -0,0 +1,20 @@ +package com.sjmresorts.opsapp.logging.dtos; + +import com.sjmresorts.opsapp.common.dtos.BasePageRequest; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Setter +@Getter +@NoArgsConstructor +public class ApiLogPageRequest extends BasePageRequest { + + private String searchType; + + private String searchDebugLevel; + + private List searchDate; +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/dtos/ApiLogPageResponse.java b/src/main/java/com/sjmresorts/opsapp/logging/dtos/ApiLogPageResponse.java new file mode 100644 index 0000000..8d5b6f8 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/dtos/ApiLogPageResponse.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.logging.dtos; + +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import com.sjmresorts.opsapp.common.dtos.DataTableResult; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Api Log Info List") +public class ApiLogPageResponse extends BaseApiResponse { + + private DataTableResult data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/dtos/AuditLogDto.java b/src/main/java/com/sjmresorts/opsapp/logging/dtos/AuditLogDto.java new file mode 100644 index 0000000..e1ac6ee --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/dtos/AuditLogDto.java @@ -0,0 +1,25 @@ +package com.sjmresorts.opsapp.logging.dtos; + +import lombok.Data; + +import java.time.Instant; + +@Data +public class AuditLogDto { + + private Long auditLogId; + + private String username; + + private String action; + + private String details; + + private String oldValue; + + private String newValue; + + private Instant actionTime; + + private String userId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/dtos/AuditLogPageRequest.java b/src/main/java/com/sjmresorts/opsapp/logging/dtos/AuditLogPageRequest.java new file mode 100644 index 0000000..ae05948 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/dtos/AuditLogPageRequest.java @@ -0,0 +1,20 @@ +package com.sjmresorts.opsapp.logging.dtos; + +import com.sjmresorts.opsapp.common.dtos.BasePageRequest; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +@EqualsAndHashCode(callSuper = true) +@Data +public class AuditLogPageRequest extends BasePageRequest { + + private String searchUsername; + + private String searchAction; + + private String searchAdminUserCode; + + private List searchDate; +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/dtos/AuditLogPageResponse.java b/src/main/java/com/sjmresorts/opsapp/logging/dtos/AuditLogPageResponse.java new file mode 100644 index 0000000..be8d07d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/dtos/AuditLogPageResponse.java @@ -0,0 +1,15 @@ +package com.sjmresorts.opsapp.logging.dtos; + +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import com.sjmresorts.opsapp.common.dtos.DataTableResult; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Setter +@Getter +@NoArgsConstructor +public class AuditLogPageResponse extends BaseApiResponse { + + private DataTableResult data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/dtos/CreateAppLogRequest.java b/src/main/java/com/sjmresorts/opsapp/logging/dtos/CreateAppLogRequest.java new file mode 100644 index 0000000..90437bd --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/dtos/CreateAppLogRequest.java @@ -0,0 +1,36 @@ +package com.sjmresorts.opsapp.logging.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Column; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Request to create app log for frond-end") +public class CreateAppLogRequest { + + @Schema(description = "Event code of log") + @NotNull(message = "Event Code must not be null") + private String eventCode; + @Schema(description = "Request time of app log") + @NotNull(message = "Request time must not be null") + private Instant requestTime; + @Schema(description = "Log description") + @NotNull(message = "Log description must not be null") + private String logDesc; + @Column(name = "tag_success") + @NotNull(message = "Tag success must not be null") + private Integer tagSuccess; + @Schema(description = "Log by information detail") + private String logBy; + @Schema(description = "User Id to request log, Optional and using token userId in first priority ") + private String userId; +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/dtos/CreateAppLogResponse.java b/src/main/java/com/sjmresorts/opsapp/logging/dtos/CreateAppLogResponse.java new file mode 100644 index 0000000..809da48 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/dtos/CreateAppLogResponse.java @@ -0,0 +1,23 @@ +package com.sjmresorts.opsapp.logging.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of create app log") +public class CreateAppLogResponse { + @Schema(description = "Indicate success status") + private boolean success=false; + + @Schema(description = "Status Code of response",example="200") + private String code; + + @Schema(description = "Description of result") + private String message; +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/dtos/GetApiLogResponse.java b/src/main/java/com/sjmresorts/opsapp/logging/dtos/GetApiLogResponse.java new file mode 100644 index 0000000..047254a --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/dtos/GetApiLogResponse.java @@ -0,0 +1,16 @@ +package com.sjmresorts.opsapp.logging.dtos; + +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of getting api log") +public class GetApiLogResponse extends BaseApiResponse { + + private ApiLogDto data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/dtos/GetAuditLogResponse.java b/src/main/java/com/sjmresorts/opsapp/logging/dtos/GetAuditLogResponse.java new file mode 100644 index 0000000..4ff5ada --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/dtos/GetAuditLogResponse.java @@ -0,0 +1,16 @@ +package com.sjmresorts.opsapp.logging.dtos; + +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of getting audit log") +public class GetAuditLogResponse extends BaseApiResponse { + + private AuditLogDto data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/dtos/GetNotificationLogResponse.java b/src/main/java/com/sjmresorts/opsapp/logging/dtos/GetNotificationLogResponse.java new file mode 100644 index 0000000..2a1a44c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/dtos/GetNotificationLogResponse.java @@ -0,0 +1,16 @@ +package com.sjmresorts.opsapp.logging.dtos; + +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@Schema(description = "Response info of getting notification log") +public class GetNotificationLogResponse extends BaseApiResponse { + + private NotificationLogDto data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/dtos/NotificationLogDto.java b/src/main/java/com/sjmresorts/opsapp/logging/dtos/NotificationLogDto.java new file mode 100644 index 0000000..db79f30 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/dtos/NotificationLogDto.java @@ -0,0 +1,29 @@ +package com.sjmresorts.opsapp.logging.dtos; + +import lombok.Data; + +import java.time.Instant; + +@Data +public class NotificationLogDto { + + private Long id; + + private String notificationType; + + private String triggerTask; + + private String recipients; + + private String subject; + + private String notificationContent; + + private Instant requestedTime; + + private Instant finishedTime; + + private String resultStatus; + + private String errorMessage; +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/dtos/NotificationLogPageRequest.java b/src/main/java/com/sjmresorts/opsapp/logging/dtos/NotificationLogPageRequest.java new file mode 100644 index 0000000..20513aa --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/dtos/NotificationLogPageRequest.java @@ -0,0 +1,18 @@ +package com.sjmresorts.opsapp.logging.dtos; + +import com.sjmresorts.opsapp.common.dtos.BasePageRequest; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.List; + +@EqualsAndHashCode(callSuper = true) +@Data +public class NotificationLogPageRequest extends BasePageRequest { + + private String searchType; + + private String searchResultStatus; + + private List searchDate; +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/dtos/NotificationLogPageResponse.java b/src/main/java/com/sjmresorts/opsapp/logging/dtos/NotificationLogPageResponse.java new file mode 100644 index 0000000..b4d39b5 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/dtos/NotificationLogPageResponse.java @@ -0,0 +1,15 @@ +package com.sjmresorts.opsapp.logging.dtos; + +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import com.sjmresorts.opsapp.common.dtos.DataTableResult; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Setter +@Getter +@NoArgsConstructor +public class NotificationLogPageResponse extends BaseApiResponse { + + private DataTableResult data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/filters/ApiLoggingFilter.java b/src/main/java/com/sjmresorts/opsapp/logging/filters/ApiLoggingFilter.java new file mode 100644 index 0000000..e77d8b6 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/filters/ApiLoggingFilter.java @@ -0,0 +1,232 @@ +package com.sjmresorts.opsapp.logging.filters; + + +import com.alibaba.fastjson2.JSONObject; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import com.sjmresorts.opsapp.common.utils.NetworkUtil; +import com.sjmresorts.opsapp.logging.models.ApiLog; +import com.sjmresorts.opsapp.logging.services.ApiLogService; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.util.ContentCachingRequestWrapper; +import org.springframework.web.util.ContentCachingResponseWrapper; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +@RequiredArgsConstructor +public class ApiLoggingFilter extends OncePerRequestFilter { + + @Setter + private int debugLevelCode=500; + @Setter + private String debugLevel="ERROR"; + private final ApiLogService apiLogService; + + @Value("${ops-app.api-logging-level:ERROR}") + private String apiLogLevel; + + @PostConstruct + void applyApiLogLevel() { + if ("WARN".equalsIgnoreCase(apiLogLevel)) { + debugLevel = "WARN"; + debugLevelCode = 400; + } else if ("INFO".equalsIgnoreCase(apiLogLevel)) { + debugLevel = "INFO"; + debugLevelCode = 0; + } else { + debugLevel = "ERROR"; + debugLevelCode = 500; + } + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + if (uri == null) { + return false; + } + // 高频轮询 / 大体积 PDF 提交不写 API 日志,减轻 DB 与连接池压力 + return uri.contains("/esign/ios-task-list") + || uri.contains("/sinosecu/ios-task-list") + || uri.contains("/esign/ios-pending-tasks") + || uri.contains("/sinosecu/ios-pending-tasks") + || uri.contains("/esign/printer-pdf-task-submit") + || uri.contains("/sinosecu/printer-pdf-task-submit") + || uri.contains("/esign/printer-dashboard") + || uri.contains("/sinosecu/printer-dashboard") + || uri.contains("/esign/printer-queue") + || uri.contains("/sinosecu/printer-queue") + || uri.contains("/esign/printer-devices") + || uri.contains("/sinosecu/printer-devices") + || uri.contains("/esign/printer-device-link") + || uri.contains("/sinosecu/printer-device-link") + || uri.contains("/esign/printer-push") + || uri.contains("/sinosecu/printer-push") + || uri.contains("/esign/printer-event-logs") + || uri.contains("/sinosecu/printer-event-logs") + || uri.contains("/esign/printer-pdf-archive") + || uri.contains("/sinosecu/printer-pdf-archive"); + } + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) + throws IOException, ServletException { + Throwable handleException = null; + ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request); + ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response); + LocalDateTime startTime = LocalDateTime.now(); + try { + filterChain.doFilter(wrappedRequest, wrappedResponse); + }catch(Exception err){ + handleException = err; + throw err; + } + finally { + String errorMessage = handleException!=null?handleException.getMessage():""; + if(StringUtils.isBlank(errorMessage)){ + errorMessage = checkResponseErrors(wrappedRequest); + } + if(!"OPTIONS".equalsIgnoreCase(request.getMethod())){ + int statusCode = wrappedResponse.getStatus(); + if(statusCode>=debugLevelCode || handleException!=null){ + String debugLevel = handleException!=null?"ERROR":getDebugLevelByCode(statusCode); + ApiLog requestLog = createRequestLog(request, startTime); + requestLog.setResponseTime(LocalDateTime.now()); + if(StringUtils.isNotBlank(errorMessage)){ + requestLog.setResponseBody(errorMessage); + }else{ + requestLog.setResponseBody(getResponseContent(wrappedResponse)); + } + requestLog.setStatusCode(String.valueOf(statusCode)); + requestLog.setDebugLevel(debugLevel); + apiLogService.saveApiLog(requestLog); + } + } + wrappedResponse.copyBodyToResponse(); + } + } + + private String checkResponseErrors(HttpServletRequest request) { + String errorMessage = ""; + Object exceptionVal = request.getAttribute("AUTH_EXCEPTION"); + errorMessage = exceptionVal!=null?exceptionVal.toString():""; + if(StringUtils.isBlank(errorMessage)){ + exceptionVal = request.getAttribute(DispatcherServlet.EXCEPTION_ATTRIBUTE); + errorMessage = exceptionVal!=null?exceptionVal.toString():""; + } + return errorMessage; + } + + private String getDebugLevelByCode(int statusCode){ + String debugLevel = "INFO"; + if(statusCode>=400 && statusCode<500){ + debugLevel="WARN"; + }else if(statusCode>=500){ + debugLevel="ERROR"; + } + return debugLevel; + } + + private ApiLog createRequestLog(HttpServletRequest request, LocalDateTime startTime) { + ApiLog log = new ApiLog(); + log.setType(request.getMethod()); + log.setUrl(getFullUrl(request)); + log.setRequestTime(startTime); + log.setHeader(getHeadersAsString(request)); + log.setRequestBy(getRequestSource(request)); + if (request instanceof ContentCachingRequestWrapper wrappedRequest) { + byte[] content = wrappedRequest.getContentAsByteArray(); + if (content.length > 0) { + try { + String body = new String(content, wrappedRequest.getCharacterEncoding()); + log.setRequestBody(body); + }catch(Exception ignored){ + log.setRequestBody(new String(content)); + } + } + } + return log; + } + + private String getFullUrl(HttpServletRequest request) { + String scheme = request.getScheme(); + String serverName = request.getServerName(); + int serverPort = request.getServerPort(); + String contextPath = request.getContextPath(); + String servletPath = request.getServletPath(); + String pathInfo = request.getPathInfo(); + String queryString = request.getQueryString(); + + StringBuilder url = new StringBuilder(); + url.append(scheme).append("://").append(serverName); + if ((scheme.equals("http") && serverPort != 80) || + (scheme.equals("https") && serverPort != 443)) { + url.append(":").append(serverPort); + } + url.append(contextPath).append(servletPath); + if (pathInfo != null) { + url.append(pathInfo); + } + if (StringUtils.isNotBlank(queryString)) { + url.append("?").append(queryString); + } + return url.toString(); + } + + private String getRequestSource(HttpServletRequest request){ + Map requestSource = new HashMap(); + requestSource.put("RequestIP", NetworkUtil.getClientIpByRequest(request)); + LoginUserInfo user = JwtTokenUtil.parseUserFromRequest(request); + if(user!=null){ + requestSource.put("employeeId",user.getEmployeeId()); + requestSource.put("userName",user.getUserName()); + } + return JSONObject.toJSONString(requestSource); + } + + private String getResponseContent(HttpServletResponse response) { + String responseBody=""; + if (response instanceof ContentCachingResponseWrapper wrappedResponse) { + byte[] content = wrappedResponse.getContentAsByteArray(); + if (content.length > 0) { + try { + responseBody = new String(content, wrappedResponse.getCharacterEncoding()); + }catch(Exception ignored){ + responseBody = new String(content); + } + } + } + return responseBody; + } + + private String getHeadersAsString(HttpServletRequest request) { + StringBuilder headers = new StringBuilder(); + request.getHeaderNames().asIterator().forEachRemaining(headerName -> { + headers.append(headerName).append(": ").append(request.getHeader(headerName)).append("; "); + }); + return headers.toString(); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/mappers/ApiLogMapper.java b/src/main/java/com/sjmresorts/opsapp/logging/mappers/ApiLogMapper.java new file mode 100644 index 0000000..5b59076 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/mappers/ApiLogMapper.java @@ -0,0 +1,15 @@ +package com.sjmresorts.opsapp.logging.mappers; + +import com.sjmresorts.opsapp.logging.dtos.ApiLogDto; +import com.sjmresorts.opsapp.logging.models.ApiLog; +import org.mapstruct.Mapper; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface ApiLogMapper { + + ApiLogDto toApiLogDto(ApiLog apiLog); + + List toApiLogDtoList(List apiLogList); +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/mappers/AppLogMapper.java b/src/main/java/com/sjmresorts/opsapp/logging/mappers/AppLogMapper.java new file mode 100644 index 0000000..20d91a1 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/mappers/AppLogMapper.java @@ -0,0 +1,11 @@ +package com.sjmresorts.opsapp.logging.mappers; + +import com.sjmresorts.opsapp.logging.dtos.CreateAppLogRequest; +import com.sjmresorts.opsapp.logging.models.AppLog; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface AppLogMapper { + + AppLog requestToEntity(CreateAppLogRequest dto); +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/mappers/AuditLogMapper.java b/src/main/java/com/sjmresorts/opsapp/logging/mappers/AuditLogMapper.java new file mode 100644 index 0000000..124c496 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/mappers/AuditLogMapper.java @@ -0,0 +1,16 @@ +package com.sjmresorts.opsapp.logging.mappers; + +import com.sjmresorts.opsapp.logging.dtos.AuditLogDto; +import com.sjmresorts.opsapp.logging.models.AuditLog; +import org.mapstruct.Mapper; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface AuditLogMapper { + + AuditLogDto toAuditLogDto(AuditLog auditLog); + + List toApiLogDtoList(List content); + +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/mappers/NotificationLogMapper.java b/src/main/java/com/sjmresorts/opsapp/logging/mappers/NotificationLogMapper.java new file mode 100644 index 0000000..9fd1e81 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/mappers/NotificationLogMapper.java @@ -0,0 +1,15 @@ +package com.sjmresorts.opsapp.logging.mappers; + +import com.sjmresorts.opsapp.logging.dtos.NotificationLogDto; +import com.sjmresorts.opsapp.logging.models.NotificationLog; +import org.mapstruct.Mapper; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface NotificationLogMapper { + + NotificationLogDto toNotificationLogDto(NotificationLog notificationLog); + + List toNotificationLogDtoList(List notificationLogList); +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/models/ApiLog.java b/src/main/java/com/sjmresorts/opsapp/logging/models/ApiLog.java new file mode 100644 index 0000000..aa95dcd --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/models/ApiLog.java @@ -0,0 +1,42 @@ +package com.sjmresorts.opsapp.logging.models; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name="api_logs") +public class ApiLog { + + @Id + @Column(name = "api_log_id",nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long apiLogId; + @Column(name = "url") + private String url; + @Column(name = "type") + private String type; + @Column(name = "request_dtm") + private LocalDateTime requestTime; + @Column(name = "response_dtm") + private LocalDateTime responseTime; + @Column(name = "header") + private String header=""; + @Column(name = "request_body") + private String requestBody=""; + @Column(name = "request_by") + private String requestBy=""; + @Column(name = "response_body") + private String responseBody=""; + @Column(name = "debug_level") + private String debugLevel=""; + @Column(name = "status_code") + private String statusCode=""; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/models/AppLog.java b/src/main/java/com/sjmresorts/opsapp/logging/models/AppLog.java new file mode 100644 index 0000000..003d2b1 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/models/AppLog.java @@ -0,0 +1,32 @@ +package com.sjmresorts.opsapp.logging.models; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name="app_logs") +public class AppLog { + @Id + @Column(name = "app_logs_id",nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long appLogId; + @Column(name = "event_code",nullable = false) + private String eventCode; + @Column(name = "request_dtm",nullable = false) + private Instant requestTime; + @Column(name = "user_id") + private String userId; + @Column(name = "log_desc",nullable = false) + private String logDesc; + @Column(name = "tag_success") + private Integer tagSuccess; + @Column(name = "log_by") + private String logBy; +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/models/AuditLog.java b/src/main/java/com/sjmresorts/opsapp/logging/models/AuditLog.java new file mode 100644 index 0000000..451b47f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/models/AuditLog.java @@ -0,0 +1,36 @@ +package com.sjmresorts.opsapp.logging.models; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name="audit_logs") +public class AuditLog { + + @Id + @Column(name = "audit_log_id",nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long auditLogId; + @Column(name = "user_id") + private String userId=""; + @Column(name = "username") + private String userName=""; + @Column(name = "action") + private String action=""; + @Column(name = "details") + private String details; + @Column(name = "old_value") + private String oldValue; + @Column(name = "new_value") + private String newValue; + @Column(name = "timestamp") + private Instant actionTime; + +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/models/NotificationLog.java b/src/main/java/com/sjmresorts/opsapp/logging/models/NotificationLog.java new file mode 100644 index 0000000..f9749a9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/models/NotificationLog.java @@ -0,0 +1,38 @@ +package com.sjmresorts.opsapp.logging.models; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name="notification_logs") +public class NotificationLog { + @Id + @Column(name = "id",nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(name = "notification_type",nullable = false) + private String notificationType; + @Column(name = "trigger_task") + private String triggerTask; + @Column(name = "recipients") + private String recipients; + @Column(name = "subject") + private String subject; + @Column(name = "notification_content") + private String notificationContent; + @Column(name = "requested_dtm") + private Instant requestedTime; + @Column(name = "finished_dtm") + private Instant finishedTime; + @Column(name = "result_status") + private String resultStatus; + @Column(name = "error_message") + private String errorMessage; +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/repositories/ApiLogRepository.java b/src/main/java/com/sjmresorts/opsapp/logging/repositories/ApiLogRepository.java new file mode 100644 index 0000000..2f9f74f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/repositories/ApiLogRepository.java @@ -0,0 +1,14 @@ +package com.sjmresorts.opsapp.logging.repositories; + +import com.sjmresorts.opsapp.logging.models.ApiLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ApiLogRepository extends JpaRepository { + + Page findAll(Specification listSpec, Pageable pageable); +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/repositories/AppLogRepository.java b/src/main/java/com/sjmresorts/opsapp/logging/repositories/AppLogRepository.java new file mode 100644 index 0000000..f39da04 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/repositories/AppLogRepository.java @@ -0,0 +1,10 @@ +package com.sjmresorts.opsapp.logging.repositories; + +import com.sjmresorts.opsapp.logging.models.AppLog; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AppLogRepository extends JpaRepository { + +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/repositories/AuditLogRepository.java b/src/main/java/com/sjmresorts/opsapp/logging/repositories/AuditLogRepository.java new file mode 100644 index 0000000..341b688 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/repositories/AuditLogRepository.java @@ -0,0 +1,14 @@ +package com.sjmresorts.opsapp.logging.repositories; + +import com.sjmresorts.opsapp.logging.models.AuditLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AuditLogRepository extends JpaRepository { + + Page findAll(Specification listSpec, Pageable pageable); +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/repositories/NotificationLogRepository.java b/src/main/java/com/sjmresorts/opsapp/logging/repositories/NotificationLogRepository.java new file mode 100644 index 0000000..dfe75e2 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/repositories/NotificationLogRepository.java @@ -0,0 +1,14 @@ +package com.sjmresorts.opsapp.logging.repositories; + +import com.sjmresorts.opsapp.logging.models.NotificationLog; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface NotificationLogRepository extends JpaRepository { + + Page findAll(Specification listSpec, Pageable pageable); +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/services/ApiLogService.java b/src/main/java/com/sjmresorts/opsapp/logging/services/ApiLogService.java new file mode 100644 index 0000000..dc91ca0 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/services/ApiLogService.java @@ -0,0 +1,107 @@ +package com.sjmresorts.opsapp.logging.services; + +import com.sjmresorts.opsapp.common.dtos.DataTableResult; +import com.sjmresorts.opsapp.common.utils.DaoUtil; +import com.sjmresorts.opsapp.common.utils.DateTimeUtil; +import com.sjmresorts.opsapp.logging.dtos.ApiLogDto; +import com.sjmresorts.opsapp.logging.dtos.ApiLogPageRequest; +import com.sjmresorts.opsapp.logging.mappers.ApiLogMapper; +import com.sjmresorts.opsapp.logging.models.ApiLog; +import com.sjmresorts.opsapp.logging.repositories.ApiLogRepository; +import jakarta.persistence.criteria.Predicate; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + + +@Service +@RequiredArgsConstructor +public class ApiLogService { + private final Logger logger = LoggerFactory.getLogger(ApiLogService.class); + + private final ApiLogRepository apiLogRepository; + + private final ApiLogMapper apiLogMapper; + + @Async + public void saveApiLog(ApiLog requestLog) { + try { + if (requestLog.getRequestBody() == null) { + requestLog.setRequestBody(""); + } + apiLogRepository.save(requestLog); + } catch (Exception e) { + logger.error("Failed to save api log to database with error:{}", e.getMessage()); + } + } + + public DataTableResult getApiLogListByPage(ApiLogPageRequest pageRequest) { + + Specification listSpec = buildSpecificationByPageRequest(pageRequest); + int pageSize = pageRequest.getPageSize(); + int pageStart = pageRequest.getStartRow() / pageSize; + Sort sort = DaoUtil.buildSortByString(pageRequest.getSortOrder(), "requestTime"); + Pageable pageable = PageRequest.of(pageStart, pageSize, sort); + Page resultPage = apiLogRepository.findAll(listSpec, pageable); + List resultList = apiLogMapper.toApiLogDtoList(resultPage.getContent()); + DataTableResult dataResult = new DataTableResult<>(); + dataResult.setTotalRecords(resultPage.getTotalElements()); + dataResult.setPageSize(pageSize); + if (resultList == null) { + resultList = new ArrayList<>(); + } + dataResult.setRowData(resultList); + return dataResult; + } + + private Specification buildSpecificationByPageRequest(ApiLogPageRequest pageRequest) { + + return ((root, criteriaQuery, cb) -> { + List list = new ArrayList<>(); + if (StringUtils.isNotBlank(pageRequest.getSearchType())) { + list.add(cb.like(cb.upper(root.get("type")), + "%" + pageRequest.getSearchType().toUpperCase() + "%")); + } + if (StringUtils.isNotBlank(pageRequest.getSearchDebugLevel())) { + list.add(cb.like(cb.upper(root.get("debugLevel")), + "%" + pageRequest.getSearchDebugLevel().toUpperCase() + "%")); + } + if (pageRequest.getSearchDate() != null && !pageRequest.getSearchDate().isEmpty() + && StringUtils.isNotBlank(pageRequest.getSearchDate().getFirst())) { + Date checkOperateBeginTime = DateTimeUtil.getDateFromString(pageRequest.getSearchDate().getFirst() + " 00:00:00", "yyyy-MM-dd hh:mm:ss"); + if (checkOperateBeginTime != null) { + list.add(cb.greaterThanOrEqualTo(root.get("requestTime"), checkOperateBeginTime)); + } + } + if (pageRequest.getSearchDate() != null && pageRequest.getSearchDate().size() > 1 + && StringUtils.isNotBlank(pageRequest.getSearchDate().get(1))) { + Date checkOperateEndTime = DateTimeUtil.getDateFromString(pageRequest.getSearchDate().get(1) + " 23:59:59", "yyyy-MM-dd hh:mm:ss"); + if (checkOperateEndTime != null) { + list.add(cb.lessThanOrEqualTo(root.get("requestTime"), checkOperateEndTime)); + } + } + Predicate[] predicates = new Predicate[list.size()]; + criteriaQuery.where(list.toArray(predicates)); + return cb.and(list.toArray(predicates)); + }); + } + + public ApiLogDto queryApiLogById(@NotBlank Long apiLogId) { + ApiLog apiLog = apiLogRepository.findById(apiLogId).orElse(null); + if (apiLog == null) return null; + return apiLogMapper.toApiLogDto(apiLog); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/services/AppLogService.java b/src/main/java/com/sjmresorts/opsapp/logging/services/AppLogService.java new file mode 100644 index 0000000..f20fba0 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/services/AppLogService.java @@ -0,0 +1,25 @@ +package com.sjmresorts.opsapp.logging.services; + +import com.sjmresorts.opsapp.logging.models.AppLog; +import com.sjmresorts.opsapp.logging.repositories.AppLogRepository; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class AppLogService { + private final Logger logger = LoggerFactory.getLogger(AppLogService.class); + private final AppLogRepository appLogRepository; + + @Async + public void saveAppLog(AppLog appLog) { + try { + appLogRepository.save(appLog); + } catch (Exception e) { + logger.error("Failed to save app log to database with error:{}", e.getMessage()); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/services/AuditLogService.java b/src/main/java/com/sjmresorts/opsapp/logging/services/AuditLogService.java new file mode 100644 index 0000000..4314ef9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/services/AuditLogService.java @@ -0,0 +1,147 @@ +package com.sjmresorts.opsapp.logging.services; + +import com.alibaba.fastjson2.JSONObject; +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.common.dtos.DataTableResult; +import com.sjmresorts.opsapp.common.utils.DaoUtil; +import com.sjmresorts.opsapp.common.utils.DateTimeUtil; +import com.sjmresorts.opsapp.logging.dtos.AuditLogDto; +import com.sjmresorts.opsapp.logging.dtos.AuditLogPageRequest; +import com.sjmresorts.opsapp.logging.mappers.AuditLogMapper; +import com.sjmresorts.opsapp.logging.models.AuditLog; +import com.sjmresorts.opsapp.logging.repositories.AuditLogRepository; +import jakarta.persistence.criteria.Predicate; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + + +@Service +@RequiredArgsConstructor +public class AuditLogService { + private final Logger logger = LoggerFactory.getLogger(AuditLogService.class); + private final AuditLogRepository auditLogRepository; + + private final AuditLogMapper auditLogMapper; + + @Async + public void saveAuditLog(AuditLog auditLog) { + try { + if(auditLog.getAction()==null){ + auditLog.setAction(""); + } + if(auditLog.getActionTime()==null) + auditLog.setActionTime(Instant.now()); + auditLogRepository.save(auditLog); + } catch (Exception e) { + logger.error("Failed to save audit log to database", e); + } + } + + public AuditLog createAuditLog(String action, String detail, LoginUserInfo loginUser ){ + AuditLog auditLog = new AuditLog(); + auditLog.setAction(action==null?"":action); + auditLog.setDetails(detail); + auditLog.setUserId(loginUser==null?"":loginUser.getEmployeeId()); + auditLog.setUserName(loginUser==null?"":loginUser.getUserName()); + return auditLog; + } + + public void setLogOldValue(AuditLog auditLog, Object oldValue){ + if(oldValue!=null){ + try { + auditLog.setOldValue(JSONObject.toJSONString(oldValue)); + }catch(Exception e){ + auditLog.setOldValue(e.getMessage()); + } + }else{ + auditLog.setOldValue(""); + } + } + + public void setLogNewValue(AuditLog auditLog, Object newValue){ + if(newValue!=null){ + try { + auditLog.setNewValue(JSONObject.toJSONString(newValue)); + }catch(Exception e){ + auditLog.setNewValue(e.getMessage()); + } + }else{ + auditLog.setNewValue(""); + } + auditLog.setActionTime(Instant.now()); + } + + public DataTableResult getAuditLogListByPage(AuditLogPageRequest pageRequest) { + Specification listSpec = buildSpecificationByPageRequest(pageRequest); + int pageSize = pageRequest.getPageSize(); + int pageStart = pageRequest.getStartRow() / pageSize; + Sort sort = DaoUtil.buildSortByString(pageRequest.getSortOrder(), "actionTime"); + Pageable pageable = PageRequest.of(pageStart, pageSize, sort); + Page resultPage = auditLogRepository.findAll(listSpec, pageable); + List resultList = auditLogMapper.toApiLogDtoList(resultPage.getContent()); + DataTableResult dataResult = new DataTableResult<>(); + dataResult.setTotalRecords(resultPage.getTotalElements()); + dataResult.setPageSize(pageSize); + if (resultList == null) { + resultList = new ArrayList<>(); + } + dataResult.setRowData(resultList); + return dataResult; + } + + private Specification buildSpecificationByPageRequest(AuditLogPageRequest pageRequest) { + return ((root, criteriaQuery, cb) -> { + List list = new ArrayList<>(); + if (StringUtils.isNotBlank(pageRequest.getSearchUsername())) { + list.add(cb.like(cb.upper(root.get("username")), + "%" + pageRequest.getSearchUsername().toUpperCase() + "%")); + } + if (StringUtils.isNotBlank(pageRequest.getSearchAction())) { + list.add(cb.like(cb.upper(root.get("action")), + "%" + pageRequest.getSearchAction().toUpperCase() + "%")); + } + if (StringUtils.isNotBlank(pageRequest.getSearchAdminUserCode())) { + list.add(cb.like(cb.upper(root.get("userId")), + "%" + pageRequest.getSearchAdminUserCode().toUpperCase() + "%")); + } + if (pageRequest.getSearchDate() != null && !pageRequest.getSearchDate().isEmpty() + && StringUtils.isNotBlank(pageRequest.getSearchDate().getFirst())) { + Date checkOperateBeginTime = DateTimeUtil.getDateFromString(pageRequest.getSearchDate().getFirst() + " 00:00:00", "yyyy-MM-dd hh:mm:ss"); + if (checkOperateBeginTime != null) { + list.add(cb.greaterThanOrEqualTo(root.get("actionTime"), checkOperateBeginTime)); + } + } + if (pageRequest.getSearchDate() != null && pageRequest.getSearchDate().size() > 1 + && StringUtils.isNotBlank(pageRequest.getSearchDate().get(1))) { + Date checkOperateEndTime = DateTimeUtil.getDateFromString(pageRequest.getSearchDate().get(1) + " 23:59:59", "yyyy-MM-dd hh:mm:ss"); + if (checkOperateEndTime != null) { + list.add(cb.lessThanOrEqualTo(root.get("actionTime"), checkOperateEndTime)); + } + } + Predicate[] predicates = new Predicate[list.size()]; + criteriaQuery.where(list.toArray(predicates)); + return cb.and(list.toArray(predicates)); + }); + } + + public AuditLogDto queryAuditLogById(@NotBlank Long auditLogId) { + AuditLog auditLog = auditLogRepository.findById(auditLogId).orElse(null); + if (auditLog == null) return null; + return auditLogMapper.toAuditLogDto(auditLog); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/logging/services/NotificationLogService.java b/src/main/java/com/sjmresorts/opsapp/logging/services/NotificationLogService.java new file mode 100644 index 0000000..33d2575 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/logging/services/NotificationLogService.java @@ -0,0 +1,25 @@ +package com.sjmresorts.opsapp.logging.services; + +import com.sjmresorts.opsapp.logging.models.NotificationLog; +import com.sjmresorts.opsapp.logging.repositories.NotificationLogRepository; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class NotificationLogService { + private final Logger logger = LoggerFactory.getLogger(NotificationLogService.class); + private final NotificationLogRepository notificationLogRepository; + + @Async + public void saveNotificationLog(NotificationLog notificationLog) { + try { + notificationLogRepository.save(notificationLog); + } catch (Exception e) { + logger.error("Failed to save notification log to database", e); + } + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/config/FirebaseConfig.java b/src/main/java/com/sjmresorts/opsapp/notifications/config/FirebaseConfig.java new file mode 100644 index 0000000..4bd426d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/config/FirebaseConfig.java @@ -0,0 +1,105 @@ +package com.sjmresorts.opsapp.notifications.config; + +import com.google.api.client.http.HttpTransport; +import com.google.auth.http.HttpTransportFactory; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.sjmresorts.opsapp.common.config.GoogleApiProxyFactory; +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.system.ApplicationHome; +import org.springframework.core.io.ClassPathResource; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; + +@Slf4j +@Component +@RequiredArgsConstructor +@Getter +public class FirebaseConfig { + + private final GoogleApiProxyFactory googleApiProxyFactory; + + @Value("${firebase.credential.path:}") + private String credentialPath; + + @Value("${firebase.dry-run:false}") + private boolean dryRun; + + private volatile boolean initialized; + + @PostConstruct + public void init() { + if (dryRun) { + log.warn("firebase.dry-run=true, FCM messages will not be sent to Firebase"); + initialized = true; + return; + } + if (StringUtils.isBlank(credentialPath)) { + log.warn("firebase.credential.path is empty, FCM push is disabled"); + return; + } + try (InputStream credentials = openCredentialStream(credentialPath)) { + if (credentials == null) { + log.warn("Firebase credential file not found: {}, FCM push is disabled", credentialPath); + return; + } + byte[] credentialBytes = credentials.readAllBytes(); + HttpTransport httpTransport = googleApiProxyFactory.createHttpTransport(); + HttpTransportFactory transportFactory = googleApiProxyFactory.createTransportFactory(); + GoogleCredentials googleCredentials = GoogleCredentials.fromStream( + new ByteArrayInputStream(credentialBytes), + transportFactory); + + FirebaseOptions.Builder optionsBuilder = FirebaseOptions.builder() + .setCredentials(googleCredentials) + .setHttpTransport(httpTransport); + FirebaseOptions options = optionsBuilder.build(); + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(options); + } + initialized = true; + log.info("Firebase Admin SDK initialized (proxy enabled for Google HTTP transport)"); + } catch (IOException e) { + log.error("Failed to initialize Firebase Admin SDK", e); + } + } + + public boolean isReady() { + return initialized && (dryRun || !FirebaseApp.getApps().isEmpty()); + } + + private InputStream openCredentialStream(String path) throws IOException { + if (path.startsWith("classpath:")) { + String classpathLocation = path.substring("classpath:".length()); + ClassPathResource resource = new ClassPathResource(classpathLocation); + if (resource.exists()) { + return resource.getInputStream(); + } + return null; + } + ClassPathResource classpathResource = new ClassPathResource(path); + if (classpathResource.exists()) { + return classpathResource.getInputStream(); + } + File runtimeFile = new File(new ApplicationHome(FirebaseConfig.class).getDir(), path); + if (runtimeFile.isFile()) { + return new FileInputStream(runtimeFile); + } + File absoluteFile = new File(path); + if (absoluteFile.isFile()) { + return new FileInputStream(absoluteFile); + } + return null; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/controllers/NotificationController.java b/src/main/java/com/sjmresorts/opsapp/notifications/controllers/NotificationController.java new file mode 100644 index 0000000..3a0f75c --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/controllers/NotificationController.java @@ -0,0 +1,63 @@ +package com.sjmresorts.opsapp.notifications.controllers; + +import com.sjmresorts.opsapp.authentication.dtos.LoginUserInfo; +import com.sjmresorts.opsapp.common.utils.JwtTokenUtil; +import com.sjmresorts.opsapp.notifications.dtos.SendNotificationRequest; +import com.sjmresorts.opsapp.notifications.dtos.SendNotificationResponse; +import com.sjmresorts.opsapp.notifications.services.NotificationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CrossOrigin; +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; + +@Tag(name = "Notifications", description = "FCM push notification APIs") +@RestController +@RequestMapping("/api/v1/notifications") +@CrossOrigin(origins = "*") +public class NotificationController { + + @Resource + private NotificationService notificationService; + + @Operation( + summary = "Send FCM notification", + description = "Send push notification to employees, devices, tokens, or a topic. Auth temporarily disabled for integration testing." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "All targets delivered"), + @ApiResponse(responseCode = "207", description = "Partial delivery") + }) + @PostMapping(value = "/send", produces = "application/json;charset=UTF-8") + public ResponseEntity sendNotification( + HttpServletRequest request, + @Parameter(description = "Notification send request", required = true) + @Valid @RequestBody SendNotificationRequest requestData) { + + LoginUserInfo loginUser = JwtTokenUtil.parseUserFromRequest(request); + String requesterId = "anonymous"; + if (loginUser != null) { + requesterId = loginUser.getEmployeeId() != null + ? loginUser.getEmployeeId() + : loginUser.getUserCode(); + } + SendNotificationResponse response = notificationService.send(requestData.getData(), requesterId); + + HttpStatus status = switch (response.getCode()) { + case "200" -> HttpStatus.OK; + case "207" -> HttpStatus.MULTI_STATUS; + default -> HttpStatus.BAD_REQUEST; + }; + return ResponseEntity.status(status).body(response); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/dtos/BatchFcmSendResult.java b/src/main/java/com/sjmresorts/opsapp/notifications/dtos/BatchFcmSendResult.java new file mode 100644 index 0000000..e1117d6 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/dtos/BatchFcmSendResult.java @@ -0,0 +1,15 @@ +package com.sjmresorts.opsapp.notifications.dtos; + +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Getter +@Builder +public class BatchFcmSendResult { + + private final int successCount; + private final int failureCount; + private final List results; +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/dtos/FcmSendResult.java b/src/main/java/com/sjmresorts/opsapp/notifications/dtos/FcmSendResult.java new file mode 100644 index 0000000..82d463f --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/dtos/FcmSendResult.java @@ -0,0 +1,16 @@ +package com.sjmresorts.opsapp.notifications.dtos; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class FcmSendResult { + + private final boolean success; + private final String messageId; + private final String token; + private final String errorCode; + private final String errorMessage; + private final boolean tokenInvalid; +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/dtos/NotificationFailureDto.java b/src/main/java/com/sjmresorts/opsapp/notifications/dtos/NotificationFailureDto.java new file mode 100644 index 0000000..de9daf9 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/dtos/NotificationFailureDto.java @@ -0,0 +1,27 @@ +package com.sjmresorts.opsapp.notifications.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "Per-target send failure detail") +public class NotificationFailureDto { + + @Schema(description = "Target identifier (employeeId, deviceId, or token prefix)") + private String target; + + @Schema(description = "Failure reason code", example = "NO_DEVICE_TOKEN") + private String reason; + + @Schema(description = "Firebase or internal error detail for debugging") + private String detail; + + public NotificationFailureDto(String target, String reason) { + this.target = target; + this.reason = reason; + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/dtos/NotificationPayloadDto.java b/src/main/java/com/sjmresorts/opsapp/notifications/dtos/NotificationPayloadDto.java new file mode 100644 index 0000000..127bdd0 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/dtos/NotificationPayloadDto.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.notifications.dtos; + +import lombok.Builder; +import lombok.Getter; + +import java.util.Map; + +@Getter +@Builder +public class NotificationPayloadDto { + + private final String title; + private final String body; + private final String imageUrl; + private final String clickAction; + private final Map data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/dtos/SendNotificationData.java b/src/main/java/com/sjmresorts/opsapp/notifications/dtos/SendNotificationData.java new file mode 100644 index 0000000..4742abc --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/dtos/SendNotificationData.java @@ -0,0 +1,46 @@ +package com.sjmresorts.opsapp.notifications.dtos; + +import com.sjmresorts.opsapp.notifications.enums.NotificationTargetType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Data +@NoArgsConstructor +@Schema(description = "FCM notification send payload") +public class SendNotificationData { + + @NotNull(message = "targetType must not be null") + @Schema(description = "Recipient resolution type: EMPLOYEE | DEVICE | TOKEN | TOPIC | ESIGN_USER", example = "EMPLOYEE") + private NotificationTargetType targetType; + + @NotEmpty(message = "targets must not be empty") + @Schema(description = "Employee IDs, device IDs, FCM tokens, or topic name") + private List targets; + + @NotBlank(message = "title must not be blank") + @Schema(description = "Notification title") + private String title; + + @NotBlank(message = "body must not be blank") + @Schema(description = "Notification body") + private String body; + + @Schema(description = "Business trigger code for audit log", example = "PIT_ALERT") + private String triggerTask; + + @Schema(description = "Custom data passed to the mobile client (string values only)") + private Map data; + + @Schema(description = "Optional large image URL") + private String imageUrl; + + @Schema(description = "Optional click action identifier") + private String clickAction; +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/dtos/SendNotificationRequest.java b/src/main/java/com/sjmresorts/opsapp/notifications/dtos/SendNotificationRequest.java new file mode 100644 index 0000000..fbd9356 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/dtos/SendNotificationRequest.java @@ -0,0 +1,22 @@ +package com.sjmresorts.opsapp.notifications.dtos; + +import com.sjmresorts.opsapp.common.dtos.SchemaEntry; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@Schema(description = "Request to send FCM notification") +public class SendNotificationRequest { + + @Schema(description = "Optional schema metadata from caller") + private SchemaEntry schema; + + @Valid + @NotNull(message = "data must not be null") + @Schema(description = "Notification send parameters") + private SendNotificationData data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/dtos/SendNotificationResponse.java b/src/main/java/com/sjmresorts/opsapp/notifications/dtos/SendNotificationResponse.java new file mode 100644 index 0000000..89979fc --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/dtos/SendNotificationResponse.java @@ -0,0 +1,17 @@ +package com.sjmresorts.opsapp.notifications.dtos; + +import com.sjmresorts.opsapp.common.dtos.BaseApiResponse; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +@Schema(description = "Response of FCM notification send API") +public class SendNotificationResponse extends BaseApiResponse { + + @Schema(description = "Send result details") + private SendNotificationResultData data; +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/dtos/SendNotificationResultData.java b/src/main/java/com/sjmresorts/opsapp/notifications/dtos/SendNotificationResultData.java new file mode 100644 index 0000000..3fc1fdb --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/dtos/SendNotificationResultData.java @@ -0,0 +1,29 @@ +package com.sjmresorts.opsapp.notifications.dtos; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.ArrayList; +import java.util.List; + +@Data +@NoArgsConstructor +@Schema(description = "FCM send result summary") +public class SendNotificationResultData { + + @Schema(description = "Total targets in request") + private int totalTargets; + + @Schema(description = "Successfully delivered count") + private int successCount; + + @Schema(description = "Failed delivery count") + private int failureCount; + + @Schema(description = "Overall result status", example = "SUCCESS") + private String resultStatus; + + @Schema(description = "Per-target failures") + private List failures = new ArrayList<>(); +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/enums/NotificationFailureReason.java b/src/main/java/com/sjmresorts/opsapp/notifications/enums/NotificationFailureReason.java new file mode 100644 index 0000000..6d4558e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/enums/NotificationFailureReason.java @@ -0,0 +1,10 @@ +package com.sjmresorts.opsapp.notifications.enums; + +public enum NotificationFailureReason { + NO_DEVICE_TOKEN, + USER_NOT_FOUND, + TOKEN_INVALID, + FCM_ERROR, + NETWORK_ERROR, + FCM_NOT_INITIALIZED +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/enums/NotificationResultStatus.java b/src/main/java/com/sjmresorts/opsapp/notifications/enums/NotificationResultStatus.java new file mode 100644 index 0000000..894a83d --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/enums/NotificationResultStatus.java @@ -0,0 +1,7 @@ +package com.sjmresorts.opsapp.notifications.enums; + +public enum NotificationResultStatus { + SUCCESS, + PARTIAL, + FAILED +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/enums/NotificationTargetType.java b/src/main/java/com/sjmresorts/opsapp/notifications/enums/NotificationTargetType.java new file mode 100644 index 0000000..742a6d6 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/enums/NotificationTargetType.java @@ -0,0 +1,10 @@ +package com.sjmresorts.opsapp.notifications.enums; + +public enum NotificationTargetType { + EMPLOYEE, + DEVICE, + TOKEN, + TOPIC, + /** Esign 用户 ID(ESIGN_USER.id,与登录 JWT employeeId 一致) */ + ESIGN_USER +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/resolvers/RecipientResolver.java b/src/main/java/com/sjmresorts/opsapp/notifications/resolvers/RecipientResolver.java new file mode 100644 index 0000000..85446ea --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/resolvers/RecipientResolver.java @@ -0,0 +1,90 @@ +package com.sjmresorts.opsapp.notifications.resolvers; + +import com.sjmresorts.opsapp.authentication.models.User; +import com.sjmresorts.opsapp.authentication.repositories.UserRepository; +import com.sjmresorts.opsapp.esign.auth.models.EsignUser; +import com.sjmresorts.opsapp.esign.auth.repositories.EsignUserRepository; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class RecipientResolver { + + private final UserRepository userRepository; + private final EsignUserRepository esignUserRepository; + + public List resolveByEmployeeIds(List employeeIds) { + if (employeeIds == null || employeeIds.isEmpty()) { + return List.of(); + } + List normalizedIds = employeeIds.stream() + .filter(StringUtils::isNotBlank) + .map(String::trim) + .distinct() + .toList(); + if (normalizedIds.isEmpty()) { + return List.of(); + } + Map byEmployeeId = new LinkedHashMap<>(); + for (User user : userRepository.findByEmployeeIdIn(normalizedIds)) { + if (user != null && StringUtils.isNotBlank(user.getEmployeeId())) { + byEmployeeId.putIfAbsent(user.getEmployeeId(), user); + } + } + for (String employeeId : normalizedIds) { + userRepository.findByEmployeeId(employeeId).ifPresent(user -> + byEmployeeId.putIfAbsent(employeeId, user)); + } + return new ArrayList<>(byEmployeeId.values()); + } + + public Optional resolveByDeviceId(String deviceId) { + if (StringUtils.isBlank(deviceId)) { + return Optional.empty(); + } + return userRepository.findByDeviceId(deviceId.trim()); + } + + public Optional resolveByToken(String token) { + if (StringUtils.isBlank(token)) { + return Optional.empty(); + } + return userRepository.findByDeviceToken(token.trim()); + } + + public List resolveEsignByIds(List userIds) { + if (userIds == null || userIds.isEmpty()) { + return List.of(); + } + List ids = new ArrayList<>(); + for (String raw : userIds) { + if (StringUtils.isBlank(raw)) { + continue; + } + try { + ids.add(Long.parseLong(raw.trim())); + } catch (NumberFormatException ignored) { + // skip invalid id + } + } + if (ids.isEmpty()) { + return List.of(); + } + return esignUserRepository.findByIdIn(ids); + } + + public Optional resolveEsignByDeviceToken(String token) { + if (StringUtils.isBlank(token)) { + return Optional.empty(); + } + return esignUserRepository.findByDeviceToken(token.trim()); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/services/FcmPushService.java b/src/main/java/com/sjmresorts/opsapp/notifications/services/FcmPushService.java new file mode 100644 index 0000000..8d9040e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/services/FcmPushService.java @@ -0,0 +1,16 @@ +package com.sjmresorts.opsapp.notifications.services; + +import com.sjmresorts.opsapp.notifications.dtos.BatchFcmSendResult; +import com.sjmresorts.opsapp.notifications.dtos.FcmSendResult; +import com.sjmresorts.opsapp.notifications.dtos.NotificationPayloadDto; + +import java.util.List; + +public interface FcmPushService { + + FcmSendResult sendToToken(String token, NotificationPayloadDto payload); + + BatchFcmSendResult sendToTokens(List tokens, NotificationPayloadDto payload); + + FcmSendResult sendToTopic(String topic, NotificationPayloadDto payload); +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/services/NotificationService.java b/src/main/java/com/sjmresorts/opsapp/notifications/services/NotificationService.java new file mode 100644 index 0000000..eb9fa3e --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/services/NotificationService.java @@ -0,0 +1,9 @@ +package com.sjmresorts.opsapp.notifications.services; + +import com.sjmresorts.opsapp.notifications.dtos.SendNotificationData; +import com.sjmresorts.opsapp.notifications.dtos.SendNotificationResponse; + +public interface NotificationService { + + SendNotificationResponse send(SendNotificationData data, String requesterId); +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/services/impl/FcmPushServiceImpl.java b/src/main/java/com/sjmresorts/opsapp/notifications/services/impl/FcmPushServiceImpl.java new file mode 100644 index 0000000..01903f6 --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/services/impl/FcmPushServiceImpl.java @@ -0,0 +1,283 @@ +package com.sjmresorts.opsapp.notifications.services.impl; + +import com.google.firebase.messaging.BatchResponse; +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.FirebaseMessagingException; +import com.google.firebase.messaging.AndroidConfig; +import com.google.firebase.messaging.AndroidNotification; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.MessagingErrorCode; +import com.google.firebase.messaging.MulticastMessage; +import com.google.firebase.messaging.Notification; +import com.google.firebase.messaging.SendResponse; +import com.sjmresorts.opsapp.notifications.config.FirebaseConfig; +import com.sjmresorts.opsapp.notifications.dtos.BatchFcmSendResult; +import com.sjmresorts.opsapp.notifications.dtos.FcmSendResult; +import com.sjmresorts.opsapp.notifications.dtos.NotificationPayloadDto; +import com.sjmresorts.opsapp.notifications.enums.NotificationFailureReason; +import com.sjmresorts.opsapp.notifications.services.FcmPushService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FcmPushServiceImpl implements FcmPushService { + + private static final int FCM_MULTICAST_LIMIT = 500; + /** 与 aps-fcm aar 中 @string/dcloud_feature_aps_notification_channel 默认值一致 */ + private static final String ANDROID_NOTIFICATION_CHANNEL_ID = "unipush"; + + private final FirebaseConfig firebaseConfig; + + @Value("${ops-app.notification.batch-size:500}") + private int batchSize; + + /** + * false(默认):带 notification,后台/桌面系统通知栏可收到。 + * true:仅 data 透传,利于 DCloud 前台 plus.push receive,但后台往往不再自动弹系统通知。 + */ + @Value("${ops-app.notification.android-data-only:false}") + private boolean androidDataOnly; + + @Override + public FcmSendResult sendToToken(String token, NotificationPayloadDto payload) { + if (!firebaseConfig.isReady()) { + return notInitializedResult(token); + } + if (firebaseConfig.isDryRun()) { + return dryRunResult(token); + } + try { + String messageId = FirebaseMessaging.getInstance().send(buildMessage(token, null, payload)); + return FcmSendResult.builder() + .success(true) + .messageId(messageId) + .token(token) + .build(); + } catch (FirebaseMessagingException e) { + return failedResult(token, e); + } + } + + @Override + public BatchFcmSendResult sendToTokens(List tokens, NotificationPayloadDto payload) { + List validTokens = tokens == null ? List.of() : tokens.stream() + .filter(StringUtils::isNotBlank) + .map(String::trim) + .distinct() + .toList(); + if (validTokens.isEmpty()) { + return BatchFcmSendResult.builder() + .successCount(0) + .failureCount(0) + .results(List.of()) + .build(); + } + if (!firebaseConfig.isReady()) { + List results = validTokens.stream() + .map(this::notInitializedResult) + .toList(); + return summarize(results); + } + if (firebaseConfig.isDryRun()) { + List results = validTokens.stream() + .map(this::dryRunResult) + .toList(); + return summarize(results); + } + + int chunkSize = Math.min(Math.max(batchSize, 1), FCM_MULTICAST_LIMIT); + List allResults = new ArrayList<>(); + for (int index = 0; index < validTokens.size(); index += chunkSize) { + List chunk = validTokens.subList(index, Math.min(index + chunkSize, validTokens.size())); + allResults.addAll(sendChunk(chunk, payload)); + } + return summarize(allResults); + } + + @Override + public FcmSendResult sendToTopic(String topic, NotificationPayloadDto payload) { + if (StringUtils.isBlank(topic)) { + return FcmSendResult.builder() + .success(false) + .token(topic) + .errorCode(NotificationFailureReason.FCM_ERROR.name()) + .errorMessage("Topic must not be blank") + .build(); + } + if (!firebaseConfig.isReady()) { + return notInitializedResult(topic); + } + if (firebaseConfig.isDryRun()) { + return dryRunResult(topic); + } + try { + String messageId = FirebaseMessaging.getInstance() + .send(buildMessage(null, topic.trim(), payload)); + return FcmSendResult.builder() + .success(true) + .messageId(messageId) + .token(topic) + .build(); + } catch (FirebaseMessagingException e) { + return failedResult(topic, e); + } + } + + private List sendChunk(List tokens, NotificationPayloadDto payload) { + if (tokens.size() == 1) { + return List.of(sendToToken(tokens.get(0), payload)); + } + try { + MulticastMessage.Builder builder = MulticastMessage.builder() + .addAllTokens(tokens) + .putAllData(normalizeData(payload)) + .setAndroidConfig(buildAndroidConfig(payload)); + if (!androidDataOnly) { + builder.setNotification(buildNotification(payload)); + } + BatchResponse batchResponse = FirebaseMessaging.getInstance().sendEachForMulticast(builder.build()); + List results = new ArrayList<>(); + List responses = batchResponse.getResponses(); + for (int i = 0; i < tokens.size(); i++) { + String token = tokens.get(i); + SendResponse response = i < responses.size() ? responses.get(i) : null; + if (response != null && response.isSuccessful()) { + results.add(FcmSendResult.builder() + .success(true) + .messageId(response.getMessageId()) + .token(token) + .build()); + } else { + FirebaseMessagingException exception = response == null ? null : response.getException(); + if (exception != null) { + results.add(failedResult(token, exception)); + } else { + results.add(FcmSendResult.builder() + .success(false) + .token(token) + .errorCode(NotificationFailureReason.FCM_ERROR.name()) + .errorMessage("Unknown FCM batch failure") + .build()); + } + } + } + return results; + } catch (FirebaseMessagingException e) { + log.error("FCM multicast send failed", e); + return tokens.stream() + .map(token -> failedResult(token, e)) + .toList(); + } + } + + private Message buildMessage(String token, String topic, NotificationPayloadDto payload) { + Message.Builder builder = Message.builder() + .putAllData(normalizeData(payload)) + .setAndroidConfig(buildAndroidConfig(payload)); + if (!androidDataOnly) { + builder.setNotification(buildNotification(payload)); + } + if (StringUtils.isNotBlank(token)) { + builder.setToken(token); + } + if (StringUtils.isNotBlank(topic)) { + builder.setTopic(topic); + } + return builder.build(); + } + + private AndroidConfig buildAndroidConfig(NotificationPayloadDto payload) { + AndroidConfig.Builder builder = AndroidConfig.builder() + .setPriority(AndroidConfig.Priority.HIGH); + if (!androidDataOnly && StringUtils.isNotBlank(payload.getTitle())) { + builder.setNotification(AndroidNotification.builder() + .setTitle(payload.getTitle()) + .setBody(StringUtils.defaultString(payload.getBody())) + .setChannelId(ANDROID_NOTIFICATION_CHANNEL_ID) + .build()); + } + return builder.build(); + } + + private Notification buildNotification(NotificationPayloadDto payload) { + Notification.Builder builder = Notification.builder() + .setTitle(payload.getTitle()) + .setBody(payload.getBody()); + if (StringUtils.isNotBlank(payload.getImageUrl())) { + builder.setImage(payload.getImageUrl()); + } + return builder.build(); + } + + private Map normalizeData(NotificationPayloadDto payload) { + Map normalized = new HashMap<>(); + if (payload.getData() != null) { + payload.getData().forEach((key, value) -> { + if (StringUtils.isNotBlank(key) && value != null) { + normalized.put(key, value); + } + }); + } + // 前台 onMessage 主要读 data;把 title/body 写入 data 便于 App 内展示 + if (StringUtils.isNotBlank(payload.getTitle())) { + normalized.putIfAbsent("title", payload.getTitle()); + } + if (StringUtils.isNotBlank(payload.getBody())) { + normalized.putIfAbsent("body", payload.getBody()); + normalized.putIfAbsent("content", payload.getBody()); + } + if (StringUtils.isNotBlank(payload.getClickAction())) { + normalized.put("clickAction", payload.getClickAction()); + } + return normalized; + } + + private FcmSendResult failedResult(String token, FirebaseMessagingException exception) { + MessagingErrorCode errorCode = exception.getMessagingErrorCode(); + boolean tokenInvalid = errorCode == MessagingErrorCode.UNREGISTERED + || errorCode == MessagingErrorCode.INVALID_ARGUMENT; + return FcmSendResult.builder() + .success(false) + .token(token) + .errorCode(errorCode != null ? errorCode.name() : NotificationFailureReason.FCM_ERROR.name()) + .errorMessage(exception.getMessage()) + .tokenInvalid(tokenInvalid) + .build(); + } + + private FcmSendResult notInitializedResult(String token) { + return FcmSendResult.builder() + .success(false) + .token(token) + .errorCode(NotificationFailureReason.FCM_NOT_INITIALIZED.name()) + .errorMessage("Firebase is not initialized") + .build(); + } + + private FcmSendResult dryRunResult(String token) { + return FcmSendResult.builder() + .success(true) + .messageId("dry-run") + .token(token) + .build(); + } + + private BatchFcmSendResult summarize(List results) { + int successCount = (int) results.stream().filter(FcmSendResult::isSuccess).count(); + return BatchFcmSendResult.builder() + .successCount(successCount) + .failureCount(results.size() - successCount) + .results(results) + .build(); + } +} diff --git a/src/main/java/com/sjmresorts/opsapp/notifications/services/impl/NotificationServiceImpl.java b/src/main/java/com/sjmresorts/opsapp/notifications/services/impl/NotificationServiceImpl.java new file mode 100644 index 0000000..52358cf --- /dev/null +++ b/src/main/java/com/sjmresorts/opsapp/notifications/services/impl/NotificationServiceImpl.java @@ -0,0 +1,412 @@ +package com.sjmresorts.opsapp.notifications.services.impl; + +import com.alibaba.fastjson2.JSON; +import com.sjmresorts.opsapp.authentication.models.User; +import com.sjmresorts.opsapp.authentication.repositories.UserRepository; +import com.sjmresorts.opsapp.esign.auth.models.EsignUser; +import com.sjmresorts.opsapp.esign.auth.repositories.EsignUserRepository; +import com.sjmresorts.opsapp.common.constants.AppConstants; +import com.sjmresorts.opsapp.logging.models.NotificationLog; +import com.sjmresorts.opsapp.logging.services.NotificationLogService; +import com.sjmresorts.opsapp.notifications.dtos.BatchFcmSendResult; +import com.sjmresorts.opsapp.notifications.dtos.FcmSendResult; +import com.sjmresorts.opsapp.notifications.dtos.NotificationFailureDto; +import com.sjmresorts.opsapp.notifications.dtos.NotificationPayloadDto; +import com.sjmresorts.opsapp.notifications.dtos.SendNotificationData; +import com.sjmresorts.opsapp.notifications.dtos.SendNotificationResponse; +import com.sjmresorts.opsapp.notifications.dtos.SendNotificationResultData; +import com.sjmresorts.opsapp.notifications.enums.NotificationFailureReason; +import com.sjmresorts.opsapp.notifications.enums.NotificationResultStatus; +import com.sjmresorts.opsapp.notifications.enums.NotificationTargetType; +import com.sjmresorts.opsapp.notifications.resolvers.RecipientResolver; +import com.sjmresorts.opsapp.notifications.services.FcmPushService; +import com.sjmresorts.opsapp.notifications.services.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationServiceImpl implements NotificationService { + + private final FcmPushService fcmPushService; + private final RecipientResolver recipientResolver; + private final UserRepository userRepository; + private final EsignUserRepository esignUserRepository; + private final NotificationLogService notificationLogService; + + @Value("${ops-app.notification.invalid-token-cleanup:true}") + private boolean invalidTokenCleanup; + + @Override + public SendNotificationResponse send(SendNotificationData data, String requesterId) { + Instant requestedAt = Instant.now(); + NotificationLog auditLog = buildAuditLog(data, requesterId, requestedAt); + + SendNotificationResultData resultData = new SendNotificationResultData(); + List failures = new ArrayList<>(); + int successCount = 0; + + List targets = normalizeTargets(data.getTargets()); + resultData.setTotalTargets(targets.size()); + + if (targets.isEmpty()) { + resultData.setSuccessCount(0); + resultData.setFailureCount(0); + return buildResponse(resultData, failures, auditLog, + NotificationResultStatus.FAILED, "targets must not be empty"); + } + + NotificationPayloadDto payload = buildPayload(data); + + if (data.getTargetType() == NotificationTargetType.TOPIC) { + return sendToTopic(data, targets, payload, resultData, failures, auditLog, requestedAt); + } + + Map tokenRecipients = new LinkedHashMap<>(); + resolveRecipients(data.getTargetType(), targets, failures, tokenRecipients); + + if (tokenRecipients.isEmpty()) { + resultData.setSuccessCount(0); + resultData.setFailureCount(failures.size()); + return buildResponse(resultData, failures, auditLog, + NotificationResultStatus.FAILED, + "No valid FCM tokens to send"); + } + + BatchFcmSendResult batchResult = fcmPushService.sendToTokens( + new ArrayList<>(tokenRecipients.keySet()), + payload); + + for (FcmSendResult sendResult : batchResult.getResults()) { + ResolvedRecipient recipient = tokenRecipients.get(sendResult.getToken()); + String targetKey = recipient == null ? maskToken(sendResult.getToken()) : recipient.targetKey(); + if (sendResult.isSuccess()) { + successCount++; + } else { + failures.add(new NotificationFailureDto( + targetKey, + mapFailureReason(sendResult), + formatFailureDetail(sendResult))); + if (invalidTokenCleanup && sendResult.isTokenInvalid()) { + cleanupInvalidToken(sendResult.getToken(), recipient); + } + } + } + + resultData.setSuccessCount(successCount); + resultData.setFailureCount(failures.size()); + resultData.setFailures(failures); + + NotificationResultStatus status = resolveStatus(successCount, failures.size(), targets.size()); + String message = status == NotificationResultStatus.SUCCESS + ? "Notification sent" + : "Notification sent with failures"; + + return buildResponse(resultData, failures, auditLog, status, message); + } + + private SendNotificationResponse sendToTopic( + SendNotificationData data, + List targets, + NotificationPayloadDto payload, + SendNotificationResultData resultData, + List failures, + NotificationLog auditLog, + Instant requestedAt) { + + String topic = targets.get(0); + if (targets.size() > 1) { + log.warn("TOPIC send uses first target only, ignored {} extra targets", targets.size() - 1); + } + + FcmSendResult sendResult = fcmPushService.sendToTopic(topic, payload); + if (sendResult.isSuccess()) { + resultData.setSuccessCount(1); + resultData.setFailureCount(0); + return buildResponse(resultData, failures, auditLog, + NotificationResultStatus.SUCCESS, "Notification sent to topic"); + } + + failures.add(new NotificationFailureDto(topic, mapFailureReason(sendResult), formatFailureDetail(sendResult))); + resultData.setSuccessCount(0); + resultData.setFailureCount(1); + return buildResponse(resultData, failures, auditLog, + NotificationResultStatus.FAILED, "Failed to send topic notification"); + } + + private void resolveRecipients( + NotificationTargetType targetType, + List targets, + List failures, + Map tokenRecipients) { + + switch (targetType) { + case EMPLOYEE -> resolveEmployees(targets, failures, tokenRecipients); + case DEVICE -> resolveDevices(targets, failures, tokenRecipients); + case TOKEN -> resolveTokens(targets, failures, tokenRecipients); + case ESIGN_USER -> resolveEsignUsers(targets, failures, tokenRecipients); + default -> targets.forEach(target -> failures.add(new NotificationFailureDto( + target, NotificationFailureReason.FCM_ERROR.name()))); + } + } + + private void resolveEmployees( + List targets, + List failures, + Map tokenRecipients) { + Map usersByEmployee = new LinkedHashMap<>(); + for (User user : recipientResolver.resolveByEmployeeIds(targets)) { + usersByEmployee.put(user.getEmployeeId(), user); + } + for (String employeeId : targets) { + User user = usersByEmployee.get(employeeId); + if (user == null) { + failures.add(new NotificationFailureDto(employeeId, NotificationFailureReason.USER_NOT_FOUND.name())); + continue; + } + addTokenRecipient(employeeId, user, failures, tokenRecipients); + } + } + + private void resolveDevices( + List targets, + List failures, + Map tokenRecipients) { + for (String deviceId : targets) { + Optional userOptional = recipientResolver.resolveByDeviceId(deviceId); + if (userOptional.isEmpty()) { + failures.add(new NotificationFailureDto(deviceId, NotificationFailureReason.USER_NOT_FOUND.name())); + continue; + } + addTokenRecipient(deviceId, userOptional.get(), failures, tokenRecipients); + } + } + + private void resolveTokens( + List targets, + List failures, + Map tokenRecipients) { + for (String token : targets) { + if (StringUtils.isBlank(token)) { + failures.add(new NotificationFailureDto("", NotificationFailureReason.TOKEN_INVALID.name())); + continue; + } + String trimmedToken = token.trim(); + User user = recipientResolver.resolveByToken(trimmedToken).orElse(null); + EsignUser esignUser = recipientResolver.resolveEsignByDeviceToken(trimmedToken).orElse(null); + tokenRecipients.putIfAbsent(trimmedToken, + new ResolvedRecipient(maskToken(trimmedToken), trimmedToken, user, esignUser)); + } + } + + private void resolveEsignUsers( + List targets, + List failures, + Map tokenRecipients) { + Map byId = new LinkedHashMap<>(); + for (EsignUser user : recipientResolver.resolveEsignByIds(targets)) { + byId.put(user.getId(), user); + } + for (String target : targets) { + if (StringUtils.isBlank(target)) { + continue; + } + Long id; + try { + id = Long.parseLong(target.trim()); + } catch (NumberFormatException e) { + failures.add(new NotificationFailureDto(target, NotificationFailureReason.USER_NOT_FOUND.name())); + continue; + } + EsignUser user = byId.get(id); + if (user == null) { + failures.add(new NotificationFailureDto(target, NotificationFailureReason.USER_NOT_FOUND.name())); + continue; + } + addEsignTokenRecipient(target, user, failures, tokenRecipients); + } + } + + private void addEsignTokenRecipient( + String targetKey, + EsignUser user, + List failures, + Map tokenRecipients) { + if (StringUtils.isBlank(user.getDeviceToken())) { + failures.add(new NotificationFailureDto(targetKey, NotificationFailureReason.NO_DEVICE_TOKEN.name())); + return; + } + tokenRecipients.putIfAbsent(user.getDeviceToken(), + new ResolvedRecipient(targetKey, user.getDeviceToken(), null, user)); + } + + private void addTokenRecipient( + String targetKey, + User user, + List failures, + Map tokenRecipients) { + if (StringUtils.isBlank(user.getDeviceToken())) { + failures.add(new NotificationFailureDto(targetKey, NotificationFailureReason.NO_DEVICE_TOKEN.name())); + return; + } + tokenRecipients.putIfAbsent(user.getDeviceToken(), + new ResolvedRecipient(targetKey, user.getDeviceToken(), user, null)); + } + + private void cleanupInvalidToken(String token, ResolvedRecipient recipient) { + if (StringUtils.isBlank(token)) { + return; + } + try { + int cleared = userRepository.clearDeviceTokenByToken(token); + if (cleared == 0 && recipient != null && recipient.user() != null + && StringUtils.isNotBlank(recipient.user().getEmployeeId())) { + userRepository.clearDeviceTokenByEmployeeId(recipient.user().getEmployeeId()); + } + if (recipient != null && recipient.esignUser() != null) { + EsignUser esignUser = recipient.esignUser(); + esignUser.setDeviceToken(null); + esignUserRepository.save(esignUser); + } + } catch (Exception e) { + log.warn("Failed to clear invalid device token", e); + } + } + + private NotificationPayloadDto buildPayload(SendNotificationData data) { + Map payloadData = new HashMap<>(); + if (data.getData() != null) { + payloadData.putAll(data.getData()); + } + if (StringUtils.isNotBlank(data.getTriggerTask())) { + payloadData.put("triggerTask", data.getTriggerTask()); + } + return NotificationPayloadDto.builder() + .title(data.getTitle()) + .body(data.getBody()) + .imageUrl(data.getImageUrl()) + .clickAction(data.getClickAction()) + .data(payloadData) + .build(); + } + + private NotificationLog buildAuditLog(SendNotificationData data, String requesterId, Instant requestedAt) { + NotificationLog auditLog = new NotificationLog(); + auditLog.setNotificationType(AppConstants.FIREBASE_NOTIFICATION); + auditLog.setTriggerTask(StringUtils.defaultIfBlank(data.getTriggerTask(), requesterId)); + auditLog.setRecipients(String.join(",", normalizeTargets(data.getTargets()))); + auditLog.setSubject(data.getTitle()); + auditLog.setNotificationContent(JSON.toJSONString(data)); + auditLog.setRequestedTime(requestedAt); + return auditLog; + } + + private SendNotificationResponse buildResponse( + SendNotificationResultData resultData, + List failures, + NotificationLog auditLog, + NotificationResultStatus status, + String message) { + + resultData.setFailures(failures); + resultData.setResultStatus(status.name()); + if (resultData.getFailureCount() == 0 && !failures.isEmpty()) { + resultData.setFailureCount(failures.size()); + } + + auditLog.setFinishedTime(Instant.now()); + auditLog.setResultStatus(status.name()); + if (status == NotificationResultStatus.FAILED && !failures.isEmpty()) { + auditLog.setErrorMessage(failures.get(0).getReason()); + } else if (status == NotificationResultStatus.PARTIAL) { + auditLog.setErrorMessage("partial failures: " + failures.size()); + } + notificationLogService.saveNotificationLog(auditLog); + + SendNotificationResponse response = new SendNotificationResponse(); + response.setSuccess(status != NotificationResultStatus.FAILED); + response.setCode(switch (status) { + case SUCCESS -> "200"; + case PARTIAL -> "207"; + case FAILED -> "400"; + }); + response.setMessage(message); + response.setData(resultData); + return response; + } + + private NotificationResultStatus resolveStatus(int successCount, int failureCount, int totalTargets) { + if (successCount <= 0) { + return NotificationResultStatus.FAILED; + } + if (failureCount > 0 || successCount < totalTargets) { + return NotificationResultStatus.PARTIAL; + } + return NotificationResultStatus.SUCCESS; + } + + private String mapFailureReason(FcmSendResult sendResult) { + if (sendResult.isTokenInvalid()) { + return NotificationFailureReason.TOKEN_INVALID.name(); + } + if (NotificationFailureReason.FCM_NOT_INITIALIZED.name().equals(sendResult.getErrorCode())) { + return NotificationFailureReason.FCM_NOT_INITIALIZED.name(); + } + if ("INVALID_ARGUMENT".equals(sendResult.getErrorCode()) + || "UNREGISTERED".equals(sendResult.getErrorCode())) { + return NotificationFailureReason.TOKEN_INVALID.name(); + } + String detail = StringUtils.defaultString(sendResult.getErrorMessage()) + + " " + StringUtils.defaultString(sendResult.getErrorCode()); + if (detail.contains("Connect timed out") + || detail.contains("Timed out") + || detail.contains("getting access token")) { + return NotificationFailureReason.NETWORK_ERROR.name(); + } + return NotificationFailureReason.FCM_ERROR.name(); + } + + private String formatFailureDetail(FcmSendResult sendResult) { + if (sendResult == null) { + return null; + } + String code = sendResult.getErrorCode(); + String message = sendResult.getErrorMessage(); + if (StringUtils.isNotBlank(code) && StringUtils.isNotBlank(message)) { + return code + ": " + message; + } + return StringUtils.defaultIfBlank(message, code); + } + + private List normalizeTargets(List targets) { + if (targets == null) { + return List.of(); + } + return targets.stream() + .filter(StringUtils::isNotBlank) + .map(String::trim) + .toList(); + } + + private String maskToken(String token) { + if (StringUtils.isBlank(token) || token.length() <= 12) { + return "token"; + } + return token.substring(0, 6) + "..." + token.substring(token.length() - 4); + } + + private record ResolvedRecipient(String targetKey, String token, User user, EsignUser esignUser) { + } +} diff --git a/src/main/resources/application-db-mysql.yml b/src/main/resources/application-db-mysql.yml new file mode 100644 index 0000000..490c998 --- /dev/null +++ b/src/main/resources/application-db-mysql.yml @@ -0,0 +1,17 @@ +# 叠加用法:--spring.profiles.active=local,db-mysql +# 或环境变量 OPS_APP_DB_TYPE=mysql,并自行改 jdbc-url +ops-app: + db: + type: mysql + +spring: + datasource: + ops-app-db: + driver-class-name: com.mysql.cj.jdbc.Driver + jdbc-url: jdbc:mysql://192.168.0.78:3306/db_will_e-sign_QA?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true + username: navicat_user + password: Abcd1234 + jpa: + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect diff --git a/src/main/resources/application-db-postgres.yml b/src/main/resources/application-db-postgres.yml new file mode 100644 index 0000000..fbf0c74 --- /dev/null +++ b/src/main/resources/application-db-postgres.yml @@ -0,0 +1,14 @@ +# 叠加用法:--spring.profiles.active=local,db-postgres +ops-app: + db: + type: postgres + +spring: + datasource: + ops-app-db: + driver-class-name: org.postgresql.Driver + # jdbc-url: jdbc:postgresql://127.0.0.1:5432/db_esign + jpa: + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect diff --git a/src/main/resources/application-db-sqlserver.yml b/src/main/resources/application-db-sqlserver.yml new file mode 100644 index 0000000..4a7725c --- /dev/null +++ b/src/main/resources/application-db-sqlserver.yml @@ -0,0 +1,13 @@ +# 显式指定 SQL Server(默认 local/dev 已是 SQL Server;一般无需叠加) +ops-app: + db: + type: sqlserver + +spring: + datasource: + ops-app-db: + driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver + jpa: + properties: + hibernate: + dialect: org.hibernate.dialect.SQLServerDialect diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..f4c1459 --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,167 @@ +proxy: + enabled: true # set to true to enable proxy + host: 10.33.35.10 + port: 8080 +server: + port: 8086 +spring: + datasource: + ops-app-db: + driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver + jdbc-url: jdbc:sqlserver://QVMHADBS01;databasename=db_sjm_profile;encrypt=false;trustServerCertificate=true;hostNameInCertificate=*;loginTimeout=30 + username: s-tg-user + password: P@ssw0rd12345678 + hikari: + pool-name: ops-app-db + maximum-pool-size: 50 + minimum-idle: 5 + connection-timeout: 20000 + idle-timeout: 300000 + max-lifetime: 1200000 + leak-detection-threshold: 60000 + jpa: + open-in-view: false + show-sql: false + properties: + hibernate: + format_sql: true + dialect: org.hibernate.dialect.SQLServerDialect + # 运行时由 DbDialect 覆盖;切库请用 OPS_APP_DB_TYPE 或 profile db-mysql / db-postgres + proc: + param_null_passing: true + mail: + host: mail.macausjm-glp.com + port: 25 + username: qa-kitpun@sjmresorts.com + password: Qweqwe@123123123 + protocol: smtp + properties: + mail: + smtp: + auth: false + starttls: + enable: false + ssl: + enable: false + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 + ldap: + urls: ldap://macausjm-glp.com:389 + base: DC=macausjm-glp,DC=com + username: s-toa-app + password: KJYr9y6gx@&PjA9 + base-environment: + java.naming.referral: follow +springdoc: + swagger-ui: + path: /swagger-ui.html + operations-sorter: alpha + tags-sorter: alpha + doc-expansion: none + api-docs: + path: /v3/api-docs + default-consumes-media-type: application/json + default-produces-media-type: application/json +firebase: + credential: + path: serviceKey.json + dry-run: false +ops-app: + deeplink: + url: myapp://open + opera: + close-popup: true + parent-origin: https://mucu1ua.oraclehospitality.us-ashburn-1.ocs.oraclecloud.com + close-delay-ms: 200 + notification: + batch-size: 500 + invalid-token-cleanup: true + api-logging-level: error + frontend: + url: http://localhost:8080/#/ + app-setting: + cache-minutes: 5 + schedule-job: + max-thread-pool: 10 + task-config: + escalation-level-1: 1 * * * * ? + check-idle-session-interval: 300000 + network-share: + basePath: \\qvhubint51\GLPGMAPP\MobileHub\ + # \\qvhubin51\GLPGMAPP\MobileHub\ + domain: macausjm-glp.com + username: qa-xxxxxx + password: xxxxxxxx + security: + jwt: + secret_key: SecretKeyWhichShouldBeLongEnoughToBeSecure67848563 + app_authorization_code: SecretKeyOfAppLoginAuthorization + api_token_expiration: 5 + cms_session_expiration: 15 + app_session_expiration: 300 + notification: + authorization_code: SecretKeyOfAuthorization + expiration: 15 + esign: + legacy-api: + enabled: true + warn-headers: true + reservation: + notification: + enabled: true + broadcast-if-no-hotel-match: true + title: 新预订待处理 + body-template: "预订号 {reservationId} 已就绪,请点击查看" + trigger-task: RESERVATION_NOTIFY + action: OPEN_RESERVATION + third-party: + base-url: https://mucu1ua.oraclehospitality.us-ashburn-1.ocs.oraclecloud.com + timeout-ms: 30000 + oauth: + enabled: true + token-path: /oauth/v1/tokens + client-id: + client-secret: + app-key: + enterprise-id: + hotel-id: + scope: urn:opc:hgbu:ws:__myscopes__ + grant-type: client_credentials + timeout-ms: 30000 + registration-card: + regenerate: false + signed-only: false + reservation-id-context: OPERA + reservation-id-type: Reservation + language: EN + signed-upload: + enabled: true + reservation-lookup: + path-template: /rsv/v1/hotels/{hotelId}/reservations + fetch-instructions: Reservation + file-attachment: + path: /med/config/v1/fileAttachments + user-name: USER@HOTEL + description: E + link-type: Reservation + overwrite-existing: Y + global-yn: Y + profile-fields: + enabled: true + path-template: /crm/v1/profiles/{profileId} + authorization: + auth-user: + auth-password: + profile: + security: + # 是否启用认证(JWT解密和LDAP认证) + # false: 不启用认证,使用默认测试组(用于开发测试) + # true: 启用认证,需要JWT token和LDAP验证 + authentication-enabled: true + report: + api-server: http://10.51.92.11:8082 + gaming-report-data-url: /api/gamingDashboard + request-timeout-seconds: 30 + auth-user: apiuser + auth-password: apipassword \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 0000000..6e36659 --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,180 @@ +# Clash V 系统代理:127.0.0.1:7897(与客户端「系统代理设置」一致) +proxy: + enabled: false + host: 127.0.0.1 + port: 7897 +server: + port: 8077 +spring: + application: + name: auto-check-in + datasource: + ops-app-db: + driver-class-name: com.mysql.cj.jdbc.Driver + jdbc-url: jdbc:mysql://192.168.10.204:3306/db_will_e-sign_QA?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true + username: navicat_user + password: Abcd1234 + hikari: + pool-name: ops-app-db + maximum-pool-size: 50 + minimum-idle: 5 + connection-timeout: 20000 + idle-timeout: 300000 + max-lifetime: 1200000 + leak-detection-threshold: 60000 + jpa: + open-in-view: false + show-sql: false + properties: + hibernate: + format_sql: true + # dialect 由 DbDialect / ops-app.db.type 在运行时设置;此处仅作 fallback + dialect: org.hibernate.dialect.MySQLDialect + proc: + param_null_passing: true + ldap: + urls: ldap://192.168.1.39:389 + base: dc=example,dc=com + username: admin@example.com + password: your_password +springdoc: + swagger-ui: + path: /swagger-ui.html + operations-sorter: alpha + tags-sorter: alpha + doc-expansion: none + api-docs: + path: /v3/api-docs + default-consumes-media-type: application/json + default-produces-media-type: application/json +firebase: + credential: + # src/main/resources/config/service/serviceKey.json + path: classpath:config/service/serviceKey.json + dry-run: false +ops-app: + db: + type: mysql + hub-promotion: + media-dir: /data/auto-check-in/hub-promotions + deeplink: + url: myapp://open + # 本机 Java 启动 exe(默认关闭,改用浏览器 myapp://) + local-exe: + enabled: false + opera: + # popup = Opera Flex Link 弹窗(iframe),须装外层代理(Chrome 扩展 scripts/opera-close-extension 或 Tampermonkey) + # new-window = 若 Opera 配置为「新浏览器窗口」打开 Flex Link,可改此项并无需外层代理 + launch-mode: popup + close-popup: true + close-modal-message: true + flex-link-origin: http://127.0.0.1:8066 + parent-origin: https://mtcs2ua.hospitality-api.ap-singapore-1.ocs.oc-test.com + open-delay-ms: 1200 + # popup 关窗不再等待 open-delay;仅 new-window 标签关窗使用。越小关 Pop-up 越快(建议 50~120) + close-delay-ms: 50 + debug-page: false + # Opera Flex Link Pop-up 尺寸(SET_DIMENSION_EVENT) + popup-width: 100% + popup-height: 800px + notification: + batch-size: 500 + invalid-token-cleanup: true + # DCloud FCM:false=带 notification(推荐,桌面能收);true=仅 data 透传(前台实验,易丢后台通知) + android-data-only: false + api-logging-level: error + app-setting: + cache-minutes: 5 + security: + jwt: + secret_key: SecretKeyWhichShouldBeLongEnoughToBeSecure67848563 + app_authorization_code: SecretKeyOfAppLoginAuthorization + api_token_expiration: 5 + cms_session_expiration: 30 + app_session_expiration: 300 + notification: + authorization_code: SecretKeyOfAuthorization + expiration: 15 + esign: + legacy-api: + enabled: true + warn-headers: true + confirmation-extract: + ocr-enabled: true + tessdata-path: + ocr-language: eng + ocr-dpi: 260 + ocr-max-pages: 2 + ocr-header-ratio: 0.38 + printer-pdf: + storage-dir: /data/auto-check-in/printer-pdf + store-on-disk: true + pdf-archive-ocr: + log-dir: /data/auto-check-in/pdf-archive-ocr-logs + batch-max: 2000 + ohip-retry: + fixed-delay-ms: 300000 + reservation: + notification: + enabled: true + broadcast-if-no-hotel-match: true + title: 新预订待处理 + body-template: "预订号 {reservationId} 已就绪,请点击查看" + trigger-task: RESERVATION_NOTIFY + action: OPEN_RESERVATION + third-party: + # Oracle Hospitality (OHIP) Gateway + base-url: https://mtcs2ua.hospitality-api.ap-singapore-1.ocs.oc-test.com + timeout-ms: 30000 + registration-card: + regenerate: false + signed-only: false + reservation-id-context: OPERA + reservation-id-type: Reservation + language: EN + signed-upload: + enabled: true + reservation-lookup: + path-template: /rsv/v1/hotels/{hotelId}/reservations + fetch-instructions: Reservation + file-attachment: + path: /med/config/v1/fileAttachments + user-name: USER@HOTEL + description: E + link-type: Reservation + overwrite-existing: Y + global-yn: Y + profile-fields: + enabled: true + path-template: /crm/v1/profiles/{profileId} + oauth: + enabled: true + token-path: /oauth/v1/tokens + # Postman Authorization → Basic Auth:用户名=CLIENT_ID,密码=CLIENT_SECRET + client-id: RPHALVA-UAT-SHKPRH-mtcs2ua-willtech_test + client-secret: idcscs-138f2ac5-563c-47fc-ab8f-5ff8b7177af4 + # Postman Header {{AppKey}}(非 HashedAppKey) + app-key: a895c827-603e-4cf8-8aaf-7631dbd0d309 + enterprise-id: SHKPRH + hotel-id: RPH + scope: urn:opc:hgbu:ws:__myscopes__ + grant-type: client_credentials + timeout-ms: 30000 + # 未启用 oauth 时的备用鉴权 + authorization: + auth-user: + auth-password: + +# 日志(与 logback-spring.xml 联动;勿指定 logging.config 为已删除的 logback.xml) +# 日志目录 C:\auto-check-in\log;主文件名为 spring.application.name(auto-check-in.log) +logging: + file: + path: /data/auto-check-in/log + logback: + rollingpolicy: + max-file-size: 100MB + max-history: 90 + total-size-cap: 5GB + level: + root: INFO + com.sjmresorts.opsapp: DEBUG diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..49897bc --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,56 @@ +# 生产环境示例:复制到服务器后按实际修改(或通过环境变量注入) +spring: + application: + name: auto-check-in + +# 日志目录:与本地/部署约定一致 +logging: + file: + path: C:/auto-check-in/log + logback: + rollingpolicy: + max-file-size: 100MB + max-history: 90 + total-size-cap: 5GB + level: + root: INFO + com.sjmresorts.opsapp: INFO + +ops-app: + hub-promotion: + media-dir: /var/auto-check-in/hub-promotions + esign: + legacy-api: + enabled: true + warn-headers: true + printer-pdf: + storage-dir: /var/auto-check-in/printer-pdf + store-on-disk: true + confirmation-extract: + # 扫描件 PDF 才需要 OCR。Linux 未装 libtesseract 时请保持 false,避免请求报错。 + ocr-enabled: false + # 安装 tesseract-ocr 后改为 true,并确认 tessdata 路径(RHEL 常见 /usr/share/tesseract/tessdata) + tessdata-path: /usr/share/tesseract/tessdata + pdf-archive-ocr: + log-dir: /var/auto-check-in/pdf-archive-ocr-logs + batch-max: 2000 + reservation: + third-party: + profile-fields: + enabled: true + path-template: /crm/v1/profiles/{profileId} + security: + jwt: + # 旅宿平板登录 token 有效期(分钟);须与 EsignLoginService 中 app_session 一致 + app_session_expiration: 300 + +# 以下请按生产库与安全策略填写(勿提交真实密码到仓库) +# spring: +# datasource: +# ... + +server: + tomcat: + max-http-post-size: 67108864 + max-http-form-post-size: 67108864 + max-swallow-size: 67108864 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..5982e2f --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,28 @@ +spring: + application: + name: auto-check-in + profiles: + active: local,mysql + servlet: + multipart: + max-file-size: 20MB + max-request-size: 25MB + +# 数据库类型:auto(按 jdbc-url 推断)| sqlserver | mysql | postgres +# 环境变量:OPS_APP_DB_TYPE=mysql +# Profile 叠加:--spring.profiles.active=local,db-mysql +ops-app: + db: + type: mysql + +# 日志目录(logback-spring.xml 读取);与 application-local 部署约定一致 +logging: + file: + path: C:/auto-check-in/log + +# 證件採集 JSON、CMS 推广图 Base64 等大请求体需放大上限(Nginx 默认 1m 也会 413) +server: + tomcat: + max-http-post-size: 67108864 + max-http-form-post-size: 67108864 + max-swallow-size: 67108864 diff --git a/src/main/resources/config/ssl/dev/CA.crt b/src/main/resources/config/ssl/dev/CA.crt new file mode 100644 index 0000000..75a14b9 --- /dev/null +++ b/src/main/resources/config/ssl/dev/CA.crt @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFsDCCA5igAwIBAgIQGeJOemuC/45NmfyB+HQVJDANBgkqhkiG9w0BAQsFADBW +MRMwEQYKCZImiZPyLGQBGRYDY29tMRwwGgYKCZImiZPyLGQBGRYMbWFjYXVzam0t +Z2xwMSEwHwYDVQQDExhNYWNhdSBTSk0gR0xQIFFBIFJvb3QgQ0EwIBcNMjAxMTEw +MDY0MTIxWhgPMjA3MDExMTAwNjUxMjFaMFYxEzARBgoJkiaJk/IsZAEZFgNjb20x +HDAaBgoJkiaJk/IsZAEZFgxtYWNhdXNqbS1nbHAxITAfBgNVBAMTGE1hY2F1IFNK +TSBHTFAgUUEgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB +AKmIyr2R3/Ruvh654YWWG4ETUZr1/Pxc+1G2OIBmw7O5dK2m1O7i18lcE0aZn5Mq +J6FcuCPpEISr2YzCk7foboleomXGSWCbpMxEa/7b/l8yNADts93WWbW/dCO3FW6V +RwSlkM8CaQ8llxvIzPz3NO3BeeewJb3+M32kS5Ff7fDhDf9j/EFs0HcqzQd+uMmC +KYfXuOnHh+pClsbVPyBaCUUp6MWLtmwbU7TEXMWTLKoZsk97b3tTab25wS3dv/Ri +2CZOSaQAwpKMdc4a3gcG90awIw91jJnYdsjv7qUzF8/4btQkeasyD8ro7s4y6/fI +qIn+Umsi9ItIT8HP2C6uQ1ulhrUE4dcgKIs/7WSFQZaAy/N0QUI+ko0HWEFvYgoQ +jSzKIB2CkkFDKi44OMoCwDMH/zGpoKxeyhM1y8tv8zoojleI7BM/LNMnAatcZ+18 +kwfFnQjoNRawACUfGHXQ88w7rzz2Fa3jXmYG6Fbc83T5Jq5C/ijQYle/Dkw/M1oN +/pBHV586t7oNbnnjb5egI9RIS7FmFp9I5Cw536LXjdvFflszg01S28HQIbzLxLpN +6Xjj7+7X2+chbpzcfIL/Eqn1fgUH6+rUMcAy0AgNBhaGgWgFN7KGeh0rsMbXnxHk +ptroK0B0wWEMSSwCRxl6qaA/bV7PtgGk/4FAZcCp5YinAgMBAAGjeDB2MAsGA1Ud +DwQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBR/9j+qTF7gQfYly9GJ +vux2rbTHfTASBgkrBgEEAYI3FQEEBQIDAQABMCMGCSsGAQQBgjcVAgQWBBRmSnzJ +MCRWE28mRn6v+d5RvJ4FUjANBgkqhkiG9w0BAQsFAAOCAgEAYALyCkuABbVNMn/I +lDMnors5bYnhkr6zZv7fH+1x9d/AQfNOGIhOoY2YQZZoekk5G1UMl1TCgc5CEKqU +CwdMY4eUXkFn6KkGF+UXajhRZWOJD7/stuJsYhGlhk9x5t++INwZGEiU3ryhVyFO +OTBSJL2GFadvrEdm6Ud0P+uoKZ14QwD5C0vYTn+FlNNRtpbE/1ehpm/uVugf87t3 +yGhRO768G06E9Jowf+efQ1fSKObqwzWb1I+YiJQCZF+Ni/kxy/Z2O7hh0gF7W18d +D81us2Px1H0cpl624gLxVNcCgy4X2WgCKFECWa28M+Q16dV0ufdQYPoMVT3Zrh3r +kMLV8LBWfqk6AS7SFARYp4Dbl5VDUqbIE8qkCZiIl153Su5kdYXDH7GvnMQZ7Xlx +XkV2EUCnHeqe7IOB33oW/XzRD7fGak46qtFV8ABgVyjcvs7C2dTtEYCeTnMWBRr4 +EVDv3LTPfctlGJnDG3rdn3KJhb0OFdU3MEXizLd3ZaE2ItDdw/RDQOzGuHGA6024 +/dAfLc7X8/XD48A1cVYIMLuiaj8jF2qiHCvGXmoeCcRxl0VT65nA2q4sV1kved7i +IIfusfF3CWmuQ/vNm4K37dYQoPnqZ+X5MEQqWavcem6v46/15m2y/Wjf7yBWfBdk +in1WAZRgJkcbfdtK5gcQdPRqezE= +-----END CERTIFICATE----- diff --git a/src/main/resources/config/ssl/dev/keystore.p12 b/src/main/resources/config/ssl/dev/keystore.p12 new file mode 100644 index 0000000000000000000000000000000000000000..3c8387e8668b9b5b852fa22635eba18534a7a307 GIT binary patch literal 5842 zcma)=Ra6vU_wHxtZibeS&Vd1>r5mIhDe0k0VCaScq@p}Tu%X^>Dt@b6jY ztnXXr>YR(c_Pc-2yZ7aD@qoiYlPEw`a5(5a1~zB3a`bO}ASSRN9OQ@=4zm4+t-#^v z%m0^zK7tmGj`)Xr|5Yaj?*G}s!vUfcgriIUgA!o7|7@V+gN?x={~htbTxevkr<-7= z-|m;^eJgKW7}z*>Udd0Ops*3402#n|7+C-NA__J-08EF0Z5gc$v_(Y&a-tD6zOp5i zXOS0`%E+Cd!6w&23rCwyjv7g#NeTayJ%L!$j3^_J?_fRp@o{ggBe>44*Tyhw%-Blr z01FEaTBRK}FR{nSOWAKV@Ti`TFQC8mhsZhOy-_+JCnDw0ET4RYTr7AiVi|o>*>m|W znmYPfAgY66a5N|mR(Im9fQoLL?vrAnp`k)o)graUbswx5&+eK`IrszOTBIK2X+q|| zt9{}lzg}0!h}-pBI>sbT%5kW>l7I+a?wryPvp0XP4aqI4@eFfHzFTR`psXNH-Wg~> z-@_;z<%=W7mp%gWr>_ExIrFIWwTSBt9Pwpx`c3*l6AF*iNt6~&rxN|!Osa-XRr49h z*D^2ENYwf|iTDDOyrU{#@Fl^AG-CzcesiYh|Ng> zsG4yGd~$e-29m^qMuPE`GNEf{8Ysnq;#zjvy~Tl6l(X%vyLHO^*l-G3xBTzG4+5Eg z;le7~^Xb8*ISWoD&WJ8n$XI9LVi@RirZE9ma}3*QF}~An z50_ekc<%i&9m%G7KJ_hU{aAZC2pgel(ZO@#reho!IsdWkaAX6B=u^dk*2r2nuA zkaSVm0=58;zoKojw^A@vE=ZM`h{-=cdpq z=^D}`*zvu$JZUB2uCl>7_J&ck%XBv3teaZ<5&GqjLZ`bZmT2#f_?alAxB%_Ut!V3l zTp_A-XV8M3qhc=!1DY`!Dot(b_ngRzT=IdW)8X8g5`}x+Fa#ZsKGC@4ej-?20*yHH z;4>Os507&n!a+eipQ+=i+VI1}&T*{sk!z|wlKYX8x6UeZ5zq%GV6FW=Ct^|t9<@A@#!)2*R}EV3pXf-F@5=wlAq`n2hB`gA3frE1f+v%tuE`0I#_J}+jxc_qW3calby=i~CF zM?HHI)4CUlRu9zKL;Tx4a$|Qb{pY0IqpTgll$S&;eoTdYWK<0WW##6wI>yvkSuECP zNg3r50TD*t;fuIE_avdr;{6yf_t_CGoQ=lu9;-K$@N2Pg5;4t|l@Tm02@%x`IMG(F z0`ySH$zYuU-o1C%o5FI1HW`xk!!fDC1>1*Uy;cscAzdagU8~}o-?K+Fnr?uWDV`hq zZp0(^kBR4!TMI4i)ZKA#f(B4*Xeiz1FYfa+t=fPO`NJ2Z#W9)>s~Z%9hk<@U8OnGF z3DF?$PO2SL)F + + + + + + + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %5p [%15.15t] %-40.40logger{39} : %m%n%ex + UTF-8 + + + + + + ${LOG_FILE} + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + UTF-8 + + + ${LOG_PATH}/${APP_LOG_NAME}.%d{yyyy-MM-dd}.%i.log.gz + ${MAX_FILE_SIZE} + ${MAX_HISTORY} + ${TOTAL_SIZE_CAP} + false + + + + + + ${LOG_PATH}/${APP_LOG_NAME}-error.log + + ERROR + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n + UTF-8 + + + ${LOG_PATH}/${APP_LOG_NAME}-error.%d{yyyy-MM-dd}.%i.log.gz + ${MAX_FILE_SIZE} + ${MAX_HISTORY} + 1GB + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/mockup/dashboard_report_mockup.json b/src/main/resources/mockup/dashboard_report_mockup.json new file mode 100644 index 0000000..c59b7bb --- /dev/null +++ b/src/main/resources/mockup/dashboard_report_mockup.json @@ -0,0 +1,18835 @@ +{ + "current": [ + { + "floorName": "1F", + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 1, + "casinoCode": "OCE", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "areas": [ + { + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 1, + "areaName": "BPIT23", + "casinoCode": "OCE", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "PB2303", + "casinoCode": "OCE", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 10.0000, + "maxBet": 10000000.0000, + "minBetByTable": "PB2303-10", + "open": true + } + ] + } + ] + }, + { + "floorName": "LT", + "totalUnit": 4, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 4, + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "areas": [ + { + "totalUnit": 2, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 2, + "areaName": "PPIT01", + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "LP0103", + "casinoCode": "GLP", + "gameCode": "AR", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0103-1000", + "open": true + }, + { + "tableName": "LP0107", + "casinoCode": "GLP", + "gameCode": "DTCB", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0107-1000", + "open": true + } + ] + }, + { + "totalUnit": 2, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 2, + "areaName": "PPIT77", + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "LP7701", + "casinoCode": "GLP", + "gameCode": "LT", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP7701-1000", + "open": true + }, + { + "tableName": "LP7704", + "casinoCode": "GLP", + "gameCode": "LT", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP7704-1000", + "open": true + } + ] + } + ] + }, + { + "floorName": "KLM (PA)", + "totalUnit": 26, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 26, + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "areas": [ + { + "totalUnit": 26, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 26, + "areaName": "PPIT13", + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "LP1301", + "casinoCode": "GLP", + "gameCode": "VBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1301-1000", + "open": true + }, + { + "tableName": "LP1302", + "casinoCode": "GLP", + "gameCode": "VBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1302-1000", + "open": true + }, + { + "tableName": "LP1303", + "casinoCode": "GLP", + "gameCode": "VBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1303-1000", + "open": true + }, + { + "tableName": "LP1304", + "casinoCode": "GLP", + "gameCode": "JBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1304-1000", + "open": true + }, + { + "tableName": "LP1305", + "casinoCode": "GLP", + "gameCode": "VBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1305-1000", + "open": true + }, + { + "tableName": "LP1306", + "casinoCode": "GLP", + "gameCode": "VBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1306-1000", + "open": true + }, + { + "tableName": "LP1307", + "casinoCode": "GLP", + "gameCode": "PNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1307-1000", + "open": true + }, + { + "tableName": "LP1308", + "casinoCode": "GLP", + "gameCode": "PNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1308-1000", + "open": true + }, + { + "tableName": "LP1309", + "casinoCode": "GLP", + "gameCode": "PNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1309-1000", + "open": true + }, + { + "tableName": "LP1314", + "casinoCode": "GLP", + "gameCode": "PNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1314-1000", + "open": true + }, + { + "tableName": "LP1315", + "casinoCode": "GLP", + "gameCode": "PNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1315-1000", + "open": true + }, + { + "tableName": "LP1316", + "casinoCode": "GLP", + "gameCode": "PNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1316-1000", + "open": true + }, + { + "tableName": "LP1317", + "casinoCode": "GLP", + "gameCode": "PBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1317-1000", + "open": true + }, + { + "tableName": "LP1318", + "casinoCode": "GLP", + "gameCode": "PBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1318-1000", + "open": true + }, + { + "tableName": "LP1319", + "casinoCode": "GLP", + "gameCode": "PBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1319-1000", + "open": true + }, + { + "tableName": "LP1320", + "casinoCode": "GLP", + "gameCode": "PBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1320-1000", + "open": true + }, + { + "tableName": "LP1321", + "casinoCode": "GLP", + "gameCode": "PNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1321-1000", + "open": true + }, + { + "tableName": "LP1322", + "casinoCode": "GLP", + "gameCode": "PNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1322-1000", + "open": true + }, + { + "tableName": "LP1323", + "casinoCode": "GLP", + "gameCode": "PNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1323-1000", + "open": true + }, + { + "tableName": "LP1324", + "casinoCode": "GLP", + "gameCode": "PNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1324-1000", + "open": true + }, + { + "tableName": "LP1325", + "casinoCode": "GLP", + "gameCode": "PNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1325-1000", + "open": true + }, + { + "tableName": "LP1326", + "casinoCode": "GLP", + "gameCode": "PNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1326-1000", + "open": true + }, + { + "tableName": "LP1327", + "casinoCode": "GLP", + "gameCode": "PNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1327-1000", + "open": true + }, + { + "tableName": "LP1328", + "casinoCode": "GLP", + "gameCode": "PNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1328-1000", + "open": true + }, + { + "tableName": "LP1329", + "casinoCode": "GLP", + "gameCode": "PNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1329-1000", + "open": true + }, + { + "tableName": "LP1330", + "casinoCode": "GLP", + "gameCode": "PNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP1330-1000", + "open": true + } + ] + } + ] + }, + { + "floorName": "UNKNOWN_FLOOR", + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 0, + "casinoCode": "Test", + "openPercentage": "0%", + "openPercentageWithNumber": 0.00, + "winPerUnit": 0, + "areas": [ + { + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 0, + "areaName": "TEST_AREA", + "casinoCode": "Test", + "openPercentage": "0%", + "openPercentageWithNumber": 0.00, + "winPerUnit": 0, + "tableViews": [ + { + "tableName": "TEST_TABLE", + "casinoCode": "Test", + "gameCode": "****", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 100000000.0000, + "minBetByTable": "TEST_TABLE-100", + "open": false + } + ] + } + ] + }, + { + "floorName": "Tak Chun", + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 1, + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "areas": [ + { + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 1, + "areaName": "PPIT68", + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "LP6802", + "casinoCode": "GLP", + "gameCode": "JBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "LP6802-0", + "open": true + } + ] + } + ] + }, + { + "floorName": "North", + "totalUnit": 12, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 12, + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "areas": [ + { + "totalUnit": 12, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 12, + "areaName": "PPIT03", + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "LP0302", + "casinoCode": "GLP", + "gameCode": "CSP", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "LP0302-0", + "open": true + }, + { + "tableName": "LP0305", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0305-1000", + "open": true + }, + { + "tableName": "LP0309", + "casinoCode": "GLP", + "gameCode": "HLBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0309-1000", + "open": true + }, + { + "tableName": "LP0310", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0310-1000", + "open": true + }, + { + "tableName": "LP0311", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0311-1000", + "open": true + }, + { + "tableName": "LP0312", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0312-1000", + "open": true + }, + { + "tableName": "LP0315", + "casinoCode": "GLP", + "gameCode": "HLBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0315-1000", + "open": true + }, + { + "tableName": "LP0321", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0321-1000", + "open": true + }, + { + "tableName": "LP0322", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0322-1000", + "open": true + }, + { + "tableName": "LP0323", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0323-1000", + "open": true + }, + { + "tableName": "LP0324", + "casinoCode": "GLP", + "gameCode": "DSB", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0324-1000", + "open": true + }, + { + "tableName": "LP0334", + "casinoCode": "GLP", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0334-1000", + "open": true + } + ] + } + ] + }, + { + "floorName": "Jai Alai", + "totalUnit": 2, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 2, + "casinoCode": "OCE", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "areas": [ + { + "totalUnit": 2, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 2, + "areaName": "JPIT88", + "casinoCode": "OCE", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "PB8801", + "casinoCode": "OCE", + "gameCode": "HLBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 1500000.0000, + "minBetByTable": "PB8801-500", + "open": true + }, + { + "tableName": "PB8802", + "casinoCode": "OCE", + "gameCode": "HLBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 1500000.0000, + "minBetByTable": "PB8802-500", + "open": true + } + ] + } + ] + }, + { + "floorName": "West", + "totalUnit": 39, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 39, + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "areas": [ + { + "totalUnit": 20, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 20, + "areaName": "PPIT02", + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "LP0202", + "casinoCode": "GLP", + "gameCode": "LT", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0202-1000", + "open": true + }, + { + "tableName": "LP0203", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0203-1000", + "open": true + }, + { + "tableName": "LP0204", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0204-1000", + "open": true + }, + { + "tableName": "LP0205", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0205-1000", + "open": true + }, + { + "tableName": "LP0207", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0207-1000", + "open": true + }, + { + "tableName": "LP0208", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0208-1000", + "open": true + }, + { + "tableName": "LP0209", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0209-1000", + "open": true + }, + { + "tableName": "LP0210", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0210-1000", + "open": true + }, + { + "tableName": "LP0212", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0212-1000", + "open": true + }, + { + "tableName": "LP0213", + "casinoCode": "GLP", + "gameCode": "PBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0213-1000", + "open": true + }, + { + "tableName": "LP0214", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0214-1000", + "open": true + }, + { + "tableName": "LP0215", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0215-1000", + "open": true + }, + { + "tableName": "LP0217", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0217-1000", + "open": true + }, + { + "tableName": "LP0218", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0218-1000", + "open": true + }, + { + "tableName": "LP0219", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0219-1000", + "open": true + }, + { + "tableName": "LP0220", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0220-1000", + "open": true + }, + { + "tableName": "LP0221", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0221-1000", + "open": true + }, + { + "tableName": "LP0222", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0222-1000", + "open": true + }, + { + "tableName": "LP0223", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0223-1000", + "open": true + }, + { + "tableName": "LP0224", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0224-1000", + "open": true + } + ] + }, + { + "totalUnit": 19, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 19, + "areaName": "PPIT01", + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "LP0109", + "casinoCode": "GLP", + "gameCode": "JBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0109-1000", + "open": true + }, + { + "tableName": "LP0110", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0110-1000", + "open": true + }, + { + "tableName": "LP0111", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0111-1000", + "open": true + }, + { + "tableName": "LP0112", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0112-1000", + "open": true + }, + { + "tableName": "LP0113", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 100000.0000, + "minBetByTable": "LP0113-0", + "open": true + }, + { + "tableName": "LP0114", + "casinoCode": "GLP", + "gameCode": "DSB", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 80000.0000, + "minBetByTable": "LP0114-0", + "open": true + }, + { + "tableName": "LP0115", + "casinoCode": "GLP", + "gameCode": "DSB", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0115-1000", + "open": true + }, + { + "tableName": "LP0116", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0116-1000", + "open": true + }, + { + "tableName": "LP0117", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0117-1000", + "open": true + }, + { + "tableName": "LP0118", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0118-1000", + "open": true + }, + { + "tableName": "LP0119", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0119-1000", + "open": true + }, + { + "tableName": "LP0120", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0120-1000", + "open": true + }, + { + "tableName": "LP0121", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0121-1000", + "open": true + }, + { + "tableName": "LP0122", + "casinoCode": "GLP", + "gameCode": "DSB", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0122-1000", + "open": true + }, + { + "tableName": "LP0195", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 1000000.0000, + "minBetByTable": "LP0195-1000", + "open": true + }, + { + "tableName": "LP0196", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 5000.0000, + "maxBet": 5000000.0000, + "minBetByTable": "LP0196-5000", + "open": true + }, + { + "tableName": "LP0197", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 10000000.0000, + "minBetByTable": "LP0197-500", + "open": true + }, + { + "tableName": "LP0198", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 1000000.0000, + "minBetByTable": "LP0198-1000", + "open": true + }, + { + "tableName": "LP0199", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 10000.0000, + "maxBet": 10000000.0000, + "minBetByTable": "LP0199-10000", + "open": true + } + ] + } + ] + }, + { + "floorName": "UNKNOWN_FLOOR", + "totalUnit": 15, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 12, + "casinoCode": "LIS", + "openPercentage": "80%", + "openPercentageWithNumber": 80.00, + "winPerUnit": 0.0000, + "areas": [ + { + "totalUnit": 3, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 3, + "areaName": "LPIT04", + "casinoCode": "LIS", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "CL0401", + "casinoCode": "LIS", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 500000.0000, + "minBetByTable": "CL0401-500", + "open": true + }, + { + "tableName": "CL0402", + "casinoCode": "LIS", + "gameCode": "DSB", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "CL0402-0", + "open": true + }, + { + "tableName": "CL0410", + "casinoCode": "LIS", + "gameCode": "SSB", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "CL0410-0", + "open": true + } + ] + }, + { + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 1, + "areaName": "LPIT01", + "casinoCode": "LIS", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "CL0101", + "casinoCode": "LIS", + "gameCode": "PBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 10000000.0000, + "minBetByTable": "CL0101-100", + "open": true + } + ] + }, + { + "totalUnit": 3, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 3, + "areaName": "LPIT16", + "casinoCode": "LIS", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "CL1631", + "casinoCode": "LIS", + "gameCode": "VBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 500000.0000, + "minBetByTable": "CL1631-500", + "open": true + }, + { + "tableName": "CL1632", + "casinoCode": "LIS", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 300000.0000, + "minBetByTable": "CL1632-100", + "open": true + }, + { + "tableName": "CL1633", + "casinoCode": "LIS", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 300.0000, + "maxBet": 300000.0000, + "minBetByTable": "CL1633-300", + "open": true + } + ] + }, + { + "totalUnit": 5, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 2, + "areaName": "LPIT05", + "casinoCode": "LIS", + "openPercentage": "40%", + "openPercentageWithNumber": 40.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "CL0501", + "casinoCode": "LIS", + "gameCode": "VBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 1000000.0000, + "minBetByTable": "CL0501-1000", + "open": true + }, + { + "tableName": "CL0502", + "casinoCode": "LIS", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "CL0502-0", + "open": false + }, + { + "tableName": "CL0503", + "casinoCode": "LIS", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "CL0503-0", + "open": true + }, + { + "tableName": "CL0504", + "casinoCode": "LIS", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "CL0504-0", + "open": false + }, + { + "tableName": "CL0505", + "casinoCode": "LIS", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "CL0505-0", + "open": false + } + ] + }, + { + "totalUnit": 3, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 3, + "areaName": "LPIT06", + "casinoCode": "LIS", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "CL0601", + "casinoCode": "LIS", + "gameCode": "PBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 5000.0000, + "maxBet": 1000000.0000, + "minBetByTable": "CL0601-5000", + "open": true + }, + { + "tableName": "CL0603", + "casinoCode": "LIS", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 100000.0000, + "minBetByTable": "CL0603-100", + "open": true + }, + { + "tableName": "CL0604", + "casinoCode": "LIS", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "CL0604-0", + "open": true + } + ] + } + ] + }, + { + "floorName": "Dome", + "totalUnit": 53, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 52, + "casinoCode": "GLP", + "openPercentage": "98%", + "openPercentageWithNumber": 98.11, + "winPerUnit": 0.0000, + "areas": [ + { + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 1, + "areaName": "PPIT01", + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "LP0108", + "casinoCode": "GLP", + "gameCode": "DTCB", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0108-1000", + "open": true + } + ] + }, + { + "totalUnit": 24, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 24, + "areaName": "PPIT06", + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "LP0601", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0601-1000", + "open": true + }, + { + "tableName": "LP0602", + "casinoCode": "GLP", + "gameCode": "LT", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 50000000.0000, + "minBetByTable": "LP0602-1000", + "open": true + }, + { + "tableName": "LP0603", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 50000000.0000, + "minBetByTable": "LP0603-1000", + "open": true + }, + { + "tableName": "LP0604", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 50000000.0000, + "minBetByTable": "LP0604-1000", + "open": true + }, + { + "tableName": "LP0605", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 50000000.0000, + "minBetByTable": "LP0605-1000", + "open": true + }, + { + "tableName": "LP0606", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0606-1000", + "open": true + }, + { + "tableName": "LP0607", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0607-1000", + "open": true + }, + { + "tableName": "LP0608", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0608-1000", + "open": true + }, + { + "tableName": "LP0609", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0609-1000", + "open": true + }, + { + "tableName": "LP0610", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0610-1000", + "open": true + }, + { + "tableName": "LP0611", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0611-1000", + "open": true + }, + { + "tableName": "LP0612", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0612-1000", + "open": true + }, + { + "tableName": "LP0613", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0613-1000", + "open": true + }, + { + "tableName": "LP0614", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0614-1000", + "open": true + }, + { + "tableName": "LP0615", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0615-1000", + "open": true + }, + { + "tableName": "LP0616", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0616-1000", + "open": true + }, + { + "tableName": "LP0617", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0617-1000", + "open": true + }, + { + "tableName": "LP0618", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0618-1000", + "open": true + }, + { + "tableName": "LP0619", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0619-1000", + "open": true + }, + { + "tableName": "LP0620", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0620-1000", + "open": true + }, + { + "tableName": "LP0621", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0621-1000", + "open": true + }, + { + "tableName": "LP0622", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0622-1000", + "open": true + }, + { + "tableName": "LP0623", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0623-1000", + "open": true + }, + { + "tableName": "LP0624", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0624-1000", + "open": true + } + ] + }, + { + "totalUnit": 17, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 16, + "areaName": "PPIT05", + "casinoCode": "GLP", + "openPercentage": "94%", + "openPercentageWithNumber": 94.12, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "LP0501", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0501-1000", + "open": false + }, + { + "tableName": "LP0502", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0502-1000", + "open": true + }, + { + "tableName": "LP0503", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0503-1000", + "open": true + }, + { + "tableName": "LP0504", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0504-1000", + "open": true + }, + { + "tableName": "LP0505", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0505-1000", + "open": true + }, + { + "tableName": "LP0506", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0506-1000", + "open": true + }, + { + "tableName": "LP0507", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0507-1000", + "open": true + }, + { + "tableName": "LP0508", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0508-1000", + "open": true + }, + { + "tableName": "LP0509", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0509-1000", + "open": true + }, + { + "tableName": "LP0510", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0510-1000", + "open": true + }, + { + "tableName": "LP0511", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0511-1000", + "open": true + }, + { + "tableName": "LP0512", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0512-1000", + "open": true + }, + { + "tableName": "LP0513", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0513-1000", + "open": true + }, + { + "tableName": "LP0514", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0514-1000", + "open": true + }, + { + "tableName": "LP0515", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0515-1000", + "open": true + }, + { + "tableName": "LP0516", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0516-1000", + "open": true + }, + { + "tableName": "LP0520", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 50000000.0000, + "minBetByTable": "LP0520-100", + "open": true + } + ] + }, + { + "totalUnit": 11, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 11, + "areaName": "PPIT07", + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "LP0702", + "casinoCode": "GLP", + "gameCode": "JBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0702-1000", + "open": true + }, + { + "tableName": "LP0703", + "casinoCode": "GLP", + "gameCode": "CSP", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0703-1000", + "open": true + }, + { + "tableName": "LP0706", + "casinoCode": "GLP", + "gameCode": "CSP", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0706-1000", + "open": true + }, + { + "tableName": "LP0707", + "casinoCode": "GLP", + "gameCode": "FTC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0707-1000", + "open": true + }, + { + "tableName": "LP0710", + "casinoCode": "GLP", + "gameCode": "BJ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0710-1000", + "open": true + }, + { + "tableName": "LP0711", + "casinoCode": "GLP", + "gameCode": "BJ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0711-1000", + "open": true + }, + { + "tableName": "LP0714", + "casinoCode": "GLP", + "gameCode": "BJ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0714-1000", + "open": true + }, + { + "tableName": "LP0715", + "casinoCode": "GLP", + "gameCode": "BJ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0715-1000", + "open": true + }, + { + "tableName": "LP0723", + "casinoCode": "GLP", + "gameCode": "AR", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0723-1000", + "open": true + }, + { + "tableName": "LP0724", + "casinoCode": "GLP", + "gameCode": "AR", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0724-1000", + "open": true + }, + { + "tableName": "LP0726", + "casinoCode": "GLP", + "gameCode": "BJ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0726-1000", + "open": true + } + ] + } + ] + }, + { + "floorName": "West", + "totalUnit": 56, + "totalCasinoWin": 0.0000, + "totalDrop": 0.0000, + "totalHeadCounts": 1, + "openUnit": 51, + "casinoCode": "GLC", + "openPercentage": "91%", + "openPercentageWithNumber": 91.07, + "winPerUnit": 0.0000, + "areas": [ + { + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 1, + "areaName": "GPIT20", + "casinoCode": "GLC", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "GLTest", + "casinoCode": "GLC", + "gameCode": "DSB", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GLTest-0", + "open": true + } + ] + }, + { + "totalUnit": 7, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 2, + "areaName": "GPIT01", + "casinoCode": "GLC", + "openPercentage": "29%", + "openPercentageWithNumber": 28.57, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "GL0107", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL0107-100", + "open": true + }, + { + "tableName": "GL0108", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 100000000.0000, + "minBetByTable": "GL0108-100", + "open": true + }, + { + "tableName": "GL0195", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL0195-1000", + "open": false + }, + { + "tableName": "GL0196", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL0196-1000", + "open": false + }, + { + "tableName": "GL0197", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 5000000.0000, + "minBetByTable": "GL0197-500", + "open": false + }, + { + "tableName": "GL0198", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 10000000.0000, + "minBetByTable": "GL0198-1000", + "open": false + }, + { + "tableName": "GL0199", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 10000000.0000, + "minBetByTable": "GL0199-1000", + "open": false + } + ] + }, + { + "totalUnit": 45, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 45, + "areaName": "GPIT22", + "casinoCode": "GLC", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "GL2221", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 500000.0000, + "minBetByTable": "GL2221-0", + "open": true + }, + { + "tableName": "GL2222", + "casinoCode": "GLC", + "gameCode": "HLBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 10000000.0000, + "minBetByTable": "GL2222-0", + "open": true + }, + { + "tableName": "GL2310", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 50000000.0000, + "minBetByTable": "GL2310-100", + "open": true + }, + { + "tableName": "GL2311", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2311-0", + "open": true + }, + { + "tableName": "GL2313", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2313-0", + "open": true + }, + { + "tableName": "GL2314", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 100000000.0000, + "minBetByTable": "GL2314-0", + "open": true + }, + { + "tableName": "GL2315", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2315-0", + "open": true + }, + { + "tableName": "GL2316", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2316-0", + "open": true + }, + { + "tableName": "GL2317", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2317-0", + "open": true + }, + { + "tableName": "GL2319", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2319-0", + "open": true + }, + { + "tableName": "GL2320", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2320-0", + "open": true + }, + { + "tableName": "GL2321", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2321-0", + "open": true + }, + { + "tableName": "GL2322", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2322-0", + "open": true + }, + { + "tableName": "GL2323", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2323-0", + "open": true + }, + { + "tableName": "GL2324", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2324-0", + "open": true + }, + { + "tableName": "GL2325", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2325-0", + "open": true + }, + { + "tableName": "GL2326", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2326-0", + "open": true + }, + { + "tableName": "GL2327", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2327-0", + "open": true + }, + { + "tableName": "GL2328", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2328-0", + "open": true + }, + { + "tableName": "GL2329", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2329-0", + "open": true + }, + { + "tableName": "GL2330", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2330-0", + "open": true + }, + { + "tableName": "GL2331", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2331-0", + "open": true + }, + { + "tableName": "GL2332", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2332-0", + "open": true + }, + { + "tableName": "GL2333", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2333-0", + "open": true + }, + { + "tableName": "GL2334", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2334-0", + "open": true + }, + { + "tableName": "GL2335", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2335-0", + "open": true + }, + { + "tableName": "GL2336", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2336-0", + "open": true + }, + { + "tableName": "GL2337", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2337-0", + "open": true + }, + { + "tableName": "GL2338", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2338-0", + "open": true + }, + { + "tableName": "GL2339", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2339-0", + "open": true + }, + { + "tableName": "GL2340", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2340-0", + "open": true + }, + { + "tableName": "GL2341", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2341-0", + "open": true + }, + { + "tableName": "GL2342", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2342-0", + "open": true + }, + { + "tableName": "GL2343", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2343-0", + "open": true + }, + { + "tableName": "GL2344", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2344-0", + "open": true + }, + { + "tableName": "GL2345", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2345-0", + "open": true + }, + { + "tableName": "GL2346", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2346-0", + "open": true + }, + { + "tableName": "GL2347", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2347-0", + "open": true + }, + { + "tableName": "GL2348", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2348-0", + "open": true + }, + { + "tableName": "GL2349", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2349-0", + "open": true + }, + { + "tableName": "GL2350", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2350-0", + "open": true + }, + { + "tableName": "GL2351", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2351-0", + "open": true + }, + { + "tableName": "GL2352", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2352-0", + "open": true + }, + { + "tableName": "GL2353", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2353-0", + "open": true + }, + { + "tableName": "GL2354", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2354-0", + "open": true + } + ] + }, + { + "totalUnit": 3, + "totalCasinoWin": 0.0000, + "totalDrop": 0.0000, + "totalHeadCounts": 1, + "openUnit": 3, + "areaName": "GPIT29", + "casinoCode": "GLC", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "GL2901", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": 0.0000, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL2901-500", + "open": true + }, + { + "tableName": "GL2902", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": 0.0000, + "headCounts": 1, + "minBet": 300.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL2902-300", + "open": true + }, + { + "tableName": "GL2903", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": 0.0000, + "headCounts": 0, + "minBet": 1.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL2903-1", + "open": true + } + ] + } + ] + }, + { + "floorName": "DOME", + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 1, + "casinoCode": "GLC", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "areas": [ + { + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 1, + "areaName": "GPIT20", + "casinoCode": "GLC", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "GL3617", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 80000000.0000, + "minBetByTable": "GL3617-100", + "open": true + } + ] + } + ] + }, + { + "floorName": "Dome", + "totalUnit": 2, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 2, + "casinoCode": "GLC", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "areas": [ + { + "totalUnit": 2, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 2, + "areaName": "GPIT20", + "casinoCode": "GLC", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "GL1534", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 8000000.0000, + "minBetByTable": "GL1534-100", + "open": true + }, + { + "tableName": "GL2030", + "casinoCode": "GLC", + "gameCode": "BJ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 10.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL2030-10", + "open": true + } + ] + } + ] + }, + { + "floorName": "UNKNOWN_FLOOR", + "totalUnit": 23, + "totalCasinoWin": 0.0000, + "totalDrop": 0.0000, + "totalHeadCounts": 0, + "openUnit": 21, + "casinoCode": "GLC", + "openPercentage": "91%", + "openPercentageWithNumber": 91.30, + "winPerUnit": 0.0000, + "areas": [ + { + "totalUnit": 5, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 5, + "areaName": "GPIT20", + "casinoCode": "GLC", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "GL2031", + "casinoCode": "GLC", + "gameCode": "AR", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1.0000, + "maxBet": 10000000.0000, + "minBetByTable": "GL2031-1", + "open": true + }, + { + "tableName": "GL2032", + "casinoCode": "GLC", + "gameCode": "AR", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL2032-100", + "open": true + }, + { + "tableName": "GL2033", + "casinoCode": "GLC", + "gameCode": "AR", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL2033-100", + "open": true + }, + { + "tableName": "GL2043", + "casinoCode": "GLC", + "gameCode": "VBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL2043-100", + "open": true + }, + { + "tableName": "GL2044", + "casinoCode": "GLC", + "gameCode": "BJ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 10000000.0000, + "minBetByTable": "GL2044-100", + "open": true + } + ] + }, + { + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 1, + "areaName": "GPIT67", + "casinoCode": "GLC", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "GL6713", + "casinoCode": "GLC", + "gameCode": "JBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL6713-0", + "open": true + } + ] + }, + { + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 1, + "areaName": "GPIT12", + "casinoCode": "GLC", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "GL1229", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL1229-0", + "open": true + } + ] + }, + { + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 1, + "areaName": "GPIT77", + "casinoCode": "GLC", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "GL7701", + "casinoCode": "GLC", + "gameCode": "LT", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 2000.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL7701-2000", + "open": true + } + ] + }, + { + "totalUnit": 3, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 2, + "areaName": "GPIT22", + "casinoCode": "GLC", + "openPercentage": "67%", + "openPercentageWithNumber": 66.67, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "GL2312", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 10.0000, + "maxBet": 10000000.0000, + "minBetByTable": "GL2312-10", + "open": true + }, + { + "tableName": "GL2318", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL2318-0", + "open": true + }, + { + "tableName": "GL2355", + "casinoCode": "GLC", + "gameCode": "AR", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1.0000, + "maxBet": 10.0000, + "minBetByTable": "GL2355-1", + "open": false + } + ] + }, + { + "totalUnit": 2, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 2, + "areaName": "GPIT02", + "casinoCode": "GLC", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "GL0201", + "casinoCode": "GLC", + "gameCode": "LT", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 200000.0000, + "minBetByTable": "GL0201-0", + "open": true + }, + { + "tableName": "GL0203", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "GL0203-0", + "open": true + } + ] + }, + { + "totalUnit": 4, + "totalCasinoWin": 0.0000, + "totalDrop": 0.0000, + "totalHeadCounts": 0, + "openUnit": 3, + "areaName": "GPIT29", + "casinoCode": "GLC", + "openPercentage": "75%", + "openPercentageWithNumber": 75.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "GL2904", + "casinoCode": "GLC", + "gameCode": "DTCB", + "casinoWin": 0.0000, + "totalDrop": 0.0000, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL2904-500", + "open": true + }, + { + "tableName": "GL2905", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": 0.0000, + "headCounts": 0, + "minBet": 2.0000, + "maxBet": 100000.0000, + "minBetByTable": "GL2905-2", + "open": true + }, + { + "tableName": "GL2906", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": 0.0000, + "headCounts": 0, + "minBet": 300.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL2906-300", + "open": true + }, + { + "tableName": "GL2907", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 200.0000, + "maxBet": 5000000.0000, + "minBetByTable": "GL2907-200", + "open": false + } + ] + }, + { + "totalUnit": 6, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 6, + "areaName": "GPIT28", + "casinoCode": "GLC", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "GL2801", + "casinoCode": "GLC", + "gameCode": "HLNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 10000000.0000, + "minBetByTable": "GL2801-0", + "open": true + }, + { + "tableName": "GL2802", + "casinoCode": "GLC", + "gameCode": "HLNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL2802-0", + "open": true + }, + { + "tableName": "GL2803", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL2803-0", + "open": true + }, + { + "tableName": "GL2804", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 1000000.0000, + "minBetByTable": "GL2804-0", + "open": true + }, + { + "tableName": "GL2805", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 100000.0000, + "minBetByTable": "GL2805-0", + "open": true + }, + { + "tableName": "GL2899", + "casinoCode": "GLC", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 10000000.0000, + "minBetByTable": "GL2899-100", + "open": true + } + ] + } + ] + }, + { + "floorName": "UNKNOWN_FLOOR", + "totalUnit": 9, + "totalCasinoWin": 7016600.0000, + "totalDrop": 0.0000, + "totalHeadCounts": 0, + "openUnit": 9, + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 779622.2222222222, + "areas": [ + { + "totalUnit": 7, + "totalCasinoWin": 7016600.0000, + "totalDrop": 0.0000, + "totalHeadCounts": 0, + "openUnit": 7, + "areaName": "PPIT67", + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 1002371.428571429, + "tableViews": [ + { + "tableName": "LP6701", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": 0.0000, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 300000.0000, + "minBetByTable": "LP6701-500", + "open": true + }, + { + "tableName": "LP6702", + "casinoCode": "GLP", + "gameCode": "JBQ", + "casinoWin": 0.0000, + "totalDrop": 0.0000, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP6702-1000", + "open": true + }, + { + "tableName": "LP6703", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": 0.0000, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 300000.0000, + "minBetByTable": "LP6703-500", + "open": true + }, + { + "tableName": "LP6705", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": 0.0000, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 300000.0000, + "minBetByTable": "LP6705-500", + "open": true + }, + { + "tableName": "LP6706", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 7016600.0000, + "totalDrop": 0.0000, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 300000.0000, + "minBetByTable": "LP6706-500", + "open": true + }, + { + "tableName": "LP6707", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 300000.0000, + "minBetByTable": "LP6707-500", + "open": true + }, + { + "tableName": "LP6709", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": 0.0000, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 10000000.0000, + "minBetByTable": "LP6709-500", + "open": true + } + ] + }, + { + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 1, + "areaName": "PPIT77", + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "LP7705", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP7705-1000", + "open": true + } + ] + }, + { + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 1, + "areaName": "PPIT07", + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "LP0727", + "casinoCode": "GLP", + "gameCode": "LT", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0727-1000", + "open": true + } + ] + } + ] + }, + { + "floorName": "South (HL)", + "totalUnit": 28, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 28, + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "areas": [ + { + "totalUnit": 28, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 28, + "areaName": "PPIT08", + "casinoCode": "GLP", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "LP0801", + "casinoCode": "GLP", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0801-1000", + "open": true + }, + { + "tableName": "LP0802", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0802-1000", + "open": true + }, + { + "tableName": "LP0803", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0803-1000", + "open": true + }, + { + "tableName": "LP0806", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0806-1000", + "open": true + }, + { + "tableName": "LP0807", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0807-1000", + "open": true + }, + { + "tableName": "LP0808", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0808-1000", + "open": true + }, + { + "tableName": "LP0815", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0815-1000", + "open": true + }, + { + "tableName": "LP0816", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0816-1000", + "open": true + }, + { + "tableName": "LP0817", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0817-1000", + "open": true + }, + { + "tableName": "LP0818", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0818-1000", + "open": true + }, + { + "tableName": "LP0819", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0819-1000", + "open": true + }, + { + "tableName": "LP0820", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0820-1000", + "open": true + }, + { + "tableName": "LP0821", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0821-1000", + "open": true + }, + { + "tableName": "LP0822", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0822-1000", + "open": true + }, + { + "tableName": "LP0823", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0823-1000", + "open": true + }, + { + "tableName": "LP0824", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0824-1000", + "open": true + }, + { + "tableName": "LP0825", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0825-1000", + "open": true + }, + { + "tableName": "LP0826", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0826-1000", + "open": true + }, + { + "tableName": "LP0827", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0827-1000", + "open": true + }, + { + "tableName": "LP0828", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0828-1000", + "open": true + }, + { + "tableName": "LP0829", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0829-1000", + "open": true + }, + { + "tableName": "LP0830", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0830-1000", + "open": true + }, + { + "tableName": "LP0837", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0837-1000", + "open": true + }, + { + "tableName": "LP0838", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0838-1000", + "open": true + }, + { + "tableName": "LP0839", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0839-1000", + "open": true + }, + { + "tableName": "LP0842", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0842-1000", + "open": true + }, + { + "tableName": "LP0843", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0843-1000", + "open": true + }, + { + "tableName": "LP0844", + "casinoCode": "GLP", + "gameCode": "HLNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 500000.0000, + "minBetByTable": "LP0844-1000", + "open": true + } + ] + } + ] + }, + { + "floorName": "Oceanus", + "totalUnit": 2, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 2, + "casinoCode": "OCE", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "areas": [ + { + "totalUnit": 2, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 2, + "areaName": "OPIT38", + "casinoCode": "OCE", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "PB3801", + "casinoCode": "OCE", + "gameCode": "MBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 1500000.0000, + "minBetByTable": "PB3801-1000", + "open": true + }, + { + "tableName": "PB3802", + "casinoCode": "OCE", + "gameCode": "MBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 800000.0000, + "minBetByTable": "PB3802-500", + "open": true + } + ] + } + ] + }, + { + "floorName": "UNKNOWN_FLOOR", + "totalUnit": 1224, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 1212, + "casinoCode": "OCE", + "openPercentage": "99%", + "openPercentageWithNumber": 99.02, + "winPerUnit": 0.0000, + "areas": [ + { + "totalUnit": 11, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 9, + "areaName": "BPIT22", + "casinoCode": "OCE", + "openPercentage": "82%", + "openPercentageWithNumber": 81.82, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "OC2225", + "casinoCode": "OCE", + "gameCode": "MNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "OC2225-0", + "open": true + }, + { + "tableName": "PB2201A", + "casinoCode": "OCE", + "gameCode": "VBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 10.0000, + "maxBet": 1000000.0000, + "minBetByTable": "PB2201A-10", + "open": false + }, + { + "tableName": "PB2202A", + "casinoCode": "OCE", + "gameCode": "MBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2202A-0", + "open": true + }, + { + "tableName": "PB2203A", + "casinoCode": "OCE", + "gameCode": "MBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2203A-0", + "open": true + }, + { + "tableName": "PB2204", + "casinoCode": "OCE", + "gameCode": "MNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 10000000.0000, + "minBetByTable": "PB2204-100", + "open": true + }, + { + "tableName": "PB2205", + "casinoCode": "OCE", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2205-0", + "open": false + }, + { + "tableName": "PB2206", + "casinoCode": "OCE", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 10.0000, + "maxBet": 10000000.0000, + "minBetByTable": "PB2206-10", + "open": true + }, + { + "tableName": "PB2207", + "casinoCode": "OCE", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 10.0000, + "maxBet": 500000.0000, + "minBetByTable": "PB2207-10", + "open": true + }, + { + "tableName": "PB2208", + "casinoCode": "OCE", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2208-0", + "open": true + }, + { + "tableName": "PB2225", + "casinoCode": "OCE", + "gameCode": "MNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2225-0", + "open": true + }, + { + "tableName": "PB2228", + "casinoCode": "OCE", + "gameCode": "HLC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2228-0", + "open": true + } + ] + }, + { + "totalUnit": 11, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 2, + "areaName": "BPIT21", + "casinoCode": "OCE", + "openPercentage": "18%", + "openPercentageWithNumber": 18.18, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "PB0195", + "casinoCode": "OCE", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 10000000.0000, + "minBetByTable": "PB0195-1000", + "open": false + }, + { + "tableName": "PB0196", + "casinoCode": "OCE", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 1000000.0000, + "minBetByTable": "PB0196-1000", + "open": false + }, + { + "tableName": "PB0197", + "casinoCode": "OCE", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 1000000.0000, + "minBetByTable": "PB0197-1000", + "open": false + }, + { + "tableName": "PB0198", + "casinoCode": "OCE", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 1000000.0000, + "minBetByTable": "PB0198-1000", + "open": false + }, + { + "tableName": "PB0199", + "casinoCode": "OCE", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 1000.0000, + "maxBet": 1000000.0000, + "minBetByTable": "PB0199-1000", + "open": false + }, + { + "tableName": "PB2101", + "casinoCode": "OCE", + "gameCode": "JBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 100.0000, + "minBetByTable": "PB2101-0", + "open": false + }, + { + "tableName": "PB2101A", + "casinoCode": "OCE", + "gameCode": "MBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 1000000.0000, + "minBetByTable": "PB2101A-500", + "open": true + }, + { + "tableName": "PB2102", + "casinoCode": "OCE", + "gameCode": "PBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 300.0000, + "maxBet": 1500000.0000, + "minBetByTable": "PB2102-300", + "open": true + }, + { + "tableName": "PB2103", + "casinoCode": "OCE", + "gameCode": "PBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 100.0000, + "minBetByTable": "PB2103-0", + "open": false + }, + { + "tableName": "PB2103A", + "casinoCode": "OCE", + "gameCode": "MBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2103A-0", + "open": false + }, + { + "tableName": "PB2169", + "casinoCode": "OCE", + "gameCode": "MBQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2169-0", + "open": false + } + ] + }, + { + "totalUnit": 1, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 0, + "areaName": "BPIT24", + "casinoCode": "OCE", + "openPercentage": "0%", + "openPercentageWithNumber": 0.00, + "winPerUnit": 0, + "tableViews": [ + { + "tableName": "PB6401", + "casinoCode": "OCE", + "gameCode": "MNC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 5000000.0000, + "minBetByTable": "PB6401-0", + "open": false + } + ] + }, + { + "totalUnit": 1201, + "totalCasinoWin": 0.0000, + "totalDrop": 0, + "totalHeadCounts": 0, + "openUnit": 1201, + "areaName": "BPIT23", + "casinoCode": "OCE", + "openPercentage": "100%", + "openPercentageWithNumber": 100.00, + "winPerUnit": 0.0000, + "tableViews": [ + { + "tableName": "PB2301", + "casinoCode": "OCE", + "gameCode": "MNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 10.0000, + "maxBet": 20000000.0000, + "minBetByTable": "PB2301-10", + "open": true + }, + { + "tableName": "PB2302", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2302-0", + "open": true + }, + { + "tableName": "PB2304", + "casinoCode": "OCE", + "gameCode": "MNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 10000000.0000, + "minBetByTable": "PB2304-100", + "open": true + }, + { + "tableName": "PB2305", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2305-0", + "open": true + }, + { + "tableName": "PB2306", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2306-0", + "open": true + }, + { + "tableName": "PB2307", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2307-0", + "open": true + }, + { + "tableName": "PB2308", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2308-0", + "open": true + }, + { + "tableName": "PB2309", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2309-0", + "open": true + }, + { + "tableName": "PB2310", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 100.0000, + "maxBet": 10000000.0000, + "minBetByTable": "PB2310-100", + "open": true + }, + { + "tableName": "PB2311", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2311-0", + "open": true + }, + { + "tableName": "PB2312", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2312-0", + "open": true + }, + { + "tableName": "PB2313", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2313-0", + "open": true + }, + { + "tableName": "PB2314", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2314-0", + "open": true + }, + { + "tableName": "PB2315", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2315-0", + "open": true + }, + { + "tableName": "PB2316", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2316-0", + "open": true + }, + { + "tableName": "PB2317", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2317-0", + "open": true + }, + { + "tableName": "PB2318", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2318-0", + "open": true + }, + { + "tableName": "PB2319", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2319-0", + "open": true + }, + { + "tableName": "PB2320", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2320-0", + "open": true + }, + { + "tableName": "PB2321", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2321-0", + "open": true + }, + { + "tableName": "PB2322", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2322-0", + "open": true + }, + { + "tableName": "PB2323", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2323-0", + "open": true + }, + { + "tableName": "PB2324", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2324-0", + "open": true + }, + { + "tableName": "PB2325", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2325-0", + "open": true + }, + { + "tableName": "PB2326", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2326-0", + "open": true + }, + { + "tableName": "PB2327", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2327-0", + "open": true + }, + { + "tableName": "PB2328", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2328-0", + "open": true + }, + { + "tableName": "PB2329", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2329-0", + "open": true + }, + { + "tableName": "PB2330", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2330-0", + "open": true + }, + { + "tableName": "PB2331", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2331-0", + "open": true + }, + { + "tableName": "PB2332", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2332-0", + "open": true + }, + { + "tableName": "PB2333", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2333-0", + "open": true + }, + { + "tableName": "PB2334", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2334-0", + "open": true + }, + { + "tableName": "PB2335", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2335-0", + "open": true + }, + { + "tableName": "PB2336", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2336-0", + "open": true + }, + { + "tableName": "PB2337", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2337-0", + "open": true + }, + { + "tableName": "PB2338", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2338-0", + "open": true + }, + { + "tableName": "PB2339", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2339-0", + "open": true + }, + { + "tableName": "PB2340", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2340-0", + "open": true + }, + { + "tableName": "PB2341", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2341-0", + "open": true + }, + { + "tableName": "PB2342", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2342-0", + "open": true + }, + { + "tableName": "PB2343", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2343-0", + "open": true + }, + { + "tableName": "PB2344", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2344-0", + "open": true + }, + { + "tableName": "PB2345", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2345-0", + "open": true + }, + { + "tableName": "PB2346", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2346-0", + "open": true + }, + { + "tableName": "PB2347", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2347-0", + "open": true + }, + { + "tableName": "PB2348", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2348-0", + "open": true + }, + { + "tableName": "PB2349", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2349-0", + "open": true + }, + { + "tableName": "PB2350", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2350-0", + "open": true + }, + { + "tableName": "PB2351", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2351-0", + "open": true + }, + { + "tableName": "PB2352", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2352-0", + "open": true + }, + { + "tableName": "PB2353", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2353-0", + "open": true + }, + { + "tableName": "PB2354", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2354-0", + "open": true + }, + { + "tableName": "PB2355", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2355-0", + "open": true + }, + { + "tableName": "PB2356", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2356-0", + "open": true + }, + { + "tableName": "PB2357", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2357-0", + "open": true + }, + { + "tableName": "PB2358", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2358-0", + "open": true + }, + { + "tableName": "PB2359", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2359-0", + "open": true + }, + { + "tableName": "PB2360", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2360-0", + "open": true + }, + { + "tableName": "PB2361", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2361-0", + "open": true + }, + { + "tableName": "PB2362", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2362-0", + "open": true + }, + { + "tableName": "PB2363", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2363-0", + "open": true + }, + { + "tableName": "PB2364", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2364-0", + "open": true + }, + { + "tableName": "PB2365", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2365-0", + "open": true + }, + { + "tableName": "PB2366", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2366-0", + "open": true + }, + { + "tableName": "PB2367", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2367-0", + "open": true + }, + { + "tableName": "PB2368", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2368-0", + "open": true + }, + { + "tableName": "PB2369", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2369-0", + "open": true + }, + { + "tableName": "PB2370", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2370-0", + "open": true + }, + { + "tableName": "PB2371", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2371-0", + "open": true + }, + { + "tableName": "PB2372", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2372-0", + "open": true + }, + { + "tableName": "PB2373", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2373-0", + "open": true + }, + { + "tableName": "PB2374", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2374-0", + "open": true + }, + { + "tableName": "PB2375", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2375-0", + "open": true + }, + { + "tableName": "PB2376", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2376-0", + "open": true + }, + { + "tableName": "PB2377", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2377-0", + "open": true + }, + { + "tableName": "PB2378", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2378-0", + "open": true + }, + { + "tableName": "PB2379", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2379-0", + "open": true + }, + { + "tableName": "PB2380", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2380-0", + "open": true + }, + { + "tableName": "PB2381", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2381-0", + "open": true + }, + { + "tableName": "PB2382", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2382-0", + "open": true + }, + { + "tableName": "PB2383", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2383-0", + "open": true + }, + { + "tableName": "PB2384", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2384-0", + "open": true + }, + { + "tableName": "PB2385", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2385-0", + "open": true + }, + { + "tableName": "PB2386", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2386-0", + "open": true + }, + { + "tableName": "PB2387", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2387-0", + "open": true + }, + { + "tableName": "PB2388", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2388-0", + "open": true + }, + { + "tableName": "PB2389", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2389-0", + "open": true + }, + { + "tableName": "PB2390", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2390-0", + "open": true + }, + { + "tableName": "PB2391", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2391-0", + "open": true + }, + { + "tableName": "PB2392", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2392-0", + "open": true + }, + { + "tableName": "PB2393", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2393-0", + "open": true + }, + { + "tableName": "PB2394", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2394-0", + "open": true + }, + { + "tableName": "PB2395", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2395-0", + "open": true + }, + { + "tableName": "PB2396", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2396-0", + "open": true + }, + { + "tableName": "PB2397", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2397-0", + "open": true + }, + { + "tableName": "PB2398", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2398-0", + "open": true + }, + { + "tableName": "PB2399", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2399-0", + "open": true + }, + { + "tableName": "PB2400", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2400-0", + "open": true + }, + { + "tableName": "PB2401", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2401-0", + "open": true + }, + { + "tableName": "PB2402", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2402-0", + "open": true + }, + { + "tableName": "PB2403", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2403-0", + "open": true + }, + { + "tableName": "PB2404", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2404-0", + "open": true + }, + { + "tableName": "PB2405", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2405-0", + "open": true + }, + { + "tableName": "PB2406", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2406-0", + "open": true + }, + { + "tableName": "PB2407", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2407-0", + "open": true + }, + { + "tableName": "PB2408", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2408-0", + "open": true + }, + { + "tableName": "PB2409", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2409-0", + "open": true + }, + { + "tableName": "PB2410", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2410-0", + "open": true + }, + { + "tableName": "PB2411", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2411-0", + "open": true + }, + { + "tableName": "PB2412", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2412-0", + "open": true + }, + { + "tableName": "PB2413", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2413-0", + "open": true + }, + { + "tableName": "PB2414", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2414-0", + "open": true + }, + { + "tableName": "PB2415", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2415-0", + "open": true + }, + { + "tableName": "PB2416", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2416-0", + "open": true + }, + { + "tableName": "PB2417", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2417-0", + "open": true + }, + { + "tableName": "PB2418", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2418-0", + "open": true + }, + { + "tableName": "PB2419", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2419-0", + "open": true + }, + { + "tableName": "PB2420", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2420-0", + "open": true + }, + { + "tableName": "PB2421", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2421-0", + "open": true + }, + { + "tableName": "PB2422", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2422-0", + "open": true + }, + { + "tableName": "PB2423", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2423-0", + "open": true + }, + { + "tableName": "PB2424", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2424-0", + "open": true + }, + { + "tableName": "PB2425", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2425-0", + "open": true + }, + { + "tableName": "PB2426", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2426-0", + "open": true + }, + { + "tableName": "PB2427", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2427-0", + "open": true + }, + { + "tableName": "PB2428", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2428-0", + "open": true + }, + { + "tableName": "PB2429", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2429-0", + "open": true + }, + { + "tableName": "PB2430", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2430-0", + "open": true + }, + { + "tableName": "PB2431", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2431-0", + "open": true + }, + { + "tableName": "PB2432", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2432-0", + "open": true + }, + { + "tableName": "PB2433", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2433-0", + "open": true + }, + { + "tableName": "PB2434", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2434-0", + "open": true + }, + { + "tableName": "PB2435", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2435-0", + "open": true + }, + { + "tableName": "PB2436", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2436-0", + "open": true + }, + { + "tableName": "PB2437", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2437-0", + "open": true + }, + { + "tableName": "PB2438", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2438-0", + "open": true + }, + { + "tableName": "PB2439", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2439-0", + "open": true + }, + { + "tableName": "PB2440", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2440-0", + "open": true + }, + { + "tableName": "PB2441", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2441-0", + "open": true + }, + { + "tableName": "PB2442", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2442-0", + "open": true + }, + { + "tableName": "PB2443", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2443-0", + "open": true + }, + { + "tableName": "PB2444", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2444-0", + "open": true + }, + { + "tableName": "PB2445", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2445-0", + "open": true + }, + { + "tableName": "PB2446", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2446-0", + "open": true + }, + { + "tableName": "PB2447", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2447-0", + "open": true + }, + { + "tableName": "PB2448", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2448-0", + "open": true + }, + { + "tableName": "PB2449", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2449-0", + "open": true + }, + { + "tableName": "PB2450", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2450-0", + "open": true + }, + { + "tableName": "PB2451", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2451-0", + "open": true + }, + { + "tableName": "PB2452", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2452-0", + "open": true + }, + { + "tableName": "PB2453", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2453-0", + "open": true + }, + { + "tableName": "PB2454", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2454-0", + "open": true + }, + { + "tableName": "PB2455", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2455-0", + "open": true + }, + { + "tableName": "PB2456", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2456-0", + "open": true + }, + { + "tableName": "PB2457", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2457-0", + "open": true + }, + { + "tableName": "PB2458", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2458-0", + "open": true + }, + { + "tableName": "PB2459", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2459-0", + "open": true + }, + { + "tableName": "PB2460", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2460-0", + "open": true + }, + { + "tableName": "PB2461", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2461-0", + "open": true + }, + { + "tableName": "PB2462", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2462-0", + "open": true + }, + { + "tableName": "PB2463", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2463-0", + "open": true + }, + { + "tableName": "PB2464", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2464-0", + "open": true + }, + { + "tableName": "PB2465", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2465-0", + "open": true + }, + { + "tableName": "PB2466", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2466-0", + "open": true + }, + { + "tableName": "PB2467", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2467-0", + "open": true + }, + { + "tableName": "PB2468", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2468-0", + "open": true + }, + { + "tableName": "PB2469", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2469-0", + "open": true + }, + { + "tableName": "PB2470", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2470-0", + "open": true + }, + { + "tableName": "PB2471", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2471-0", + "open": true + }, + { + "tableName": "PB2472", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2472-0", + "open": true + }, + { + "tableName": "PB2473", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2473-0", + "open": true + }, + { + "tableName": "PB2474", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2474-0", + "open": true + }, + { + "tableName": "PB2475", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2475-0", + "open": true + }, + { + "tableName": "PB2476", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2476-0", + "open": true + }, + { + "tableName": "PB2477", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2477-0", + "open": true + }, + { + "tableName": "PB2478", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2478-0", + "open": true + }, + { + "tableName": "PB2479", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2479-0", + "open": true + }, + { + "tableName": "PB2480", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2480-0", + "open": true + }, + { + "tableName": "PB2481", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2481-0", + "open": true + }, + { + "tableName": "PB2482", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2482-0", + "open": true + }, + { + "tableName": "PB2483", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2483-0", + "open": true + }, + { + "tableName": "PB2484", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2484-0", + "open": true + }, + { + "tableName": "PB2485", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2485-0", + "open": true + }, + { + "tableName": "PB2486", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2486-0", + "open": true + }, + { + "tableName": "PB2487", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2487-0", + "open": true + }, + { + "tableName": "PB2488", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2488-0", + "open": true + }, + { + "tableName": "PB2489", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2489-0", + "open": true + }, + { + "tableName": "PB2490", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2490-0", + "open": true + }, + { + "tableName": "PB2491", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2491-0", + "open": true + }, + { + "tableName": "PB2492", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2492-0", + "open": true + }, + { + "tableName": "PB2493", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2493-0", + "open": true + }, + { + "tableName": "PB2494", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2494-0", + "open": true + }, + { + "tableName": "PB2495", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2495-0", + "open": true + }, + { + "tableName": "PB2496", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2496-0", + "open": true + }, + { + "tableName": "PB2497", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2497-0", + "open": true + }, + { + "tableName": "PB2498", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2498-0", + "open": true + }, + { + "tableName": "PB2499", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2499-0", + "open": true + }, + { + "tableName": "PB2500", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2500-0", + "open": true + }, + { + "tableName": "PB2501", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2501-0", + "open": true + }, + { + "tableName": "PB2502", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2502-0", + "open": true + }, + { + "tableName": "PB2503", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2503-0", + "open": true + }, + { + "tableName": "PB2504", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2504-0", + "open": true + }, + { + "tableName": "PB2505", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2505-0", + "open": true + }, + { + "tableName": "PB2506", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2506-0", + "open": true + }, + { + "tableName": "PB2507", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2507-0", + "open": true + }, + { + "tableName": "PB2508", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2508-0", + "open": true + }, + { + "tableName": "PB2509", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2509-0", + "open": true + }, + { + "tableName": "PB2510", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2510-0", + "open": true + }, + { + "tableName": "PB2511", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2511-0", + "open": true + }, + { + "tableName": "PB2512", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2512-0", + "open": true + }, + { + "tableName": "PB2513", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2513-0", + "open": true + }, + { + "tableName": "PB2514", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2514-0", + "open": true + }, + { + "tableName": "PB2515", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2515-0", + "open": true + }, + { + "tableName": "PB2516", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2516-0", + "open": true + }, + { + "tableName": "PB2517", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2517-0", + "open": true + }, + { + "tableName": "PB2518", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2518-0", + "open": true + }, + { + "tableName": "PB2519", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2519-0", + "open": true + }, + { + "tableName": "PB2520", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2520-0", + "open": true + }, + { + "tableName": "PB2521", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2521-0", + "open": true + }, + { + "tableName": "PB2522", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2522-0", + "open": true + }, + { + "tableName": "PB2523", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2523-0", + "open": true + }, + { + "tableName": "PB2524", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2524-0", + "open": true + }, + { + "tableName": "PB2525", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2525-0", + "open": true + }, + { + "tableName": "PB2526", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2526-0", + "open": true + }, + { + "tableName": "PB2527", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2527-0", + "open": true + }, + { + "tableName": "PB2528", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2528-0", + "open": true + }, + { + "tableName": "PB2529", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2529-0", + "open": true + }, + { + "tableName": "PB2530", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2530-0", + "open": true + }, + { + "tableName": "PB2531", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2531-0", + "open": true + }, + { + "tableName": "PB2532", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2532-0", + "open": true + }, + { + "tableName": "PB2533", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2533-0", + "open": true + }, + { + "tableName": "PB2534", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2534-0", + "open": true + }, + { + "tableName": "PB2535", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2535-0", + "open": true + }, + { + "tableName": "PB2536", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2536-0", + "open": true + }, + { + "tableName": "PB2537", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2537-0", + "open": true + }, + { + "tableName": "PB2538", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2538-0", + "open": true + }, + { + "tableName": "PB2539", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2539-0", + "open": true + }, + { + "tableName": "PB2540", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2540-0", + "open": true + }, + { + "tableName": "PB2541", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2541-0", + "open": true + }, + { + "tableName": "PB2542", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2542-0", + "open": true + }, + { + "tableName": "PB2543", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2543-0", + "open": true + }, + { + "tableName": "PB2544", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2544-0", + "open": true + }, + { + "tableName": "PB2545", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2545-0", + "open": true + }, + { + "tableName": "PB2546", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2546-0", + "open": true + }, + { + "tableName": "PB2547", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2547-0", + "open": true + }, + { + "tableName": "PB2548", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2548-0", + "open": true + }, + { + "tableName": "PB2549", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2549-0", + "open": true + }, + { + "tableName": "PB2550", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2550-0", + "open": true + }, + { + "tableName": "PB2551", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2551-0", + "open": true + }, + { + "tableName": "PB2552", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2552-0", + "open": true + }, + { + "tableName": "PB2553", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2553-0", + "open": true + }, + { + "tableName": "PB2554", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2554-0", + "open": true + }, + { + "tableName": "PB2555", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2555-0", + "open": true + }, + { + "tableName": "PB2556", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2556-0", + "open": true + }, + { + "tableName": "PB2557", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2557-0", + "open": true + }, + { + "tableName": "PB2558", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2558-0", + "open": true + }, + { + "tableName": "PB2559", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2559-0", + "open": true + }, + { + "tableName": "PB2560", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2560-0", + "open": true + }, + { + "tableName": "PB2561", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2561-0", + "open": true + }, + { + "tableName": "PB2562", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2562-0", + "open": true + }, + { + "tableName": "PB2563", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2563-0", + "open": true + }, + { + "tableName": "PB2564", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2564-0", + "open": true + }, + { + "tableName": "PB2565", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2565-0", + "open": true + }, + { + "tableName": "PB2566", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2566-0", + "open": true + }, + { + "tableName": "PB2567", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2567-0", + "open": true + }, + { + "tableName": "PB2568", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2568-0", + "open": true + }, + { + "tableName": "PB2569", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2569-0", + "open": true + }, + { + "tableName": "PB2570", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2570-0", + "open": true + }, + { + "tableName": "PB2571", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2571-0", + "open": true + }, + { + "tableName": "PB2572", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2572-0", + "open": true + }, + { + "tableName": "PB2573", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2573-0", + "open": true + }, + { + "tableName": "PB2574", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2574-0", + "open": true + }, + { + "tableName": "PB2575", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2575-0", + "open": true + }, + { + "tableName": "PB2576", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2576-0", + "open": true + }, + { + "tableName": "PB2577", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2577-0", + "open": true + }, + { + "tableName": "PB2578", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2578-0", + "open": true + }, + { + "tableName": "PB2579", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2579-0", + "open": true + }, + { + "tableName": "PB2580", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2580-0", + "open": true + }, + { + "tableName": "PB2581", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2581-0", + "open": true + }, + { + "tableName": "PB2582", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2582-0", + "open": true + }, + { + "tableName": "PB2583", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2583-0", + "open": true + }, + { + "tableName": "PB2584", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2584-0", + "open": true + }, + { + "tableName": "PB2585", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2585-0", + "open": true + }, + { + "tableName": "PB2586", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2586-0", + "open": true + }, + { + "tableName": "PB2587", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2587-0", + "open": true + }, + { + "tableName": "PB2588", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2588-0", + "open": true + }, + { + "tableName": "PB2589", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2589-0", + "open": true + }, + { + "tableName": "PB2590", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2590-0", + "open": true + }, + { + "tableName": "PB2591", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2591-0", + "open": true + }, + { + "tableName": "PB2592", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2592-0", + "open": true + }, + { + "tableName": "PB2593", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2593-0", + "open": true + }, + { + "tableName": "PB2594", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2594-0", + "open": true + }, + { + "tableName": "PB2595", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2595-0", + "open": true + }, + { + "tableName": "PB2596", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2596-0", + "open": true + }, + { + "tableName": "PB2597", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2597-0", + "open": true + }, + { + "tableName": "PB2598", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2598-0", + "open": true + }, + { + "tableName": "PB2599", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2599-0", + "open": true + }, + { + "tableName": "PB2600", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2600-0", + "open": true + }, + { + "tableName": "PB2601", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2601-0", + "open": true + }, + { + "tableName": "PB2602", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2602-0", + "open": true + }, + { + "tableName": "PB2603", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2603-0", + "open": true + }, + { + "tableName": "PB2604", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2604-0", + "open": true + }, + { + "tableName": "PB2605", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2605-0", + "open": true + }, + { + "tableName": "PB2606", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2606-0", + "open": true + }, + { + "tableName": "PB2607", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2607-0", + "open": true + }, + { + "tableName": "PB2608", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2608-0", + "open": true + }, + { + "tableName": "PB2609", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2609-0", + "open": true + }, + { + "tableName": "PB2610", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2610-0", + "open": true + }, + { + "tableName": "PB2611", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2611-0", + "open": true + }, + { + "tableName": "PB2612", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2612-0", + "open": true + }, + { + "tableName": "PB2613", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2613-0", + "open": true + }, + { + "tableName": "PB2614", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2614-0", + "open": true + }, + { + "tableName": "PB2615", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2615-0", + "open": true + }, + { + "tableName": "PB2616", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2616-0", + "open": true + }, + { + "tableName": "PB2617", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2617-0", + "open": true + }, + { + "tableName": "PB2618", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2618-0", + "open": true + }, + { + "tableName": "PB2619", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2619-0", + "open": true + }, + { + "tableName": "PB2620", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2620-0", + "open": true + }, + { + "tableName": "PB2621", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2621-0", + "open": true + }, + { + "tableName": "PB2622", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2622-0", + "open": true + }, + { + "tableName": "PB2623", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2623-0", + "open": true + }, + { + "tableName": "PB2624", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2624-0", + "open": true + }, + { + "tableName": "PB2625", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2625-0", + "open": true + }, + { + "tableName": "PB2626", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2626-0", + "open": true + }, + { + "tableName": "PB2627", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2627-0", + "open": true + }, + { + "tableName": "PB2628", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2628-0", + "open": true + }, + { + "tableName": "PB2629", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2629-0", + "open": true + }, + { + "tableName": "PB2630", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2630-0", + "open": true + }, + { + "tableName": "PB2631", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2631-0", + "open": true + }, + { + "tableName": "PB2632", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2632-0", + "open": true + }, + { + "tableName": "PB2633", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2633-0", + "open": true + }, + { + "tableName": "PB2634", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2634-0", + "open": true + }, + { + "tableName": "PB2635", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2635-0", + "open": true + }, + { + "tableName": "PB2636", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2636-0", + "open": true + }, + { + "tableName": "PB2637", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2637-0", + "open": true + }, + { + "tableName": "PB2638", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2638-0", + "open": true + }, + { + "tableName": "PB2639", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2639-0", + "open": true + }, + { + "tableName": "PB2640", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2640-0", + "open": true + }, + { + "tableName": "PB2641", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2641-0", + "open": true + }, + { + "tableName": "PB2642", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2642-0", + "open": true + }, + { + "tableName": "PB2643", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2643-0", + "open": true + }, + { + "tableName": "PB2644", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2644-0", + "open": true + }, + { + "tableName": "PB2645", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2645-0", + "open": true + }, + { + "tableName": "PB2646", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2646-0", + "open": true + }, + { + "tableName": "PB2647", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2647-0", + "open": true + }, + { + "tableName": "PB2648", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2648-0", + "open": true + }, + { + "tableName": "PB2649", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2649-0", + "open": true + }, + { + "tableName": "PB2650", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2650-0", + "open": true + }, + { + "tableName": "PB2651", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2651-0", + "open": true + }, + { + "tableName": "PB2652", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2652-0", + "open": true + }, + { + "tableName": "PB2653", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2653-0", + "open": true + }, + { + "tableName": "PB2654", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2654-0", + "open": true + }, + { + "tableName": "PB2655", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2655-0", + "open": true + }, + { + "tableName": "PB2656", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2656-0", + "open": true + }, + { + "tableName": "PB2657", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2657-0", + "open": true + }, + { + "tableName": "PB2658", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2658-0", + "open": true + }, + { + "tableName": "PB2659", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2659-0", + "open": true + }, + { + "tableName": "PB2660", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2660-0", + "open": true + }, + { + "tableName": "PB2661", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2661-0", + "open": true + }, + { + "tableName": "PB2662", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2662-0", + "open": true + }, + { + "tableName": "PB2663", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2663-0", + "open": true + }, + { + "tableName": "PB2664", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2664-0", + "open": true + }, + { + "tableName": "PB2665", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2665-0", + "open": true + }, + { + "tableName": "PB2666", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2666-0", + "open": true + }, + { + "tableName": "PB2667", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2667-0", + "open": true + }, + { + "tableName": "PB2668", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2668-0", + "open": true + }, + { + "tableName": "PB2669", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2669-0", + "open": true + }, + { + "tableName": "PB2670", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2670-0", + "open": true + }, + { + "tableName": "PB2671", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2671-0", + "open": true + }, + { + "tableName": "PB2672", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2672-0", + "open": true + }, + { + "tableName": "PB2673", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2673-0", + "open": true + }, + { + "tableName": "PB2674", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2674-0", + "open": true + }, + { + "tableName": "PB2675", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2675-0", + "open": true + }, + { + "tableName": "PB2676", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2676-0", + "open": true + }, + { + "tableName": "PB2677", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2677-0", + "open": true + }, + { + "tableName": "PB2678", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2678-0", + "open": true + }, + { + "tableName": "PB2679", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2679-0", + "open": true + }, + { + "tableName": "PB2680", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2680-0", + "open": true + }, + { + "tableName": "PB2681", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2681-0", + "open": true + }, + { + "tableName": "PB2682", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2682-0", + "open": true + }, + { + "tableName": "PB2683", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2683-0", + "open": true + }, + { + "tableName": "PB2684", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2684-0", + "open": true + }, + { + "tableName": "PB2685", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2685-0", + "open": true + }, + { + "tableName": "PB2686", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2686-0", + "open": true + }, + { + "tableName": "PB2687", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2687-0", + "open": true + }, + { + "tableName": "PB2688", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2688-0", + "open": true + }, + { + "tableName": "PB2689", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2689-0", + "open": true + }, + { + "tableName": "PB2690", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2690-0", + "open": true + }, + { + "tableName": "PB2691", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2691-0", + "open": true + }, + { + "tableName": "PB2692", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2692-0", + "open": true + }, + { + "tableName": "PB2693", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2693-0", + "open": true + }, + { + "tableName": "PB2694", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2694-0", + "open": true + }, + { + "tableName": "PB2695", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2695-0", + "open": true + }, + { + "tableName": "PB2696", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2696-0", + "open": true + }, + { + "tableName": "PB2697", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2697-0", + "open": true + }, + { + "tableName": "PB2698", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2698-0", + "open": true + }, + { + "tableName": "PB2699", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2699-0", + "open": true + }, + { + "tableName": "PB2700", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2700-0", + "open": true + }, + { + "tableName": "PB2701", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2701-0", + "open": true + }, + { + "tableName": "PB2702", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2702-0", + "open": true + }, + { + "tableName": "PB2703", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2703-0", + "open": true + }, + { + "tableName": "PB2704", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2704-0", + "open": true + }, + { + "tableName": "PB2705", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2705-0", + "open": true + }, + { + "tableName": "PB2706", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2706-0", + "open": true + }, + { + "tableName": "PB2707", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2707-0", + "open": true + }, + { + "tableName": "PB2708", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2708-0", + "open": true + }, + { + "tableName": "PB2709", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2709-0", + "open": true + }, + { + "tableName": "PB2710", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2710-0", + "open": true + }, + { + "tableName": "PB2711", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2711-0", + "open": true + }, + { + "tableName": "PB2712", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2712-0", + "open": true + }, + { + "tableName": "PB2713", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2713-0", + "open": true + }, + { + "tableName": "PB2714", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2714-0", + "open": true + }, + { + "tableName": "PB2715", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2715-0", + "open": true + }, + { + "tableName": "PB2716", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2716-0", + "open": true + }, + { + "tableName": "PB2717", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2717-0", + "open": true + }, + { + "tableName": "PB2718", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2718-0", + "open": true + }, + { + "tableName": "PB2719", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2719-0", + "open": true + }, + { + "tableName": "PB2720", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2720-0", + "open": true + }, + { + "tableName": "PB2721", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2721-0", + "open": true + }, + { + "tableName": "PB2722", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2722-0", + "open": true + }, + { + "tableName": "PB2723", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2723-0", + "open": true + }, + { + "tableName": "PB2724", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2724-0", + "open": true + }, + { + "tableName": "PB2725", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2725-0", + "open": true + }, + { + "tableName": "PB2726", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2726-0", + "open": true + }, + { + "tableName": "PB2727", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2727-0", + "open": true + }, + { + "tableName": "PB2728", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2728-0", + "open": true + }, + { + "tableName": "PB2729", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2729-0", + "open": true + }, + { + "tableName": "PB2730", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2730-0", + "open": true + }, + { + "tableName": "PB2731", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2731-0", + "open": true + }, + { + "tableName": "PB2732", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2732-0", + "open": true + }, + { + "tableName": "PB2733", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2733-0", + "open": true + }, + { + "tableName": "PB2734", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2734-0", + "open": true + }, + { + "tableName": "PB2735", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2735-0", + "open": true + }, + { + "tableName": "PB2736", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2736-0", + "open": true + }, + { + "tableName": "PB2737", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2737-0", + "open": true + }, + { + "tableName": "PB2738", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2738-0", + "open": true + }, + { + "tableName": "PB2739", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2739-0", + "open": true + }, + { + "tableName": "PB2740", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2740-0", + "open": true + }, + { + "tableName": "PB2741", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2741-0", + "open": true + }, + { + "tableName": "PB2742", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2742-0", + "open": true + }, + { + "tableName": "PB2743", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2743-0", + "open": true + }, + { + "tableName": "PB2744", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2744-0", + "open": true + }, + { + "tableName": "PB2745", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2745-0", + "open": true + }, + { + "tableName": "PB2746", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2746-0", + "open": true + }, + { + "tableName": "PB2747", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2747-0", + "open": true + }, + { + "tableName": "PB2748", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2748-0", + "open": true + }, + { + "tableName": "PB2749", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2749-0", + "open": true + }, + { + "tableName": "PB2750", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2750-0", + "open": true + }, + { + "tableName": "PB2751", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2751-0", + "open": true + }, + { + "tableName": "PB2752", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2752-0", + "open": true + }, + { + "tableName": "PB2753", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2753-0", + "open": true + }, + { + "tableName": "PB2754", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2754-0", + "open": true + }, + { + "tableName": "PB2755", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2755-0", + "open": true + }, + { + "tableName": "PB2756", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2756-0", + "open": true + }, + { + "tableName": "PB2757", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2757-0", + "open": true + }, + { + "tableName": "PB2758", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2758-0", + "open": true + }, + { + "tableName": "PB2759", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2759-0", + "open": true + }, + { + "tableName": "PB2760", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2760-0", + "open": true + }, + { + "tableName": "PB2761", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2761-0", + "open": true + }, + { + "tableName": "PB2762", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2762-0", + "open": true + }, + { + "tableName": "PB2763", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2763-0", + "open": true + }, + { + "tableName": "PB2764", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2764-0", + "open": true + }, + { + "tableName": "PB2765", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2765-0", + "open": true + }, + { + "tableName": "PB2766", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2766-0", + "open": true + }, + { + "tableName": "PB2767", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2767-0", + "open": true + }, + { + "tableName": "PB2768", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2768-0", + "open": true + }, + { + "tableName": "PB2769", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2769-0", + "open": true + }, + { + "tableName": "PB2770", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2770-0", + "open": true + }, + { + "tableName": "PB2771", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2771-0", + "open": true + }, + { + "tableName": "PB2772", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2772-0", + "open": true + }, + { + "tableName": "PB2773", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2773-0", + "open": true + }, + { + "tableName": "PB2774", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2774-0", + "open": true + }, + { + "tableName": "PB2775", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2775-0", + "open": true + }, + { + "tableName": "PB2776", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2776-0", + "open": true + }, + { + "tableName": "PB2777", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2777-0", + "open": true + }, + { + "tableName": "PB2778", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2778-0", + "open": true + }, + { + "tableName": "PB2779", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2779-0", + "open": true + }, + { + "tableName": "PB2780", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2780-0", + "open": true + }, + { + "tableName": "PB2781", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2781-0", + "open": true + }, + { + "tableName": "PB2782", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2782-0", + "open": true + }, + { + "tableName": "PB2783", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2783-0", + "open": true + }, + { + "tableName": "PB2784", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2784-0", + "open": true + }, + { + "tableName": "PB2785", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2785-0", + "open": true + }, + { + "tableName": "PB2786", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2786-0", + "open": true + }, + { + "tableName": "PB2787", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2787-0", + "open": true + }, + { + "tableName": "PB2788", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2788-0", + "open": true + }, + { + "tableName": "PB2789", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2789-0", + "open": true + }, + { + "tableName": "PB2790", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2790-0", + "open": true + }, + { + "tableName": "PB2791", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2791-0", + "open": true + }, + { + "tableName": "PB2792", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2792-0", + "open": true + }, + { + "tableName": "PB2793", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2793-0", + "open": true + }, + { + "tableName": "PB2794", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2794-0", + "open": true + }, + { + "tableName": "PB2795", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2795-0", + "open": true + }, + { + "tableName": "PB2796", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2796-0", + "open": true + }, + { + "tableName": "PB2797", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2797-0", + "open": true + }, + { + "tableName": "PB2798", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2798-0", + "open": true + }, + { + "tableName": "PB2799", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2799-0", + "open": true + }, + { + "tableName": "PB2800", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2800-0", + "open": true + }, + { + "tableName": "PB2801", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2801-0", + "open": true + }, + { + "tableName": "PB2802", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2802-0", + "open": true + }, + { + "tableName": "PB2803", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2803-0", + "open": true + }, + { + "tableName": "PB2804", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2804-0", + "open": true + }, + { + "tableName": "PB2805", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2805-0", + "open": true + }, + { + "tableName": "PB2806", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2806-0", + "open": true + }, + { + "tableName": "PB2807", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2807-0", + "open": true + }, + { + "tableName": "PB2808", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2808-0", + "open": true + }, + { + "tableName": "PB2809", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2809-0", + "open": true + }, + { + "tableName": "PB2810", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2810-0", + "open": true + }, + { + "tableName": "PB2811", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2811-0", + "open": true + }, + { + "tableName": "PB2812", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2812-0", + "open": true + }, + { + "tableName": "PB2813", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2813-0", + "open": true + }, + { + "tableName": "PB2814", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2814-0", + "open": true + }, + { + "tableName": "PB2815", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2815-0", + "open": true + }, + { + "tableName": "PB2816", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2816-0", + "open": true + }, + { + "tableName": "PB2817", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2817-0", + "open": true + }, + { + "tableName": "PB2818", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2818-0", + "open": true + }, + { + "tableName": "PB2819", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2819-0", + "open": true + }, + { + "tableName": "PB2820", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2820-0", + "open": true + }, + { + "tableName": "PB2821", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2821-0", + "open": true + }, + { + "tableName": "PB2822", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2822-0", + "open": true + }, + { + "tableName": "PB2823", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2823-0", + "open": true + }, + { + "tableName": "PB2824", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2824-0", + "open": true + }, + { + "tableName": "PB2825", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2825-0", + "open": true + }, + { + "tableName": "PB2826", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2826-0", + "open": true + }, + { + "tableName": "PB2827", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2827-0", + "open": true + }, + { + "tableName": "PB2828", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2828-0", + "open": true + }, + { + "tableName": "PB2829", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2829-0", + "open": true + }, + { + "tableName": "PB2830", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2830-0", + "open": true + }, + { + "tableName": "PB2831", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2831-0", + "open": true + }, + { + "tableName": "PB2832", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2832-0", + "open": true + }, + { + "tableName": "PB2833", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2833-0", + "open": true + }, + { + "tableName": "PB2834", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2834-0", + "open": true + }, + { + "tableName": "PB2835", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2835-0", + "open": true + }, + { + "tableName": "PB2836", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2836-0", + "open": true + }, + { + "tableName": "PB2837", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2837-0", + "open": true + }, + { + "tableName": "PB2838", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2838-0", + "open": true + }, + { + "tableName": "PB2839", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2839-0", + "open": true + }, + { + "tableName": "PB2840", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2840-0", + "open": true + }, + { + "tableName": "PB2841", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2841-0", + "open": true + }, + { + "tableName": "PB2842", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2842-0", + "open": true + }, + { + "tableName": "PB2843", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2843-0", + "open": true + }, + { + "tableName": "PB2844", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2844-0", + "open": true + }, + { + "tableName": "PB2845", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2845-0", + "open": true + }, + { + "tableName": "PB2846", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2846-0", + "open": true + }, + { + "tableName": "PB2847", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2847-0", + "open": true + }, + { + "tableName": "PB2848", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2848-0", + "open": true + }, + { + "tableName": "PB2849", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2849-0", + "open": true + }, + { + "tableName": "PB2850", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2850-0", + "open": true + }, + { + "tableName": "PB2851", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2851-0", + "open": true + }, + { + "tableName": "PB2852", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2852-0", + "open": true + }, + { + "tableName": "PB2853", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2853-0", + "open": true + }, + { + "tableName": "PB2854", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2854-0", + "open": true + }, + { + "tableName": "PB2855", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2855-0", + "open": true + }, + { + "tableName": "PB2856", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2856-0", + "open": true + }, + { + "tableName": "PB2857", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2857-0", + "open": true + }, + { + "tableName": "PB2858", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2858-0", + "open": true + }, + { + "tableName": "PB2859", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2859-0", + "open": true + }, + { + "tableName": "PB2860", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2860-0", + "open": true + }, + { + "tableName": "PB2861", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2861-0", + "open": true + }, + { + "tableName": "PB2862", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2862-0", + "open": true + }, + { + "tableName": "PB2863", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2863-0", + "open": true + }, + { + "tableName": "PB2864", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2864-0", + "open": true + }, + { + "tableName": "PB2865", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2865-0", + "open": true + }, + { + "tableName": "PB2866", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2866-0", + "open": true + }, + { + "tableName": "PB2867", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2867-0", + "open": true + }, + { + "tableName": "PB2868", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2868-0", + "open": true + }, + { + "tableName": "PB2869", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2869-0", + "open": true + }, + { + "tableName": "PB2870", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2870-0", + "open": true + }, + { + "tableName": "PB2871", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2871-0", + "open": true + }, + { + "tableName": "PB2872", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2872-0", + "open": true + }, + { + "tableName": "PB2873", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2873-0", + "open": true + }, + { + "tableName": "PB2874", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2874-0", + "open": true + }, + { + "tableName": "PB2875", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2875-0", + "open": true + }, + { + "tableName": "PB2876", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2876-0", + "open": true + }, + { + "tableName": "PB2877", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2877-0", + "open": true + }, + { + "tableName": "PB2878", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2878-0", + "open": true + }, + { + "tableName": "PB2879", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2879-0", + "open": true + }, + { + "tableName": "PB2880", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2880-0", + "open": true + }, + { + "tableName": "PB2881", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2881-0", + "open": true + }, + { + "tableName": "PB2882", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2882-0", + "open": true + }, + { + "tableName": "PB2883", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2883-0", + "open": true + }, + { + "tableName": "PB2884", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2884-0", + "open": true + }, + { + "tableName": "PB2885", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2885-0", + "open": true + }, + { + "tableName": "PB2886", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2886-0", + "open": true + }, + { + "tableName": "PB2887", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2887-0", + "open": true + }, + { + "tableName": "PB2888", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2888-0", + "open": true + }, + { + "tableName": "PB2889", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2889-0", + "open": true + }, + { + "tableName": "PB2890", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2890-0", + "open": true + }, + { + "tableName": "PB2891", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2891-0", + "open": true + }, + { + "tableName": "PB2892", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2892-0", + "open": true + }, + { + "tableName": "PB2893", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2893-0", + "open": true + }, + { + "tableName": "PB2894", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2894-0", + "open": true + }, + { + "tableName": "PB2895", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2895-0", + "open": true + }, + { + "tableName": "PB2896", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2896-0", + "open": true + }, + { + "tableName": "PB2897", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2897-0", + "open": true + }, + { + "tableName": "PB2898", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2898-0", + "open": true + }, + { + "tableName": "PB2899", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2899-0", + "open": true + }, + { + "tableName": "PB2900", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2900-0", + "open": true + }, + { + "tableName": "PB2901", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2901-0", + "open": true + }, + { + "tableName": "PB2902", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2902-0", + "open": true + }, + { + "tableName": "PB2903", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2903-0", + "open": true + }, + { + "tableName": "PB2904", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2904-0", + "open": true + }, + { + "tableName": "PB2905", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2905-0", + "open": true + }, + { + "tableName": "PB2906", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2906-0", + "open": true + }, + { + "tableName": "PB2907", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2907-0", + "open": true + }, + { + "tableName": "PB2908", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2908-0", + "open": true + }, + { + "tableName": "PB2909", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2909-0", + "open": true + }, + { + "tableName": "PB2910", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2910-0", + "open": true + }, + { + "tableName": "PB2911", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2911-0", + "open": true + }, + { + "tableName": "PB2912", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2912-0", + "open": true + }, + { + "tableName": "PB2913", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2913-0", + "open": true + }, + { + "tableName": "PB2914", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2914-0", + "open": true + }, + { + "tableName": "PB2915", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2915-0", + "open": true + }, + { + "tableName": "PB2916", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2916-0", + "open": true + }, + { + "tableName": "PB2917", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2917-0", + "open": true + }, + { + "tableName": "PB2918", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2918-0", + "open": true + }, + { + "tableName": "PB2919", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2919-0", + "open": true + }, + { + "tableName": "PB2920", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2920-0", + "open": true + }, + { + "tableName": "PB2921", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2921-0", + "open": true + }, + { + "tableName": "PB2922", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2922-0", + "open": true + }, + { + "tableName": "PB2923", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2923-0", + "open": true + }, + { + "tableName": "PB2924", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2924-0", + "open": true + }, + { + "tableName": "PB2925", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2925-0", + "open": true + }, + { + "tableName": "PB2926", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2926-0", + "open": true + }, + { + "tableName": "PB2927", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2927-0", + "open": true + }, + { + "tableName": "PB2928", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2928-0", + "open": true + }, + { + "tableName": "PB2929", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2929-0", + "open": true + }, + { + "tableName": "PB2930", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2930-0", + "open": true + }, + { + "tableName": "PB2931", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2931-0", + "open": true + }, + { + "tableName": "PB2932", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2932-0", + "open": true + }, + { + "tableName": "PB2933", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2933-0", + "open": true + }, + { + "tableName": "PB2934", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2934-0", + "open": true + }, + { + "tableName": "PB2935", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2935-0", + "open": true + }, + { + "tableName": "PB2936", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2936-0", + "open": true + }, + { + "tableName": "PB2937", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2937-0", + "open": true + }, + { + "tableName": "PB2938", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2938-0", + "open": true + }, + { + "tableName": "PB2939", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2939-0", + "open": true + }, + { + "tableName": "PB2940", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2940-0", + "open": true + }, + { + "tableName": "PB2941", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2941-0", + "open": true + }, + { + "tableName": "PB2942", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2942-0", + "open": true + }, + { + "tableName": "PB2943", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2943-0", + "open": true + }, + { + "tableName": "PB2944", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2944-0", + "open": true + }, + { + "tableName": "PB2945", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2945-0", + "open": true + }, + { + "tableName": "PB2946", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2946-0", + "open": true + }, + { + "tableName": "PB2947", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2947-0", + "open": true + }, + { + "tableName": "PB2948", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2948-0", + "open": true + }, + { + "tableName": "PB2949", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2949-0", + "open": true + }, + { + "tableName": "PB2950", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2950-0", + "open": true + }, + { + "tableName": "PB2951", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2951-0", + "open": true + }, + { + "tableName": "PB2952", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2952-0", + "open": true + }, + { + "tableName": "PB2953", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2953-0", + "open": true + }, + { + "tableName": "PB2954", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2954-0", + "open": true + }, + { + "tableName": "PB2955", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2955-0", + "open": true + }, + { + "tableName": "PB2956", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2956-0", + "open": true + }, + { + "tableName": "PB2957", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2957-0", + "open": true + }, + { + "tableName": "PB2958", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2958-0", + "open": true + }, + { + "tableName": "PB2959", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2959-0", + "open": true + }, + { + "tableName": "PB2960", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2960-0", + "open": true + }, + { + "tableName": "PB2961", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2961-0", + "open": true + }, + { + "tableName": "PB2962", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2962-0", + "open": true + }, + { + "tableName": "PB2963", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2963-0", + "open": true + }, + { + "tableName": "PB2964", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2964-0", + "open": true + }, + { + "tableName": "PB2965", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2965-0", + "open": true + }, + { + "tableName": "PB2966", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2966-0", + "open": true + }, + { + "tableName": "PB2967", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2967-0", + "open": true + }, + { + "tableName": "PB2968", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2968-0", + "open": true + }, + { + "tableName": "PB2969", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2969-0", + "open": true + }, + { + "tableName": "PB2970", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2970-0", + "open": true + }, + { + "tableName": "PB2971", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2971-0", + "open": true + }, + { + "tableName": "PB2972", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2972-0", + "open": true + }, + { + "tableName": "PB2973", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2973-0", + "open": true + }, + { + "tableName": "PB2974", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2974-0", + "open": true + }, + { + "tableName": "PB2975", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2975-0", + "open": true + }, + { + "tableName": "PB2976", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2976-0", + "open": true + }, + { + "tableName": "PB2977", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2977-0", + "open": true + }, + { + "tableName": "PB2978", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2978-0", + "open": true + }, + { + "tableName": "PB2979", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2979-0", + "open": true + }, + { + "tableName": "PB2980", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2980-0", + "open": true + }, + { + "tableName": "PB2981", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2981-0", + "open": true + }, + { + "tableName": "PB2982", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2982-0", + "open": true + }, + { + "tableName": "PB2983", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2983-0", + "open": true + }, + { + "tableName": "PB2984", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2984-0", + "open": true + }, + { + "tableName": "PB2985", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2985-0", + "open": true + }, + { + "tableName": "PB2986", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2986-0", + "open": true + }, + { + "tableName": "PB2987", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2987-0", + "open": true + }, + { + "tableName": "PB2988", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2988-0", + "open": true + }, + { + "tableName": "PB2989", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2989-0", + "open": true + }, + { + "tableName": "PB2990", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2990-0", + "open": true + }, + { + "tableName": "PB2991", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2991-0", + "open": true + }, + { + "tableName": "PB2992", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2992-0", + "open": true + }, + { + "tableName": "PB2993", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2993-0", + "open": true + }, + { + "tableName": "PB2994", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2994-0", + "open": true + }, + { + "tableName": "PB2995", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2995-0", + "open": true + }, + { + "tableName": "PB2996", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2996-0", + "open": true + }, + { + "tableName": "PB2997", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2997-0", + "open": true + }, + { + "tableName": "PB2998", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2998-0", + "open": true + }, + { + "tableName": "PB2999", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB2999-0", + "open": true + }, + { + "tableName": "PB3000", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3000-0", + "open": true + }, + { + "tableName": "PB3001", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3001-0", + "open": true + }, + { + "tableName": "PB3002", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3002-0", + "open": true + }, + { + "tableName": "PB3003", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3003-0", + "open": true + }, + { + "tableName": "PB3004", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3004-0", + "open": true + }, + { + "tableName": "PB3005", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3005-0", + "open": true + }, + { + "tableName": "PB3006", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3006-0", + "open": true + }, + { + "tableName": "PB3007", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3007-0", + "open": true + }, + { + "tableName": "PB3008", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3008-0", + "open": true + }, + { + "tableName": "PB3009", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3009-0", + "open": true + }, + { + "tableName": "PB3010", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3010-0", + "open": true + }, + { + "tableName": "PB3011", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3011-0", + "open": true + }, + { + "tableName": "PB3012", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3012-0", + "open": true + }, + { + "tableName": "PB3013", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3013-0", + "open": true + }, + { + "tableName": "PB3014", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3014-0", + "open": true + }, + { + "tableName": "PB3015", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3015-0", + "open": true + }, + { + "tableName": "PB3016", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3016-0", + "open": true + }, + { + "tableName": "PB3017", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3017-0", + "open": true + }, + { + "tableName": "PB3018", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3018-0", + "open": true + }, + { + "tableName": "PB3019", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3019-0", + "open": true + }, + { + "tableName": "PB3020", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3020-0", + "open": true + }, + { + "tableName": "PB3021", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3021-0", + "open": true + }, + { + "tableName": "PB3022", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3022-0", + "open": true + }, + { + "tableName": "PB3023", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3023-0", + "open": true + }, + { + "tableName": "PB3024", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3024-0", + "open": true + }, + { + "tableName": "PB3025", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3025-0", + "open": true + }, + { + "tableName": "PB3026", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3026-0", + "open": true + }, + { + "tableName": "PB3027", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3027-0", + "open": true + }, + { + "tableName": "PB3028", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3028-0", + "open": true + }, + { + "tableName": "PB3029", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3029-0", + "open": true + }, + { + "tableName": "PB3030", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3030-0", + "open": true + }, + { + "tableName": "PB3031", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3031-0", + "open": true + }, + { + "tableName": "PB3032", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3032-0", + "open": true + }, + { + "tableName": "PB3033", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3033-0", + "open": true + }, + { + "tableName": "PB3034", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3034-0", + "open": true + }, + { + "tableName": "PB3035", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3035-0", + "open": true + }, + { + "tableName": "PB3036", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3036-0", + "open": true + }, + { + "tableName": "PB3037", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3037-0", + "open": true + }, + { + "tableName": "PB3038", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3038-0", + "open": true + }, + { + "tableName": "PB3039", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3039-0", + "open": true + }, + { + "tableName": "PB3040", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3040-0", + "open": true + }, + { + "tableName": "PB3041", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3041-0", + "open": true + }, + { + "tableName": "PB3042", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3042-0", + "open": true + }, + { + "tableName": "PB3043", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3043-0", + "open": true + }, + { + "tableName": "PB3044", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3044-0", + "open": true + }, + { + "tableName": "PB3045", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3045-0", + "open": true + }, + { + "tableName": "PB3046", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3046-0", + "open": true + }, + { + "tableName": "PB3047", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3047-0", + "open": true + }, + { + "tableName": "PB3048", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3048-0", + "open": true + }, + { + "tableName": "PB3049", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3049-0", + "open": true + }, + { + "tableName": "PB3050", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3050-0", + "open": true + }, + { + "tableName": "PB3051", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3051-0", + "open": true + }, + { + "tableName": "PB3052", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3052-0", + "open": true + }, + { + "tableName": "PB3053", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3053-0", + "open": true + }, + { + "tableName": "PB3054", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3054-0", + "open": true + }, + { + "tableName": "PB3055", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3055-0", + "open": true + }, + { + "tableName": "PB3056", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3056-0", + "open": true + }, + { + "tableName": "PB3057", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3057-0", + "open": true + }, + { + "tableName": "PB3058", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3058-0", + "open": true + }, + { + "tableName": "PB3059", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3059-0", + "open": true + }, + { + "tableName": "PB3060", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3060-0", + "open": true + }, + { + "tableName": "PB3061", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3061-0", + "open": true + }, + { + "tableName": "PB3062", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3062-0", + "open": true + }, + { + "tableName": "PB3063", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3063-0", + "open": true + }, + { + "tableName": "PB3064", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3064-0", + "open": true + }, + { + "tableName": "PB3065", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3065-0", + "open": true + }, + { + "tableName": "PB3066", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3066-0", + "open": true + }, + { + "tableName": "PB3067", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3067-0", + "open": true + }, + { + "tableName": "PB3068", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3068-0", + "open": true + }, + { + "tableName": "PB3069", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3069-0", + "open": true + }, + { + "tableName": "PB3070", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3070-0", + "open": true + }, + { + "tableName": "PB3071", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3071-0", + "open": true + }, + { + "tableName": "PB3072", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3072-0", + "open": true + }, + { + "tableName": "PB3073", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3073-0", + "open": true + }, + { + "tableName": "PB3074", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3074-0", + "open": true + }, + { + "tableName": "PB3075", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3075-0", + "open": true + }, + { + "tableName": "PB3076", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3076-0", + "open": true + }, + { + "tableName": "PB3077", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3077-0", + "open": true + }, + { + "tableName": "PB3078", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3078-0", + "open": true + }, + { + "tableName": "PB3079", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3079-0", + "open": true + }, + { + "tableName": "PB3080", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3080-0", + "open": true + }, + { + "tableName": "PB3081", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3081-0", + "open": true + }, + { + "tableName": "PB3082", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3082-0", + "open": true + }, + { + "tableName": "PB3083", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3083-0", + "open": true + }, + { + "tableName": "PB3084", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3084-0", + "open": true + }, + { + "tableName": "PB3085", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3085-0", + "open": true + }, + { + "tableName": "PB3086", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3086-0", + "open": true + }, + { + "tableName": "PB3087", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3087-0", + "open": true + }, + { + "tableName": "PB3088", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3088-0", + "open": true + }, + { + "tableName": "PB3089", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3089-0", + "open": true + }, + { + "tableName": "PB3090", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3090-0", + "open": true + }, + { + "tableName": "PB3091", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3091-0", + "open": true + }, + { + "tableName": "PB3092", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3092-0", + "open": true + }, + { + "tableName": "PB3093", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3093-0", + "open": true + }, + { + "tableName": "PB3094", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3094-0", + "open": true + }, + { + "tableName": "PB3095", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3095-0", + "open": true + }, + { + "tableName": "PB3096", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3096-0", + "open": true + }, + { + "tableName": "PB3097", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3097-0", + "open": true + }, + { + "tableName": "PB3098", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3098-0", + "open": true + }, + { + "tableName": "PB3099", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3099-0", + "open": true + }, + { + "tableName": "PB3100", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3100-0", + "open": true + }, + { + "tableName": "PB3101", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3101-0", + "open": true + }, + { + "tableName": "PB3102", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3102-0", + "open": true + }, + { + "tableName": "PB3103", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3103-0", + "open": true + }, + { + "tableName": "PB3104", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3104-0", + "open": true + }, + { + "tableName": "PB3105", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3105-0", + "open": true + }, + { + "tableName": "PB3106", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3106-0", + "open": true + }, + { + "tableName": "PB3107", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3107-0", + "open": true + }, + { + "tableName": "PB3108", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3108-0", + "open": true + }, + { + "tableName": "PB3109", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3109-0", + "open": true + }, + { + "tableName": "PB3110", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3110-0", + "open": true + }, + { + "tableName": "PB3111", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3111-0", + "open": true + }, + { + "tableName": "PB3112", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3112-0", + "open": true + }, + { + "tableName": "PB3113", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3113-0", + "open": true + }, + { + "tableName": "PB3114", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3114-0", + "open": true + }, + { + "tableName": "PB3115", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3115-0", + "open": true + }, + { + "tableName": "PB3116", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3116-0", + "open": true + }, + { + "tableName": "PB3117", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3117-0", + "open": true + }, + { + "tableName": "PB3118", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3118-0", + "open": true + }, + { + "tableName": "PB3119", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3119-0", + "open": true + }, + { + "tableName": "PB3120", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3120-0", + "open": true + }, + { + "tableName": "PB3121", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3121-0", + "open": true + }, + { + "tableName": "PB3122", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3122-0", + "open": true + }, + { + "tableName": "PB3123", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3123-0", + "open": true + }, + { + "tableName": "PB3124", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3124-0", + "open": true + }, + { + "tableName": "PB3125", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3125-0", + "open": true + }, + { + "tableName": "PB3126", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3126-0", + "open": true + }, + { + "tableName": "PB3127", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3127-0", + "open": true + }, + { + "tableName": "PB3128", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3128-0", + "open": true + }, + { + "tableName": "PB3129", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3129-0", + "open": true + }, + { + "tableName": "PB3130", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3130-0", + "open": true + }, + { + "tableName": "PB3131", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3131-0", + "open": true + }, + { + "tableName": "PB3132", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3132-0", + "open": true + }, + { + "tableName": "PB3133", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3133-0", + "open": true + }, + { + "tableName": "PB3134", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3134-0", + "open": true + }, + { + "tableName": "PB3135", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3135-0", + "open": true + }, + { + "tableName": "PB3136", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3136-0", + "open": true + }, + { + "tableName": "PB3137", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3137-0", + "open": true + }, + { + "tableName": "PB3138", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3138-0", + "open": true + }, + { + "tableName": "PB3139", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3139-0", + "open": true + }, + { + "tableName": "PB3140", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3140-0", + "open": true + }, + { + "tableName": "PB3141", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3141-0", + "open": true + }, + { + "tableName": "PB3142", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3142-0", + "open": true + }, + { + "tableName": "PB3143", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3143-0", + "open": true + }, + { + "tableName": "PB3144", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3144-0", + "open": true + }, + { + "tableName": "PB3145", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3145-0", + "open": true + }, + { + "tableName": "PB3146", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3146-0", + "open": true + }, + { + "tableName": "PB3147", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3147-0", + "open": true + }, + { + "tableName": "PB3148", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3148-0", + "open": true + }, + { + "tableName": "PB3149", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3149-0", + "open": true + }, + { + "tableName": "PB3150", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3150-0", + "open": true + }, + { + "tableName": "PB3151", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3151-0", + "open": true + }, + { + "tableName": "PB3152", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3152-0", + "open": true + }, + { + "tableName": "PB3153", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3153-0", + "open": true + }, + { + "tableName": "PB3154", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3154-0", + "open": true + }, + { + "tableName": "PB3155", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3155-0", + "open": true + }, + { + "tableName": "PB3156", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3156-0", + "open": true + }, + { + "tableName": "PB3157", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3157-0", + "open": true + }, + { + "tableName": "PB3158", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3158-0", + "open": true + }, + { + "tableName": "PB3159", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3159-0", + "open": true + }, + { + "tableName": "PB3160", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3160-0", + "open": true + }, + { + "tableName": "PB3161", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3161-0", + "open": true + }, + { + "tableName": "PB3162", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3162-0", + "open": true + }, + { + "tableName": "PB3163", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3163-0", + "open": true + }, + { + "tableName": "PB3164", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3164-0", + "open": true + }, + { + "tableName": "PB3165", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3165-0", + "open": true + }, + { + "tableName": "PB3166", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3166-0", + "open": true + }, + { + "tableName": "PB3167", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3167-0", + "open": true + }, + { + "tableName": "PB3168", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3168-0", + "open": true + }, + { + "tableName": "PB3169", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3169-0", + "open": true + }, + { + "tableName": "PB3170", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3170-0", + "open": true + }, + { + "tableName": "PB3171", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3171-0", + "open": true + }, + { + "tableName": "PB3172", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3172-0", + "open": true + }, + { + "tableName": "PB3173", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3173-0", + "open": true + }, + { + "tableName": "PB3174", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3174-0", + "open": true + }, + { + "tableName": "PB3175", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3175-0", + "open": true + }, + { + "tableName": "PB3176", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3176-0", + "open": true + }, + { + "tableName": "PB3177", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3177-0", + "open": true + }, + { + "tableName": "PB3178", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3178-0", + "open": true + }, + { + "tableName": "PB3179", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3179-0", + "open": true + }, + { + "tableName": "PB3180", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3180-0", + "open": true + }, + { + "tableName": "PB3181", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3181-0", + "open": true + }, + { + "tableName": "PB3182", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3182-0", + "open": true + }, + { + "tableName": "PB3183", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3183-0", + "open": true + }, + { + "tableName": "PB3184", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3184-0", + "open": true + }, + { + "tableName": "PB3185", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3185-0", + "open": true + }, + { + "tableName": "PB3186", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3186-0", + "open": true + }, + { + "tableName": "PB3187", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3187-0", + "open": true + }, + { + "tableName": "PB3188", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3188-0", + "open": true + }, + { + "tableName": "PB3189", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3189-0", + "open": true + }, + { + "tableName": "PB3190", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3190-0", + "open": true + }, + { + "tableName": "PB3191", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3191-0", + "open": true + }, + { + "tableName": "PB3192", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3192-0", + "open": true + }, + { + "tableName": "PB3193", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3193-0", + "open": true + }, + { + "tableName": "PB3194", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3194-0", + "open": true + }, + { + "tableName": "PB3195", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3195-0", + "open": true + }, + { + "tableName": "PB3196", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3196-0", + "open": true + }, + { + "tableName": "PB3197", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3197-0", + "open": true + }, + { + "tableName": "PB3198", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3198-0", + "open": true + }, + { + "tableName": "PB3199", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3199-0", + "open": true + }, + { + "tableName": "PB3200", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3200-0", + "open": true + }, + { + "tableName": "PB3201", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3201-0", + "open": true + }, + { + "tableName": "PB3202", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3202-0", + "open": true + }, + { + "tableName": "PB3203", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3203-0", + "open": true + }, + { + "tableName": "PB3204", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3204-0", + "open": true + }, + { + "tableName": "PB3205", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3205-0", + "open": true + }, + { + "tableName": "PB3206", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3206-0", + "open": true + }, + { + "tableName": "PB3207", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3207-0", + "open": true + }, + { + "tableName": "PB3208", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3208-0", + "open": true + }, + { + "tableName": "PB3209", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3209-0", + "open": true + }, + { + "tableName": "PB3210", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3210-0", + "open": true + }, + { + "tableName": "PB3211", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3211-0", + "open": true + }, + { + "tableName": "PB3212", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3212-0", + "open": true + }, + { + "tableName": "PB3213", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3213-0", + "open": true + }, + { + "tableName": "PB3214", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3214-0", + "open": true + }, + { + "tableName": "PB3215", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3215-0", + "open": true + }, + { + "tableName": "PB3216", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3216-0", + "open": true + }, + { + "tableName": "PB3217", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3217-0", + "open": true + }, + { + "tableName": "PB3218", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3218-0", + "open": true + }, + { + "tableName": "PB3219", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3219-0", + "open": true + }, + { + "tableName": "PB3220", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3220-0", + "open": true + }, + { + "tableName": "PB3221", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3221-0", + "open": true + }, + { + "tableName": "PB3222", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3222-0", + "open": true + }, + { + "tableName": "PB3223", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3223-0", + "open": true + }, + { + "tableName": "PB3224", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3224-0", + "open": true + }, + { + "tableName": "PB3225", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3225-0", + "open": true + }, + { + "tableName": "PB3226", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3226-0", + "open": true + }, + { + "tableName": "PB3227", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3227-0", + "open": true + }, + { + "tableName": "PB3228", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3228-0", + "open": true + }, + { + "tableName": "PB3229", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3229-0", + "open": true + }, + { + "tableName": "PB3230", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3230-0", + "open": true + }, + { + "tableName": "PB3231", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3231-0", + "open": true + }, + { + "tableName": "PB3232", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3232-0", + "open": true + }, + { + "tableName": "PB3233", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3233-0", + "open": true + }, + { + "tableName": "PB3234", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3234-0", + "open": true + }, + { + "tableName": "PB3235", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3235-0", + "open": true + }, + { + "tableName": "PB3236", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3236-0", + "open": true + }, + { + "tableName": "PB3237", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3237-0", + "open": true + }, + { + "tableName": "PB3238", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3238-0", + "open": true + }, + { + "tableName": "PB3239", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3239-0", + "open": true + }, + { + "tableName": "PB3240", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3240-0", + "open": true + }, + { + "tableName": "PB3241", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3241-0", + "open": true + }, + { + "tableName": "PB3242", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3242-0", + "open": true + }, + { + "tableName": "PB3243", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3243-0", + "open": true + }, + { + "tableName": "PB3244", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3244-0", + "open": true + }, + { + "tableName": "PB3245", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3245-0", + "open": true + }, + { + "tableName": "PB3246", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3246-0", + "open": true + }, + { + "tableName": "PB3247", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3247-0", + "open": true + }, + { + "tableName": "PB3248", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3248-0", + "open": true + }, + { + "tableName": "PB3249", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3249-0", + "open": true + }, + { + "tableName": "PB3250", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3250-0", + "open": true + }, + { + "tableName": "PB3251", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3251-0", + "open": true + }, + { + "tableName": "PB3252", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3252-0", + "open": true + }, + { + "tableName": "PB3253", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3253-0", + "open": true + }, + { + "tableName": "PB3254", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3254-0", + "open": true + }, + { + "tableName": "PB3255", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3255-0", + "open": true + }, + { + "tableName": "PB3256", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3256-0", + "open": true + }, + { + "tableName": "PB3257", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3257-0", + "open": true + }, + { + "tableName": "PB3258", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3258-0", + "open": true + }, + { + "tableName": "PB3259", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3259-0", + "open": true + }, + { + "tableName": "PB3260", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3260-0", + "open": true + }, + { + "tableName": "PB3261", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3261-0", + "open": true + }, + { + "tableName": "PB3262", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3262-0", + "open": true + }, + { + "tableName": "PB3263", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3263-0", + "open": true + }, + { + "tableName": "PB3264", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3264-0", + "open": true + }, + { + "tableName": "PB3265", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3265-0", + "open": true + }, + { + "tableName": "PB3266", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3266-0", + "open": true + }, + { + "tableName": "PB3267", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3267-0", + "open": true + }, + { + "tableName": "PB3268", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3268-0", + "open": true + }, + { + "tableName": "PB3269", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3269-0", + "open": true + }, + { + "tableName": "PB3270", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3270-0", + "open": true + }, + { + "tableName": "PB3271", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3271-0", + "open": true + }, + { + "tableName": "PB3272", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3272-0", + "open": true + }, + { + "tableName": "PB3273", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3273-0", + "open": true + }, + { + "tableName": "PB3274", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3274-0", + "open": true + }, + { + "tableName": "PB3275", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3275-0", + "open": true + }, + { + "tableName": "PB3276", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3276-0", + "open": true + }, + { + "tableName": "PB3277", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3277-0", + "open": true + }, + { + "tableName": "PB3278", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3278-0", + "open": true + }, + { + "tableName": "PB3279", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3279-0", + "open": true + }, + { + "tableName": "PB3280", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3280-0", + "open": true + }, + { + "tableName": "PB3281", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3281-0", + "open": true + }, + { + "tableName": "PB3282", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3282-0", + "open": true + }, + { + "tableName": "PB3283", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3283-0", + "open": true + }, + { + "tableName": "PB3284", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3284-0", + "open": true + }, + { + "tableName": "PB3285", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3285-0", + "open": true + }, + { + "tableName": "PB3286", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3286-0", + "open": true + }, + { + "tableName": "PB3287", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3287-0", + "open": true + }, + { + "tableName": "PB3288", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3288-0", + "open": true + }, + { + "tableName": "PB3289", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3289-0", + "open": true + }, + { + "tableName": "PB3290", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3290-0", + "open": true + }, + { + "tableName": "PB3291", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3291-0", + "open": true + }, + { + "tableName": "PB3292", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3292-0", + "open": true + }, + { + "tableName": "PB3293", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3293-0", + "open": true + }, + { + "tableName": "PB3294", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3294-0", + "open": true + }, + { + "tableName": "PB3295", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3295-0", + "open": true + }, + { + "tableName": "PB3296", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3296-0", + "open": true + }, + { + "tableName": "PB3297", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3297-0", + "open": true + }, + { + "tableName": "PB3298", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3298-0", + "open": true + }, + { + "tableName": "PB3299", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3299-0", + "open": true + }, + { + "tableName": "PB3300", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3300-0", + "open": true + }, + { + "tableName": "PB3301", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3301-0", + "open": true + }, + { + "tableName": "PB3302", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3302-0", + "open": true + }, + { + "tableName": "PB3303", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3303-0", + "open": true + }, + { + "tableName": "PB3304", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3304-0", + "open": true + }, + { + "tableName": "PB3305", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3305-0", + "open": true + }, + { + "tableName": "PB3306", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3306-0", + "open": true + }, + { + "tableName": "PB3307", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3307-0", + "open": true + }, + { + "tableName": "PB3308", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3308-0", + "open": true + }, + { + "tableName": "PB3309", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3309-0", + "open": true + }, + { + "tableName": "PB3310", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3310-0", + "open": true + }, + { + "tableName": "PB3311", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3311-0", + "open": true + }, + { + "tableName": "PB3312", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3312-0", + "open": true + }, + { + "tableName": "PB3313", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3313-0", + "open": true + }, + { + "tableName": "PB3314", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3314-0", + "open": true + }, + { + "tableName": "PB3315", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3315-0", + "open": true + }, + { + "tableName": "PB3316", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3316-0", + "open": true + }, + { + "tableName": "PB3317", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3317-0", + "open": true + }, + { + "tableName": "PB3318", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3318-0", + "open": true + }, + { + "tableName": "PB3319", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3319-0", + "open": true + }, + { + "tableName": "PB3320", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3320-0", + "open": true + }, + { + "tableName": "PB3321", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3321-0", + "open": true + }, + { + "tableName": "PB3322", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3322-0", + "open": true + }, + { + "tableName": "PB3323", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3323-0", + "open": true + }, + { + "tableName": "PB3324", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3324-0", + "open": true + }, + { + "tableName": "PB3325", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3325-0", + "open": true + }, + { + "tableName": "PB3326", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3326-0", + "open": true + }, + { + "tableName": "PB3327", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3327-0", + "open": true + }, + { + "tableName": "PB3328", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3328-0", + "open": true + }, + { + "tableName": "PB3329", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3329-0", + "open": true + }, + { + "tableName": "PB3330", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3330-0", + "open": true + }, + { + "tableName": "PB3331", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3331-0", + "open": true + }, + { + "tableName": "PB3332", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3332-0", + "open": true + }, + { + "tableName": "PB3333", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3333-0", + "open": true + }, + { + "tableName": "PB3334", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3334-0", + "open": true + }, + { + "tableName": "PB3335", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3335-0", + "open": true + }, + { + "tableName": "PB3336", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3336-0", + "open": true + }, + { + "tableName": "PB3337", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3337-0", + "open": true + }, + { + "tableName": "PB3338", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3338-0", + "open": true + }, + { + "tableName": "PB3339", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3339-0", + "open": true + }, + { + "tableName": "PB3340", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3340-0", + "open": true + }, + { + "tableName": "PB3341", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3341-0", + "open": true + }, + { + "tableName": "PB3342", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3342-0", + "open": true + }, + { + "tableName": "PB3343", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3343-0", + "open": true + }, + { + "tableName": "PB3344", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3344-0", + "open": true + }, + { + "tableName": "PB3345", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3345-0", + "open": true + }, + { + "tableName": "PB3346", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3346-0", + "open": true + }, + { + "tableName": "PB3347", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3347-0", + "open": true + }, + { + "tableName": "PB3348", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3348-0", + "open": true + }, + { + "tableName": "PB3349", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3349-0", + "open": true + }, + { + "tableName": "PB3350", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3350-0", + "open": true + }, + { + "tableName": "PB3351", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3351-0", + "open": true + }, + { + "tableName": "PB3352", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3352-0", + "open": true + }, + { + "tableName": "PB3353", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3353-0", + "open": true + }, + { + "tableName": "PB3354", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3354-0", + "open": true + }, + { + "tableName": "PB3355", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3355-0", + "open": true + }, + { + "tableName": "PB3356", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3356-0", + "open": true + }, + { + "tableName": "PB3357", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3357-0", + "open": true + }, + { + "tableName": "PB3358", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3358-0", + "open": true + }, + { + "tableName": "PB3359", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3359-0", + "open": true + }, + { + "tableName": "PB3360", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3360-0", + "open": true + }, + { + "tableName": "PB3361", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3361-0", + "open": true + }, + { + "tableName": "PB3362", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3362-0", + "open": true + }, + { + "tableName": "PB3363", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3363-0", + "open": true + }, + { + "tableName": "PB3364", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3364-0", + "open": true + }, + { + "tableName": "PB3365", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3365-0", + "open": true + }, + { + "tableName": "PB3366", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3366-0", + "open": true + }, + { + "tableName": "PB3367", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3367-0", + "open": true + }, + { + "tableName": "PB3368", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3368-0", + "open": true + }, + { + "tableName": "PB3369", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3369-0", + "open": true + }, + { + "tableName": "PB3370", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3370-0", + "open": true + }, + { + "tableName": "PB3371", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3371-0", + "open": true + }, + { + "tableName": "PB3372", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3372-0", + "open": true + }, + { + "tableName": "PB3373", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3373-0", + "open": true + }, + { + "tableName": "PB3374", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3374-0", + "open": true + }, + { + "tableName": "PB3375", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3375-0", + "open": true + }, + { + "tableName": "PB3376", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3376-0", + "open": true + }, + { + "tableName": "PB3377", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3377-0", + "open": true + }, + { + "tableName": "PB3378", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3378-0", + "open": true + }, + { + "tableName": "PB3379", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3379-0", + "open": true + }, + { + "tableName": "PB3380", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3380-0", + "open": true + }, + { + "tableName": "PB3381", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3381-0", + "open": true + }, + { + "tableName": "PB3382", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3382-0", + "open": true + }, + { + "tableName": "PB3383", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3383-0", + "open": true + }, + { + "tableName": "PB3384", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3384-0", + "open": true + }, + { + "tableName": "PB3385", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3385-0", + "open": true + }, + { + "tableName": "PB3386", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3386-0", + "open": true + }, + { + "tableName": "PB3387", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3387-0", + "open": true + }, + { + "tableName": "PB3388", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3388-0", + "open": true + }, + { + "tableName": "PB3389", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3389-0", + "open": true + }, + { + "tableName": "PB3390", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3390-0", + "open": true + }, + { + "tableName": "PB3391", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3391-0", + "open": true + }, + { + "tableName": "PB3392", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3392-0", + "open": true + }, + { + "tableName": "PB3393", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3393-0", + "open": true + }, + { + "tableName": "PB3394", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3394-0", + "open": true + }, + { + "tableName": "PB3395", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3395-0", + "open": true + }, + { + "tableName": "PB3396", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3396-0", + "open": true + }, + { + "tableName": "PB3397", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3397-0", + "open": true + }, + { + "tableName": "PB3398", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3398-0", + "open": true + }, + { + "tableName": "PB3399", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3399-0", + "open": true + }, + { + "tableName": "PB3400", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3400-0", + "open": true + }, + { + "tableName": "PB3401", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3401-0", + "open": true + }, + { + "tableName": "PB3402", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3402-0", + "open": true + }, + { + "tableName": "PB3403", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3403-0", + "open": true + }, + { + "tableName": "PB3404", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3404-0", + "open": true + }, + { + "tableName": "PB3405", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3405-0", + "open": true + }, + { + "tableName": "PB3406", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3406-0", + "open": true + }, + { + "tableName": "PB3407", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3407-0", + "open": true + }, + { + "tableName": "PB3408", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3408-0", + "open": true + }, + { + "tableName": "PB3409", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3409-0", + "open": true + }, + { + "tableName": "PB3410", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3410-0", + "open": true + }, + { + "tableName": "PB3411", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3411-0", + "open": true + }, + { + "tableName": "PB3412", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3412-0", + "open": true + }, + { + "tableName": "PB3413", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3413-0", + "open": true + }, + { + "tableName": "PB3414", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3414-0", + "open": true + }, + { + "tableName": "PB3415", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3415-0", + "open": true + }, + { + "tableName": "PB3416", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3416-0", + "open": true + }, + { + "tableName": "PB3417", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3417-0", + "open": true + }, + { + "tableName": "PB3418", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3418-0", + "open": true + }, + { + "tableName": "PB3419", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3419-0", + "open": true + }, + { + "tableName": "PB3420", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3420-0", + "open": true + }, + { + "tableName": "PB3421", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3421-0", + "open": true + }, + { + "tableName": "PB3422", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3422-0", + "open": true + }, + { + "tableName": "PB3423", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3423-0", + "open": true + }, + { + "tableName": "PB3424", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3424-0", + "open": true + }, + { + "tableName": "PB3425", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3425-0", + "open": true + }, + { + "tableName": "PB3426", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3426-0", + "open": true + }, + { + "tableName": "PB3427", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3427-0", + "open": true + }, + { + "tableName": "PB3428", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3428-0", + "open": true + }, + { + "tableName": "PB3429", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3429-0", + "open": true + }, + { + "tableName": "PB3430", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3430-0", + "open": true + }, + { + "tableName": "PB3431", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3431-0", + "open": true + }, + { + "tableName": "PB3432", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3432-0", + "open": true + }, + { + "tableName": "PB3433", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3433-0", + "open": true + }, + { + "tableName": "PB3434", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3434-0", + "open": true + }, + { + "tableName": "PB3435", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3435-0", + "open": true + }, + { + "tableName": "PB3436", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3436-0", + "open": true + }, + { + "tableName": "PB3437", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3437-0", + "open": true + }, + { + "tableName": "PB3438", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3438-0", + "open": true + }, + { + "tableName": "PB3439", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3439-0", + "open": true + }, + { + "tableName": "PB3440", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3440-0", + "open": true + }, + { + "tableName": "PB3441", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3441-0", + "open": true + }, + { + "tableName": "PB3442", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3442-0", + "open": true + }, + { + "tableName": "PB3443", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3443-0", + "open": true + }, + { + "tableName": "PB3444", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3444-0", + "open": true + }, + { + "tableName": "PB3445", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3445-0", + "open": true + }, + { + "tableName": "PB3446", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3446-0", + "open": true + }, + { + "tableName": "PB3447", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3447-0", + "open": true + }, + { + "tableName": "PB3448", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3448-0", + "open": true + }, + { + "tableName": "PB3449", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3449-0", + "open": true + }, + { + "tableName": "PB3450", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3450-0", + "open": true + }, + { + "tableName": "PB3451", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3451-0", + "open": true + }, + { + "tableName": "PB3452", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3452-0", + "open": true + }, + { + "tableName": "PB3453", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3453-0", + "open": true + }, + { + "tableName": "PB3454", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3454-0", + "open": true + }, + { + "tableName": "PB3455", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3455-0", + "open": true + }, + { + "tableName": "PB3456", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3456-0", + "open": true + }, + { + "tableName": "PB3457", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3457-0", + "open": true + }, + { + "tableName": "PB3458", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3458-0", + "open": true + }, + { + "tableName": "PB3459", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3459-0", + "open": true + }, + { + "tableName": "PB3460", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3460-0", + "open": true + }, + { + "tableName": "PB3461", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3461-0", + "open": true + }, + { + "tableName": "PB3462", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3462-0", + "open": true + }, + { + "tableName": "PB3463", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3463-0", + "open": true + }, + { + "tableName": "PB3464", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3464-0", + "open": true + }, + { + "tableName": "PB3465", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3465-0", + "open": true + }, + { + "tableName": "PB3466", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3466-0", + "open": true + }, + { + "tableName": "PB3467", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3467-0", + "open": true + }, + { + "tableName": "PB3468", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3468-0", + "open": true + }, + { + "tableName": "PB3469", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3469-0", + "open": true + }, + { + "tableName": "PB3470", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3470-0", + "open": true + }, + { + "tableName": "PB3471", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3471-0", + "open": true + }, + { + "tableName": "PB3472", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3472-0", + "open": true + }, + { + "tableName": "PB3473", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3473-0", + "open": true + }, + { + "tableName": "PB3474", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3474-0", + "open": true + }, + { + "tableName": "PB3475", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3475-0", + "open": true + }, + { + "tableName": "PB3476", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3476-0", + "open": true + }, + { + "tableName": "PB3477", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3477-0", + "open": true + }, + { + "tableName": "PB3478", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3478-0", + "open": true + }, + { + "tableName": "PB3479", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3479-0", + "open": true + }, + { + "tableName": "PB3480", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3480-0", + "open": true + }, + { + "tableName": "PB3481", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3481-0", + "open": true + }, + { + "tableName": "PB3482", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3482-0", + "open": true + }, + { + "tableName": "PB3483", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3483-0", + "open": true + }, + { + "tableName": "PB3484", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3484-0", + "open": true + }, + { + "tableName": "PB3485", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3485-0", + "open": true + }, + { + "tableName": "PB3486", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3486-0", + "open": true + }, + { + "tableName": "PB3487", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3487-0", + "open": true + }, + { + "tableName": "PB3488", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3488-0", + "open": true + }, + { + "tableName": "PB3489", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3489-0", + "open": true + }, + { + "tableName": "PB3490", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3490-0", + "open": true + }, + { + "tableName": "PB3491", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3491-0", + "open": true + }, + { + "tableName": "PB3492", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3492-0", + "open": true + }, + { + "tableName": "PB3493", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3493-0", + "open": true + }, + { + "tableName": "PB3494", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3494-0", + "open": true + }, + { + "tableName": "PB3495", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3495-0", + "open": true + }, + { + "tableName": "PB3496", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3496-0", + "open": true + }, + { + "tableName": "PB3497", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3497-0", + "open": true + }, + { + "tableName": "PB3498", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3498-0", + "open": true + }, + { + "tableName": "PB3499", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3499-0", + "open": true + }, + { + "tableName": "PB3500", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3500-0", + "open": true + }, + { + "tableName": "PB3501", + "casinoCode": "OCE", + "gameCode": "MBC", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 0.0000, + "maxBet": 0.0000, + "minBetByTable": "PB3501-0", + "open": true + }, + { + "tableName": "PB3502", + "casinoCode": "OCE", + "gameCode": "MNQ", + "casinoWin": 0.0000, + "totalDrop": null, + "headCounts": 0, + "minBet": 500.0000, + "maxBet": 10000000.0000, + "minBetByTable": "PB3502-500", + "open": true + } + ] + } + ] + } + ], + "previous": [], + "cache": false, + "currentCacheUpdatedAt": "2025-11-18T10:47:08.0957793" +} \ No newline at end of file diff --git a/src/main/resources/sql/create_detail_view_log_table.sql b/src/main/resources/sql/create_detail_view_log_table.sql new file mode 100644 index 0000000..529b790 --- /dev/null +++ b/src/main/resources/sql/create_detail_view_log_table.sql @@ -0,0 +1,13 @@ + + CREATE TABLE [dbo].[detail_view_log] ( + [log_id] BIGINT IDENTITY(1,1) NOT NULL, + [operation_type] NVARCHAR(200) NULL, + [username] NVARCHAR(100) NULL, + [member_no] NVARCHAR(50) NULL, + [detail_data] NVARCHAR(MAX) NULL, + [view_time] DATETIME2 NOT NULL, + [created_at] DATETIME2 NOT NULL DEFAULT GETDATE(), + CONSTRAINT [PK_detail_view_log] PRIMARY KEY CLUSTERED ([log_id] ASC) + ); + + diff --git a/src/main/resources/sql/create_query_log_table.sql b/src/main/resources/sql/create_query_log_table.sql new file mode 100644 index 0000000..130c799 --- /dev/null +++ b/src/main/resources/sql/create_query_log_table.sql @@ -0,0 +1,16 @@ + + CREATE TABLE [dbo].[query_log] ( + [log_id] BIGINT IDENTITY(1,1) NOT NULL, + [operation] NVARCHAR(200) NOT NULL, + [username] NVARCHAR(100) NULL, + [query_conditions] NVARCHAR(MAX) NULL, + [record_count] INT NULL, + [execution_time] BIGINT NULL, + [query_time] DATETIME2 NOT NULL, + [status] NVARCHAR(20) NOT NULL, + [error_message] NVARCHAR(MAX) NULL, + [created_at] DATETIME2 NOT NULL DEFAULT GETDATE(), + CONSTRAINT [PK_query_log] PRIMARY KEY CLUSTERED ([log_id] ASC) + ); + + diff --git a/src/test/java/com/sjmresorts/opsapp/logging/support/RegistrationCardMetaExtractorTest.java b/src/test/java/com/sjmresorts/opsapp/logging/support/RegistrationCardMetaExtractorTest.java new file mode 100644 index 0000000..eed2deb --- /dev/null +++ b/src/test/java/com/sjmresorts/opsapp/logging/support/RegistrationCardMetaExtractorTest.java @@ -0,0 +1,120 @@ +package com.sjmresorts.opsapp.logging.support; + +import com.sjmresorts.opsapp.esign.printer.dtos.RegistrationCardMeta; +import com.sjmresorts.opsapp.esign.printer.dtos.RegistrationCardMetaExtractor; +import org.junit.Assert; +import org.junit.Test; + +public class RegistrationCardMetaExtractorTest { + + @Test + public void extractOperaRoomNumberFromNextLine() { + String text = + "ORACLE HOSPITALITY REGISTRATION CARD\n" + + "Guest Name / 姓名\n" + + "Mr Tsz Wai CHAN\n" + + "Conf. No. / 确认号\n" + + "3033472\n" + + "STAY INFORMATION / 住宿资讯\n" + + "Arrival / 入住 02-05-26\n" + + "Departure / 退房 31-05-26\n" + + "Room Type / 房型\n" + + "DLXK\n" + + "Room Number / 房号\n" + + "0202\n" + + "Number of Adults / 人数\n" + + "2\n" + + "Rate / 房价\n" + + "1,578.00\n"; + + RegistrationCardMeta meta = RegistrationCardMetaExtractor.extractFromText(text); + Assert.assertEquals("0202", meta.getRoomNo()); + Assert.assertEquals("3033472", meta.getConfirmationNo()); + } + + @Test + public void extractRoyalParkConfirmationNumberFromSameLineLabel() { + String text = + "ROYAL PARK HOTEL Guest Registration\n" + + "Confirmation No. 訂房編號 317462715\n" + + "Room Type 房間類別 Superior Double\n" + + "Check-In Date 入住日期 16-08-2025\n"; + + RegistrationCardMeta meta = RegistrationCardMetaExtractor.extractFromText(text); + Assert.assertEquals("317462715", meta.getConfirmationNo()); + } + + @Test + public void extractOperaRoomNumber0232WithDatesAndRate() { + String text = + "ORACLE HOSPITALITY REGISTRATION CARD\n" + + "Conf. No. / 确认号\n" + + "3033472\n" + + "Arrival / 入住 02-05-26\n" + + "Departure / 退房 03-05-26\n" + + "Room Type / 房型\n" + + "DLXK\n" + + "Room Number / 房号\n" + + "0232\n" + + "Phone / 电话\n" + + "+853 2254 1234\n" + + "Rate / 房价\n" + + "2,254.00\n"; + + RegistrationCardMeta meta = RegistrationCardMetaExtractor.extractFromText(text); + Assert.assertEquals("0232", meta.getRoomNo()); + } + + @Test + public void roomNumberLabelBeforeDatesDoesNotGrabDateDigits() { + String text = + "Room Number / 房号\n" + + "Arrival / 入住 02-05-26\n" + + "Departure / 退房 03-05-26\n" + + "0232\n"; + + RegistrationCardMeta meta = RegistrationCardMetaExtractor.extractFromText(text); + Assert.assertEquals("0232", meta.getRoomNo()); + } + + @Test + public void ignoreRateAmountWhenNoRoomLabel() { + String text = + "STAY INFORMATION\n" + + "Room Type / 房型\n" + + "DLXK\n" + + "Rate / 房价\n" + + "78.00 MOP\n" + + "Room Number / 房号\n" + + "0202\n"; + + RegistrationCardMeta meta = RegistrationCardMetaExtractor.extractFromText(text); + Assert.assertEquals("0202", meta.getRoomNo()); + } + + @Test + public void mergedWithPrefersLongerRoomOverTwoDigitNoise() { + RegistrationCardMeta textMeta = + RegistrationCardMeta.builder().roomNo("78").confirmationNo("3033472").build(); + RegistrationCardMeta ocrMeta = + RegistrationCardMeta.builder().roomNo("0202").confirmationNo("3033472").build(); + + RegistrationCardMeta merged = textMeta.mergedWith(ocrMeta); + Assert.assertEquals("0202", merged.getRoomNo()); + } + + @Test + public void mergedWithPrefersLeadingZeroRoomOverPhoneFragment() { + RegistrationCardMeta textMeta = + RegistrationCardMeta.builder() + .roomNo("2254") + .confirmationNo("3033472") + .phone("+853 2254 1234") + .build(); + RegistrationCardMeta ocrMeta = + RegistrationCardMeta.builder().roomNo("0232").confirmationNo("3033472").build(); + + RegistrationCardMeta merged = textMeta.mergedWith(ocrMeta); + Assert.assertEquals("0232", merged.getRoomNo()); + } +}