From e77d0b1f4f829b613ee80cc4f553bbe017649b43 Mon Sep 17 00:00:00 2001 From: "Ray.Hao" <1490493387@qq.com> Date: Wed, 28 Oct 2026 08:25:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(oidc):=20=E7=BB=9F=E4=B8=80=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E8=BA=AB=E4=BB=BD=E5=85=B3=E8=81=94=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E4=BB=85=E6=9C=AC=E4=BA=BA=E5=8F=AF=E6=93=8D?= =?UTF-8?q?=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除身份关联接口的权限标识校验 - 增加当前用户校验,拒绝操作他人的身份关联 - 扩展脚本清理管理员代绑权限项 --- README.en.md | 13 +- README.md | 13 +- docs/README.md | 0 pom.xml | 50 +- sql/extensions/dynamic-form.sql | 76 ++- sql/extensions/oidc.sql | 38 +- sql/extensions/workflow.sql | 42 +- sql/youlai-admin.sql | 65 +-- .../oidc/config/ConditionalOnOidcEnabled.java | 22 + .../config/OidcClientRegistrationConfig.java | 48 ++ .../boot/auth/oidc/config/OidcProperties.java | 43 ++ .../auth/oidc/config/OidcSecurityConfig.java | 67 +++ .../oidc/controller/OidcAuthController.java | 44 ++ .../OidcUserIdentityController.java | 107 ++++ .../auth/oidc/handler/OidcLoginHandler.java | 110 +++++ .../auth/oidc/model/OidcTicketContext.java | 12 + .../model/form/OidcTicketExchangeForm.java | 20 + .../oidc/model/form/OidcUserIdentityForm.java | 23 + .../oidc/model/vo/OidcUserIdentityVO.java | 16 + .../auth/oidc/service/OidcLoginService.java | 70 +++ .../auth/oidc/service/OidcLogoutService.java | 81 +++ .../oidc/service/OidcSessionValidator.java | 38 ++ .../auth/oidc/service/OidcTicketService.java | 115 +++++ .../controller/PasswordAuthController.java | 12 +- .../password/service/PasswordAuthService.java | 17 +- .../controller/QrCodeAuthController.java | 4 +- .../model/context/QrCodeLoginContext.java | 4 +- .../qrcode/service/QrCodeLoginService.java | 95 ++-- .../controller/AuthSessionController.java | 45 +- .../session/service/AuthSessionService.java | 42 +- .../sms/controller/SmsAuthController.java | 12 +- .../boot/auth/sms/service/SmsAuthService.java | 14 +- .../boot/auth/wxma/config/WxMaProperties.java | 12 + .../wxma/controller/WxMaAuthController.java | 21 +- .../exception/MobileNotBoundException.java | 18 +- .../wxma/model/form/WxMaBindMobileForm.java | 8 +- .../boot/auth/wxma/model/vo/WxMaLoginVO.java | 9 +- .../provider/WxMaAuthenticationProvider.java | 47 +- .../auth/wxma/service/WxMaAuthService.java | 84 ++-- .../codegen/model/query/TablePageQuery.java | 29 -- .../boot/codegen/model/query/TableQuery.java | 4 +- .../service/impl/CodegenServiceImpl.java | 44 +- .../service/impl/GenTableServiceImpl.java | 4 +- .../youlai/boot/common/base/BaseQuery.java | 9 - .../common/constant/JwtClaimConstants.java | 29 -- .../boot/common/constant/RedisConstants.java | 26 +- .../boot/common/enums/SocialPlatformEnum.java | 33 -- .../youlai/boot/common/result/ResultCode.java | 39 +- .../com/youlai/boot/config/JacksonConfig.java | 10 +- .../boot/config/Knife4jOpenApiCustomizer.java | 150 ------ .../com/youlai/boot/config/OpenApiConfig.java | 2 +- .../youlai/boot/config/SecurityConfig.java | 49 +- .../boot/framework/mybatis/QuerySort.java | 53 ++ .../security/config/SecurityProperties.java | 75 +-- .../security/config/SessionConfig.java | 26 + .../filter/TokenAuthenticationFilter.java | 67 --- .../handler/JsonAuthenticationEntryPoint.java | 14 +- .../security/model/AuthenticationToken.java | 30 -- .../security/model/SecurityUser.java | 4 +- .../security/model/SecurityUserDetails.java | 9 +- .../framework/security/model/TokenPair.java | 29 ++ .../security/port/UserAuthenticationPort.java | 23 +- .../session/AuthenticatedSession.java | 27 + .../security/session/LoginContext.java | 54 ++ .../security/session/LoginSession.java | 11 + .../security/session/RedisSessionStore.java | 223 +++++++++ .../security/session/SessionPrincipal.java | 48 ++ .../security/session/SessionService.java | 247 ++++++++++ .../security/session/SessionValidator.java | 20 + .../security/token/AccessTokenCodec.java | 38 ++ .../security/token/JwtAccessTokenCodec.java | 96 ++++ .../security/token/JwtTokenManager.java | 299 ----------- .../token/OpaqueAccessTokenCodec.java | 48 ++ .../security/token/RedisTokenManager.java | 199 -------- .../security/token/SecureTokenGenerator.java | 51 ++ .../security/token/TokenManager.java | 72 --- .../security/util/SecurityUtils.java | 18 +- .../web/advice/GlobalExceptionHandler.java | 78 ++- .../web/advice/QueryBindingAdvice.java | 24 + .../framework/web/log/OperationLogAspect.java | 5 + .../web/ratelimit/SlidingWindowScript.java | 59 --- .../framework/web/util/ResponseWriter.java | 4 + .../form/controller/FormController.java | 27 +- .../module/form/mapper/FormDataMapper.java | 4 +- .../form/mapper/FormDefinitionMapper.java | 4 +- .../form/model/form/FormDefinitionForm.java | 26 +- ...mDataPageQuery.java => FormDataQuery.java} | 2 +- ...ageQuery.java => FormDefinitionQuery.java} | 2 +- .../module/form/service/FormDataService.java | 4 +- .../form/service/FormDefinitionService.java | 4 +- .../service/impl/FormDataServiceImpl.java | 4 +- .../impl/FormDefinitionServiceImpl.java | 18 +- .../WorkflowInstanceController.java | 4 +- .../controller/WorkflowModelController.java | 4 +- .../controller/WorkflowTaskController.java | 6 +- ...eQuery.java => WorkflowInstanceQuery.java} | 2 +- ...PageQuery.java => WorkflowModelQuery.java} | 2 +- ...kPageQuery.java => WorkflowTaskQuery.java} | 2 +- .../service/WorkflowInstanceService.java | 4 +- .../service/WorkflowModelService.java | 4 +- .../workflow/service/WorkflowTaskService.java | 6 +- .../impl/WorkflowInstanceServiceImpl.java | 4 +- .../impl/WorkflowModelServiceImpl.java | 4 +- .../service/impl/WorkflowTaskServiceImpl.java | 10 +- .../realtime/RealtimeMaintenanceTask.java | 60 +++ .../realtime/RealtimeMessageSender.java | 129 +++++ .../support/realtime/RealtimeService.java | 79 +++ .../support/realtime/RealtimeSession.java | 71 +++ .../realtime/RealtimeSessionManager.java | 404 +++++++++++++++ .../realtime/RealtimeWebSocketHandler.java | 66 +++ .../realtime/config/RealtimeConfig.java | 106 ++++ .../realtime/config/RealtimeProperties.java | 20 + .../controller/RealtimeController.java | 94 ++++ .../model/CreateRealtimeTicketRequest.java | 29 ++ .../realtime/model/PresenceSnapshot.java | 10 + .../model/RealtimeTicketResponse.java | 14 + .../protocol/RealtimeMessageCodec.java | 54 ++ .../protocol/RealtimeServerMessage.java | 18 + .../security/RealtimeCredentialVerifier.java | 39 ++ .../security/RealtimeHandshakeHandler.java | 45 ++ .../RealtimeHandshakeInterceptor.java | 93 ++++ .../security/RealtimeTicketService.java | 119 +++++ .../boot/support/sse/OnlineUserDTO.java | 32 -- .../youlai/boot/support/sse/SseService.java | 129 ----- .../boot/support/sse/SseSessionManager.java | 261 ---------- .../sse/constant/SseEventConstants.java | 19 - .../boot/support/sse/constant/SseTopics.java | 25 - .../security/UserAuthenticationAdapter.java | 16 +- .../system/controller/DictController.java | 41 -- .../system/controller/MenuController.java | 18 +- .../system/controller/NoticeController.java | 7 + .../system/controller/RoleController.java | 17 +- .../boot/system/controller/SseController.java | 46 -- .../system/controller/UserController.java | 18 +- .../boot/system/enums/MenuTypeEnum.java | 8 +- .../boot/system/event/DictChangedEvent.java | 10 + .../boot/system/event/NoticeChangedEvent.java | 10 + .../boot/system/mapper/NoticeMapper.java | 4 +- .../system/mapper/UserIdentityMapper.java | 34 ++ .../youlai/boot/system/mapper/UserMapper.java | 2 +- .../boot/system/mapper/UserNoticeMapper.java | 12 + .../boot/system/mapper/UserSocialMapper.java | 22 - .../boot/system/model/dto/DictChangeDTO.java | 39 -- .../system/model/entity/UserIdentity.java | 49 ++ .../boot/system/model/entity/UserSocial.java | 52 -- .../boot/system/model/form/RoleForm.java | 6 + .../boot/system/model/query/DeptQuery.java | 2 +- .../boot/system/model/query/UserQuery.java | 11 +- .../youlai/boot/system/model/vo/NoticeVO.java | 31 -- .../system/schedule/OnlineUserCountTask.java | 26 - .../boot/system/service/MenuService.java | 14 +- .../boot/system/service/NoticeService.java | 5 + .../boot/system/service/RoleService.java | 9 +- .../system/service/UserIdentityService.java | 57 +++ .../boot/system/service/UserRoleService.java | 6 +- .../system/service/UserSocialService.java | 78 --- .../system/service/impl/DeptServiceImpl.java | 5 +- .../service/impl/DictItemServiceImpl.java | 23 +- .../system/service/impl/DictServiceImpl.java | 25 +- .../system/service/impl/MenuServiceImpl.java | 59 +-- .../service/impl/NoticeServiceImpl.java | 76 ++- .../service/impl/RoleMenuServiceImpl.java | 122 +++-- .../system/service/impl/RoleServiceImpl.java | 41 +- .../service/impl/UserIdentityServiceImpl.java | 173 +++++++ .../service/impl/UserRoleServiceImpl.java | 111 ++--- .../system/service/impl/UserServiceImpl.java | 72 ++- .../service/impl/UserSocialServiceImpl.java | 112 ----- src/main/resources/application-dev.yml | 69 ++- src/main/resources/application-prod.yml | 32 +- .../codegen/backend/controller.java.vm | 2 +- .../resources/codegen/backend/entity.java.vm | 3 + .../resources/codegen/backend/form.java.vm | 20 +- .../resources/codegen/backend/mapper.xml.vm | 8 +- .../resources/codegen/backend/query.java.vm | 7 +- src/main/resources/codegen/backend/vo.java.vm | 8 +- .../codegen/frontend/ts/api-types.ts.vm | 28 +- .../resources/codegen/frontend/ts/api.ts.vm | 91 ++-- .../codegen/frontend/ts/index.crud.vue.vm | 465 ++++++------------ .../codegen/frontend/ts/index.vue.vm | 6 +- .../resources/mapper/system/LogMapper.xml | 2 +- .../resources/mapper/system/NoticeMapper.xml | 13 +- ...ocialMapper.xml => UserIdentityMapper.xml} | 2 +- .../resources/mapper/system/UserMapper.xml | 90 ++-- .../{ => architecture}/ArchitectureTest.java | 5 +- .../boot/{ => auth}/AuthControllerTest.java | 36 +- .../realtime/RealtimeControllerTest.java | 104 ++++ .../realtime/RealtimeMessageCodecTest.java | 48 ++ .../support/realtime/RealtimeServiceTest.java | 104 ++++ .../realtime/RealtimeSessionManagerTest.java | 214 ++++++++ .../realtime/RealtimeWebSocketTest.java | 282 +++++++++++ .../boot/validation/ValidationTest.java | 62 ++- 191 files changed, 5898 insertions(+), 3271 deletions(-) delete mode 100644 docs/README.md create mode 100644 src/main/java/com/youlai/boot/auth/oidc/config/ConditionalOnOidcEnabled.java create mode 100644 src/main/java/com/youlai/boot/auth/oidc/config/OidcClientRegistrationConfig.java create mode 100644 src/main/java/com/youlai/boot/auth/oidc/config/OidcProperties.java create mode 100644 src/main/java/com/youlai/boot/auth/oidc/config/OidcSecurityConfig.java create mode 100644 src/main/java/com/youlai/boot/auth/oidc/controller/OidcAuthController.java create mode 100644 src/main/java/com/youlai/boot/auth/oidc/controller/OidcUserIdentityController.java create mode 100644 src/main/java/com/youlai/boot/auth/oidc/handler/OidcLoginHandler.java create mode 100644 src/main/java/com/youlai/boot/auth/oidc/model/OidcTicketContext.java create mode 100644 src/main/java/com/youlai/boot/auth/oidc/model/form/OidcTicketExchangeForm.java create mode 100644 src/main/java/com/youlai/boot/auth/oidc/model/form/OidcUserIdentityForm.java create mode 100644 src/main/java/com/youlai/boot/auth/oidc/model/vo/OidcUserIdentityVO.java create mode 100644 src/main/java/com/youlai/boot/auth/oidc/service/OidcLoginService.java create mode 100644 src/main/java/com/youlai/boot/auth/oidc/service/OidcLogoutService.java create mode 100644 src/main/java/com/youlai/boot/auth/oidc/service/OidcSessionValidator.java create mode 100644 src/main/java/com/youlai/boot/auth/oidc/service/OidcTicketService.java delete mode 100644 src/main/java/com/youlai/boot/codegen/model/query/TablePageQuery.java delete mode 100644 src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java delete mode 100644 src/main/java/com/youlai/boot/common/enums/SocialPlatformEnum.java delete mode 100644 src/main/java/com/youlai/boot/config/Knife4jOpenApiCustomizer.java create mode 100644 src/main/java/com/youlai/boot/framework/mybatis/QuerySort.java create mode 100644 src/main/java/com/youlai/boot/framework/security/config/SessionConfig.java delete mode 100644 src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java delete mode 100644 src/main/java/com/youlai/boot/framework/security/model/AuthenticationToken.java create mode 100644 src/main/java/com/youlai/boot/framework/security/model/TokenPair.java create mode 100644 src/main/java/com/youlai/boot/framework/security/session/AuthenticatedSession.java create mode 100644 src/main/java/com/youlai/boot/framework/security/session/LoginContext.java create mode 100644 src/main/java/com/youlai/boot/framework/security/session/LoginSession.java create mode 100644 src/main/java/com/youlai/boot/framework/security/session/RedisSessionStore.java create mode 100644 src/main/java/com/youlai/boot/framework/security/session/SessionPrincipal.java create mode 100644 src/main/java/com/youlai/boot/framework/security/session/SessionService.java create mode 100644 src/main/java/com/youlai/boot/framework/security/session/SessionValidator.java create mode 100644 src/main/java/com/youlai/boot/framework/security/token/AccessTokenCodec.java create mode 100644 src/main/java/com/youlai/boot/framework/security/token/JwtAccessTokenCodec.java delete mode 100644 src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java create mode 100644 src/main/java/com/youlai/boot/framework/security/token/OpaqueAccessTokenCodec.java delete mode 100644 src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java create mode 100644 src/main/java/com/youlai/boot/framework/security/token/SecureTokenGenerator.java delete mode 100644 src/main/java/com/youlai/boot/framework/security/token/TokenManager.java create mode 100644 src/main/java/com/youlai/boot/framework/web/advice/QueryBindingAdvice.java delete mode 100644 src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java rename src/main/java/com/youlai/boot/module/form/model/query/{FormDataPageQuery.java => FormDataQuery.java} (88%) rename src/main/java/com/youlai/boot/module/form/model/query/{FormDefinitionPageQuery.java => FormDefinitionQuery.java} (92%) rename src/main/java/com/youlai/boot/module/workflow/model/query/{WorkflowInstancePageQuery.java => WorkflowInstanceQuery.java} (89%) rename src/main/java/com/youlai/boot/module/workflow/model/query/{WorkflowModelPageQuery.java => WorkflowModelQuery.java} (89%) rename src/main/java/com/youlai/boot/module/workflow/model/query/{WorkflowTaskPageQuery.java => WorkflowTaskQuery.java} (89%) create mode 100644 src/main/java/com/youlai/boot/support/realtime/RealtimeMaintenanceTask.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/RealtimeMessageSender.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/RealtimeService.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/RealtimeSession.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/RealtimeSessionManager.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/RealtimeWebSocketHandler.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/config/RealtimeConfig.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/config/RealtimeProperties.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/controller/RealtimeController.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/model/CreateRealtimeTicketRequest.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/model/PresenceSnapshot.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/model/RealtimeTicketResponse.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/protocol/RealtimeMessageCodec.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/protocol/RealtimeServerMessage.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/security/RealtimeCredentialVerifier.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/security/RealtimeHandshakeHandler.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/security/RealtimeHandshakeInterceptor.java create mode 100644 src/main/java/com/youlai/boot/support/realtime/security/RealtimeTicketService.java delete mode 100644 src/main/java/com/youlai/boot/support/sse/OnlineUserDTO.java delete mode 100644 src/main/java/com/youlai/boot/support/sse/SseService.java delete mode 100644 src/main/java/com/youlai/boot/support/sse/SseSessionManager.java delete mode 100644 src/main/java/com/youlai/boot/support/sse/constant/SseEventConstants.java delete mode 100644 src/main/java/com/youlai/boot/support/sse/constant/SseTopics.java delete mode 100644 src/main/java/com/youlai/boot/system/controller/SseController.java create mode 100644 src/main/java/com/youlai/boot/system/event/DictChangedEvent.java create mode 100644 src/main/java/com/youlai/boot/system/event/NoticeChangedEvent.java create mode 100644 src/main/java/com/youlai/boot/system/mapper/UserIdentityMapper.java delete mode 100644 src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java delete mode 100644 src/main/java/com/youlai/boot/system/model/dto/DictChangeDTO.java create mode 100644 src/main/java/com/youlai/boot/system/model/entity/UserIdentity.java delete mode 100644 src/main/java/com/youlai/boot/system/model/entity/UserSocial.java delete mode 100644 src/main/java/com/youlai/boot/system/model/vo/NoticeVO.java delete mode 100644 src/main/java/com/youlai/boot/system/schedule/OnlineUserCountTask.java create mode 100644 src/main/java/com/youlai/boot/system/service/UserIdentityService.java delete mode 100644 src/main/java/com/youlai/boot/system/service/UserSocialService.java create mode 100644 src/main/java/com/youlai/boot/system/service/impl/UserIdentityServiceImpl.java delete mode 100644 src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java rename src/main/resources/mapper/system/{UserSocialMapper.xml => UserIdentityMapper.xml} (95%) rename src/test/java/com/youlai/boot/{ => architecture}/ArchitectureTest.java (93%) rename src/test/java/com/youlai/boot/{ => auth}/AuthControllerTest.java (82%) create mode 100644 src/test/java/com/youlai/boot/support/realtime/RealtimeControllerTest.java create mode 100644 src/test/java/com/youlai/boot/support/realtime/RealtimeMessageCodecTest.java create mode 100644 src/test/java/com/youlai/boot/support/realtime/RealtimeServiceTest.java create mode 100644 src/test/java/com/youlai/boot/support/realtime/RealtimeSessionManagerTest.java create mode 100644 src/test/java/com/youlai/boot/support/realtime/RealtimeWebSocketTest.java diff --git a/README.en.md b/README.en.md index 8542c13e..18138d06 100644 --- a/README.en.md +++ b/README.en.md @@ -32,6 +32,8 @@ - 🔐 **Security** — Spring Security + JWT/Redis dual-session token model, token renewal, multi-device mutual exclusion +- 🔑 **Unified authentication (optional, disabled by default)** — [OIDC login](../docs/youlai-boot/oidc/implementation.md), delegates sign-in to an external identity provider and binds the authenticated account to a local user; nothing is loaded unless you enable it + - 🛡️ **Fine-grained permissions** — 5-level RBAC: data → menu → button → API → field - ⚡ **Code generator** — one-click generation of full-stack CRUD code @@ -42,7 +44,7 @@ - 🌐 **Multi-tenant SaaS** — data isolation + tenant config, with a standalone [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant) edition -- 🔌 **Real-time communication** — SSE push: online user count, dictionary sync, notification broadcast +- 🔌 **Real-time communication** — WebSocket: one-time tickets, node presence, dictionary and notice updates ## System Preview @@ -93,8 +95,13 @@ # Optional extensions: safe to skip, run after the base schema sql/extensions/dynamic-form.sql # Dynamic Form sql/extensions/workflow.sql # Workflow (Flowable demo processes & demo accounts, not engine tables) + sql/extensions/oidc.sql # Unified authentication: app center menu & demo identity binding ``` + > Every extension script is **optional**. Users, roles, menus, dictionaries, logs and the code generator all live in `sql/youlai-admin.sql`; importing the base schema alone is enough to run the system. + > + > `oidc.sql` targets deployments that already run an identity provider: it only writes the "App Center" menu and one demo identity binding. Single sign-on is disabled by default (`security.oidc.enabled` plus the frontend `VITE_OIDC_ENABLED`); without an issuer, client secret and frontend switch no OIDC component is loaded, so the base features are unaffected. Skip it if you do not need single sign-on. + 3. Adjust config (optional, a read-only online data source is configured by default): ```bash @@ -112,7 +119,7 @@ mvn spring-boot:run ``` - Once started, visit . If the API docs open, the backend is running properly. + Once started, visit . If the API docs open, the backend is running properly. 5. Start the frontend (optional): For a visual admin UI, start the companion frontend [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin), visit , and log in with `admin` / `123456`. @@ -154,7 +161,7 @@ youlai-boot/ │ │ ├── file/ # File storage (RustFS/Alibaba Cloud OSS/local) │ │ ├── mail/ # Email service │ │ ├── sms/ # SMS service -│ │ └── sse/ # SSE push +│ │ └── realtime/ # WebSocket communication │ └── system/ # System business (user/role/menu/dept/dict etc.) ├── docker-compose.yml # One-click middleware orchestration (MySQL/Redis/RustFS/Xxl-Job) └── pom.xml # Maven dependency management diff --git a/README.md b/README.md index 5069b361..c45f66e2 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ - 🔐 **安全体系** — Spring Security + JWT/Redis Token 双会话模式、令牌续期、多端互斥 +- 🔑 **统一认证(可选,默认关闭)** — [OIDC 登录](../docs/youlai-boot/oidc/implementation.md),把登录交给外部认证中心,认证成功后关联本地用户并复用角色权限;不启用就不会加载相关组件 + - 🛡️ **细粒度权限** — RBAC 五级:数据 → 菜单 → 按钮 → 接口 → 字段 - ⚡ **代码生成器** — 一键生成前后端 CRUD 代码 @@ -42,7 +44,7 @@ - 🌐 **多租户 SaaS** — 数据隔离 + 租户配置,独立 [youlai-boot-tenant](https://gitee.com/youlaiorg/youlai-boot-tenant) 版本 -- 🔌 **实时通信** — SSE 推送:在线用户数、字典同步、通知广播 +- 🔌 **实时通信** — [WebSocket 接入](../docs/youlai-boot/realtime.md):一次性票据鉴权、节点在线人数、字典同步、通知提示 ## 系统预览 @@ -93,8 +95,13 @@ # 可选扩展:不装不影响基础功能,需在基础库之后执行 sql/extensions/dynamic-form.sql # 动态表单 sql/extensions/workflow.sql # 工作流(Flowable 演示流程与演示账号,非引擎表) + sql/extensions/oidc.sql # 统一认证:应用中心菜单与演示身份关联 ``` + > 扩展脚本全部**可选**。用户、角色、菜单、字典、日志、代码生成等基础能力都在 `sql/youlai-admin.sql` 里,只导入基础库就能把系统跑起来。 + > + > `oidc.sql` 面向"已经有认证中心"的场景,它只写入「应用中心」菜单和一条演示用的身份关联。单点登录本身默认关闭(`security.oidc.enabled` 与前端 `VITE_OIDC_ENABLED`),不填认证中心地址、客户端密钥和前端开关就不会加载 OIDC 组件,对基础功能零影响。暂时没有单点登录需求,跳过它即可。 + 3. 修改配置(可选,默认已配置线上只读数据源): ```bash @@ -112,7 +119,7 @@ mvn spring-boot:run ``` - 启动后访问 ,能打开接口文档即说明后端已正常运行。 + 启动后访问 ,能打开接口文档即说明后端已正常运行。 5. 启动前端(可选): 如需可视化操作界面,启动配套前端 [vue3-element-admin](https://gitee.com/youlaiorg/vue3-element-admin),访问 ,使用 `admin` / `123456` 登录。 @@ -156,7 +163,7 @@ youlai-boot/ │ │ ├── file/ # 文件存储(RustFS/阿里云OSS/本地) │ │ ├── mail/ # 邮件服务 │ │ ├── sms/ # 短信服务 -│ │ └── sse/ # SSE 消息推送 +│ │ └── realtime/ # WebSocket 实时通信 │ └── system/ # 系统业务(用户/角色/菜单/部门/字典等) ├── docker-compose.yml # 中间件一键编排(MySQL/Redis/RustFS/Xxl-Job) └── pom.xml # Maven 依赖管理 diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index e69de29b..00000000 diff --git a/pom.xml b/pom.xml index 47ed810b..54182db8 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.youlai youlai-boot - 4.12.0 + 4.14.0 基于 Java 17 + SpringBoot 4 + Spring Security 构建的权限管理系统。 @@ -19,6 +19,7 @@ 17 17 + external 5.8.47 @@ -28,8 +29,7 @@ 1.2.24 3.5.15 - 4.5.0 - 2.8.9 + 3.1.1 1.6.3 0.2.0 @@ -118,6 +118,11 @@ spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-websocket + + org.springframework.boot spring-boot-starter-test @@ -138,11 +143,29 @@ test + + + com.h2database + h2 + test + + org.springframework.boot spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-security-oauth2-client + + + + org.springframework.boot + spring-boot-starter-security-oauth2-resource-server + + org.springframework.boot spring-boot-starter-data-redis @@ -204,18 +227,7 @@ ${mybatis-plus.version} - - - com.github.xiaoymin - knife4j-openapi3-jakarta-spring-boot-starter - ${knife4j.version} - - - org.springdoc - springdoc-openapi-starter-webmvc-ui - - - + org.springdoc springdoc-openapi-starter-webmvc-ui @@ -329,6 +341,14 @@ ${project.artifactId} + + org.apache.maven.plugins + maven-surefire-plugin + + + ${excludedGroups} + + org.springframework.boot spring-boot-maven-plugin diff --git a/sql/extensions/dynamic-form.sql b/sql/extensions/dynamic-form.sql index 7e5d7f4f..e7d565b7 100644 --- a/sql/extensions/dynamic-form.sql +++ b/sql/extensions/dynamic-form.sql @@ -1,10 +1,7 @@ -# 动态表单模块(可选扩展) -# Copyright (c) 2026-present, youlai.tech +-- 动态表单模块扩展:建表、菜单与示例数据,可重复执行 +-- 需先执行 ../youlai-admin.sql +-- Copyright (c) 2026-present, youlai.tech --- ---------------------------------------------------------------------------- --- 动态表单模块扩展脚本(全量执行,可重复运行) --- 前置:请先执行 ../youlai-admin.sql(本脚本向 sys_menu/sys_role_menu 插入数据) --- ---------------------------------------------------------------------------- USE youlai_admin; SET NAMES utf8mb4; @@ -57,9 +54,8 @@ CREATE TABLE `form_data` ( -- ---------------------------- -- 表结构:form_snapshot -- ---------------------------- --- 版本快照表:发布时固化规则,数据回显按提交时版本加载, --- 防止表单定义变更(字段删除/改名)导致历史数据回显漂移; --- 快照只增不改,update_time 仅因实体继承 BaseEntity 保留列,无业务语义 +-- 发布时固化规则,回显按提交时的版本加载,定义变更不影响历史数据 +-- 只增不改;update_time 由实体继承 BaseEntity 带入,无业务语义 DROP TABLE IF EXISTS `form_snapshot`; CREATE TABLE `form_snapshot` ( `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', @@ -75,52 +71,48 @@ CREATE TABLE `form_snapshot` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='表单版本快照表'; -- ---------------------------- --- 菜单(扩展段顶级 ID=8,基础段 1-7 见 youlai-admin.sql) --- 顶级目录 route_path 为 /dynamic-form,子菜单 component 指向 views/dynamic-form 下的视图且 route_path 只填当前段 +-- 菜单(顶级 ID=90;子菜单 route_path 只填当前段,component 指向 views/dynamic-form 下的视图) +-- 顶级菜单 params 中的 badge 是侧边栏角标 -- ---------------------------- --- params 携 {"badge":"NEW"}:挂顶级"动态表单"一级可见(二级折叠态角标不可见),宣传期过后清空 params 即消失 -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (8, 0, '0', '动态表单', 'C', '', '/dynamic-form', 'Layout', NULL, NULL, 1, 8, 'el-icon-Document', '/dynamic-form/definition', now(), now(), '{\"badge\":\"NEW\"}'); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (801, 8, '0,8', '表单列表', 'M', 'FormDefinition', 'definition', 'dynamic-form/index', NULL, 1, 1, 1, 'el-icon-List', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80101, 801, '0,8,801', '表单查询', 'B', NULL, '', NULL, 'form:definition:list', NULL, 1, 1, '', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80102, 801, '0,8,801', '表单新增', 'B', NULL, '', NULL, 'form:definition:create', NULL, 1, 2, '', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80103, 801, '0,8,801', '表单修改', 'B', NULL, '', NULL, 'form:definition:update', NULL, 1, 3, '', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80104, 801, '0,8,801', '表单删除', 'B', NULL, '', NULL, 'form:definition:delete', NULL, 1, 4, '', NULL, now(), now(), NULL); --- 表单设计器(隐藏菜单,visible=0,列表"设计"按钮携 id 跳转) -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (803, 8, '0,8', '表单设计器', 'M', 'FormDesigner', 'designer', 'dynamic-form/designer', NULL, 1, 0, 3, '', NULL, now(), now(), NULL); --- 表单预览(隐藏菜单,visible=0,设计器"预览"按钮携 id 跳转,运行态真预览) -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (804, 8, '0,8', '表单预览', 'M', 'FormPreview', 'preview', 'dynamic-form/preview', NULL, 1, 0, 4, '', NULL, now(), now(), NULL); --- 表单数据(隐藏菜单,visible=0,列表"数据"按钮携 formKey 跳转) -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (802, 8, '0,8', '表单数据', 'M', 'FormData', 'data', 'dynamic-form/data', NULL, 1, 0, 2, '', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80201, 802, '0,8,802', '数据查询', 'B', NULL, '', NULL, 'form:data:list', NULL, 1, 1, '', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80202, 802, '0,8,802', '数据删除', 'B', NULL, '', NULL, 'form:data:delete', NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (90, 0, '0', '动态表单', 'C', '', '/dynamic-form', 'Layout', NULL, NULL, 1, 8, 'el-icon-Document', '/dynamic-form/definition', now(), now(), '{\"badge\":\"NEW\"}'); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (9001, 90, '0,90', '表单列表', 'M', 'FormDefinition', 'definition', 'dynamic-form/index', NULL, 1, 1, 1, 'el-icon-List', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (900101, 9001, '0,90,9001', '表单查询', 'B', NULL, '', NULL, 'form:definition:list', NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (900102, 9001, '0,90,9001', '表单新增', 'B', NULL, '', NULL, 'form:definition:create', NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (900103, 9001, '0,90,9001', '表单修改', 'B', NULL, '', NULL, 'form:definition:update', NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (900104, 9001, '0,90,9001', '表单删除', 'B', NULL, '', NULL, 'form:definition:delete', NULL, 1, 4, '', NULL, now(), now(), NULL); +-- 表单设计器:隐藏菜单,列表「设计」按钮带 id 跳转 +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (9003, 90, '0,90', '表单设计器', 'M', 'FormDesigner', 'designer', 'dynamic-form/designer', NULL, 1, 0, 3, '', NULL, now(), now(), NULL); +-- 表单预览:隐藏菜单,设计器「预览」按钮带 id 跳转 +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (9004, 90, '0,90', '表单预览', 'M', 'FormPreview', 'preview', 'dynamic-form/preview', NULL, 1, 0, 4, '', NULL, now(), now(), NULL); +-- 表单数据:隐藏菜单,列表「数据」按钮带 formKey 跳转 +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (9002, 90, '0,90', '表单数据', 'M', 'FormData', 'data', 'dynamic-form/data', NULL, 1, 0, 2, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (900201, 9002, '0,90,9002', '数据查询', 'B', NULL, '', NULL, 'form:data:list', NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (900202, 9002, '0,90,9002', '数据删除', 'B', NULL, '', NULL, 'form:data:delete', NULL, 1, 2, '', NULL, now(), now(), NULL); -- ---------------------------- --- 示例表单入口(预置双入口:内嵌菜单 + 公开外链;与 saveFormMenu 生成的结构完全一致—— --- type=M、component=dynamic-form/render、route_path=formKey、params 携 formKey、 --- route_name 遵循 FormRender+驼峰(formKey) 全局唯一约束,防发布向导重复生成) +-- 示例表单入口,结构与 saveFormMenu 生成的菜单一致(type=M、component=dynamic-form/render、 +-- route_path=formKey、params 带 formKey、route_name 为 FormRender+驼峰 formKey) -- ---------------------------- -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (805, 8, '0,8', '表单示例', 'C', '', 'sample', 'Layout', NULL, NULL, 1, 5, 'el-icon-Files', '/dynamic-form/sample/open_source_survey', now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80501, 805, '0,8,805', '开源项目问卷收集', 'M', 'FormRenderOpenSourceSurvey', 'open_source_survey', 'dynamic-form/render', NULL, NULL, 1, 1, 'el-icon-Tickets', NULL, now(), now(), '{"formKey":"open_source_survey"}'); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80502, 805, '0,8,805', '活动报名表', 'M', 'FormRenderActivityRegistration', 'activity_registration', 'dynamic-form/render', NULL, NULL, 1, 2, 'el-icon-Calendar', NULL, now(), now(), '{"formKey":"activity_registration"}'); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (80503, 805, '0,8,805', '意见反馈', 'M', 'FormRenderFeedback', 'feedback', 'dynamic-form/render', NULL, NULL, 1, 3, 'el-icon-ChatLineRound', NULL, now(), now(), '{"formKey":"feedback"}'); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (9005, 90, '0,90', '表单示例', 'C', '', 'sample', 'Layout', NULL, NULL, 1, 5, 'el-icon-Files', '/dynamic-form/sample/open_source_survey', now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (900501, 9005, '0,90,9005', '开源项目问卷收集', 'M', 'FormRenderOpenSourceSurvey', 'open_source_survey', 'dynamic-form/render', NULL, NULL, 1, 1, 'el-icon-Tickets', NULL, now(), now(), '{"formKey":"open_source_survey"}'); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (900502, 9005, '0,90,9005', '活动报名表', 'M', 'FormRenderActivityRegistration', 'activity_registration', 'dynamic-form/render', NULL, NULL, 1, 2, 'el-icon-Calendar', NULL, now(), now(), '{"formKey":"activity_registration"}'); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (900503, 9005, '0,90,9005', '意见反馈', 'M', 'FormRenderFeedback', 'feedback', 'dynamic-form/render', NULL, NULL, 1, 3, 'el-icon-ChatLineRound', NULL, now(), now(), '{"formKey":"feedback"}'); -- ---------------------------- -- 系统管理员角色授权(role_id=2) -- ---------------------------- -INSERT IGNORE INTO `sys_role_menu` VALUES (2, 8), (2, 801), (2, 80101), (2, 80102), (2, 80103), (2, 80104), (2, 802), (2, 80201), (2, 80202), (2, 803), (2, 804), (2, 805), (2, 80501), (2, 80502), (2, 80503); +INSERT IGNORE INTO `sys_role_menu` VALUES (2, 90), (2, 9001), (2, 900101), (2, 900102), (2, 900103), (2, 900104), (2, 9002), (2, 900201), (2, 900202), (2, 9003), (2, 9004), (2, 9005), (2, 900501), (2, 900502), (2, 900503); -- ---------------------------- --- 预置示例表单(开箱即用,覆盖常用组件类型;ID 段 1001-1003,避开用户自建的自增 ID) --- 已发布(status=1)+ 双入口预置: --- 内嵌:菜单 80501/80502/80503(动态表单 > 表单示例),menu_id 已回填,发布向导"入口管理"可直接改挂载/角色 --- 外链:is_public=1,匿名访问 /f/{form_key}(问卷/报名/反馈天然适合对外收集) +-- 示例表单:已发布,同时挂了内嵌菜单(900501-900503)与匿名外链 /f/{form_key} +-- ID 固定 1001-1003,避开用户自建的自增段 -- ---------------------------- INSERT IGNORE INTO `form_definition` (`id`, `form_key`, `form_name`, `description`, `form_json`, `options_json`, `status`, `is_public`, `menu_id`, `version`, `create_time`, `update_time`, `create_by`, `update_by`, `is_deleted`) VALUES -(1001, 'open_source_survey', '开源项目问卷收集', '面向社区的开源项目推荐与信息收集问卷', '[{"type":"input","field":"project_name","title":"项目名称","info":"","props":{"placeholder":"如 vue3-element-admin"},"validate":[{"required":true,"message":"请输入项目名称"}]},{"type":"input","field":"repo_url","title":"项目地址","info":"","props":{"placeholder":"https://github.com/xxx/xxx"},"validate":[{"required":true,"message":"请输入项目地址"}]},{"type":"select","field":"category","title":"项目领域","info":"","props":{},"options":[{"label":"前端","value":"frontend"},{"label":"后端","value":"backend"},{"label":"移动端","value":"mobile"},{"label":"AI 与大模型","value":"ai"},{"label":"开发工具","value":"tools"},{"label":"其他","value":"other"}],"validate":[{"required":true,"message":"请选择项目领域"}]},{"type":"radio","field":"license","title":"开源协议","info":"","options":[{"label":"MIT","value":"MIT"},{"label":"Apache-2.0","value":"Apache-2.0"},{"label":"GPLv3","value":"GPLv3"},{"label":"其他","value":"other"}]},{"type":"select","field":"stars","title":"Star 数量级","info":"","props":{},"options":[{"label":"100 以内","value":"lt100"},{"label":"100 ~ 1k","value":"100to1k"},{"label":"1k ~ 10k","value":"1kto10k"},{"label":"10k 以上","value":"gt10k"}]},{"type":"textarea","field":"recommend","title":"推荐理由","info":"","props":{"placeholder":"一句话说说它解决了什么问题"},"validate":[{"required":true,"message":"请填写推荐理由"}]},{"type":"input","field":"contact","title":"联系邮箱","info":"","props":{"placeholder":"选填,便于回访"}}]', '{"labelPosition":"right","size":"default"}', 1, 1, 80501, 1, now(), now(), NULL, NULL, 0), -(1002, 'activity_registration', '活动报名表', '线下活动/聚会报名登记,含场次选择与用餐需求', '[{"type":"input","field":"name","title":"姓名","info":"","props":{},"validate":[{"required":true,"message":"请输入姓名"}]},{"type":"input","field":"phone","title":"手机号码","info":"","props":{"placeholder":"用于接收活动通知"},"validate":[{"required":true,"message":"请输入手机号码"}]},{"type":"radio","field":"gender","title":"性别","info":"","options":[{"label":"男","value":"male"},{"label":"女","value":"female"}]},{"type":"checkbox","field":"sessions","title":"参加场次","info":"","options":[{"label":"主论坛","value":"main"},{"label":"工作坊 A","value":"workshop_a"},{"label":"工作坊 B","value":"workshop_b"},{"label":"晚间交流","value":"evening"}]},{"type":"inputNumber","field":"attendees","title":"同行人数","info":"","props":{"min":0,"max":10}},{"type":"radio","field":"dinner","title":"用餐需求","info":"","options":[{"label":"正常","value":"normal"},{"label":"素食","value":"vegetarian"},{"label":"无需用餐","value":"none"}]},{"type":"textarea","field":"remark","title":"备注","info":"","props":{"placeholder":"选填"}}]', '{"labelPosition":"right","size":"default"}', 1, 1, 80502, 1, now(), now(), NULL, NULL, 0), -(1003, 'feedback', '意见反馈', '产品/系统通用意见反馈表,含满意度评分', '[{"type":"select","field":"type","title":"反馈类型","info":"","props":{},"options":[{"label":"功能建议","value":"feature"},{"label":"问题报告","value":"bug"},{"label":"体验吐槽","value":"ux"},{"label":"其他","value":"other"}],"validate":[{"required":true,"message":"请选择反馈类型"}]},{"type":"rate","field":"rating","title":"整体满意度","info":"","props":{}},{"type":"textarea","field":"content","title":"反馈内容","info":"","props":{"placeholder":"请具体描述你的建议或遇到的问题"},"validate":[{"required":true,"message":"请填写反馈内容"}]},{"type":"input","field":"contact","title":"联系方式","info":"","props":{"placeholder":"选填,便于跟进回复"}}]', '{"labelPosition":"right","size":"default"}', 1, 1, 80503, 1, now(), now(), NULL, NULL, 0); +(1001, 'open_source_survey', '开源项目问卷收集', '面向社区的开源项目推荐与信息收集问卷', '[{"type":"input","field":"project_name","title":"项目名称","info":"","props":{"placeholder":"如 vue3-element-admin"},"validate":[{"required":true,"message":"请输入项目名称"}]},{"type":"input","field":"repo_url","title":"项目地址","info":"","props":{"placeholder":"https://github.com/xxx/xxx"},"validate":[{"required":true,"message":"请输入项目地址"}]},{"type":"select","field":"category","title":"项目领域","info":"","props":{},"options":[{"label":"前端","value":"frontend"},{"label":"后端","value":"backend"},{"label":"移动端","value":"mobile"},{"label":"AI 与大模型","value":"ai"},{"label":"开发工具","value":"tools"},{"label":"其他","value":"other"}],"validate":[{"required":true,"message":"请选择项目领域"}]},{"type":"radio","field":"license","title":"开源协议","info":"","options":[{"label":"MIT","value":"MIT"},{"label":"Apache-2.0","value":"Apache-2.0"},{"label":"GPLv3","value":"GPLv3"},{"label":"其他","value":"other"}]},{"type":"select","field":"stars","title":"Star 数量级","info":"","props":{},"options":[{"label":"100 以内","value":"lt100"},{"label":"100 ~ 1k","value":"100to1k"},{"label":"1k ~ 10k","value":"1kto10k"},{"label":"10k 以上","value":"gt10k"}]},{"type":"textarea","field":"recommend","title":"推荐理由","info":"","props":{"placeholder":"一句话说说它解决了什么问题"},"validate":[{"required":true,"message":"请填写推荐理由"}]},{"type":"input","field":"contact","title":"联系邮箱","info":"","props":{"placeholder":"选填,便于回访"}}]', '{"labelPosition":"right","size":"default"}', 1, 1, 900501, 1, now(), now(), NULL, NULL, 0), +(1002, 'activity_registration', '活动报名表', '线下活动/聚会报名登记,含场次选择与用餐需求', '[{"type":"input","field":"name","title":"姓名","info":"","props":{},"validate":[{"required":true,"message":"请输入姓名"}]},{"type":"input","field":"phone","title":"手机号码","info":"","props":{"placeholder":"用于接收活动通知"},"validate":[{"required":true,"message":"请输入手机号码"}]},{"type":"radio","field":"gender","title":"性别","info":"","options":[{"label":"男","value":"male"},{"label":"女","value":"female"}]},{"type":"checkbox","field":"sessions","title":"参加场次","info":"","options":[{"label":"主论坛","value":"main"},{"label":"工作坊 A","value":"workshop_a"},{"label":"工作坊 B","value":"workshop_b"},{"label":"晚间交流","value":"evening"}]},{"type":"inputNumber","field":"attendees","title":"同行人数","info":"","props":{"min":0,"max":10}},{"type":"radio","field":"dinner","title":"用餐需求","info":"","options":[{"label":"正常","value":"normal"},{"label":"素食","value":"vegetarian"},{"label":"无需用餐","value":"none"}]},{"type":"textarea","field":"remark","title":"备注","info":"","props":{"placeholder":"选填"}}]', '{"labelPosition":"right","size":"default"}', 1, 1, 900502, 1, now(), now(), NULL, NULL, 0), +(1003, 'feedback', '意见反馈', '产品/系统通用意见反馈表,含满意度评分', '[{"type":"select","field":"type","title":"反馈类型","info":"","props":{},"options":[{"label":"功能建议","value":"feature"},{"label":"问题报告","value":"bug"},{"label":"体验吐槽","value":"ux"},{"label":"其他","value":"other"}],"validate":[{"required":true,"message":"请选择反馈类型"}]},{"type":"rate","field":"rating","title":"整体满意度","info":"","props":{}},{"type":"textarea","field":"content","title":"反馈内容","info":"","props":{"placeholder":"请具体描述你的建议或遇到的问题"},"validate":[{"required":true,"message":"请填写反馈内容"}]},{"type":"input","field":"contact","title":"联系方式","info":"","props":{"placeholder":"选填,便于跟进回复"}}]', '{"labelPosition":"right","size":"default"}', 1, 1, 900503, 1, now(), now(), NULL, NULL, 0); --- 预置表单版本快照(已发布语义 = 固化 v1 规则,防止后续编辑定义导致历史数据回显漂移) +-- 示例表单的 v1 快照 INSERT IGNORE INTO `form_snapshot` (`id`, `form_id`, `version`, `form_json`, `options_json`, `create_time`, `update_time`, `is_deleted`) VALUES (1001, 1001, 1, (SELECT `form_json` FROM `form_definition` WHERE `id` = 1001), (SELECT `options_json` FROM `form_definition` WHERE `id` = 1001), now(), now(), 0), (1002, 1002, 1, (SELECT `form_json` FROM `form_definition` WHERE `id` = 1002), (SELECT `options_json` FROM `form_definition` WHERE `id` = 1002), now(), now(), 0), diff --git a/sql/extensions/oidc.sql b/sql/extensions/oidc.sql index 4c5756e6..d81d837c 100644 --- a/sql/extensions/oidc.sql +++ b/sql/extensions/oidc.sql @@ -1,33 +1,19 @@ --- 统一认证(OIDC)扩展:菜单、权限与演示账号绑定 --- 需先执行 sql/youlai-admin.sql;本脚本手动执行,不随应用启动 --- 菜单 ID:模块顶级 92;补在基础菜单下的按钮取父 ID 加 90 起(101 → 10190) +-- 统一认证扩展:应用中心入口与演示身份关联。 +-- 先执行 sql/youlai-admin.sql;provider 必须与 security.oidc.provider 一致。 +-- 身份绑定由用户在个人中心自助完成,不设管理权限:开关由 security.oidc 控制。 --- ---------------------------- --- 应用中心(顶级菜单,全员可见) --- ---------------------------- INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (92, 0, '0', '应用中心', 'M', 'AppCenter', '/apps', 'workspace/apps/index', NULL, 1, 1, 10, 'el-icon-Grid', NULL, now(), now(), NULL); --- ---------------------------- --- 用户管理页的账号绑定按钮(挂在基础菜单 101 下) --- ---------------------------- -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES - (10190, 101, '0,1,101', '绑定查询', 'B', NULL, '', NULL, 'sso:binding:list', NULL, 1, 8, '', NULL, now(), now(), NULL), - (10191, 101, '0,1,101', '新增绑定', 'B', NULL, '', NULL, 'sso:binding:create', NULL, 1, 9, '', NULL, now(), now(), NULL), - (10192, 101, '0,1,101', '改绑', 'B', NULL, '', NULL, 'sso:binding:rebind', NULL, 1, 10, '', NULL, now(), now(), NULL), - (10193, 101, '0,1,101', '解除绑定', 'B', NULL, '', NULL, 'sso:binding:delete', NULL, 1, 11, '', NULL, now(), now(), NULL); +-- 身份绑定改为个人中心自助,清理早期脚本建立的管理员代绑权限。 +DELETE FROM `sys_role_menu` WHERE `menu_id` IN (10190, 10191, 10192, 10193); +DELETE FROM `sys_menu` WHERE `id` IN (10190, 10191, 10192, 10193); --- ---------------------------- --- 角色授权 --- 应用中心全员可见(基础角色 2-7;ROOT 由代码全量放行) --- 账号绑定按钮仅系统管理员(role_id=2) --- ---------------------------- +-- 应用中心沿用演示角色授权。 INSERT IGNORE INTO `sys_role_menu` VALUES (2, 92), (3, 92), (4, 92), (5, 92), (6, 92), (7, 92); -INSERT IGNORE INTO `sys_role_menu` VALUES (2, 10190), (2, 10191), (2, 10192), (2, 10193); --- ---------------------------- --- 演示账号绑定:认证中心 youlai realm 的 demo@youlai.tech 绑到本地 youlai 账号 --- external_id 是认证中心签发的 sub,重建该用户或换 realm 后会变,届时按新 ID Token 的 sub 改这一行 --- ---------------------------- -INSERT IGNORE INTO `sys_user_identity` (`platform`, `issuer`, `external_id`, `user_id`, `bind_method`, `create_time`, `update_time`) -VALUES ('OIDC', 'https://auth.youlai.tech/realms/youlai', '33c85d2f-6c6c-40e1-a8ec-85612afc2bf5', 1, 'IMPORT', now(), now()); +-- 保留演示关联:当前演示认证中心签发的 sub 对应本地 youlai 用户。 +-- issuer 在 security.oidc 中配置;更换认证中心或重建外部用户后按实际 sub 更新。 +INSERT IGNORE INTO `sys_user_identity` + (`provider`, `provider_account_id`, `user_id`, `display_name`, `create_time`, `update_time`) +VALUES ('oidc-main', '33c85d2f-6c6c-40e1-a8ec-85612afc2bf5', 1, 'demo@youlai.tech', now(), now()); diff --git a/sql/extensions/workflow.sql b/sql/extensions/workflow.sql index 279e223d..c3f12f83 100644 --- a/sql/extensions/workflow.sql +++ b/sql/extensions/workflow.sql @@ -13,31 +13,31 @@ SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- ---------------------------- --- 菜单(顶级 ID=9,顺延动态表单 ID=8) +-- 菜单(顶级 ID=91,顺延动态表单 ID=90) -- ---------------------------- -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (9, 0, '0', '工作流', 'C', '', '/workflow', 'Layout', NULL, NULL, 1, 9, 'el-icon-Connection', '/workflow/launch', now(), now(), '{\"badge\":\"NEW\"}'); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (901, 9, '0,9', '流程设计', 'M', 'WorkflowModel', 'model', 'workflow/model', NULL, NULL, 1, 1, 'el-icon-Coin', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (90101, 901, '0,9,901', '流程查询', 'B', NULL, '', NULL, 'workflow:model:list', NULL, 1, 1, '', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (90102, 901, '0,9,901', '流程新增', 'B', NULL, '', NULL, 'workflow:model:create', NULL, 1, 2, '', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (90103, 901, '0,9,901', '流程修改', 'B', NULL, '', NULL, 'workflow:model:update', NULL, 1, 3, '', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (90104, 901, '0,9,901', '流程删除', 'B', NULL, '', NULL, 'workflow:model:delete', NULL, 1, 4, '', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (90105, 901, '0,9,901', '流程发布', 'B', NULL, '', NULL, 'workflow:model:deploy', NULL, 1, 5, '', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (90106, 901, '0,9,901', '流程重置', 'B', NULL, '', NULL, 'workflow:model:reset', NULL, 1, 6, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (91, 0, '0', '工作流', 'C', '', '/workflow', 'Layout', NULL, NULL, 1, 9, 'el-icon-Connection', '/workflow/launch', now(), now(), '{\"badge\":\"NEW\"}'); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (9101, 91, '0,91', '流程设计', 'M', 'WorkflowModel', 'model', 'workflow/model', NULL, NULL, 1, 1, 'el-icon-Coin', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (910101, 9101, '0,91,9101', '流程查询', 'B', NULL, '', NULL, 'workflow:model:list', NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (910102, 9101, '0,91,9101', '流程新增', 'B', NULL, '', NULL, 'workflow:model:create', NULL, 1, 2, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (910103, 9101, '0,91,9101', '流程修改', 'B', NULL, '', NULL, 'workflow:model:update', NULL, 1, 3, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (910104, 9101, '0,91,9101', '流程删除', 'B', NULL, '', NULL, 'workflow:model:delete', NULL, 1, 4, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (910105, 9101, '0,91,9101', '流程发布', 'B', NULL, '', NULL, 'workflow:model:deploy', NULL, 1, 5, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (910106, 9101, '0,91,9101', '流程重置', 'B', NULL, '', NULL, 'workflow:model:reset', NULL, 1, 6, '', NULL, now(), now(), NULL); -- 流程启停:启用开关走 definition:update,删除复用 model:delete -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (90107, 901, '0,9,901', '流程启停', 'B', NULL, '', NULL, 'workflow:definition:update', NULL, 1, 7, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (910107, 9101, '0,91,9101', '流程启停', 'B', NULL, '', NULL, 'workflow:definition:update', NULL, 1, 7, '', NULL, now(), now(), NULL); -- 流程设计器(隐藏菜单,列表"设计"按钮跳转) -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (902, 9, '0,9', '流程设计器', 'M', 'WorkflowDesigner', 'designer', 'workflow/designer', NULL, 1, 0, 2, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (9102, 91, '0,91', '流程设计器', 'M', 'WorkflowDesigner', 'designer', 'workflow/designer', NULL, 1, 0, 2, '', NULL, now(), now(), NULL); -- 发起/审批中心仅登录校验,不设按钮权限 -- 审批中心 = 待办/已办/我发起 三个页签 -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (904, 9, '0,9', '发起流程', 'M', 'WorkflowLaunch', 'launch', 'workflow/launch', NULL, NULL, 1, 4, 'el-icon-Promotion', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (905, 9, '0,9', '审批中心', 'M', 'WorkflowTask', 'task', 'workflow/task/index', NULL, NULL, 1, 5, 'el-icon-Stamp', NULL, now(), now(), NULL); -INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (90501, 905, '0,9,905', '终止流程', 'B', NULL, '', NULL, 'workflow:instance:terminate', NULL, 1, 1, '', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (9104, 91, '0,91', '发起流程', 'M', 'WorkflowLaunch', 'launch', 'workflow/launch', NULL, NULL, 1, 4, 'el-icon-Promotion', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (9105, 91, '0,91', '审批中心', 'M', 'WorkflowTask', 'task', 'workflow/task/index', NULL, NULL, 1, 5, 'el-icon-Stamp', NULL, now(), now(), NULL); +INSERT IGNORE INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (910501, 9105, '0,91,9105', '终止流程', 'B', NULL, '', NULL, 'workflow:instance:terminate', NULL, 1, 1, '', NULL, now(), now(), NULL); -- ---------------------------- -- 系统管理员角色授权(role_id=2) -- ---------------------------- -INSERT IGNORE INTO `sys_role_menu` VALUES (2, 9), (2, 901), (2, 90101), (2, 90102), (2, 90103), (2, 90104), (2, 90105), (2, 90106), (2, 90107), (2, 902), (2, 904), (2, 905), (2, 90501); +INSERT IGNORE INTO `sys_role_menu` VALUES (2, 91), (2, 9101), (2, 910101), (2, 910102), (2, 910103), (2, 910104), (2, 910105), (2, 910106), (2, 910107), (2, 9102), (2, 9104), (2, 9105), (2, 910501); -- ---------------------------- -- 演示角色与用户(ID 段 8-10,顺延 youlai-admin.sql 预置 1-7;密码 123456) @@ -56,12 +56,12 @@ INSERT IGNORE INTO `sys_user_role` VALUES (8, 8), (9, 9), (10, 10); -- ---------------------------- -- 演示角色的工作流菜单授权(不含流程设计等管理菜单) -- ---------------------------- -INSERT IGNORE INTO `sys_role_menu` VALUES (4, 9), (4, 904), (4, 905); -INSERT IGNORE INTO `sys_role_menu` VALUES (5, 9), (5, 904), (5, 905); -INSERT IGNORE INTO `sys_role_menu` VALUES (6, 9), (6, 904), (6, 905); -INSERT IGNORE INTO `sys_role_menu` VALUES (8, 9), (8, 904), (8, 905); -INSERT IGNORE INTO `sys_role_menu` VALUES (9, 9), (9, 904), (9, 905); -INSERT IGNORE INTO `sys_role_menu` VALUES (10, 9), (10, 904), (10, 905); +INSERT IGNORE INTO `sys_role_menu` VALUES (4, 91), (4, 9104), (4, 9105); +INSERT IGNORE INTO `sys_role_menu` VALUES (5, 91), (5, 9104), (5, 9105); +INSERT IGNORE INTO `sys_role_menu` VALUES (6, 91), (6, 9104), (6, 9105); +INSERT IGNORE INTO `sys_role_menu` VALUES (8, 91), (8, 9104), (8, 9105); +INSERT IGNORE INTO `sys_role_menu` VALUES (9, 91), (9, 9104), (9, 9105); +INSERT IGNORE INTO `sys_role_menu` VALUES (10, 91), (10, 9104), (10, 9105); -- ---------------------------- -- 演示流程的发起表单(依赖动态表单扩展) diff --git a/sql/youlai-admin.sql b/sql/youlai-admin.sql index d47c3f4c..3c2f2e26 100644 --- a/sql/youlai-admin.sql +++ b/sql/youlai-admin.sql @@ -138,13 +138,17 @@ CREATE TABLE `sys_menu` ( -- ---------------------------- -- 表数据:sys_menu -- ---------------------------- --- 扩展段 8-99 由 sql/extensions/ 脚本占用:动态表单(8,dynamic-form.sql)/工作流(9,workflow.sql),新扩展模块从 10 领号顺延 -- ID 规则:子 ID = 父 ID × 100 + 两位序号(如 10101 即用户管理 101 的第 1 个按钮),tree_path 与 ID 同构可机器校验 +-- 号段:基础库顶级 1-89、各层序号 01-89;扩展模块顶级取末两位 90-99 连排(动态表单 90、工作流 91、统一认证 92),扩展脚本往基础菜单下补行用 90-99 序号(如 10190) INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (1, 0, '0', '系统管理', 'C', '', '/system', 'Layout', NULL, NULL, 1, 1, 'system', '/system/user', now(), now(), NULL); -- 数据大屏走站内外链:E 型新标签页模式,external_url 填站内路径(前端按当前站点补全域名),指向布局外的独立全屏页 INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `external_url`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (2, 0, '0', '数据大屏', 'E', NULL, NULL, NULL, '/data-screen', NULL, NULL, 1, 2, 'el-icon-Monitor', NULL, now(), now(), NULL); INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (3, 0, '0', '代码生成', 'M', 'Codegen', '/codegen', 'codegen/index', NULL, 1, 1, 3, 'code', NULL, now(), now(), NULL); +-- 节点在线人数权限挂在日志管理下,普通用户默认不授予。 +INSERT INTO sys_menu (id, parent_id, tree_path, name, type, perm, visible, sort, create_time, update_time) +VALUES (10702, 107, '0,1,107', '在线人数查看', 'B', 'sys:online:view', 1, 2, now(), now()); + -- 系统管理 INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (101, 1, '0,1', '用户管理', 'M', 'User', 'user', 'system/user/index', NULL, 1, 1, 1, 'el-icon-User', NULL, now(), now(), NULL); INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (10101, 101, '0,1,101', '用户查询', 'B', NULL, '', NULL, 'sys:user:list', NULL, 1, 1, '', NULL, now(), now(), NULL); @@ -206,8 +210,8 @@ INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_n -- 实战文档双形态置顶(内嵌 iframe + 外链新标签),直观演示系统对两种外链打开方式的支持 -- ============================ 通用组件(顶级 ID=4) ============================ --- 前端模板招牌能力聚合,独立顶级便于访客沉淀组件库;按用途三分组:基础/表单/表格 --- 物理目录 views/demo/component(基础与表单)与 views/demo/table(表格)一一对应 +-- 前端模板招牌能力聚合,独立顶级便于访客沉淀组件库;按用途四分组:基础/表单/CRUD/表格 +-- 物理目录 views/demo/component(基础与表单)、views/demo/crud(CRUD)与 views/demo/table(表格)一一对应 INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (4, 0, '0', '通用组件', 'C', '', '/component', 'Layout', NULL, NULL, 1, 4, 'el-icon-SetUp', '/component/basic/icon-demo', now(), now(), NULL); -- 基础组件(不依附表单/表格场景的通用 UI 能力) @@ -225,12 +229,13 @@ INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_n INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (40204, 402, '0,4,402', '字典组件', 'M', 'DictDemo', 'dict-demo', 'demo/component/dictionary', NULL, 1, 1, 4, '', '', now(), now(), NULL); INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (40205, 402, '0,4,402', '列表选择器', 'M', 'TableSelect', 'table-select', 'demo/component/table-select/index', NULL, 1, 1, 5, '', '', now(), now(), NULL); +-- CRUD 组件(完整业务页面,物理目录 views/demo/crud 与之对应) +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (403, 4, '0,4', 'CRUD 示例', 'M', 'CrudDemo', 'crud', 'demo/crud/index', NULL, 1, 1, 3, 'el-icon-Document', '', now(), now(), NULL); + -- 表格组件(数据展示场景,物理目录 views/demo/table 与之对应) -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (403, 4, '0,4', '表格组件', 'C', '', 'table', 'Layout', NULL, NULL, 1, 3, 'el-icon-Grid', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (40301, 403, '0,4,403', '增删改查', 'M', 'Crud', 'crud', 'demo/table/crud/index', NULL, 1, 1, 1, '', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (40302, 403, '0,4,403', 'CRUD单文件', 'M', 'CrudSingle', 'crud-single', 'demo/table/crud-single', NULL, 1, 1, 2, 'el-icon-Reading', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (40303, 403, '0,4,403', 'VxeTable', 'M', 'VxeTable', 'vxe-table', 'demo/table/vxe-table/index', NULL, 1, 1, 3, 'el-icon-MagicStick', '', now(), now(), NULL); -INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (40304, 403, '0,4,403', '自适应表格操作列', 'M', 'AutoOperationColumn', 'operation-column', 'demo/table/auto-operation-column', NULL, 1, 1, 4, '', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (404, 4, '0,4', '表格组件', 'C', '', 'table', 'Layout', NULL, NULL, 1, 4, 'el-icon-Grid', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (40401, 404, '0,4,404', 'VxeTable', 'M', 'VxeTable', 'vxe-table', 'demo/table/vxe-table/index', NULL, 1, 1, 1, 'el-icon-MagicStick', '', now(), now(), NULL); +INSERT INTO `sys_menu` (`id`, `parent_id`, `tree_path`, `name`, `type`, `route_name`, `route_path`, `component`, `perm`, `keep_alive`, `visible`, `sort`, `icon`, `redirect`, `create_time`, `update_time`, `params`) VALUES (40402, 404, '0,4,404', '自适应表格操作列', 'M', 'AutoOperationColumn', 'operation-column', 'demo/table/auto-operation-column', NULL, 1, 1, 2, '', '', now(), now(), NULL); -- ============================ 多级菜单(顶级 ID=5) ============================ -- 独立顶级,直观演示菜单驱动动态路由的嵌套能力,避免折叠导致顶级菜单不可见 @@ -318,6 +323,7 @@ INSERT INTO `sys_role_menu` VALUES (2, 103), (2, 10301), (2, 10302), (2, 10303), INSERT INTO `sys_role_menu` VALUES (2, 104), (2, 10401), (2, 10402), (2, 10403), (2, 10404); INSERT INTO `sys_role_menu` VALUES (2, 105), (2, 10501), (2, 10502), (2, 10503), (2, 10504), (2, 10505), (2, 10506), (2, 10507), (2, 10508); INSERT INTO `sys_role_menu` VALUES (2, 107), (2, 10701); +INSERT INTO `sys_role_menu` VALUES (2, 10702); INSERT INTO `sys_role_menu` VALUES (2, 108), (2, 10801), (2, 10802), (2, 10803), (2, 10804), (2, 10805); INSERT INTO `sys_role_menu` VALUES (2, 109), (2, 10901), (2, 10902), (2, 10903), (2, 10904), (2, 10905), (2, 10906); @@ -336,10 +342,11 @@ INSERT IGNORE INTO `sys_role_menu` VALUES (6, 102), (6, 10201), (6, 10202), (6, INSERT IGNORE INTO `sys_role_menu` VALUES (7, 1); INSERT IGNORE INTO `sys_role_menu` VALUES (7, 101), (7, 10101), (7, 10102), (7, 10103), (7, 10104), (7, 10105), (7, 10106), (7, 10107); INSERT IGNORE INTO `sys_role_menu` VALUES (7, 102), (7, 10201), (7, 10202), (7, 10203), (7, 10204), (7, 10205); --- 通用组件(401 基础 / 402 表单 / 403 表格) +-- 通用组件(401 基础 / 402 表单 / 403 CRUD / 404 表格) INSERT INTO `sys_role_menu` VALUES (2, 401), (2, 40101), (2, 40102), (2, 40103), (2, 40104); INSERT INTO `sys_role_menu` VALUES (2, 402), (2, 40201), (2, 40202), (2, 40203), (2, 40204), (2, 40205); -INSERT INTO `sys_role_menu` VALUES (2, 403), (2, 40301), (2, 40302), (2, 40303), (2, 40304); +INSERT INTO `sys_role_menu` VALUES (2, 403); +INSERT INTO `sys_role_menu` VALUES (2, 404), (2, 40401), (2, 40402); -- 多级菜单 / 路由示例 INSERT INTO `sys_role_menu` VALUES (2, 501), (2, 50101), (2, 5010101), (2, 5010102); INSERT INTO `sys_role_menu` VALUES (2, 601), (2, 602), (2, 603), (2, 604), (2, 605); @@ -498,7 +505,7 @@ CREATE TABLE `sys_config` ( PRIMARY KEY (`id`) ) ENGINE=InnoDB COMMENT='系统配置表'; -INSERT INTO `sys_config` VALUES (1, '系统限流QPS', 'IP_QPS_THRESHOLD_LIMIT', '10', '单个IP请求的最大每秒查询数(QPS)阈值Key', now(), 1, NULL, NULL, 0); +-- 限流阈值由 yml 的 rate-limit.* 维护,不在 sys_config 中配置 -- ---------------------------- -- 通知公告表 @@ -548,7 +555,8 @@ CREATE TABLE `sys_user_notice` ( `create_time` datetime NOT NULL COMMENT '创建时间', `update_time` datetime COMMENT '更新时间', `is_deleted` tinyint DEFAULT '0' COMMENT '逻辑删除(0: 未删除, 1: 已删除)', - PRIMARY KEY (`id`) USING BTREE + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_notice_user_deleted` (`notice_id`, `user_id`, `is_deleted`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户通知公告关联表'; INSERT INTO `sys_user_notice` VALUES (1, 1, 2, 1, NULL, now(), now(), 0); @@ -586,24 +594,21 @@ INSERT INTO `sys_user_notice` VALUES (28, 9, 3, 1, NULL, now(), now(), 0); INSERT INTO `sys_user_notice` VALUES (29, 10, 3, 0, NULL, now(), now(), 0); -- ---------------------------- --- 表结构:sys_user_social +-- 表结构:sys_user_identity -- ---------------------------- -DROP TABLE IF EXISTS `sys_user_social`; -CREATE TABLE `sys_user_social` ( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `user_id` bigint NOT NULL COMMENT '用户ID', - `platform` varchar(20) NOT NULL COMMENT '平台类型(WECHAT_MINI/WECHAT_MP/ALIPAY/QQ/APPLE)', - `openid` varchar(64) NOT NULL COMMENT '平台openid', - `unionid` varchar(64) DEFAULT NULL COMMENT '微信unionid', - `nickname` varchar(64) DEFAULT NULL COMMENT '第三方昵称', - `avatar` varchar(255) DEFAULT NULL COMMENT '第三方头像URL', - `session_key` varchar(128) DEFAULT NULL COMMENT '微信session_key', - `verified` tinyint(1) DEFAULT 1 COMMENT '是否已验证(1-已验证 0-未验证)', - `create_time` datetime DEFAULT NULL COMMENT '绑定时间', - `update_time` datetime DEFAULT NULL COMMENT '更新时间', +DROP TABLE IF EXISTS `sys_user_identity`; +CREATE TABLE `sys_user_identity` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '关联记录ID', + `user_id` bigint NOT NULL COMMENT '本地用户ID,角色与数据权限归属此用户', + `provider` varchar(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL COMMENT '具体认证源标识,如oidc-main、wechat-mini-main、github', + `provider_account_id` varchar(255) COLLATE utf8mb4_0900_bin NOT NULL COMMENT '来源方账号ID:OIDC sub、微信OpenID或平台用户ID', + `display_name` varchar(128) DEFAULT NULL COMMENT '外部账号展示名,不参与身份匹配', + `avatar_url` varchar(512) DEFAULT NULL COMMENT '外部账号头像,不覆盖本地资料', + `last_login_time` datetime DEFAULT NULL COMMENT '最近一次通过该外部账号登录的时间', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '关联创建时间', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间', PRIMARY KEY (`id`), - UNIQUE KEY `uk_platform_openid` (`platform`, `openid`), - KEY `idx_user_id` (`user_id`), - KEY `idx_unionid` (`unionid`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户第三方账号绑定表'; + UNIQUE KEY `uk_provider_account` (`provider`, `provider_account_id`), + UNIQUE KEY `uk_user_provider` (`user_id`, `provider`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='本地用户与外部账号的关联'; diff --git a/src/main/java/com/youlai/boot/auth/oidc/config/ConditionalOnOidcEnabled.java b/src/main/java/com/youlai/boot/auth/oidc/config/ConditionalOnOidcEnabled.java new file mode 100644 index 00000000..6887bd96 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/oidc/config/ConditionalOnOidcEnabled.java @@ -0,0 +1,22 @@ +package com.youlai.boot.auth.oidc.config; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty; + +/** + * 统一认证启用条件 + * + * @author Ray.Hao + * @since 4.12.0 + */ +@Target({ElementType.TYPE, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@ConditionalOnBooleanProperty(prefix = "security.oidc", name = "enabled") +public @interface ConditionalOnOidcEnabled { +} diff --git a/src/main/java/com/youlai/boot/auth/oidc/config/OidcClientRegistrationConfig.java b/src/main/java/com/youlai/boot/auth/oidc/config/OidcClientRegistrationConfig.java new file mode 100644 index 00000000..1f981a05 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/oidc/config/OidcClientRegistrationConfig.java @@ -0,0 +1,48 @@ +package com.youlai.boot.auth.oidc.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.registration.ClientRegistrations; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.oidc.OidcScopes; + +/** + * 启用 OIDC 时注册客户端并读取发现文档 + * + * @author Ray.Hao + * @since 4.12.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnOidcEnabled +@RequiredArgsConstructor +public class OidcClientRegistrationConfig { + + // OAuth 入口与回调的注册标识;不作为持久化账号归属 + public static final String REGISTRATION_ID = "youlai"; + + private final OidcProperties oidcProperties; + + /** + * 根据 issuer 发现端点,使用授权码登录 + */ + @Bean + public ClientRegistrationRepository clientRegistrationRepository() { + ClientRegistration registration = ClientRegistrations + .fromIssuerLocation(oidcProperties.getIssuer()) + .registrationId(REGISTRATION_ID) + .clientName("统一认证") + .clientId(oidcProperties.getClientId()) + .clientSecret(oidcProperties.getClientSecret()) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}") + .scope(OidcScopes.OPENID, OidcScopes.PROFILE) + .build(); + return new InMemoryClientRegistrationRepository(registration); + } +} diff --git a/src/main/java/com/youlai/boot/auth/oidc/config/OidcProperties.java b/src/main/java/com/youlai/boot/auth/oidc/config/OidcProperties.java new file mode 100644 index 00000000..6487fa1c --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/oidc/config/OidcProperties.java @@ -0,0 +1,43 @@ +package com.youlai.boot.auth.oidc.config; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +/** + * OIDC 登录配置 + * + * @author Ray.Hao + * @since 4.12.0 + */ +@Data +@Component +@ConditionalOnOidcEnabled +@Validated +@ConfigurationProperties(prefix = "security.oidc") +public class OidcProperties { + + private boolean enabled = false; + + // 身份表中的稳定标识;更换认证中心时使用新值或迁移关联 + @NotBlank + @Pattern(regexp = "[a-z][a-z0-9-]{0,63}") + private String provider = "oidc-main"; + + // 必须与认证中心公布的 issuer 完全一致 + @NotBlank + private String issuer; + + @NotBlank + private String clientId; + + @NotBlank + private String clientSecret; + + // 认证成功后的前端回调地址 + @NotBlank + private String frontendCallbackUri; +} diff --git a/src/main/java/com/youlai/boot/auth/oidc/config/OidcSecurityConfig.java b/src/main/java/com/youlai/boot/auth/oidc/config/OidcSecurityConfig.java new file mode 100644 index 00000000..bcc0a3d3 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/oidc/config/OidcSecurityConfig.java @@ -0,0 +1,67 @@ +package com.youlai.boot.auth.oidc.config; + +import com.youlai.boot.auth.oidc.handler.OidcLoginHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.web.SecurityFilterChain; + +import static org.springframework.security.config.Customizer.withDefaults; + +/** + * OIDC 授权与票据兑换的安全配置 + * + * @author Ray.Hao + * @since 4.12.0 + */ +@Configuration(proxyBeanMethods = false) +@ConditionalOnOidcEnabled +@RequiredArgsConstructor +public class OidcSecurityConfig { + + private final OidcLoginHandler oidcLoginHandler; + private final ClientRegistrationRepository clientRegistrationRepository; + + /** + * 临时授权会话只用于 OIDC 流程,不参与业务接口认证 + */ + @Bean + @Order(0) + public SecurityFilterChain oidcSecurityFilterChain(HttpSecurity http) throws Exception { + http + .securityMatcher("/oauth2/authorization/**", "/login/oauth2/code/**", + "/api/v1/auth/oidc/tickets/exchange") + .authorizeHttpRequests(authorize -> authorize.anyRequest().permitAll()) + .oauth2Login(login -> login + .successHandler(oidcLoginHandler) + .failureHandler(oidcLoginHandler) + .authorizationEndpoint(endpoint -> endpoint + .authorizationRequestResolver(createAuthorizationRequestResolver()))) + // 票据兑换校验一次性票据和浏览器 Cookie + .csrf(csrf -> csrf.ignoringRequestMatchers("/api/v1/auth/oidc/tickets/exchange")) + .requestCache(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .cors(withDefaults()); + return http.build(); + } + + /** + * 授权码流程附加 PKCE(S256),授权请求的 code_verifier 保存在临时会话中 + */ + private OAuth2AuthorizationRequestResolver createAuthorizationRequestResolver() { + DefaultOAuth2AuthorizationRequestResolver resolver = + new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository); + resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce()); + return resolver; + } +} diff --git a/src/main/java/com/youlai/boot/auth/oidc/controller/OidcAuthController.java b/src/main/java/com/youlai/boot/auth/oidc/controller/OidcAuthController.java new file mode 100644 index 00000000..aed15955 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/oidc/controller/OidcAuthController.java @@ -0,0 +1,44 @@ +package com.youlai.boot.auth.oidc.controller; + +import com.youlai.boot.auth.oidc.config.ConditionalOnOidcEnabled; +import com.youlai.boot.auth.oidc.model.form.OidcTicketExchangeForm; +import com.youlai.boot.auth.oidc.service.OidcLoginService; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.framework.security.model.TokenPair; +import com.youlai.boot.framework.web.ratelimit.RateLimit; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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; + +/** + * OIDC 登录票据兑换接口 + * + * @author Ray.Hao + * @since 4.12.0 + */ +@Tag(name = "01.认证中心") +@RestController +@RequestMapping("/api/v1/auth/oidc") +@ConditionalOnOidcEnabled +@RequiredArgsConstructor +public class OidcAuthController { + + private final OidcLoginService oidcLoginService; + + /** + * 消费一次性认证票据并创建本地会话 + */ + @Operation(summary = "[OIDC] 票据兑换登录令牌") + @PostMapping("/tickets/exchange") + @RateLimit + public Result exchangeTicket(@RequestBody @Valid OidcTicketExchangeForm form, + HttpServletRequest request) { + return Result.success(oidcLoginService.exchangeTicket(form.getTicket(), request)); + } +} diff --git a/src/main/java/com/youlai/boot/auth/oidc/controller/OidcUserIdentityController.java b/src/main/java/com/youlai/boot/auth/oidc/controller/OidcUserIdentityController.java new file mode 100644 index 00000000..9852de2f --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/oidc/controller/OidcUserIdentityController.java @@ -0,0 +1,107 @@ +package com.youlai.boot.auth.oidc.controller; + +import com.youlai.boot.auth.oidc.config.ConditionalOnOidcEnabled; +import com.youlai.boot.auth.oidc.config.OidcProperties; +import com.youlai.boot.auth.oidc.model.form.OidcUserIdentityForm; +import com.youlai.boot.auth.oidc.model.vo.OidcUserIdentityVO; +import com.youlai.boot.common.annotation.Log; +import com.youlai.boot.common.enums.ActionTypeEnum; +import com.youlai.boot.common.enums.LogModuleEnum; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.framework.security.session.SessionService; +import com.youlai.boot.framework.security.util.SecurityUtils; +import com.youlai.boot.system.model.entity.UserIdentity; +import com.youlai.boot.system.service.UserIdentityService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; +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.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Objects; + +/** + * 当前用户与认证中心的身份关联 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Tag(name = "02.用户接口") +@Validated +@RestController +@ConditionalOnOidcEnabled +@RequestMapping("/api/v1/users/{userId}/oidc-identity") +@RequiredArgsConstructor +public class OidcUserIdentityController { + + private final OidcProperties oidcProperties; + private final UserIdentityService userIdentityService; + private final SessionService sessionService; + + /** + * 查询当前用户在当前认证源下的身份关联 + */ + @Operation(summary = "查看统一认证身份") + @GetMapping + public Result getIdentity( + @Parameter(description = "本地用户 ID") @PathVariable @Positive Long userId + ) { + requireSelf(userId); + UserIdentity identity = userIdentityService.getIdentity(userId, oidcProperties.getProvider()); + return Result.success(new OidcUserIdentityVO( + oidcProperties.getIssuer(), identity == null ? null : identity.getProviderAccountId())); + } + + /** + * 保存当前用户的身份关联,拒绝占用其他用户的身份 + */ + @Operation(summary = "设置统一认证身份") + @PutMapping + @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.UPDATE) + public Result setIdentity( + @Parameter(description = "本地用户 ID") @PathVariable @Positive Long userId, + @RequestBody @Valid OidcUserIdentityForm form + ) { + requireSelf(userId); + if (userIdentityService.setIdentity(userId, oidcProperties.getProvider(), form.getSubject())) { + // 关联改变后撤销该用户的现有会话;相同 subject 重复保存不触发下线 + sessionService.revokeUserSessions(userId); + } + return Result.success(); + } + + /** + * 解除当前用户在当前认证源下的身份关联 + */ + @Operation(summary = "解除统一认证身份") + @DeleteMapping + @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.DELETE) + public Result removeIdentity( + @Parameter(description = "本地用户 ID") @PathVariable @Positive Long userId + ) { + requireSelf(userId); + if (userIdentityService.removeIdentity(userId, oidcProperties.getProvider())) { + sessionService.revokeUserSessions(userId); + } + return Result.success(); + } + + /** + * 只允许操作当前登录用户自己的身份关联 + */ + private void requireSelf(Long userId) { + if (!Objects.equals(userId, SecurityUtils.getUserId())) { + throw new BusinessException("只能操作自己的统一认证身份"); + } + } +} diff --git a/src/main/java/com/youlai/boot/auth/oidc/handler/OidcLoginHandler.java b/src/main/java/com/youlai/boot/auth/oidc/handler/OidcLoginHandler.java new file mode 100644 index 00000000..54eaf180 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/oidc/handler/OidcLoginHandler.java @@ -0,0 +1,110 @@ +package com.youlai.boot.auth.oidc.handler; + +import com.youlai.boot.auth.oidc.config.ConditionalOnOidcEnabled; +import com.youlai.boot.auth.oidc.config.OidcProperties; +import com.youlai.boot.auth.oidc.service.OidcTicketService; +import com.youlai.boot.framework.security.session.LoginContext; +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.system.service.UserIdentityService; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +/** + * 处理 OIDC 登录结果并回跳前端 + * + * @author Ray.Hao + * @since 4.12.0 + */ +@Slf4j +@Component +@ConditionalOnOidcEnabled +@RequiredArgsConstructor +public class OidcLoginHandler implements AuthenticationSuccessHandler, AuthenticationFailureHandler { + + private final OidcProperties oidcProperties; + private final UserIdentityService userIdentityService; + private final OidcTicketService oidcTicketService; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + try { + if (!(authentication.getPrincipal() instanceof OidcUser oidcUser)) { + redirect(response, Map.of("error", ResultCode.OIDC_LOGIN_FAILED.getCode())); + return; + } + String provider = oidcProperties.getProvider(); + String subject = oidcUser.getSubject(); + Long userId = userIdentityService.findUserId(provider, subject); + if (userId == null) { + // 用户可将 subject 交给管理员完成身份关联 + redirect(response, Map.of("error", ResultCode.OIDC_ACCOUNT_NOT_BOUND.getCode(), "subject", subject)); + return; + } + var context = LoginContext.oidc(provider, oidcUser.getIssuer().toString(), subject, + oidcUser.getIdToken().getTokenValue()); + String ticket = oidcTicketService.issue(userId, context, request, response); + redirect(response, Map.of("ticket", ticket)); + } finally { + clearAuthentication(request); + } + } + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException { + clearAuthentication(request); + log.warn("OIDC 登录失败:{}", exception.getClass().getSimpleName()); + redirect(response, Map.of("error", ResultCode.OIDC_LOGIN_FAILED.getCode())); + } + + /** + * 清理授权流程的临时会话,业务登录由票据兑换完成 + */ + private void clearAuthentication(HttpServletRequest request) { + SecurityContextHolder.clearContext(); + var session = request.getSession(false); + if (session != null) { + session.invalidate(); + } + } + + /** + * 回跳前端,查询参数附在路由后 + */ + private void redirect(HttpServletResponse response, Map parameters) throws IOException { + String callbackUri = oidcProperties.getFrontendCallbackUri(); + if (StringUtils.isBlank(callbackUri)) { + response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "未配置统一认证回跳地址"); + return; + } + + StringBuilder url = new StringBuilder(callbackUri); + // hash 路由的查询参数位于 # 后面 + String route = callbackUri.substring(callbackUri.indexOf('#') + 1); + String separator = route.contains("?") ? "&" : "?"; + for (var parameter : parameters.entrySet()) { + url.append(separator) + .append(parameter.getKey()) + .append('=') + .append(URLEncoder.encode(parameter.getValue(), StandardCharsets.UTF_8)); + separator = "&"; + } + response.sendRedirect(url.toString()); + } +} diff --git a/src/main/java/com/youlai/boot/auth/oidc/model/OidcTicketContext.java b/src/main/java/com/youlai/boot/auth/oidc/model/OidcTicketContext.java new file mode 100644 index 00000000..2053b815 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/oidc/model/OidcTicketContext.java @@ -0,0 +1,12 @@ +package com.youlai.boot.auth.oidc.model; + +import com.youlai.boot.framework.security.session.LoginContext; + +/** + * 一次性票据中的用户、外部身份与浏览器绑定摘要 + * + * @author Ray.Hao + * @since 4.12.0 + */ +public record OidcTicketContext(Long userId, LoginContext loginContext, String browserBinding) { +} diff --git a/src/main/java/com/youlai/boot/auth/oidc/model/form/OidcTicketExchangeForm.java b/src/main/java/com/youlai/boot/auth/oidc/model/form/OidcTicketExchangeForm.java new file mode 100644 index 00000000..2f1fe756 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/oidc/model/form/OidcTicketExchangeForm.java @@ -0,0 +1,20 @@ +package com.youlai.boot.auth.oidc.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** + * 统一认证票据兑换请求 + * + * @author Ray.Hao + * @since 4.12.0 + */ +@Schema(description = "统一认证票据兑换请求") +@Data +public class OidcTicketExchangeForm { + + @Schema(description = "一次性交接票据") + @NotBlank(message = "票据不能为空") + private String ticket; +} diff --git a/src/main/java/com/youlai/boot/auth/oidc/model/form/OidcUserIdentityForm.java b/src/main/java/com/youlai/boot/auth/oidc/model/form/OidcUserIdentityForm.java new file mode 100644 index 00000000..74c6ce9e --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/oidc/model/form/OidcUserIdentityForm.java @@ -0,0 +1,23 @@ +package com.youlai.boot.auth.oidc.model.form; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; + +/** + * 设置本地用户的 OIDC 身份;issuer 和 provider 均由服务端配置确定 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Data +@Schema(description = "用户的统一认证身份设置") +public class OidcUserIdentityForm { + + // 认证中心实际签发的 sub,区分大小写,不能填写用户名或客户端 ID + @NotBlank(message = "subject 不能为空") + @Size(max = 255, message = "subject 长度不能超过255个字符") + @Schema(description = "ID Token 中的 sub") + private String subject; +} diff --git a/src/main/java/com/youlai/boot/auth/oidc/model/vo/OidcUserIdentityVO.java b/src/main/java/com/youlai/boot/auth/oidc/model/vo/OidcUserIdentityVO.java new file mode 100644 index 00000000..4e2796e3 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/oidc/model/vo/OidcUserIdentityVO.java @@ -0,0 +1,16 @@ +package com.youlai.boot.auth.oidc.model.vo; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 当前认证中心及用户的关联;未关联时 subject 为 null + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Schema(description = "用户的统一认证身份") +public record OidcUserIdentityVO( + @Schema(description = "服务端配置的认证中心 issuer") String issuer, + @Schema(description = "已关联的 sub,未关联时为空") String subject +) { +} diff --git a/src/main/java/com/youlai/boot/auth/oidc/service/OidcLoginService.java b/src/main/java/com/youlai/boot/auth/oidc/service/OidcLoginService.java new file mode 100644 index 00000000..884b802f --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/oidc/service/OidcLoginService.java @@ -0,0 +1,70 @@ +package com.youlai.boot.auth.oidc.service; + +import com.youlai.boot.auth.oidc.config.ConditionalOnOidcEnabled; +import com.youlai.boot.auth.oidc.config.OidcProperties; +import com.youlai.boot.auth.oidc.model.OidcTicketContext; +import com.youlai.boot.common.enums.StatusEnum; +import com.youlai.boot.common.exception.TokenInvalidException; +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.framework.security.model.SecurityUserDetails; +import com.youlai.boot.framework.security.model.TokenPair; +import com.youlai.boot.framework.security.session.SessionService; +import com.youlai.boot.system.service.UserIdentityService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; + +/** + * 将 OIDC 登录票据兑换为本地会话 + * + * @author Ray.Hao + * @since 4.12.0 + */ +@Service +@RequiredArgsConstructor +@ConditionalOnOidcEnabled +public class OidcLoginService { + + private final OidcTicketService oidcTicketService; + private final OidcProperties oidcProperties; + private final UserIdentityService userIdentityService; + private final SessionService sessionService; + + /** + * 消费票据,重新核对身份关联后创建本地会话 + */ + public TokenPair exchangeTicket(String ticket, HttpServletRequest request) { + OidcTicketContext ticketContext = oidcTicketService.consume(ticket, request); + + var context = ticketContext.loginContext(); + if (!oidcProperties.getProvider().equals(context.provider()) + || !oidcProperties.getIssuer().equals(context.issuer())) { + throw new TokenInvalidException(ResultCode.OIDC_LOGIN_FAILED); + } + + // 领票后可能已解除或修改关联,不能直接沿用票据中的用户 + Long currentUserId = userIdentityService.findUserId(context.provider(), context.subject()); + if (!ticketContext.userId().equals(currentUserId)) { + throw new TokenInvalidException(ResultCode.OIDC_ACCOUNT_NOT_BOUND); + } + + SecurityUser securityUser = userIdentityService.getAuthInfoByUserId(currentUserId); + if (securityUser == null) { + throw new TokenInvalidException(ResultCode.OIDC_ACCOUNT_NOT_BOUND); + } + if (!StatusEnum.ENABLE.getValue().equals(securityUser.getStatus())) { + throw new TokenInvalidException(ResultCode.ACCOUNT_FROZEN); + } + + SecurityUserDetails userDetails = new SecurityUserDetails(securityUser); + Authentication authentication = new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + + TokenPair tokenPair = sessionService.create(authentication, context); + userIdentityService.markLogin(context.provider(), context.subject()); + return tokenPair; + } +} diff --git a/src/main/java/com/youlai/boot/auth/oidc/service/OidcLogoutService.java b/src/main/java/com/youlai/boot/auth/oidc/service/OidcLogoutService.java new file mode 100644 index 00000000..2dc0aa51 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/oidc/service/OidcLogoutService.java @@ -0,0 +1,81 @@ +package com.youlai.boot.auth.oidc.service; + +import com.youlai.boot.auth.oidc.config.ConditionalOnOidcEnabled; +import com.youlai.boot.auth.oidc.config.OidcClientRegistrationConfig; +import com.youlai.boot.auth.oidc.config.OidcProperties; +import com.youlai.boot.framework.security.session.LoginContext; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.stereotype.Service; + +import java.net.URI; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +/** + * 为 OIDC 登录生成认证中心登出地址 + * + * @author Ray.Hao + * @since 4.12.0 + */ +@Service +@ConditionalOnOidcEnabled +@RequiredArgsConstructor +public class OidcLogoutService { + + private final OidcProperties oidcProperties; + private final ClientRegistrationRepository clientRegistrations; + + /** + * 本地登录或提供方不支持登出时返回 null + */ + public String getLogoutUrl(LoginContext context) { + if (context.source() != LoginContext.Source.OIDC) { + return null; + } + + var registration = clientRegistrations.findByRegistrationId(OidcClientRegistrationConfig.REGISTRATION_ID); + String redirectUri = getPostLogoutRedirectUri(); + if (registration == null || redirectUri == null) { + return null; + } + Object endpoint = registration.getProviderDetails().getConfigurationMetadata().get("end_session_endpoint"); + if (!(endpoint instanceof String logoutEndpoint) || StringUtils.isBlank(logoutEndpoint)) { + // 提供方未声明主动登出能力时,只完成本地退出 + return null; + } + + String separator = logoutEndpoint.contains("?") ? "&" : "?"; + String logoutUrl = logoutEndpoint + separator + "post_logout_redirect_uri=" + + URLEncoder.encode(redirectUri, StandardCharsets.UTF_8); + + if (StringUtils.isNotBlank(context.idToken())) { + return logoutUrl + "&id_token_hint=" + + URLEncoder.encode(context.idToken(), StandardCharsets.UTF_8); + } + + if (StringUtils.isBlank(oidcProperties.getClientId())) { + return null; + } + return logoutUrl + "&client_id=" + + URLEncoder.encode(oidcProperties.getClientId(), StandardCharsets.UTF_8); + } + + /** + * 登出后回跳的前端地址,取统一认证回调地址的同源根路径 + */ + private String getPostLogoutRedirectUri() { + String callbackUri = oidcProperties.getFrontendCallbackUri(); + if (StringUtils.isBlank(callbackUri)) { + return null; + } + + URI callback = URI.create(callbackUri); + if (callback.getScheme() == null || callback.getAuthority() == null) { + return null; + } + + return callback.getScheme() + "://" + callback.getAuthority() + "/"; + } +} diff --git a/src/main/java/com/youlai/boot/auth/oidc/service/OidcSessionValidator.java b/src/main/java/com/youlai/boot/auth/oidc/service/OidcSessionValidator.java new file mode 100644 index 00000000..d6fbfb86 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/oidc/service/OidcSessionValidator.java @@ -0,0 +1,38 @@ +package com.youlai.boot.auth.oidc.service; + +import com.youlai.boot.auth.oidc.config.ConditionalOnOidcEnabled; +import com.youlai.boot.auth.oidc.config.OidcProperties; +import com.youlai.boot.framework.security.session.LoginContext; +import com.youlai.boot.framework.security.session.LoginSession; +import com.youlai.boot.framework.security.session.SessionValidator; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.stereotype.Component; + +/** + * 检查会话所属的认证源是否仍然启用 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Component +@ConditionalOnOidcEnabled +@RequiredArgsConstructor +public class OidcSessionValidator implements SessionValidator { + + private final OidcProperties properties; + + @Override + public LoginContext.Source source() { + return LoginContext.Source.OIDC; + } + + @Override + public void validate(LoginSession session) { + var context = session.context(); + if (!properties.isEnabled() || !properties.getProvider().equals(context.provider()) + || !properties.getIssuer().equals(context.issuer())) { + throw new BadCredentialsException("OIDC provider is no longer available"); + } + } +} diff --git a/src/main/java/com/youlai/boot/auth/oidc/service/OidcTicketService.java b/src/main/java/com/youlai/boot/auth/oidc/service/OidcTicketService.java new file mode 100644 index 00000000..d9e69780 --- /dev/null +++ b/src/main/java/com/youlai/boot/auth/oidc/service/OidcTicketService.java @@ -0,0 +1,115 @@ +package com.youlai.boot.auth.oidc.service; + +import com.youlai.boot.framework.security.token.SecureTokenGenerator; +import com.youlai.boot.framework.security.session.LoginContext; +import com.youlai.boot.auth.oidc.config.ConditionalOnOidcEnabled; +import com.youlai.boot.auth.oidc.model.OidcTicketContext; +import com.youlai.boot.common.constant.RedisConstants; +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.common.exception.TokenInvalidException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Service; +import tools.jackson.databind.json.JsonMapper; + +import java.time.Duration; +import java.util.List; + +/** + * 签发并消费绑定浏览器的一次性登录票据 + * + * @author Ray.Hao + * @since 4.12.0 + */ +@Service +@ConditionalOnOidcEnabled +@RequiredArgsConstructor +public class OidcTicketService { + + public static final String BROWSER_BINDING_COOKIE = "YOULAI_OIDC_BIND"; + private static final Duration TICKET_TTL = Duration.ofSeconds(45); + + // 校验浏览器绑定后删除,票据只能兑换一次 + private static final DefaultRedisScript CONSUME = new DefaultRedisScript<>(""" + local raw = redis.call('GET', KEYS[1]) + if not raw then return nil end + if cjson.decode(raw).browserBinding ~= ARGV[1] then return nil end + redis.call('DEL', KEYS[1]) + return raw + """, String.class); + + private final StringRedisTemplate stringRedisTemplate; + private final JsonMapper jsonMapper; + + /** + * 签发票据,并用 HttpOnly Cookie 绑定当前浏览器 + */ + public String issue(Long userId, LoginContext loginContext, + HttpServletRequest request, HttpServletResponse response) { + String binding = SecureTokenGenerator.generate(); + var context = new OidcTicketContext(userId, loginContext, SecureTokenGenerator.digest(binding)); + String ticket = SecureTokenGenerator.generate(); + stringRedisTemplate.opsForValue().set(getTicketKey(ticket), jsonMapper.writeValueAsString(context), TICKET_TTL); + + ResponseCookie cookie = ResponseCookie.from(BROWSER_BINDING_COOKIE, binding) + .httpOnly(true) + .secure(request.isSecure()) + .path("/") + .maxAge(TICKET_TTL) + .sameSite("Lax") + .build(); + response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); + + return ticket; + } + + /** + * 校验浏览器绑定后原子消费票据 + */ + public OidcTicketContext consume(String ticket, HttpServletRequest request) { + String binding = getBrowserBinding(request); + if (StringUtils.isBlank(ticket) || StringUtils.isBlank(binding)) { + throw new TokenInvalidException(ResultCode.OIDC_TICKET_INVALID); + } + + String json = stringRedisTemplate.execute(CONSUME, List.of(getTicketKey(ticket)), + SecureTokenGenerator.digest(binding)); + if (StringUtils.isBlank(json)) { + throw new TokenInvalidException(ResultCode.OIDC_TICKET_INVALID); + } + + return jsonMapper.readValue(json, OidcTicketContext.class); + } + + /** + * 读取用于票据校验的浏览器绑定 Cookie + */ + private String getBrowserBinding(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) { + return null; + } + + for (Cookie cookie : cookies) { + if (BROWSER_BINDING_COOKIE.equals(cookie.getName())) { + return cookie.getValue(); + } + } + + return null; + } + + /** + * 通过票据摘要构造存储键 + */ + private String getTicketKey(String ticket) { + return RedisConstants.Auth.OIDC_TICKET.replace("{}", SecureTokenGenerator.digest(ticket)); + } +} diff --git a/src/main/java/com/youlai/boot/auth/password/controller/PasswordAuthController.java b/src/main/java/com/youlai/boot/auth/password/controller/PasswordAuthController.java index d0e74cb4..62de134a 100644 --- a/src/main/java/com/youlai/boot/auth/password/controller/PasswordAuthController.java +++ b/src/main/java/com/youlai/boot/auth/password/controller/PasswordAuthController.java @@ -7,7 +7,7 @@ import com.youlai.boot.common.annotation.Log; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.Result; -import com.youlai.boot.framework.security.model.AuthenticationToken; +import com.youlai.boot.framework.security.model.TokenPair; import com.youlai.boot.framework.web.ratelimit.RateLimit; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -33,6 +33,9 @@ public class PasswordAuthController { private final PasswordAuthService passwordAuthService; + /** + * 生成登录图形验证码 + */ @Operation(summary = "获取验证码") @GetMapping("/captcha") public Result getCaptcha() { @@ -40,12 +43,15 @@ public class PasswordAuthController { return Result.success(captcha); } + /** + * 校验账号密码并创建登录会话 + */ @Operation(summary = "账号密码登录") @PostMapping("/login") @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) @RateLimit - public Result login(@RequestBody @Valid LoginForm request) { - AuthenticationToken authenticationToken = passwordAuthService.login(request.getUsername(), request.getPassword()); + public Result login(@RequestBody @Valid LoginForm request) { + TokenPair authenticationToken = passwordAuthService.login(request.getUsername(), request.getPassword()); return Result.success(authenticationToken); } diff --git a/src/main/java/com/youlai/boot/auth/password/service/PasswordAuthService.java b/src/main/java/com/youlai/boot/auth/password/service/PasswordAuthService.java index c405219c..6eace707 100644 --- a/src/main/java/com/youlai/boot/auth/password/service/PasswordAuthService.java +++ b/src/main/java/com/youlai/boot/auth/password/service/PasswordAuthService.java @@ -1,8 +1,8 @@ package com.youlai.boot.auth.password.service; import com.youlai.boot.auth.password.model.vo.CaptchaVO; -import com.youlai.boot.framework.security.model.AuthenticationToken; -import com.youlai.boot.framework.security.token.TokenManager; +import com.youlai.boot.framework.security.model.TokenPair; +import com.youlai.boot.framework.security.session.SessionService; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -21,7 +21,7 @@ import org.springframework.stereotype.Service; public class PasswordAuthService { private final AuthenticationManager authenticationManager; - private final TokenManager tokenManager; + private final SessionService sessionService; private final CaptchaService captchaService; /** @@ -31,19 +31,16 @@ public class PasswordAuthService { * @param password 密码 * @return 访问令牌 */ - public AuthenticationToken login(String username, String password) { - // 1. 创建用于密码认证的令牌(未认证) + public TokenPair login(String username, String password) { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username.trim(), password); - // 2. 执行认证(认证中) Authentication authentication = authenticationManager.authenticate(authenticationToken); - // 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证) - AuthenticationToken authenticationTokenResponse = - tokenManager.generateToken(authentication); + TokenPair tokens = sessionService.create(authentication); + // 登录日志从安全上下文读取操作者 SecurityContextHolder.getContext().setAuthentication(authentication); - return authenticationTokenResponse; + return tokens; } /** diff --git a/src/main/java/com/youlai/boot/auth/qrcode/controller/QrCodeAuthController.java b/src/main/java/com/youlai/boot/auth/qrcode/controller/QrCodeAuthController.java index 6307e60c..4c68d61b 100644 --- a/src/main/java/com/youlai/boot/auth/qrcode/controller/QrCodeAuthController.java +++ b/src/main/java/com/youlai/boot/auth/qrcode/controller/QrCodeAuthController.java @@ -5,7 +5,7 @@ import com.youlai.boot.auth.qrcode.model.vo.QrCodeGenerateVO; import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO; import com.youlai.boot.auth.qrcode.service.QrCodeLoginService; import com.youlai.boot.common.result.Result; -import com.youlai.boot.framework.security.model.AuthenticationToken; +import com.youlai.boot.framework.security.model.TokenPair; import com.youlai.boot.framework.security.util.SecurityUtils; import com.youlai.boot.framework.web.ratelimit.RateLimit; import com.youlai.boot.framework.web.util.IPUtils; @@ -87,7 +87,7 @@ public class QrCodeAuthController { */ @Operation(summary = "[扫码]PC 端换取会话令牌") @PostMapping("/login") - public Result login(@RequestBody @Valid QrCodeTicketForm form) { + public Result login(@RequestBody @Valid QrCodeTicketForm form) { return Result.success(qrCodeLoginService.login(form.getTicket())); } } diff --git a/src/main/java/com/youlai/boot/auth/qrcode/model/context/QrCodeLoginContext.java b/src/main/java/com/youlai/boot/auth/qrcode/model/context/QrCodeLoginContext.java index c6607add..4842de72 100644 --- a/src/main/java/com/youlai/boot/auth/qrcode/model/context/QrCodeLoginContext.java +++ b/src/main/java/com/youlai/boot/auth/qrcode/model/context/QrCodeLoginContext.java @@ -1,6 +1,7 @@ package com.youlai.boot.auth.qrcode.model.context; import lombok.Data; +import lombok.ToString; /** * 扫码登录票据上下文,序列化为 JSON 存入 Redis @@ -11,7 +12,8 @@ import lombok.Data; @Data public class QrCodeLoginContext { - // 票据,UUID 无连字符 + // 票据,32 字节随机数的 Base64URL 编码 + @ToString.Exclude private String ticket; // 状态枚举名,取 {@link com.youlai.boot.auth.qrcode.enums.QrCodeLoginStatusEnum#name()} diff --git a/src/main/java/com/youlai/boot/auth/qrcode/service/QrCodeLoginService.java b/src/main/java/com/youlai/boot/auth/qrcode/service/QrCodeLoginService.java index c11621e8..6b16007c 100644 --- a/src/main/java/com/youlai/boot/auth/qrcode/service/QrCodeLoginService.java +++ b/src/main/java/com/youlai/boot/auth/qrcode/service/QrCodeLoginService.java @@ -1,6 +1,5 @@ package com.youlai.boot.auth.qrcode.service; -import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; import com.youlai.boot.auth.qrcode.enums.QrCodeLoginStatusEnum; import com.youlai.boot.auth.qrcode.model.context.QrCodeLoginContext; @@ -9,19 +8,23 @@ import com.youlai.boot.auth.qrcode.model.vo.QrCodeStatusVO; import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.common.exception.TokenInvalidException; import com.youlai.boot.common.result.ResultCode; -import com.youlai.boot.framework.security.model.AuthenticationToken; +import com.youlai.boot.framework.security.model.TokenPair; import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.framework.security.model.SecurityUserDetails; -import com.youlai.boot.framework.security.token.TokenManager; -import com.youlai.boot.system.service.UserSocialService; +import com.youlai.boot.framework.security.session.SessionService; +import com.youlai.boot.framework.security.token.SecureTokenGenerator; +import com.youlai.boot.system.service.UserIdentityService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import tools.jackson.databind.json.JsonMapper; +import java.time.Duration; +import java.util.List; import java.util.concurrent.TimeUnit; /** @@ -38,27 +41,33 @@ public class QrCodeLoginService { // 票据默认有效期(秒) private static final int DEFAULT_EXPIRE_SECONDS = 300; - // 状态流转时若 Redis 剩余 TTL 小于该值,补足到此值 - private static final int MIN_REMAIN_SECONDS = 30; + // 状态只能单向推进,写入沿用票据剩余有效期 + private static final DefaultRedisScript TRANSITION = new DefaultRedisScript<>(""" + local raw = redis.call('GET', KEYS[1]) + if not raw or cjson.decode(raw).status ~= ARGV[1] then return 0 end + local ttl = redis.call('PTTL', KEYS[1]) + if ttl <= 0 then return 0 end + redis.call('SET', KEYS[1], ARGV[2], 'PX', ttl) + return 1 + """, Long.class); - // 从 Redis 读回的值可能是不带类型信息的 Map(序列化器以 Object.class 存储), - private static final JsonMapper JSON_MAPPER = JsonMapper.builder().build(); - - private final RedisTemplate redisTemplate; - private final TokenManager tokenManager; - private final UserSocialService userSocialService; + private final StringRedisTemplate redisTemplate; + private final JsonMapper json; + private final SessionService sessionService; + private final UserIdentityService userIdentityService; /** * 生成扫码票据,写入 Redis 后返回 ticket 和有效期 */ public QrCodeGenerateVO generate(String clientIp) { - String ticket = IdUtil.fastSimpleUUID(); + String ticket = SecureTokenGenerator.generate(); QrCodeLoginContext ctx = new QrCodeLoginContext(); ctx.setTicket(ticket); ctx.setStatus(QrCodeLoginStatusEnum.WAITING.name()); ctx.setCreatedAt(System.currentTimeMillis()); ctx.setClientIp(clientIp); - save(ctx, DEFAULT_EXPIRE_SECONDS); + redisTemplate.opsForValue().set(key(ticket), json.writeValueAsString(ctx), + Duration.ofSeconds(DEFAULT_EXPIRE_SECONDS)); return QrCodeGenerateVO.builder() .ticket(ticket) .expireSeconds(DEFAULT_EXPIRE_SECONDS) @@ -82,7 +91,7 @@ public class QrCodeLoginService { fillUserInfo(ctx, userId); ctx.setStatus(QrCodeLoginStatusEnum.SCANNED.name()); ctx.setScannedAt(System.currentTimeMillis()); - save(ctx, refreshTtl(ticket)); + transition(ctx, QrCodeLoginStatusEnum.WAITING); return toResp(ctx, remainingSeconds(ticket)); } @@ -95,7 +104,7 @@ public class QrCodeLoginService { requireSameUser(ctx, userId); ctx.setStatus(QrCodeLoginStatusEnum.CONFIRMED.name()); ctx.setConfirmedAt(System.currentTimeMillis()); - save(ctx, refreshTtl(ticket)); + transition(ctx, QrCodeLoginStatusEnum.SCANNED); return toResp(ctx, remainingSeconds(ticket)); } @@ -116,49 +125,47 @@ public class QrCodeLoginService { requireSameUser(ctx, userId); } ctx.setStatus(QrCodeLoginStatusEnum.CANCELED.name()); - save(ctx, refreshTtl(ticket)); + transition(ctx, current); return toResp(ctx, remainingSeconds(ticket)); } /** * PC 端用票据换取会话令牌,CONFIRMED → LOGGED_IN(一次性) */ - public AuthenticationToken login(String ticket) { + public TokenPair login(String ticket) { QrCodeLoginContext ctx = loadOrThrow(ticket); requireStatus(ctx, QrCodeLoginStatusEnum.CONFIRMED); - SecurityUser securityUser = userSocialService.getAuthInfoByUserId(ctx.getUserId()); + SecurityUser securityUser = userIdentityService.getAuthInfoByUserId(ctx.getUserId()); if (securityUser == null) { throw new TokenInvalidException(ResultCode.ACCOUNT_NOT_FOUND); } + if (!Integer.valueOf(1).equals(securityUser.getStatus())) { + throw new TokenInvalidException(ResultCode.ACCOUNT_FROZEN); + } SecurityUserDetails details = new SecurityUserDetails(securityUser); Authentication authentication = new UsernamePasswordAuthenticationToken( details, null, details.getAuthorities()); - AuthenticationToken token = tokenManager.generateToken(authentication); - // 换取令牌成功后立即把票据置为已使用(一次性),再次 login 会在 requireStatus(CONFIRMED) 处被拒 + // 先原子消费,再签发凭证,确保并发兑换最多成功一次 ctx.setStatus(QrCodeLoginStatusEnum.LOGGED_IN.name()); - save(ctx, Math.max(remainingSeconds(ticket), MIN_REMAIN_SECONDS)); - return token; + transition(ctx, QrCodeLoginStatusEnum.CONFIRMED); + return sessionService.create(authentication); } /** * 读取票据上下文,票据为空、不存在或已过期都视为 QR_CODE_NOT_FOUND */ private QrCodeLoginContext loadOrThrow(String ticket) { - if (StrUtil.isBlank(ticket)) { + if (ticket == null || !ticket.matches("[A-Za-z0-9_-]{43}")) { throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND); } - Object raw = redisTemplate.opsForValue().get(key(ticket)); + String raw = redisTemplate.opsForValue().get(key(ticket)); if (raw == null) { throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND); } - if (raw instanceof QrCodeLoginContext ctx) { - return ctx; - } - // 序列化器以 Object.class 存储、未带类型信息,读回来是 Map,这里再转回强类型 try { - return JSON_MAPPER.convertValue(raw, QrCodeLoginContext.class); - } catch (Exception e) { - log.error("扫码登录上下文反序列化失败, ticket={}", ticket, e); + return json.readValue(raw, QrCodeLoginContext.class); + } catch (tools.jackson.core.JacksonException exception) { + log.warn("扫码票据数据格式错误"); throw new TokenInvalidException(ResultCode.QR_CODE_NOT_FOUND); } } @@ -185,7 +192,7 @@ public class QrCodeLoginService { * 扫码时把当前 APP 用户的昵称、头像写进上下文,供 PC 端 status 展示 */ private void fillUserInfo(QrCodeLoginContext ctx, Long userId) { - SecurityUser info = userSocialService.getAuthInfoByUserId(userId); + SecurityUser info = userIdentityService.getAuthInfoByUserId(userId); if (info == null) { throw new TokenInvalidException(ResultCode.ACCOUNT_NOT_FOUND); } @@ -195,10 +202,14 @@ public class QrCodeLoginService { } /** - * 重写整个上下文并刷新 TTL + * 按期望状态原子替换,保持原有截止时间 */ - private void save(QrCodeLoginContext ctx, int ttl) { - redisTemplate.opsForValue().set(key(ctx.getTicket()), ctx, ttl, TimeUnit.SECONDS); + private void transition(QrCodeLoginContext context, QrCodeLoginStatusEnum expected) { + Long changed = redisTemplate.execute(TRANSITION, List.of(key(context.getTicket())), + expected.name(), json.writeValueAsString(context)); + if (!Long.valueOf(1).equals(changed)) { + throw new TokenInvalidException(ResultCode.QR_CODE_STATUS_ILLEGAL); + } } /** @@ -209,14 +220,6 @@ public class QrCodeLoginService { return ttl == null ? 0 : Math.max(ttl.intValue(), 0); } - /** - * 状态流转时写回的 TTL:维持剩余时间,不足 MIN_REMAIN_SECONDS 则补足 - */ - private int refreshTtl(String ticket) { - int remain = remainingSeconds(ticket); - return remain < MIN_REMAIN_SECONDS ? MIN_REMAIN_SECONDS : remain; - } - /** * 上下文转前端 VO,昵称脱敏、用户信息仅在扫码后暴露 */ @@ -257,6 +260,6 @@ public class QrCodeLoginService { * 拼接票据在 Redis 中的完整 Key */ private String key(String ticket) { - return StrUtil.format(RedisConstants.Auth.QR_CODE_LOGIN, ticket); + return StrUtil.format(RedisConstants.Auth.QR_CODE_LOGIN, SecureTokenGenerator.digest(ticket)); } } diff --git a/src/main/java/com/youlai/boot/auth/session/controller/AuthSessionController.java b/src/main/java/com/youlai/boot/auth/session/controller/AuthSessionController.java index e26b13b0..79048b8f 100644 --- a/src/main/java/com/youlai/boot/auth/session/controller/AuthSessionController.java +++ b/src/main/java/com/youlai/boot/auth/session/controller/AuthSessionController.java @@ -5,15 +5,18 @@ import com.youlai.boot.common.annotation.Log; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.Result; -import com.youlai.boot.framework.security.model.AuthenticationToken; +import com.youlai.boot.framework.security.model.TokenPair; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; /** @@ -30,21 +33,43 @@ public class AuthSessionController { private final AuthSessionService authSessionService; + /** + * 退出登录 + * + *

普通登录只退本地;统一认证登录额外返回认证中心登出地址,由前端跳转完成中央退出 + */ @Operation(summary = "退出登录") @DeleteMapping("/logout") @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGOUT) - public Result logout() { - authSessionService.logout(); - return Result.success(); + public Result logout() { + return Result.success(new LogoutResult(authSessionService.logout())); } + /** + * 轮换刷新凭证并返回新令牌 + */ @Operation(summary = "刷新令牌") @PostMapping("/refresh-token") - public Result refreshToken( - @Parameter(description = "刷新令牌", example = "xxx.xxx.xxx") @RequestParam String refreshToken + public Result refreshToken( + @RequestBody @Valid RefreshTokenRequest request ) { - AuthenticationToken authenticationToken = authSessionService.refreshToken(refreshToken); - return Result.success(authenticationToken); + return Result.success(authSessionService.refreshToken(request.refreshToken())); + } + + /** + * 刷新令牌请求 + */ + public record RefreshTokenRequest( + @NotBlank(message = "刷新令牌不能为空") @Size(max = 128, message = "刷新令牌长度不合法") + @Schema(description = "刷新令牌") String refreshToken) { + } + + /** + * 退出登录结果 + * + * @param logoutUrl 认证中心登出地址;为空表示只退出了本地登录 + */ + public record LogoutResult(String logoutUrl) { } } diff --git a/src/main/java/com/youlai/boot/auth/session/service/AuthSessionService.java b/src/main/java/com/youlai/boot/auth/session/service/AuthSessionService.java index 845da193..a2571182 100644 --- a/src/main/java/com/youlai/boot/auth/session/service/AuthSessionService.java +++ b/src/main/java/com/youlai/boot/auth/session/service/AuthSessionService.java @@ -1,45 +1,45 @@ package com.youlai.boot.auth.session.service; -import cn.hutool.core.util.StrUtil; -import com.youlai.boot.framework.security.model.AuthenticationToken; -import com.youlai.boot.framework.security.token.TokenManager; -import com.youlai.boot.framework.security.util.SecurityUtils; +import com.youlai.boot.auth.oidc.service.OidcLogoutService; +import com.youlai.boot.framework.security.model.TokenPair; +import com.youlai.boot.framework.security.session.AuthenticatedSession; +import com.youlai.boot.framework.security.session.SessionService; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; /** - * 登录态服务 + * 登录会话的 HTTP 用例入口 * * @author Ray.Hao - * @since 4.8.0 + * @since 4.13.0 */ @Service @RequiredArgsConstructor public class AuthSessionService { - private final TokenManager tokenManager; + private final SessionService sessions; + private final ObjectProvider oidcLogout; /** - * 注销登录 + * 先撤销本地会话,再尝试退出认证中心 */ - public void logout() { - String token = SecurityUtils.getAccessToken(); - if (StrUtil.isNotBlank(token)) { - tokenManager.invalidateToken(token); - // 清除Security上下文 - SecurityContextHolder.clearContext(); + public String logout() { + var authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !(authentication.getDetails() instanceof AuthenticatedSession session)) { + return null; } + sessions.revokeSession(session.id()); + SecurityContextHolder.clearContext(); + var logout = oidcLogout.getIfAvailable(); + return logout == null ? null : logout.getLogoutUrl(session.context()); } /** - * 刷新令牌 - * - * @param refreshToken 刷新令牌 - * @return 新的访问令牌 + * 刷新当前登录会话 */ - public AuthenticationToken refreshToken(String refreshToken) { - return tokenManager.refreshToken(refreshToken); + public TokenPair refreshToken(String refreshToken) { + return sessions.refresh(refreshToken); } - } diff --git a/src/main/java/com/youlai/boot/auth/sms/controller/SmsAuthController.java b/src/main/java/com/youlai/boot/auth/sms/controller/SmsAuthController.java index 466e2536..270723e9 100644 --- a/src/main/java/com/youlai/boot/auth/sms/controller/SmsAuthController.java +++ b/src/main/java/com/youlai/boot/auth/sms/controller/SmsAuthController.java @@ -5,7 +5,7 @@ import com.youlai.boot.common.annotation.Log; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.Result; -import com.youlai.boot.framework.security.model.AuthenticationToken; +import com.youlai.boot.framework.security.model.TokenPair; import com.youlai.boot.framework.web.ratelimit.RateLimit; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -30,6 +30,9 @@ public class SmsAuthController { private final SmsAuthService smsAuthService; + /** + * 发送手机登录验证码 + */ @Operation(summary = "[短信]发送登录短信验证码") @PostMapping("/code") @RateLimit(limit = 1, window = 60) @@ -40,14 +43,17 @@ public class SmsAuthController { return Result.success(); } + /** + * 校验手机验证码并创建登录会话 + */ @Operation(summary = "[短信]短信验证码登录") @PostMapping("/login") @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) - public Result login( + public Result login( @Parameter(description = "手机号", example = "18888888888") @RequestParam String mobile, @Parameter(description = "验证码", example = "123456") @RequestParam String code ) { - AuthenticationToken loginResult = smsAuthService.loginBySms(mobile, code); + TokenPair loginResult = smsAuthService.loginBySms(mobile, code); return Result.success(loginResult); } diff --git a/src/main/java/com/youlai/boot/auth/sms/service/SmsAuthService.java b/src/main/java/com/youlai/boot/auth/sms/service/SmsAuthService.java index bbbd371b..d728a8f7 100644 --- a/src/main/java/com/youlai/boot/auth/sms/service/SmsAuthService.java +++ b/src/main/java/com/youlai/boot/auth/sms/service/SmsAuthService.java @@ -3,8 +3,8 @@ package com.youlai.boot.auth.sms.service; import cn.hutool.core.util.StrUtil; import com.youlai.boot.auth.sms.token.SmsAuthenticationToken; import com.youlai.boot.common.constant.RedisConstants; -import com.youlai.boot.framework.security.model.AuthenticationToken; -import com.youlai.boot.framework.security.token.TokenManager; +import com.youlai.boot.framework.security.model.TokenPair; +import com.youlai.boot.framework.security.session.SessionService; import com.youlai.boot.support.sms.SmsTypeEnum; import com.youlai.boot.support.sms.service.SmsService; import lombok.RequiredArgsConstructor; @@ -31,7 +31,7 @@ import java.util.concurrent.TimeUnit; public class SmsAuthService { private final AuthenticationManager authenticationManager; - private final TokenManager tokenManager; + private final SessionService sessionService; private final SmsService smsService; private final RedisTemplate redisTemplate; @@ -65,15 +65,13 @@ public class SmsAuthService { * @param code 验证码 * @return 访问令牌 */ - public AuthenticationToken loginBySms(String mobile, String code) { - // 1. 创建用户短信验证码认证的令牌(未认证) + public TokenPair loginBySms(String mobile, String code) { SmsAuthenticationToken smsAuthenticationToken = new SmsAuthenticationToken(mobile, code); - // 2. 执行认证(认证中) Authentication authentication = authenticationManager.authenticate(smsAuthenticationToken); - // 3. 认证成功后生成 JWT 令牌,并存入 Security 上下文,供登录日志 AOP 使用(已认证) - AuthenticationToken authenticationToken = tokenManager.generateToken(authentication); + // 登录日志从安全上下文读取操作者 + TokenPair authenticationToken = sessionService.create(authentication); SecurityContextHolder.getContext().setAuthentication(authentication); return authenticationToken; } diff --git a/src/main/java/com/youlai/boot/auth/wxma/config/WxMaProperties.java b/src/main/java/com/youlai/boot/auth/wxma/config/WxMaProperties.java index 88f6fbfa..41f70591 100644 --- a/src/main/java/com/youlai/boot/auth/wxma/config/WxMaProperties.java +++ b/src/main/java/com/youlai/boot/auth/wxma/config/WxMaProperties.java @@ -1,19 +1,31 @@ package com.youlai.boot.auth.wxma.config; import lombok.Data; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; /** * 微信小程序配置属性 * * @author Ray.Hao + * @since 4.13.0 */ @Data @Component +@Validated @ConfigurationProperties(prefix = "wx.miniapp") public class WxMaProperties { + /** + * 当前小程序的固定认证源标识;不同 AppID 使用不同标识,不能复用已有账号关联 + */ + @NotBlank + @Pattern(regexp = "[a-z][a-z0-9-]{0,63}") + private String provider = "wechat-mini-main"; + // 小程序 appid private String appid; diff --git a/src/main/java/com/youlai/boot/auth/wxma/controller/WxMaAuthController.java b/src/main/java/com/youlai/boot/auth/wxma/controller/WxMaAuthController.java index 105ce860..df804369 100644 --- a/src/main/java/com/youlai/boot/auth/wxma/controller/WxMaAuthController.java +++ b/src/main/java/com/youlai/boot/auth/wxma/controller/WxMaAuthController.java @@ -8,7 +8,7 @@ import com.youlai.boot.common.annotation.Log; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; import com.youlai.boot.common.result.Result; -import com.youlai.boot.framework.security.model.AuthenticationToken; +import com.youlai.boot.framework.security.model.TokenPair; import com.youlai.boot.framework.web.ratelimit.RateLimit; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -35,7 +35,10 @@ public class WxMaAuthController { private final WxMaAuthService wxMaAuthService; - @Operation(summary = "[小程序]静默登录", description = "通过微信 code 登录,已绑定用户直接返回 token,未绑定用户返回 openid 需绑定手机号") + /** + * 校验微信登录凭证,返回会话或待绑定状态 + */ + @Operation(summary = "[小程序]静默登录", description = "通过微信 code 登录,未关联用户返回绑定手机号标记") @PostMapping("/silent-login") @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) @RateLimit @@ -47,21 +50,27 @@ public class WxMaAuthController { return Result.success(result); } + /** + * 通过微信手机号授权完成登录 + */ @Operation(summary = "[小程序]手机号快捷登录", description = "同时使用微信 code 和手机号授权 code 登录,适用于企业认证小程序") @PostMapping("/phone-login") @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) @RateLimit - public Result phoneLogin(@Valid @RequestBody WxMaPhoneLoginForm req) { - AuthenticationToken result = wxMaAuthService.phoneLogin(req.getLoginCode(), req.getPhoneCode()); + public Result phoneLogin(@Valid @RequestBody WxMaPhoneLoginForm req) { + TokenPair result = wxMaAuthService.phoneLogin(req.getLoginCode(), req.getPhoneCode()); return Result.success(result); } + /** + * 校验微信身份和手机验证码后关联本地账号 + */ @Operation(summary = "[小程序]绑定手机号", description = "为静默登录用户绑定手机号,绑定成功后自动登录") @PostMapping("/bind-mobile") @Log(module = LogModuleEnum.LOGIN, value = ActionTypeEnum.LOGIN) @RateLimit - public Result bindMobile(@Valid @RequestBody WxMaBindMobileForm req) { - AuthenticationToken result = wxMaAuthService.bindMobile(req.getOpenid(), req.getMobile(), req.getSmsCode()); + public Result bindMobile(@Valid @RequestBody WxMaBindMobileForm req) { + TokenPair result = wxMaAuthService.bindMobile(req.getLoginCode(), req.getMobile(), req.getSmsCode()); return Result.success(result); } } diff --git a/src/main/java/com/youlai/boot/auth/wxma/exception/MobileNotBoundException.java b/src/main/java/com/youlai/boot/auth/wxma/exception/MobileNotBoundException.java index 94b60fbe..81daecf9 100644 --- a/src/main/java/com/youlai/boot/auth/wxma/exception/MobileNotBoundException.java +++ b/src/main/java/com/youlai/boot/auth/wxma/exception/MobileNotBoundException.java @@ -3,28 +3,14 @@ package com.youlai.boot.auth.wxma.exception; import org.springframework.security.core.AuthenticationException; /** - * 微信小程序登录时手机号未绑定异常。 + * 微信小程序登录时手机号未绑定异常 * * @author Ray.Hao * @since 3.1.0 */ public class MobileNotBoundException extends AuthenticationException { - private final String openid; - - private final String sessionKey; - - public MobileNotBoundException(String openid, String sessionKey) { + public MobileNotBoundException() { super("需要绑定手机号"); - this.openid = openid; - this.sessionKey = sessionKey; - } - - public String getOpenid() { - return openid; - } - - public String getSessionKey() { - return sessionKey; } } diff --git a/src/main/java/com/youlai/boot/auth/wxma/model/form/WxMaBindMobileForm.java b/src/main/java/com/youlai/boot/auth/wxma/model/form/WxMaBindMobileForm.java index bb7c353f..ce9fc882 100644 --- a/src/main/java/com/youlai/boot/auth/wxma/model/form/WxMaBindMobileForm.java +++ b/src/main/java/com/youlai/boot/auth/wxma/model/form/WxMaBindMobileForm.java @@ -8,14 +8,16 @@ import lombok.Data; * 微信小程序绑定手机号请求 * * @author Ray.Hao + * @since 4.13.0 */ @Schema(description = "微信小程序绑定手机号请求") @Data public class WxMaBindMobileForm { - @NotBlank(message = "openid 不能为空") - @Schema(description = "微信用户唯一标识", example = "oVBkZ0aYgDMDIywRdgPW8-joxXc4") - private String openid; + // 提交绑定前重新调用 wx.login,服务端据此确认 OpenID,不接受客户端指定身份 + @NotBlank(message = "微信登录凭证不能为空") + @Schema(description = "wx.login 返回的一次性 code") + private String loginCode; @NotBlank(message = "手机号不能为空") @Schema(description = "手机号码", example = "18888888888") diff --git a/src/main/java/com/youlai/boot/auth/wxma/model/vo/WxMaLoginVO.java b/src/main/java/com/youlai/boot/auth/wxma/model/vo/WxMaLoginVO.java index 62552565..48c491c6 100644 --- a/src/main/java/com/youlai/boot/auth/wxma/model/vo/WxMaLoginVO.java +++ b/src/main/java/com/youlai/boot/auth/wxma/model/vo/WxMaLoginVO.java @@ -5,11 +5,13 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.ToString; /** * 微信小程序登录响应 * * @author Ray.Hao + * @since 4.13.0 */ @Data @Builder @@ -24,18 +26,17 @@ public class WxMaLoginVO { @Schema(description = "是否需要绑定手机号") private Boolean needBindMobile; - @Schema(description = "微信openid") - private String openid; - @Schema(description = "访问令牌") + @ToString.Exclude private String accessToken; @Schema(description = "刷新令牌") + @ToString.Exclude private String refreshToken; @Schema(description = "令牌类型") private String tokenType; @Schema(description = "过期时间(秒)") - private Integer expiresIn; + private Long expiresIn; } diff --git a/src/main/java/com/youlai/boot/auth/wxma/provider/WxMaAuthenticationProvider.java b/src/main/java/com/youlai/boot/auth/wxma/provider/WxMaAuthenticationProvider.java index 4f90cc79..4adf5dbf 100644 --- a/src/main/java/com/youlai/boot/auth/wxma/provider/WxMaAuthenticationProvider.java +++ b/src/main/java/com/youlai/boot/auth/wxma/provider/WxMaAuthenticationProvider.java @@ -3,35 +3,41 @@ package com.youlai.boot.auth.wxma.provider; import cn.binarywang.wx.miniapp.api.WxMaService; import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult; import cn.hutool.core.util.ObjectUtil; +import cn.hutool.core.util.StrUtil; import com.youlai.boot.auth.wxma.exception.MobileNotBoundException; +import com.youlai.boot.auth.wxma.config.WxMaProperties; import com.youlai.boot.auth.wxma.token.WxMaAuthenticationToken; -import com.youlai.boot.common.enums.SocialPlatformEnum; +import com.youlai.boot.common.constant.RedisConstants; import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.framework.security.model.SecurityUserDetails; import com.youlai.boot.framework.security.port.UserAuthenticationPort; -import com.youlai.boot.system.model.entity.UserSocial; -import com.youlai.boot.system.service.UserSocialService; +import com.youlai.boot.system.service.UserIdentityService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import me.chanjar.weixin.common.error.WxErrorException; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.authentication.AuthenticationProvider; import org.springframework.security.authentication.DisabledException; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.util.concurrent.TimeUnit; /** * 微信小程序认证 Provider * * @author Ray.Hao + * @since 4.13.0 */ @Slf4j @RequiredArgsConstructor public class WxMaAuthenticationProvider implements AuthenticationProvider { private final WxMaService wxMaService; + private final WxMaProperties wxMaProperties; private final UserAuthenticationPort userAuthPort; - private final UserSocialService userSocialService; + private final UserIdentityService userIdentityService; + private final RedisTemplate redisTemplate; @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { @@ -49,18 +55,14 @@ public class WxMaAuthenticationProvider implements AuthenticationProvider { log.info("微信小程序登录:openid={}", openid); - UserSocial userSocial = userSocialService.getByPlatformAndOpenid(SocialPlatformEnum.WECHAT_MINI, openid); + cacheSessionKey(openid, sessionKey); - if (userSocial == null) { - log.info("微信小程序登录:用户未绑定手机号,openid={}", openid); - throw new MobileNotBoundException(openid, sessionKey); - } - - SecurityUser securityUser = userAuthPort.getAuthInfoByOpenid(SocialPlatformEnum.WECHAT_MINI, openid); + // provider 对应当前 AppID,同一个 OpenID 字符串不会跨小程序复用关联 + String provider = wxMaProperties.getProvider(); + SecurityUser securityUser = userAuthPort.getAuthInfoByProviderAccount(provider, openid); if (securityUser == null) { - log.warn("微信小程序登录失败:用户不存在,openid={}", openid); - throw new UsernameNotFoundException("用户不存在"); + throw new MobileNotBoundException(); } if (ObjectUtil.notEqual(securityUser.getStatus(), 1)) { @@ -68,9 +70,8 @@ public class WxMaAuthenticationProvider implements AuthenticationProvider { throw new DisabledException("用户已被禁用"); } - userSocialService.updateSessionKey(userSocial.getId(), sessionKey); - SecurityUserDetails userDetails = new SecurityUserDetails(securityUser); + userIdentityService.markLogin(provider, openid); log.info("微信小程序登录成功:username={}, openid={}", securityUser.getUsername(), openid); return WxMaAuthenticationToken.authenticated(userDetails, userDetails.getAuthorities()); @@ -84,4 +85,18 @@ public class WxMaAuthenticationProvider implements AuthenticationProvider { public boolean supports(Class authentication) { return WxMaAuthenticationToken.class.isAssignableFrom(authentication); } + + /** + * 会话密钥只存 Redis,不落业务库 + */ + private void cacheSessionKey(String openid, String sessionKey) { + if (StrUtil.isBlank(openid) || StrUtil.isBlank(sessionKey)) { + return; + } + redisTemplate.opsForValue().set( + StrUtil.format(RedisConstants.WxMa.SESSION_KEY, wxMaProperties.getProvider(), openid), + sessionKey, + RedisConstants.WxMa.SESSION_KEY_TTL_DAYS, + TimeUnit.DAYS); + } } diff --git a/src/main/java/com/youlai/boot/auth/wxma/service/WxMaAuthService.java b/src/main/java/com/youlai/boot/auth/wxma/service/WxMaAuthService.java index b4f2128e..fbcd6ca1 100644 --- a/src/main/java/com/youlai/boot/auth/wxma/service/WxMaAuthService.java +++ b/src/main/java/com/youlai/boot/auth/wxma/service/WxMaAuthService.java @@ -6,17 +6,17 @@ import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo; import cn.hutool.core.util.IdUtil; import cn.hutool.core.util.StrUtil; import com.youlai.boot.auth.wxma.exception.MobileNotBoundException; +import com.youlai.boot.auth.wxma.config.WxMaProperties; import com.youlai.boot.auth.wxma.model.vo.WxMaLoginVO; import com.youlai.boot.auth.wxma.token.WxMaAuthenticationToken; import com.youlai.boot.common.constant.RedisConstants; -import com.youlai.boot.common.enums.SocialPlatformEnum; -import com.youlai.boot.framework.security.model.AuthenticationToken; +import com.youlai.boot.framework.security.model.TokenPair; import com.youlai.boot.framework.security.model.SecurityUserDetails; -import com.youlai.boot.framework.security.token.TokenManager; +import com.youlai.boot.framework.security.session.SessionService; import com.youlai.boot.system.model.entity.SysUser; +import com.youlai.boot.system.service.UserIdentityService; import com.youlai.boot.system.service.UserRoleService; import com.youlai.boot.system.service.UserService; -import com.youlai.boot.system.service.UserSocialService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; @@ -29,6 +29,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.Collections; +import java.util.concurrent.TimeUnit; /** * 微信小程序认证服务 @@ -42,10 +43,11 @@ import java.util.Collections; public class WxMaAuthService { private final WxMaService wxMaService; + private final WxMaProperties wxMaProperties; private final AuthenticationManager authenticationManager; - private final TokenManager tokenManager; + private final SessionService sessionService; private final UserService userService; - private final UserSocialService userSocialService; + private final UserIdentityService userIdentityService; private final UserRoleService userRoleService; private final RedisTemplate redisTemplate; @@ -53,28 +55,27 @@ public class WxMaAuthService { * 静默登录 * * @param code 微信登录凭证(wx.login 获取) - * @return 登录结果(成功返回 token,需绑定返回 openid) + * @return 已关联时返回令牌,否则返回需绑定手机号的标记 */ public WxMaLoginVO silentLogin(String code) { WxMaAuthenticationToken token = new WxMaAuthenticationToken(code); try { Authentication authentication = authenticationManager.authenticate(token); - AuthenticationToken authToken = tokenManager.generateToken(authentication); + TokenPair authToken = sessionService.create(authentication); SecurityContextHolder.getContext().setAuthentication(authentication); return WxMaLoginVO.builder() .isNewUser(false) .needBindMobile(false) - .accessToken(authToken.getAccessToken()) - .refreshToken(authToken.getRefreshToken()) - .tokenType(authToken.getTokenType()) - .expiresIn(authToken.getExpiresIn()) + .accessToken(authToken.accessToken()) + .refreshToken(authToken.refreshToken()) + .tokenType(authToken.tokenType()) + .expiresIn(authToken.expiresIn()) .build(); } catch (MobileNotBoundException e) { return WxMaLoginVO.builder() .isNewUser(true) .needBindMobile(true) - .openid(e.getOpenid()) .build(); } } @@ -87,7 +88,7 @@ public class WxMaAuthService { * @return 认证令牌 */ @Transactional(rollbackFor = Exception.class) - public AuthenticationToken phoneLogin(String loginCode, String phoneCode) { + public TokenPair phoneLogin(String loginCode, String phoneCode) { // 1. 解析微信登录凭证,获取会话信息 WxMaJscode2SessionResult session = resolveSession(loginCode); String openid = session.getOpenid(); @@ -110,13 +111,15 @@ public class WxMaAuthService { /** * 绑定手机号 * - * @param openid 微信用户唯一标识 + * @param loginCode 提交绑定前重新获取的微信一次性登录凭证 * @param mobile 手机号码 * @param smsCode 短信验证码 * @return 认证令牌 */ @Transactional(rollbackFor = Exception.class) - public AuthenticationToken bindMobile(String openid, String mobile, String smsCode) { + public TokenPair bindMobile(String loginCode, String mobile, String smsCode) { + // OpenID 只从微信接口取得,不能把前端提交的一串 ID 当成身份确认 + WxMaJscode2SessionResult session = resolveSession(loginCode); // 1. 验证短信验证码 validateSmsCode(mobile, smsCode); @@ -124,14 +127,9 @@ public class WxMaAuthService { SysUser user = findOrCreateUser(mobile); // 3. 绑定微信 openid - userSocialService.bindOrUpdate( - user.getId(), - SocialPlatformEnum.WECHAT_MINI, - openid, - null, null, null, null - ); + bindWechatOpenid(user, session); - log.info("微信小程序绑定手机号成功:mobile={}, openid={}", mobile, openid); + log.info("微信小程序绑定手机号成功:userId={}", user.getId()); // 4. 生成认证令牌 return generateAuthToken(mobile); @@ -146,7 +144,7 @@ public class WxMaAuthService { try { return wxMaService.jsCode2SessionInfo(loginCode); } catch (Exception e) { - log.error("获取微信会话信息失败,loginCode={}", loginCode, e); + log.error("获取微信会话信息失败", e); throw new IllegalArgumentException("微信登录失败:" + e.getMessage()); } } @@ -159,7 +157,7 @@ public class WxMaAuthService { WxMaPhoneNumberInfo phoneInfo = wxMaService.getUserService().getPhoneNoInfo(phoneCode); return phoneInfo.getPhoneNumber(); } catch (Exception e) { - log.error("获取微信手机号失败,phoneCode={}", phoneCode, e); + log.error("获取微信手机号失败", e); throw new IllegalArgumentException("获取手机号失败:" + e.getMessage()); } } @@ -195,7 +193,7 @@ public class WxMaAuthService { userService.save(user); // 分配 GUEST 角色(角色ID=3) - userRoleService.saveUserRoles(user.getId(), Collections.singletonList(3L)); + userRoleService.replaceUserRoles(user.getId(), Collections.singletonList(3L)); return user; } @@ -204,20 +202,24 @@ public class WxMaAuthService { * 绑定微信 openid */ private void bindWechatOpenid(SysUser user, WxMaJscode2SessionResult session) { - try { - userSocialService.bindOrUpdate( - user.getId(), - SocialPlatformEnum.WECHAT_MINI, - session.getOpenid(), - session.getUnionid(), - user.getNickname(), - user.getAvatar(), - session.getSessionKey() - ); - } catch (Exception e) { - // 绑定失败不影响登录 - log.warn("绑定微信 openid 失败,userId={}, openid={}", user.getId(), session.getOpenid(), e); + // 冲突必须回滚本次用户创建和关联,不能吞掉失败后继续签发令牌 + userIdentityService.bindIdentity(user.getId(), wxMaProperties.getProvider(), session.getOpenid()); + cacheSessionKey(session.getOpenid(), session.getSessionKey()); + userIdentityService.markLogin(wxMaProperties.getProvider(), session.getOpenid()); + } + + /** + * 缓存微信会话密钥,供后续解密类接口使用;数据库不再保存该凭证 + */ + private void cacheSessionKey(String openid, String sessionKey) { + if (StrUtil.isBlank(openid) || StrUtil.isBlank(sessionKey)) { + return; } + redisTemplate.opsForValue().set( + StrUtil.format(RedisConstants.WxMa.SESSION_KEY, wxMaProperties.getProvider(), openid), + sessionKey, + RedisConstants.WxMa.SESSION_KEY_TTL_DAYS, + TimeUnit.DAYS); } /** @@ -238,12 +240,12 @@ public class WxMaAuthService { /** * 生成认证令牌 */ - private AuthenticationToken generateAuthToken(String mobile) { + private TokenPair generateAuthToken(String mobile) { SecurityUserDetails userDetails = new SecurityUserDetails(userService.getAuthInfoByMobile(mobile)); Authentication authentication = new UsernamePasswordAuthenticationToken( userDetails, null, userDetails.getAuthorities() ); - AuthenticationToken authToken = tokenManager.generateToken(authentication); + TokenPair authToken = sessionService.create(authentication); SecurityContextHolder.getContext().setAuthentication(authentication); return authToken; } diff --git a/src/main/java/com/youlai/boot/codegen/model/query/TablePageQuery.java b/src/main/java/com/youlai/boot/codegen/model/query/TablePageQuery.java deleted file mode 100644 index 119fefbe..00000000 --- a/src/main/java/com/youlai/boot/codegen/model/query/TablePageQuery.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.youlai.boot.codegen.model.query; - -import com.fasterxml.jackson.annotation.JsonIgnore; -import com.youlai.boot.common.base.BaseQuery; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Getter; -import lombok.Setter; - -import java.util.List; - -/** - * 数据表分页查询对象 - * - * @author Ray - * @since 2.10.0 - */ -@Schema(description = "数据表分页查询对象") -@Getter -@Setter -public class TablePageQuery extends BaseQuery { - - @Schema(description="关键字(表名)") - private String keywords; - - // 排除的表名 - @JsonIgnore - private List excludeTables; - -} diff --git a/src/main/java/com/youlai/boot/codegen/model/query/TableQuery.java b/src/main/java/com/youlai/boot/codegen/model/query/TableQuery.java index 32c0b2d0..c1bce828 100644 --- a/src/main/java/com/youlai/boot/codegen/model/query/TableQuery.java +++ b/src/main/java/com/youlai/boot/codegen/model/query/TableQuery.java @@ -9,12 +9,12 @@ import lombok.Setter; import java.util.List; /** - * 数据表查询对象 + * 数据表分页查询对象 * * @author Ray * @since 2.10.0 */ -@Schema(description = "数据表查询对象") +@Schema(description = "数据表分页查询对象") @Getter @Setter public class TableQuery extends BaseQuery { diff --git a/src/main/java/com/youlai/boot/codegen/service/impl/CodegenServiceImpl.java b/src/main/java/com/youlai/boot/codegen/service/impl/CodegenServiceImpl.java index 37f88c40..f4513a5d 100644 --- a/src/main/java/com/youlai/boot/codegen/service/impl/CodegenServiceImpl.java +++ b/src/main/java/com/youlai/boot/codegen/service/impl/CodegenServiceImpl.java @@ -36,7 +36,7 @@ import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; /** - * 代码生成服务实现类。 + * 代码生成服务实现类 * * @author Ray * @since 2.10.0 @@ -201,7 +201,7 @@ public class CodegenServiceImpl implements CodegenService { } /** - * 生成文件名。 + * 生成文件名 * * @param entityName 实体名(例如 User) * @param templateName 模板名(例如 Entity、Controller、API) @@ -224,7 +224,7 @@ public class CodegenServiceImpl implements CodegenService { } /** - * 生成文件路径。 + * 生成文件路径 * * @param templateName 模板名 * @param moduleName 模块名(例如 system) @@ -281,9 +281,10 @@ public class CodegenServiceImpl implements CodegenService { } /** - * 渲染模板,生成代码内容。 + * 渲染模板,生成代码内容 * - * @param templateConfig 模板配置 + * @param templatePath 模板路径 + * @param subpackageName 生成模型所属子包 * @param genTable 表生成配置 * @param fieldConfigs 字段配置 * @param pageType 前端页面类型 @@ -316,9 +317,12 @@ public class CodegenServiceImpl implements CodegenService { bindMap.put("entityComment", genTable.getBusinessName()); bindMap.put("fieldConfigs", fieldConfigs); + boolean hasLocalDate = false; boolean hasLocalDateTime = false; boolean hasBigDecimal = false; - boolean hasRequiredField = false; + boolean hasNotBlank = false; + boolean hasNotNull = false; + boolean hasSize = false; for (GenTableColumn fieldConfig : fieldConfigs) { @@ -326,21 +330,37 @@ public class CodegenServiceImpl implements CodegenService { fieldConfig.setFieldType(JavaTypeEnum.getJavaTypeByColumnType(fieldConfig.getColumnType())); } - if ("LocalDateTime".equals(fieldConfig.getFieldType()) || "LocalDate".equals(fieldConfig.getFieldType())) { + if ("LocalDate".equals(fieldConfig.getFieldType())) { + hasLocalDate = true; + } + if ("LocalDateTime".equals(fieldConfig.getFieldType())) { hasLocalDateTime = true; } if ("BigDecimal".equals(fieldConfig.getFieldType())) { hasBigDecimal = true; } - if (ObjectUtil.equals(fieldConfig.getIsRequired(), 1)) { - hasRequiredField = true; + if (ObjectUtil.equals(fieldConfig.getIsShowInForm(), 1) && !"id".equals(fieldConfig.getFieldName())) { + boolean isString = "String".equals(fieldConfig.getFieldType()); + if (ObjectUtil.equals(fieldConfig.getIsRequired(), 1)) { + if (isString) { + hasNotBlank = true; + } else { + hasNotNull = true; + } + } + if (isString && fieldConfig.getMaxLength() != null && fieldConfig.getMaxLength() > 0) { + hasSize = true; + } } fieldConfig.setTsType(JavaTypeEnum.getTsTypeByJavaType(fieldConfig.getFieldType())); } + bindMap.put("hasLocalDate", hasLocalDate); bindMap.put("hasLocalDateTime", hasLocalDateTime); bindMap.put("hasBigDecimal", hasBigDecimal); - bindMap.put("hasRequiredField", hasRequiredField); + bindMap.put("hasNotBlank", hasNotBlank); + bindMap.put("hasNotNull", hasNotNull); + bindMap.put("hasSize", hasSize); TemplateEngine templateEngine = TemplateUtil.createEngine(new TemplateConfig("codegen", TemplateConfig.ResourceMode.CLASSPATH)); // 根据 ui 选择不同的前端页面模板:默认 index.vue.vm;封装版使用 index.crud.vue.vm @@ -358,7 +378,7 @@ public class CodegenServiceImpl implements CodegenService { } /** - * 下载代码。 + * 下载代码 * * @param tableNames 表名数组,支持多张表 * @param ui 页面类型 @@ -384,7 +404,7 @@ public class CodegenServiceImpl implements CodegenService { } /** - * 根据表名生成代码并压缩到 zip 文件中。 + * 根据表名生成代码并压缩到 zip 文件中 * * @param tableName 表名 * @param zip 压缩文件输出流 diff --git a/src/main/java/com/youlai/boot/codegen/service/impl/GenTableServiceImpl.java b/src/main/java/com/youlai/boot/codegen/service/impl/GenTableServiceImpl.java index f00a0c04..737149f8 100644 --- a/src/main/java/com/youlai/boot/codegen/service/impl/GenTableServiceImpl.java +++ b/src/main/java/com/youlai/boot/codegen/service/impl/GenTableServiceImpl.java @@ -243,9 +243,7 @@ public class GenTableServiceImpl extends ServiceImpl i } } - //--------------------------------------------------- - // AI 辅助:未开启 AI 时调用返回业务异常 - //--------------------------------------------------- + // AI 未启用时返回业务异常 /** * 用 AI 推断代码生成配置 diff --git a/src/main/java/com/youlai/boot/common/base/BaseQuery.java b/src/main/java/com/youlai/boot/common/base/BaseQuery.java index 697d1c72..2acfd7c8 100644 --- a/src/main/java/com/youlai/boot/common/base/BaseQuery.java +++ b/src/main/java/com/youlai/boot/common/base/BaseQuery.java @@ -1,6 +1,5 @@ package com.youlai.boot.common.base; -import com.youlai.boot.common.annotation.ValidField; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; @@ -31,14 +30,6 @@ public class BaseQuery implements Serializable { @Max(value = 200, message = "每页记录数不能超过 200") private Integer pageSize = 10; - @Schema(description = "排序字段", requiredMode = Schema.RequiredMode.NOT_REQUIRED) - @ValidField(allowedValues = {"create_time", "update_time"}) - private String sortBy; - - @Schema(description = "排序方式(正序:asc;反序:desc,不区分大小写)", requiredMode = Schema.RequiredMode.NOT_REQUIRED) - @ValidField(allowedValues = {"asc", "desc", "ASC", "DESC"}) - private String order; - public boolean isPaged() { return pageNum != null && pageSize != null && pageSize > 0; } diff --git a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java b/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java deleted file mode 100644 index dbe76442..00000000 --- a/src/main/java/com/youlai/boot/common/constant/JwtClaimConstants.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.youlai.boot.common.constant; - -/** - * JWT Claims声明常量 - * - * @author haoxr - * @since 2023/11/24 - */ -public interface JwtClaimConstants { - - // 令牌类型 - String TOKEN_TYPE = "tokenType"; - - // 用户ID - String USER_ID = "userId"; - - // 部门ID - String DEPT_ID = "deptId"; - - // 数据权限列表 - String DATA_SCOPES = "dataScopes"; - - // 角色编码集合(不带 ROLE_ 前缀) - String ROLES = "roles"; - - // Token 版本号 - String TOKEN_VERSION = "tokenVersion"; - -} diff --git a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java index 45c91c2f..dc7b7702 100644 --- a/src/main/java/com/youlai/boot/common/constant/RedisConstants.java +++ b/src/main/java/com/youlai/boot/common/constant/RedisConstants.java @@ -30,21 +30,19 @@ public interface RedisConstants { * 认证模块 */ interface Auth { - // 存储访问令牌对应的用户会话信息(accessToken -> UserSession) - String ACCESS_TOKEN_USER = "auth:token:access:{}"; - // 存储刷新令牌对应的用户会话信息(refreshToken -> UserSession) - String REFRESH_TOKEN_USER = "auth:token:refresh:{}"; - // 用户与访问令牌的映射(userId -> accessToken) - String USER_ACCESS_TOKEN = "auth:user:access:{}"; - // 用户与刷新令牌的映射(userId -> refreshToken)) - String USER_REFRESH_TOKEN = "auth:user:refresh:{}"; - // 已撤销 Token 的 JTI(单端退出/会话注销):如果 jti 在撤销列表中,则 Token 立即无效 - String BLACKLIST_TOKEN = "auth:token:blacklist:{}"; - String REVOKED_JTI = BLACKLIST_TOKEN; - // 用户 Token 版本号(用于按用户失效历史 JWT):token.tokenVersion != redis.tokenVersion => token 无效 - String USER_TOKEN_VERSION = "auth:user:token_version:{}"; // 扫码登录票据上下文(ticket -> QrCodeLoginContext) - String QR_CODE_LOGIN = "auth:qr_code:{}"; + String QR_CODE_LOGIN = "auth:qr_code:v2:{}"; + // 统一认证一次性交接票据(ticket -> OidcTicketContext) + String OIDC_TICKET = "auth:oidc:ticket:v2:{}"; + } + + /** + * 微信小程序模块 + */ + interface WxMa { + // 微信会话密钥(provider + OpenID -> session_key),不同小程序分别存储 + String SESSION_KEY = "auth:wxma:session_key:{}:{}"; + long SESSION_KEY_TTL_DAYS = 3; } /** diff --git a/src/main/java/com/youlai/boot/common/enums/SocialPlatformEnum.java b/src/main/java/com/youlai/boot/common/enums/SocialPlatformEnum.java deleted file mode 100644 index 02708922..00000000 --- a/src/main/java/com/youlai/boot/common/enums/SocialPlatformEnum.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.youlai.boot.common.enums; - -import com.baomidou.mybatisplus.annotation.EnumValue; -import com.youlai.boot.common.base.IBaseEnum; -import lombok.Getter; - -/** - * 第三方登录平台类型枚举。 - * - * @author Ray.Hao - * @since 2.17.0 - */ -@Getter -public enum SocialPlatformEnum implements IBaseEnum { - - WECHAT_MINI("WECHAT_MINI", "微信小程序"), - WECHAT_MP("WECHAT_MP", "微信公众号"), - WECHAT_OPEN("WECHAT_OPEN", "微信开放平台"), - ALIPAY("ALIPAY", "支付宝"), - QQ("QQ", "QQ"), - APPLE("APPLE", "Apple ID"); - - @EnumValue - private final String value; - - private final String label; - - SocialPlatformEnum(String value, String label) { - this.value = value; - this.label = label; - } - -} diff --git a/src/main/java/com/youlai/boot/common/result/ResultCode.java b/src/main/java/com/youlai/boot/common/result/ResultCode.java index 4a91fe77..d0260e2b 100644 --- a/src/main/java/com/youlai/boot/common/result/ResultCode.java +++ b/src/main/java/com/youlai/boot/common/result/ResultCode.java @@ -12,67 +12,74 @@ public enum ResultCode implements IResultCode, Serializable { SUCCESS("00000", "成功"), - /** 一级宏观错误码:用户端错误(由客户端输入/认证/权限/请求方式等引起,需客户端配合修正) */ + // 一级宏观错误码:用户端错误(由客户端输入/认证/权限/请求方式等引起,需客户端配合修正) USER_ERROR("A0001", "用户端错误"), - /** 二级宏观错误码:用户端具体错误(按号段细分,便于定位是注册/登录/令牌/参数/防重等问题) */ + // 二级宏观错误码:用户端具体错误(按号段细分,便于定位是注册/登录/令牌/参数/防重等问题) - /** A01xx:用户注册错误 */ + // A01xx:用户注册错误 USER_REGISTRATION_ERROR("A0100", "用户注册错误"), USER_NOT_AGREE_PRIVACY_AGREEMENT("A0101", "用户未同意隐私协议"), - /** A013x:校验码输入错误 */ + // A013x:校验码输入错误 VERIFICATION_CODE_INPUT_ERROR("A0130", "校验码输入错误"), - /** A02xx:用户登录异常 */ + // A02xx:用户登录异常 USER_LOGIN_EXCEPTION("A0200", "用户登录异常"), ACCOUNT_NOT_FOUND("A0201", "用户账户不存在"), ACCOUNT_FROZEN("A0202", "用户账户被冻结"), USER_PASSWORD_ERROR("A0210", "用户名或密码错误"), - /** A023x:令牌无效或已过期 */ + // A023x:令牌无效或已过期 ACCESS_TOKEN_INVALID("A0230", "访问令牌无效或已过期"), REFRESH_TOKEN_INVALID("A0231", "刷新令牌无效或已过期"), - /** A024x:验证码错误 */ + // A024x:验证码错误 USER_VERIFICATION_CODE_ERROR("A0240", "验证码错误"), USER_VERIFICATION_CODE_ATTEMPT_LIMIT_EXCEEDED("A0241", "用户验证码尝试次数超限"), USER_VERIFICATION_CODE_EXPIRED("A0242", "用户验证码过期"), - /** A025x:扫码登录异常 */ + // A025x:扫码登录异常 QR_CODE_NOT_FOUND("A0250", "扫码登录票据不存在或已过期"), QR_CODE_STATUS_ILLEGAL("A0251", "当前扫码状态不允许该操作"), QR_CODE_USER_MISMATCH("A0252", "操作用户与扫码用户不一致"), QR_CODE_ALREADY_USED("A0253", "扫码登录票据已被使用"), - /** A03xx:访问权限异常 */ + // A026x:统一认证登录异常 + OIDC_TICKET_INVALID("A0260", "统一认证登录票据无效或已过期"), + OIDC_ACCOUNT_NOT_BOUND("A0262", "该统一认证账号尚未开通业务账号,请联系管理员"), + OIDC_LOGIN_FAILED("A0264", "统一认证登录失败"), + + // A03xx:访问权限异常 ACCESS_PERMISSION_EXCEPTION("A0300", "访问权限异常"), ACCESS_UNAUTHORIZED("A0301", "访问未授权"), - /** A04xx:用户请求参数错误 */ + // A04xx:用户请求参数错误 USER_REQUEST_PARAMETER_ERROR("A0400", "用户请求参数错误"), INVALID_USER_INPUT("A0402", "无效的用户输入"), REQUEST_REQUIRED_PARAMETER_IS_EMPTY("A0410", "请求必填参数为空"), PARAMETER_FORMAT_MISMATCH("A0421", "参数格式不匹配"), - /** A05xx:用户请求服务异常 */ + // A05xx:用户请求服务异常 USER_REQUEST_SERVICE_EXCEPTION("A0500", "用户请求服务异常"), REQUEST_CONCURRENCY_LIMIT_EXCEEDED("A0502", "请求并发数超出限制"), DUPLICATE_SUBMISSION("A0506", "请勿重复提交"), - /** A07xx:文件处理异常 */ + // A07xx:文件处理异常 UPLOAD_FILE_EXCEPTION("A0700", "上传文件异常"), DELETE_FILE_EXCEPTION("A0710", "删除文件异常"), - /** 一级宏观错误码:系统端错误(服务端内部异常/超时/不可用等,需后端排查修复) */ + // 一级宏观错误码:系统端错误(服务端内部异常/超时/不可用等,需后端排查修复) SYSTEM_ERROR("B0001", "系统执行出错"), - /** 二级宏观错误码:系统端具体错误(按号段细分,便于定位超时/限流/资源耗尽等) */ + // 二级宏观错误码:系统端具体错误(按号段细分,便于定位超时/限流/资源耗尽等) SYSTEM_EXECUTION_TIMEOUT("B0100", "系统执行超时"), + AUTH_SERVICE_UNAVAILABLE("B0101", "认证服务暂不可用,请稍后重试"), + REALTIME_DISABLED("B0102", "实时通信未启用"), - /** 一级宏观错误码:第三方服务错误(外部依赖/中间件/数据库等引起,需检查依赖健康与配置) */ + // 一级宏观错误码:第三方服务错误(外部依赖/中间件/数据库等引起,需检查依赖健康与配置) THIRD_PARTY_SERVICE_ERROR("C0001", "调用第三方服务出错"), - /** 二级宏观错误码:第三方服务具体错误(按号段细分,便于定位是接口不存在/数据库异常等) */ + // 二级宏观错误码:第三方服务具体错误(按号段细分,便于定位是接口不存在/数据库异常等) INTERFACE_NOT_EXIST("C0113", "接口不存在"), DATABASE_SERVICE_ERROR("C0300", "数据库服务出错"), DATABASE_EXECUTION_ERROR("C0310", "数据库执行异常"), diff --git a/src/main/java/com/youlai/boot/config/JacksonConfig.java b/src/main/java/com/youlai/boot/config/JacksonConfig.java index 6fd3dafd..de398f18 100644 --- a/src/main/java/com/youlai/boot/config/JacksonConfig.java +++ b/src/main/java/com/youlai/boot/config/JacksonConfig.java @@ -1,5 +1,6 @@ package com.youlai.boot.config; +import com.fasterxml.jackson.annotation.JsonFormat; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import tools.jackson.databind.cfg.DateTimeFeature; @@ -9,6 +10,7 @@ import tools.jackson.databind.ser.std.ToStringSerializer; import java.math.BigInteger; import java.text.SimpleDateFormat; +import java.time.LocalDateTime; import java.util.TimeZone; /** @@ -20,6 +22,9 @@ import java.util.TimeZone; @Configuration public class JacksonConfig { + // 默认日期时间格式,Date 与 LocalDateTime 共用 + private static final String DEFAULT_DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; + /** * 全局 JsonMapper */ @@ -27,8 +32,11 @@ public class JacksonConfig { public JsonMapper objectMapper() { return JsonMapper.builder() .disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS) - .defaultDateFormat(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss")) + .defaultDateFormat(new SimpleDateFormat(DEFAULT_DATE_TIME_PATTERN)) .defaultTimeZone(TimeZone.getTimeZone("GMT+8")) + // LocalDateTime 没有全局格式开关,按类型覆盖默认格式;字段上的 @JsonFormat 仍优先 + .withConfigOverride(LocalDateTime.class, + override -> override.setFormat(JsonFormat.Value.forPattern(DEFAULT_DATE_TIME_PATTERN))) .addModule(new SimpleModule() .addSerializer(Long.class, ToStringSerializer.instance) .addSerializer(BigInteger.class, ToStringSerializer.instance) diff --git a/src/main/java/com/youlai/boot/config/Knife4jOpenApiCustomizer.java b/src/main/java/com/youlai/boot/config/Knife4jOpenApiCustomizer.java deleted file mode 100644 index b41e151d..00000000 --- a/src/main/java/com/youlai/boot/config/Knife4jOpenApiCustomizer.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.youlai.boot.config; - - -import com.github.xiaoymin.knife4j.annotations.ApiSupport; -import com.github.xiaoymin.knife4j.core.conf.ExtensionsConstants; -import com.github.xiaoymin.knife4j.core.conf.GlobalConstants; -import com.github.xiaoymin.knife4j.spring.configuration.Knife4jProperties; -import com.github.xiaoymin.knife4j.spring.configuration.Knife4jSetting; -import com.github.xiaoymin.knife4j.spring.extension.OpenApiExtensionResolver; -import io.swagger.v3.oas.annotations.tags.Tag; -import io.swagger.v3.oas.models.OpenAPI; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.ArrayUtils; -import org.springdoc.core.customizers.GlobalOpenApiCustomizer; -import org.springdoc.core.properties.SpringDocConfigProperties; -import org.springframework.beans.factory.config.BeanDefinition; -import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; -import org.springframework.core.type.filter.AnnotationTypeFilter; -import org.springframework.util.CollectionUtils; -import org.springframework.web.bind.annotation.RestController; - -import java.lang.annotation.Annotation; -import java.util.*; -import java.util.stream.Collectors; - -/** - * 增强扩展属性支持 - * @since 4.1.0 - * @author xiaoymin@foxmail.com - * 2022/12/11 22:40 - */ -@Primary -@Configuration -@Slf4j -public class Knife4jOpenApiCustomizer extends com.github.xiaoymin.knife4j.spring.extension.Knife4jOpenApiCustomizer implements GlobalOpenApiCustomizer { - final Knife4jProperties knife4jProperties; - final SpringDocConfigProperties properties; - public Knife4jOpenApiCustomizer(Knife4jProperties knife4jProperties, SpringDocConfigProperties properties) { - super(knife4jProperties,properties); - this.knife4jProperties = knife4jProperties; - this.properties = properties; - } - - @Override - public void customise(OpenAPI openApi) { - log.debug("Knife4j OpenApiCustomizer"); - if (knife4jProperties.isEnable()) { - Knife4jSetting setting = knife4jProperties.getSetting(); - OpenApiExtensionResolver openApiExtensionResolver = new OpenApiExtensionResolver(setting, knife4jProperties.getDocuments()); - // 解析初始化 - openApiExtensionResolver.start(); - Map objectMap = new HashMap<>(); - objectMap.put(GlobalConstants.EXTENSION_OPEN_SETTING_NAME, setting); - objectMap.put(GlobalConstants.EXTENSION_OPEN_MARKDOWN_NAME, openApiExtensionResolver.getMarkdownFiles()); - openApi.addExtension(GlobalConstants.EXTENSION_OPEN_API_NAME, objectMap); - addOrderExtension(openApi); - } - } - - /** - * 往OpenAPI内tags字段添加x-order属性 - * - * @param openApi openApi - */ - - private void addOrderExtension(OpenAPI openApi) { - if (CollectionUtils.isEmpty(properties.getGroupConfigs())) { - return; - } - // 获取包扫描路径 - Set packagesToScan = - properties.getGroupConfigs().stream() - .map(SpringDocConfigProperties.GroupConfig::getPackagesToScan) - .filter(toScan -> !CollectionUtils.isEmpty(toScan)) - .flatMap(List::stream) - .collect(Collectors.toSet()); - if (CollectionUtils.isEmpty(packagesToScan)) { - return; - } - // 扫描包下被ApiSupport注解的RestController Class - Set> classes = - packagesToScan.stream() - .map(packageToScan -> scanPackageByAnnotation(packageToScan, RestController.class)) - .flatMap(Set::stream) - .filter(clazz -> clazz.isAnnotationPresent(ApiSupport.class)) - .collect(Collectors.toSet()); - if (!CollectionUtils.isEmpty(classes)) { - // ApiSupport oder值存入tagSortMap - Map tagOrderMap = new HashMap<>(); - classes.forEach( - clazz -> { - Tag tag = getTag(clazz); - if (Objects.nonNull(tag)) { - ApiSupport apiSupport = clazz.getAnnotation(ApiSupport.class); - tagOrderMap.putIfAbsent(tag.name(), apiSupport.order()); - } - }); - // 往openApi tags字段添加x-order增强属性 - if (openApi.getTags() != null) { - openApi - .getTags() - .forEach( - tag -> { - if (tagOrderMap.containsKey(tag.getName())) { - tag.addExtension( - ExtensionsConstants.EXTENSION_ORDER, tagOrderMap.get(tag.getName())); - } - }); - } - } - } - - private Tag getTag(Class clazz) { - // 从类上获取 - Tag tag = clazz.getAnnotation(Tag.class); - if (Objects.isNull(tag)) { - // 从接口上获取 - Class[] interfaces = clazz.getInterfaces(); - if (ArrayUtils.isNotEmpty(interfaces)) { - for (Class interfaceClazz : interfaces) { - Tag anno = interfaceClazz.getAnnotation(Tag.class); - if (Objects.nonNull(anno)) { - tag = anno; - break; - } - } - } - } - return tag; - } - - private Set> scanPackageByAnnotation( - String packageName, final Class annotationClass) { - ClassPathScanningCandidateComponentProvider scanner = - new ClassPathScanningCandidateComponentProvider(false); - scanner.addIncludeFilter(new AnnotationTypeFilter(annotationClass)); - Set> classes = new HashSet<>(); - for (BeanDefinition beanDefinition : scanner.findCandidateComponents(packageName)) { - try { - Class clazz = Class.forName(beanDefinition.getBeanClassName()); - classes.add(clazz); - } catch (ClassNotFoundException ignore) { - - } - } - return classes; - } -} diff --git a/src/main/java/com/youlai/boot/config/OpenApiConfig.java b/src/main/java/com/youlai/boot/config/OpenApiConfig.java index 2fb197e6..8d81f380 100644 --- a/src/main/java/com/youlai/boot/config/OpenApiConfig.java +++ b/src/main/java/com/youlai/boot/config/OpenApiConfig.java @@ -24,7 +24,7 @@ import java.util.stream.Stream; * OpenAPI 接口文档配置 * * @author Ray.Hao - * @see knife4j 快速开始 + * @see springdoc 文档 * @since 2023/2/17 */ @Configuration diff --git a/src/main/java/com/youlai/boot/config/SecurityConfig.java b/src/main/java/com/youlai/boot/config/SecurityConfig.java index 5fc58f72..038e50dd 100644 --- a/src/main/java/com/youlai/boot/config/SecurityConfig.java +++ b/src/main/java/com/youlai/boot/config/SecurityConfig.java @@ -4,16 +4,16 @@ import cn.binarywang.wx.miniapp.api.WxMaService; import cn.hutool.core.util.ArrayUtil; import com.youlai.boot.auth.password.service.CaptchaService; import com.youlai.boot.framework.security.config.SecurityProperties; -import com.youlai.boot.framework.security.filter.TokenAuthenticationFilter; import com.youlai.boot.framework.security.handler.JsonAccessDeniedHandler; import com.youlai.boot.framework.security.handler.JsonAuthenticationEntryPoint; import com.youlai.boot.framework.security.port.UserAuthenticationPort; import com.youlai.boot.framework.security.service.SecurityUserDetailsService; -import com.youlai.boot.framework.security.token.TokenManager; +import com.youlai.boot.framework.security.session.SessionService; import com.youlai.boot.auth.password.filter.CaptchaValidationFilter; import com.youlai.boot.auth.sms.provider.SmsAuthenticationProvider; import com.youlai.boot.auth.wxma.provider.WxMaAuthenticationProvider; -import com.youlai.boot.system.service.UserSocialService; +import com.youlai.boot.auth.wxma.config.WxMaProperties; +import com.youlai.boot.system.service.UserIdentityService; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -21,18 +21,22 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.config.ObjectPostProcessor; +import org.springframework.security.oauth2.server.resource.web.authentication.BearerTokenAuthenticationFilter; +import org.springframework.security.web.authentication.AuthenticationEntryPointFailureHandler; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; -import org.springframework.security.web.access.intercept.AuthorizationFilter; import jakarta.servlet.DispatcherType; +import org.springframework.http.HttpMethod; /** * Spring Security 配置类 @@ -48,17 +52,19 @@ public class SecurityConfig { private final RedisTemplate redisTemplate; private final PasswordEncoder passwordEncoder; - private final TokenManager tokenManager; + private final SessionService sessionService; private final SecurityUserDetailsService userDetailsService; private final CaptchaService captchaService; private final SecurityProperties securityProperties; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - return http + http .authorizeHttpRequests(requestMatcherRegistry -> { - // ASYNC 分发(SSE 超时/完成后的异步 dispatch)无 Authorization 头,直接放行 + // 异步响应的后续分发复用首次请求认证结果 requestMatcherRegistry.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll(); + // 握手使用一次性票据;其余实时接口仍需 Bearer 认证 + requestMatcherRegistry.requestMatchers(HttpMethod.GET, "/api/v1/realtime/ws").permitAll(); String[] permitAllUrls = securityProperties.getPermitAllUrls(); if (ArrayUtil.isNotEmpty(permitAllUrls)) { requestMatcherRegistry.requestMatchers(permitAllUrls).permitAll(); @@ -78,11 +84,26 @@ public class SecurityConfig { .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) - // 验证码校验(使用方过滤器,直接写 JSON 响应) .addFilterBefore(new CaptchaValidationFilter(captchaService), UsernamePasswordAuthenticationFilter.class) - // Token 认证(Starter 过滤器,抛 AuthenticationException 交给 ExceptionTranslationFilter 处理) - .addFilterBefore(new TokenAuthenticationFilter(tokenManager), AuthorizationFilter.class) - .build(); + .oauth2ResourceServer(resource -> resource + .authenticationManagerResolver(request -> authentication -> { + var bearer = (BearerTokenAuthenticationToken) authentication; + return sessionService.authenticate(bearer.getToken()).toAuthentication(); + }) + .authenticationEntryPoint(new JsonAuthenticationEntryPoint()) + .withObjectPostProcessor(new ObjectPostProcessor() { + @Override + public O postProcess(O filter) { + var failureHandler = new AuthenticationEntryPointFailureHandler(new JsonAuthenticationEntryPoint()); + // 存储故障返回 JSON 503,不交给容器生成错误页 + failureHandler.setRethrowAuthenticationServiceException(false); + filter.setAuthenticationFailureHandler(failureHandler); + return filter; + } + }) + .accessDeniedHandler(new JsonAccessDeniedHandler())); + + return http.build(); } @Bean @@ -100,10 +121,12 @@ public class SecurityConfig { @Bean public WxMaAuthenticationProvider wechatMiniAuthenticationProvider( WxMaService wxMaService, + WxMaProperties wxMaProperties, UserAuthenticationPort userAuthenticationPort, - UserSocialService userSocialService + UserIdentityService userIdentityService ) { - return new WxMaAuthenticationProvider(wxMaService, userAuthenticationPort, userSocialService); + return new WxMaAuthenticationProvider(wxMaService, wxMaProperties, userAuthenticationPort, + userIdentityService, redisTemplate); } @Bean diff --git a/src/main/java/com/youlai/boot/framework/mybatis/QuerySort.java b/src/main/java/com/youlai/boot/framework/mybatis/QuerySort.java new file mode 100644 index 00000000..596cbe38 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/mybatis/QuerySort.java @@ -0,0 +1,53 @@ +package com.youlai.boot.framework.mybatis; + +import com.baomidou.mybatisplus.core.metadata.OrderItem; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 将公开排序属性转换为资源允许的数据库列 + * + * @author Ray.Hao + * @since 4.13.0 + */ +public final class QuerySort { + + private static final int MAX_SORT_ITEMS = 3; + + private QuerySort() { + } + + /** + * 按输入顺序解析排序;未提供时返回空列表,由资源决定默认顺序 + */ + public static List parse(String[] expressions, Map fields) { + List orders = new ArrayList<>(); + if (expressions == null) { + return orders; + } + if (expressions.length == 0 || expressions.length > MAX_SORT_ITEMS) { + throw new IllegalArgumentException("排序规则应为 1 至 3 项"); + } + Set seen = new HashSet<>(); + for (String expression : expressions) { + String[] parts = expression == null ? new String[0] : expression.split(",", -1); + if (parts.length != 2 || !("asc".equals(parts[1]) || "desc".equals(parts[1]))) { + throw new IllegalArgumentException("排序格式应为 字段,asc 或 字段,desc"); + } + String field = parts[0]; + String column = fields.get(field); + if (column == null) { + throw new IllegalArgumentException("不支持的排序字段:" + field); + } + if (!seen.add(field)) { + throw new IllegalArgumentException("排序字段重复:" + field); + } + orders.add("asc".equals(parts[1]) ? OrderItem.asc(column) : OrderItem.desc(column)); + } + return orders; + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/config/SecurityProperties.java b/src/main/java/com/youlai/boot/framework/security/config/SecurityProperties.java index 2e5e3cce..d00aa708 100644 --- a/src/main/java/com/youlai/boot/framework/security/config/SecurityProperties.java +++ b/src/main/java/com/youlai/boot/framework/security/config/SecurityProperties.java @@ -1,20 +1,23 @@ package com.youlai.boot.framework.security.config; import jakarta.validation.Valid; -import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import lombok.Data; import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.validation.annotation.Validated; import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; /** - * 安全模块配置属性类 + * 安全配置;未选中的令牌策略不要求提供配置 * * @author Ray.Hao - * @since 2024/4/18 + * @since 4.13.0 */ @Data @Validated @@ -22,58 +25,60 @@ import org.springframework.stereotype.Component; @ConfigurationProperties(prefix = "security") public class SecurityProperties { - // 令牌策略配置 @Valid - @NotNull(message = "令牌策略配置不能为空") - private TokenConfig token; + @NotNull + private TokenConfig token = new TokenConfig(); - // 匿名放行路径(仍经过安全过滤器链,仅跳过认证与授权判断,保留安全响应头) + // 匿名路径仍经过安全过滤器链 @NotEmpty private String[] permitAllUrls; /** - * 令牌策略嵌套类 + * 访问令牌与会话期限配置 */ @Data public static class TokenConfig { - // 令牌策略类型 - @NotNull(message = "令牌类型不能为空") + + @NotNull @Pattern(regexp = "jwt|redis-token", message = "令牌类型只能是 jwt 或 redis-token") - private String type; + private String type = "jwt"; - // 访问令牌有效期(单位:秒) - @Min(-1) - private Integer accessTokenTimeToLive = 3600; + @NotNull + private Duration accessTokenTimeToLive = Duration.ofHours(2); - // 刷新令牌有效期(单位:秒) - @Min(-1) - private Integer refreshTokenTimeToLive = 604800; + @NotNull + private Duration refreshTokenTimeToLive = Duration.ofDays(7); + + private boolean allowMultiLogin = true; - // JWT 配置项(type 为 jwt 时必填) - @Valid private JwtConfig jwt; - // Redis令牌配置项(type 为 redis-token 时使用) - @Valid - private RedisTokenConfig redisToken; + /** + * 校验令牌期限为整秒,且访问期限不超过会话期限 + */ + @AssertTrue(message = "令牌有效期须为正整数秒,且访问令牌有效期不能超过刷新令牌有效期") + public boolean isLifetimeValid() { + return accessTokenTimeToLive != null && refreshTokenTimeToLive != null + && accessTokenTimeToLive.getNano() == 0 && refreshTokenTimeToLive.getNano() == 0 + && accessTokenTimeToLive.toSeconds() > 0 && refreshTokenTimeToLive.toSeconds() > 0 + && accessTokenTimeToLive.compareTo(refreshTokenTimeToLive) <= 0; + } + + /** + * 校验所选 JWT 策略的签名密钥长度 + */ + @AssertTrue(message = "JWT 模式须配置至少 32 字节的 UTF-8 签名密钥") + public boolean isSigningKeyValid() { + return !"jwt".equals(type) || jwt != null && jwt.secretKey != null + && jwt.secretKey.getBytes(StandardCharsets.UTF_8).length >= 32; + } } /** - * JWT 配置嵌套类 + * JWT 签名配置 */ @Data public static class JwtConfig { - // JWT签名密钥 - @NotNull(message = "JWT 密钥不能为空") private String secretKey; } - - /** - * Redis令牌配置嵌套类 - */ - @Data - public static class RedisTokenConfig { - // 是否允许多设备同时登录 - private Boolean allowMultiLogin = true; - } } diff --git a/src/main/java/com/youlai/boot/framework/security/config/SessionConfig.java b/src/main/java/com/youlai/boot/framework/security/config/SessionConfig.java new file mode 100644 index 00000000..9164269c --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/config/SessionConfig.java @@ -0,0 +1,26 @@ +package com.youlai.boot.framework.security.config; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; + +/** + * 会话基础组件配置 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Configuration(proxyBeanMethods = false) +public class SessionConfig { + + /** + * 提供认证使用的 UTC 时钟 + */ + @Bean + @ConditionalOnMissingBean + public Clock securityClock() { + return Clock.systemUTC(); + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java b/src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java deleted file mode 100644 index ef134da1..00000000 --- a/src/main/java/com/youlai/boot/framework/security/filter/TokenAuthenticationFilter.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.youlai.boot.framework.security.filter; - -import cn.hutool.core.util.StrUtil; -import com.youlai.boot.common.constant.SecurityConstants; -import com.youlai.boot.framework.security.token.TokenManager; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.springframework.http.HttpHeaders; -import org.springframework.security.authentication.InsufficientAuthenticationException; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; - -/** - * Token 认证过滤器。 - * - * @author wangtao - * @since 2025/3/6 - */ -public class TokenAuthenticationFilter extends OncePerRequestFilter { - - private final TokenManager tokenManager; - - public TokenAuthenticationFilter(TokenManager tokenManager) { - this.tokenManager = tokenManager; - } - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - - String rawToken = resolveToken(request); - - if (StrUtil.isNotBlank(rawToken)) { - try { - boolean isValid = tokenManager.validateToken(rawToken); - if (!isValid) { - SecurityContextHolder.clearContext(); - throw new InsufficientAuthenticationException("Token 无效或已过期"); - } - Authentication authentication = tokenManager.parseToken(rawToken); - SecurityContextHolder.getContext().setAuthentication(authentication); - } catch (AuthenticationException ex) { - SecurityContextHolder.clearContext(); - throw ex; - } catch (Exception ex) { - SecurityContextHolder.clearContext(); - throw new InsufficientAuthenticationException("Token 认证失败", ex); - } - } - - filterChain.doFilter(request, response); - } - - private String resolveToken(HttpServletRequest request) { - String header = request.getHeader(HttpHeaders.AUTHORIZATION); - if (StrUtil.isNotBlank(header) && header.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { - return header.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()); - } - return null; - } -} diff --git a/src/main/java/com/youlai/boot/framework/security/handler/JsonAuthenticationEntryPoint.java b/src/main/java/com/youlai/boot/framework/security/handler/JsonAuthenticationEntryPoint.java index 791c9126..9832b3d8 100644 --- a/src/main/java/com/youlai/boot/framework/security/handler/JsonAuthenticationEntryPoint.java +++ b/src/main/java/com/youlai/boot/framework/security/handler/JsonAuthenticationEntryPoint.java @@ -2,8 +2,8 @@ package com.youlai.boot.framework.security.handler; import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.framework.web.util.ResponseWriter; -import org.springframework.security.authentication.BadCredentialsException; -import org.springframework.security.authentication.InsufficientAuthenticationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.AuthenticationServiceException; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; @@ -19,17 +19,17 @@ import java.io.IOException; * @author Ray.Hao * @since 2.0.0 */ +@Slf4j public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { - if (authException instanceof BadCredentialsException) { - ResponseWriter.writeError(response, ResultCode.USER_PASSWORD_ERROR); - } else if (authException instanceof InsufficientAuthenticationException) { - ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID); + if (authException instanceof AuthenticationServiceException) { + log.error("认证服务不可用", authException); + ResponseWriter.writeError(response, ResultCode.AUTH_SERVICE_UNAVAILABLE); } else { - ResponseWriter.writeError(response, ResultCode.USER_LOGIN_EXCEPTION, authException.getMessage()); + ResponseWriter.writeError(response, ResultCode.ACCESS_TOKEN_INVALID); } } } diff --git a/src/main/java/com/youlai/boot/framework/security/model/AuthenticationToken.java b/src/main/java/com/youlai/boot/framework/security/model/AuthenticationToken.java deleted file mode 100644 index cd0dac5a..00000000 --- a/src/main/java/com/youlai/boot/framework/security/model/AuthenticationToken.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.youlai.boot.framework.security.model; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Builder; -import lombok.Data; - -/** - * 认证令牌响应对象 - * - * @author Ray.Hao - * @since 0.0.1 - */ -@Schema(description = "认证令牌响应对象") -@Data -@Builder -public class AuthenticationToken { - - @Schema(description = "令牌类型", example = "Bearer") - private String tokenType; - - @Schema(description = "访问令牌") - private String accessToken; - - @Schema(description = "刷新令牌") - private String refreshToken; - - @Schema(description = "过期时间(单位:秒)") - private Integer expiresIn; - -} diff --git a/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java b/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java index c0680a3b..5232542c 100644 --- a/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java +++ b/src/main/java/com/youlai/boot/framework/security/model/SecurityUser.java @@ -1,12 +1,13 @@ package com.youlai.boot.framework.security.model; import lombok.Data; +import lombok.ToString; import java.util.List; import java.util.Set; /** - * 安全模块用户数据 POJO。 + * 安全模块用户数据 POJO * * @author Ray.Hao * @since 2025/12/16 @@ -27,6 +28,7 @@ public class SecurityUser { private Long deptId; // 密码(加密后) + @ToString.Exclude private String password; // 状态(1:启用 其它:禁用) diff --git a/src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java b/src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java index dcdbefa8..1818219d 100644 --- a/src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java +++ b/src/main/java/com/youlai/boot/framework/security/model/SecurityUserDetails.java @@ -5,6 +5,7 @@ import cn.hutool.core.util.ObjectUtil; import com.youlai.boot.common.constant.SecurityConstants; import lombok.Data; import lombok.NoArgsConstructor; +import lombok.ToString; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; @@ -13,10 +14,11 @@ import java.util.*; import java.util.stream.Collectors; /** - * Spring Security 用户认证对象。 + * Spring Security 用户认证对象 * * @author Ray.Hao * @version 3.0.0 + * @since 4.13.0 */ @Data @NoArgsConstructor @@ -29,6 +31,7 @@ public class SecurityUserDetails implements UserDetails { private String username; // 密码 + @ToString.Exclude private String password; // 账号是否启用(true:启用 false:禁用) @@ -44,7 +47,7 @@ public class SecurityUserDetails implements UserDetails { private Set roles; /** - * 构造函数:根据 {@link SecurityUser} 初始化。 + * 构造函数:根据 {@link SecurityUser} 初始化 */ public SecurityUserDetails(SecurityUser user) { this.userId = user.getUserId(); @@ -78,7 +81,7 @@ public class SecurityUserDetails implements UserDetails { @Override public boolean isEnabled() { - return this.enabled; + return Boolean.TRUE.equals(this.enabled); } /** diff --git a/src/main/java/com/youlai/boot/framework/security/model/TokenPair.java b/src/main/java/com/youlai/boot/framework/security/model/TokenPair.java new file mode 100644 index 00000000..967eb184 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/model/TokenPair.java @@ -0,0 +1,29 @@ +package com.youlai.boot.framework.security.model; + +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 登录与刷新共用的令牌响应 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Schema(description = "登录令牌") +public record TokenPair( + @Schema(description = "令牌类型", example = "Bearer") String tokenType, + @Schema(description = "访问令牌") String accessToken, + @Schema(description = "刷新令牌") String refreshToken, + @Schema(description = "访问令牌剩余有效期,单位秒") long expiresIn) { + + /** + * 创建 Bearer 令牌响应,有效期单位为秒 + */ + public static TokenPair bearer(String accessToken, String refreshToken, long expiresIn) { + return new TokenPair("Bearer", accessToken, refreshToken, expiresIn); + } + + @Override + public String toString() { + return "TokenPair[tokenType=" + tokenType + ", expiresIn=" + expiresIn + ", credentials=REDACTED]"; + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/port/UserAuthenticationPort.java b/src/main/java/com/youlai/boot/framework/security/port/UserAuthenticationPort.java index dd6ec4fe..cc90e289 100644 --- a/src/main/java/com/youlai/boot/framework/security/port/UserAuthenticationPort.java +++ b/src/main/java/com/youlai/boot/framework/security/port/UserAuthenticationPort.java @@ -1,17 +1,24 @@ package com.youlai.boot.framework.security.port; -import com.youlai.boot.common.enums.SocialPlatformEnum; import com.youlai.boot.framework.security.model.SecurityUser; /** - * 用户认证信息查询端口。 + * 用户认证信息查询端口 * * @see com.youlai.boot.system.adapter.security.UserAuthenticationAdapter + * + * @author Ray.Hao + * @since 4.13.0 */ public interface UserAuthenticationPort { /** - * 根据用户名查询认证信息。 + * 根据稳定的用户 ID 重新装载账号状态和权限 + */ + SecurityUser getAuthInfoByUserId(Long userId); + + /** + * 根据用户名查询认证信息 * * @param username 用户名 * @return 认证信息,不存在返回 null @@ -19,7 +26,7 @@ public interface UserAuthenticationPort { SecurityUser getAuthInfoByUsername(String username); /** - * 根据手机号查询认证信息。 + * 根据手机号查询认证信息 * * @param mobile 手机号 * @return 认证信息,不存在返回 null @@ -27,11 +34,11 @@ public interface UserAuthenticationPort { SecurityUser getAuthInfoByMobile(String mobile); /** - * 根据第三方平台 openid 查询认证信息。 + * 根据具体认证源和来源方账号 ID 查询认证信息 * - * @param platform 第三方平台 - * @param openid openid + * @param provider 固定认证源标识,不是协议类型 + * @param providerAccountId 来源方账号 ID * @return 认证信息,未绑定返回 null */ - SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid); + SecurityUser getAuthInfoByProviderAccount(String provider, String providerAccountId); } diff --git a/src/main/java/com/youlai/boot/framework/security/session/AuthenticatedSession.java b/src/main/java/com/youlai/boot/framework/security/session/AuthenticatedSession.java new file mode 100644 index 00000000..3aeeb68b --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/session/AuthenticatedSession.java @@ -0,0 +1,27 @@ +package com.youlai.boot.framework.security.session; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; + +import java.time.Instant; + +/** + * 已验证的会话身份与访问凭证期限 + * + * @author Ray.Hao + * @since 4.13.0 + */ +public record AuthenticatedSession(String id, SessionPrincipal principal, LoginContext context, + Instant accessExpiresAt) { + + /** + * 转换为携带会话详情的 Spring Security 认证结果 + */ + public Authentication toAuthentication() { + var details = principal.toUserDetails(); + var authentication = UsernamePasswordAuthenticationToken.authenticated( + details, null, details.getAuthorities()); + authentication.setDetails(this); + return authentication; + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/session/LoginContext.java b/src/main/java/com/youlai/boot/framework/security/session/LoginContext.java new file mode 100644 index 00000000..9ae58ffe --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/session/LoginContext.java @@ -0,0 +1,54 @@ +package com.youlai.boot.framework.security.session; + +import java.util.Objects; + +/** + * 登录来源与外部身份,随本地会话保存 + * + * @author Ray.Hao + * @since 4.13.0 + */ +public record LoginContext(Source source, String provider, String issuer, String subject, String idToken) { + + /** + * 登录身份来源 + */ + public enum Source { + LOCAL, + OIDC + } + + public LoginContext { + Objects.requireNonNull(source, "source"); + if (source == Source.OIDC && (provider == null || provider.isBlank() + || issuer == null || issuer.isBlank() || subject == null || subject.isBlank())) { + throw new IllegalArgumentException("OIDC context requires provider, issuer and subject"); + } + if (source == Source.LOCAL && (provider != null || issuer != null || subject != null || idToken != null)) { + throw new IllegalArgumentException("Local context cannot contain an external identity"); + } + } + + /** + * 创建本地登录上下文 + */ + public static LoginContext local() { + return new LoginContext(Source.LOCAL, null, null, null, null); + } + + /** + * 保存 OIDC 身份,ID Token 用于认证中心登出 + */ + public static LoginContext oidc(String provider, String issuer, String subject, String idToken) { + return new LoginContext(Source.OIDC, provider, issuer, subject, idToken); + } + + /** + * 日志不输出 ID Token + */ + @Override + public String toString() { + return "LoginContext[source=" + source + ", provider=" + provider + + ", issuer=" + issuer + ", subject=" + subject + "]"; + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/session/LoginSession.java b/src/main/java/com/youlai/boot/framework/security/session/LoginSession.java new file mode 100644 index 00000000..477c1e5d --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/session/LoginSession.java @@ -0,0 +1,11 @@ +package com.youlai.boot.framework.security.session; + +/** + * Redis 中的会话记录。expiresAt 为固定截止时间,刷新不会延长它 + * + * @author Ray.Hao + * @since 4.13.0 + */ +public record LoginSession(String id, String generation, SessionPrincipal principal, LoginContext context, + String refreshDigest, long createdAt, long expiresAt) { +} diff --git a/src/main/java/com/youlai/boot/framework/security/session/RedisSessionStore.java b/src/main/java/com/youlai/boot/framework/security/session/RedisSessionStore.java new file mode 100644 index 00000000..51625c6f --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/session/RedisSessionStore.java @@ -0,0 +1,223 @@ +package com.youlai.boot.framework.security.session; + +import com.youlai.boot.framework.security.token.AccessTokenCodec; +import com.youlai.boot.framework.security.token.SecureTokenGenerator; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.stereotype.Component; +import tools.jackson.databind.json.JsonMapper; + +import java.time.Instant; +import java.util.List; +import java.util.function.Supplier; + +/** + * 保存会话和凭证摘要,使用原子脚本处理登录与刷新竞争 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Component +public class RedisSessionStore { + + private static final String PREFIX = "auth:v3:"; + private static final long REFRESH_OVERLAP_MILLIS = 5_000; + // 登录发布、单设备替换和不透明令牌索引必须一起完成 + private static final DefaultRedisScript CREATE = new DefaultRedisScript<>(""" + local generation = redis.call('GET', KEYS[2]) + if generation ~= ARGV[1] or redis.call('EXISTS', KEYS[1]) == 1 then + return 0 + end + redis.call('SET', KEYS[2], ARGV[2]) + redis.call('SET', KEYS[1], ARGV[3], 'PX', ARGV[4]) + if ARGV[5] ~= '' then + redis.call('SET', KEYS[3], ARGV[5], 'PX', ARGV[6]) + end + return 1 + """, Long.class); + // 刷新轮换:会话代次、刷新凭证与令牌索引一次写入 + private static final DefaultRedisScript ROTATE = new DefaultRedisScript<>(""" + local raw = redis.call('GET', KEYS[1]) + if not raw then return 0 end + local session = cjson.decode(raw) + local now = tonumber(ARGV[3]) + if redis.call('GET', KEYS[2]) ~= session.generation or tonumber(session.expiresAt) <= now then + return 0 + end + if session.refreshDigest ~= ARGV[1] then + local consumedAt = redis.call('GET', KEYS[3]) + -- 短时间内的并发刷新只拒绝重复请求;较晚的已用凭证重放撤销整条会话 + if consumedAt and now - tonumber(consumedAt) > tonumber(ARGV[7]) then + redis.call('DEL', KEYS[1]) + end + return 0 + end + if ARGV[2] == '' then return 0 end + redis.call('SET', KEYS[3], ARGV[3], 'PX', ARGV[4]) + redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[4]) + if ARGV[5] ~= '' then + redis.call('SET', KEYS[4], ARGV[5], 'PX', ARGV[6]) + end + return 1 + """, Long.class); + + private final StringRedisTemplate redis; + private final JsonMapper json; + + public RedisSessionStore(StringRedisTemplate redis, JsonMapper json) { + this.redis = redis; + this.json = json; + } + + /** + * 读取用户会话版本,首次访问时生成 + */ + public String currentGeneration(Long userId) { + return execute(() -> { + String key = generationKey(userId); + redis.opsForValue().setIfAbsent(key, SecureTokenGenerator.generate()); + String generation = redis.opsForValue().get(key); + if (generation == null) { + throw unavailable(); + } + return generation; + }); + } + + /** + * 更换用户会话版本,使既有会话失效 + */ + public void revokeUserSessions(Long userId) { + execute(() -> { + redis.opsForValue().set(generationKey(userId), SecureTokenGenerator.generate()); + return null; + }); + } + + /** + * 删除指定会话 + */ + public void revokeSession(String sessionId) { + execute(() -> redis.delete(sessionKey(sessionId))); + } + + /** + * 原子校验用户版本并保存会话与访问凭证索引 + */ + public boolean create(LoginSession session, String expectedGeneration, + AccessTokenCodec.IssuedToken accessToken, Instant now) { + return execute(() -> Long.valueOf(1).equals(redis.execute(CREATE, + List.of(sessionKey(session.id()), generationKey(session.principal().userId()), accessKey(accessToken)), + expectedGeneration, session.generation(), json.writeValueAsString(session), + remaining(session.expiresAt(), now), accessJson(accessToken), accessRemaining(accessToken, now)))); + } + + /** + * 读取未过期且用户版本仍有效的会话 + */ + public LoginSession findActive(String sessionId, Instant now) { + return execute(() -> { + String raw = redis.opsForValue().get(sessionKey(sessionId)); + if (raw == null) { + return null; + } + LoginSession session = json.readValue(raw, LoginSession.class); + String generation = redis.opsForValue().get(generationKey(session.principal().userId())); + if (!session.generation().equals(generation) || session.expiresAt() <= now.toEpochMilli()) { + return null; + } + return session; + }); + } + + /** + * 通过随机访问凭证的摘要查找会话引用 + */ + public AccessTokenCodec.Reference findAccessToken(String digest) { + return execute(() -> { + String raw = redis.opsForValue().get(PREFIX + "access:" + digest); + return raw == null ? null : json.readValue(raw, AccessTokenCodec.Reference.class); + }); + } + + /** + * 原子轮换刷新凭证;替换值为空时仅检查旧凭证重放 + */ + public boolean rotate(LoginSession previous, String presentedDigest, LoginSession replacement, + AccessTokenCodec.IssuedToken accessToken, Instant now) { + return execute(() -> Long.valueOf(1).equals(redis.execute(ROTATE, + List.of(sessionKey(previous.id()), generationKey(previous.principal().userId()), + PREFIX + "refresh-used:" + previous.id() + ":" + presentedDigest, + accessToken == null ? sessionKey(previous.id()) : accessKey(accessToken)), + presentedDigest, replacement == null ? "" : json.writeValueAsString(replacement), + Long.toString(now.toEpochMilli()), remaining(previous.expiresAt(), now), + accessToken == null ? "" : accessJson(accessToken), + accessToken == null ? "1" : accessRemaining(accessToken, now), + Long.toString(REFRESH_OVERLAP_MILLIS)))); + } + + /** + * 序列化随机令牌的会话引用,JWT 不保存该索引 + */ + private String accessJson(AccessTokenCodec.IssuedToken token) { + return token.lookupDigest() == null ? "" : json.writeValueAsString(token.reference()); + } + + /** + * 构造随机令牌索引键,JWT 使用会话键占位 + */ + private static String accessKey(AccessTokenCodec.IssuedToken token) { + return token.lookupDigest() == null ? sessionKey(token.reference().sessionId()) + : PREFIX + "access:" + token.lookupDigest(); + } + + /** + * 计算访问凭证剩余有效毫秒数 + */ + private static String accessRemaining(AccessTokenCodec.IssuedToken token, Instant now) { + return remaining(token.reference().expiresAt().toEpochMilli(), now); + } + + /** + * 计算剩余有效毫秒数,最小值为 1 + */ + private static String remaining(long expiresAt, Instant now) { + return Long.toString(Math.max(1, expiresAt - now.toEpochMilli())); + } + + /** + * 构造会话存储键 + */ + private static String sessionKey(String sessionId) { + return PREFIX + "session:" + sessionId; + } + + /** + * 构造用户会话版本键 + */ + private static String generationKey(Long userId) { + return PREFIX + "user-generation:" + userId; + } + + /** + * 执行存储操作,将存储和解析故障转换为认证服务异常 + */ + private static T execute(Supplier operation) { + try { + return operation.get(); + } catch (DataAccessException exception) { + throw new AuthenticationServiceException("Session storage is unavailable", exception); + } catch (tools.jackson.core.JacksonException exception) { + throw new AuthenticationServiceException("Session data cannot be read", exception); + } + } + + /** + * 创建会话存储不可用异常 + */ + private static AuthenticationServiceException unavailable() { + return new AuthenticationServiceException("Session storage is unavailable"); + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/session/SessionPrincipal.java b/src/main/java/com/youlai/boot/framework/security/session/SessionPrincipal.java new file mode 100644 index 00000000..386b0549 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/session/SessionPrincipal.java @@ -0,0 +1,48 @@ +package com.youlai.boot.framework.security.session; + +import com.youlai.boot.framework.security.model.RoleDataScope; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.framework.security.model.SecurityUserDetails; + +import java.util.List; +import java.util.Set; + +/** + * 会话中的业务身份,不保存密码 + * + * @author Ray.Hao + * @since 4.13.0 + */ +public record SessionPrincipal(Long userId, String username, Long deptId, + Set roles, List dataScopes) { + + public SessionPrincipal { + roles = roles == null ? Set.of() : Set.copyOf(roles); + dataScopes = dataScopes == null ? List.of() : dataScopes.stream() + .map(scope -> new RoleDataScope(scope.getRoleCode(), scope.getDataScope(), + scope.getCustomDeptIds() == null ? null : List.copyOf(scope.getCustomDeptIds()))) + .toList(); + } + + /** + * 从业务账号提取会话身份与权限范围 + */ + public static SessionPrincipal from(SecurityUser user) { + return new SessionPrincipal(user.getUserId(), user.getUsername(), user.getDeptId(), + user.getRoles(), user.getDataScopes()); + } + + /** + * 转换为不含密码的 Spring Security 用户详情 + */ + public SecurityUserDetails toUserDetails() { + var details = new SecurityUserDetails(); + details.setUserId(userId); + details.setUsername(username); + details.setDeptId(deptId); + details.setEnabled(true); + details.setRoles(roles); + details.setDataScopes(dataScopes); + return details; + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/session/SessionService.java b/src/main/java/com/youlai/boot/framework/security/session/SessionService.java new file mode 100644 index 00000000..b3995dfb --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/session/SessionService.java @@ -0,0 +1,247 @@ +package com.youlai.boot.framework.security.session; + +import com.youlai.boot.common.exception.TokenInvalidException; +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.framework.security.config.SecurityProperties; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.framework.security.model.SecurityUserDetails; +import com.youlai.boot.framework.security.model.TokenPair; +import com.youlai.boot.framework.security.port.UserAuthenticationPort; +import com.youlai.boot.framework.security.token.AccessTokenCodec; +import com.youlai.boot.framework.security.token.SecureTokenGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.dao.DataAccessException; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; + +/** + * 管理登录会话的创建、校验、刷新与撤销 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Service +@RequiredArgsConstructor +public class SessionService { + + private final RedisSessionStore store; + private final AccessTokenCodec tokens; + private final SecurityProperties properties; + private final Clock clock; + // 延迟解析业务端口,避免用户管理服务与会话服务形成构造依赖环 + private final ObjectProvider users; + private final ObjectProvider validators; + + /** + * 创建登录会话,并限制其最长有效期 + */ + public TokenPair create(Authentication authentication) { + return create(authentication, LoginContext.local()); + } + + /** + * 创建登录会话,并限制其最长有效期 + */ + public TokenPair create(Authentication authentication, LoginContext context) { + if (!authentication.isAuthenticated() + || !(authentication.getPrincipal() instanceof SecurityUserDetails principal) + || principal.getUserId() == null) { + throw invalidAccess(); + } + + Instant now = now(); + Instant expiresAt = now.plus(properties.getToken().getRefreshTokenTimeToLive()); + if (!expiresAt.isAfter(now)) { + throw invalidAccess(); + } + + String sessionId = SecureTokenGenerator.generate(); + String secret = SecureTokenGenerator.generate(); + var access = tokens.issue(sessionId, principal.getUserId(), now, accessExpiry(now, expiresAt)); + // 微信注册等事务提交后才发布会话;回滚不会留下可用凭证 + afterCommit(() -> publish(sessionId, principal.getUserId(), context, secret, now, expiresAt, access)); + return pair(access, sessionId, secret, now); + } + + /** + * 校验访问凭证、本地会话与外部登录来源 + */ + public AuthenticatedSession authenticate(String accessToken) { + var reference = tokens.verify(accessToken); + Instant now = now(); + if (!reference.expiresAt().isAfter(now)) { + throw invalidAccess(); + } + LoginSession session = store.findActive(reference.sessionId(), now); + if (session == null || !session.principal().userId().equals(reference.userId())) { + throw invalidAccess(); + } + validateSource(session); + return new AuthenticatedSession(session.id(), session.principal(), session.context(), reference.expiresAt()); + } + + /** + * 每次刷新轮换凭证,并重新装载账号状态及权限 + */ + public TokenPair refresh(String refreshToken) { + if (refreshToken == null || !refreshToken.matches("rt\\.[A-Za-z0-9_-]{43}\\.[A-Za-z0-9_-]{43}")) { + throw invalidRefresh(); + } + String[] parts = refreshToken.split("\\."); + Instant now = now(); + LoginSession previous = store.findActive(parts[1], now); + if (previous == null) { + throw invalidRefresh(); + } + String digest = SecureTokenGenerator.digest(parts[2]); + if (!SecureTokenGenerator.matches(previous.refreshDigest(), digest)) { + store.rotate(previous, digest, null, null, now); + throw invalidRefresh(); + } + try { + validateSource(previous); + SessionPrincipal principal = loadPrincipal(previous.principal().userId()); + String secret = SecureTokenGenerator.generate(); + var replacement = new LoginSession(previous.id(), previous.generation(), principal, previous.context(), + SecureTokenGenerator.digest(secret), previous.createdAt(), previous.expiresAt()); + var access = tokens.issue(previous.id(), principal.userId(), now, + accessExpiry(now, Instant.ofEpochMilli(previous.expiresAt()))); + if (!store.rotate(previous, digest, replacement, access, now)) { + throw invalidRefresh(); + } + return pair(access, previous.id(), secret, now); + } catch (BadCredentialsException exception) { + store.revokeSession(previous.id()); + throw invalidRefresh(); + } + } + + /** + * 撤销指定登录会话 + */ + public void revokeSession(String sessionId) { + store.revokeSession(sessionId); + } + + /** + * 数据库事务提交成功后撤销,避免回滚操作误踢用户 + */ + public void revokeUserSessions(Long userId) { + afterCommit(() -> store.revokeUserSessions(userId)); + } + + /** + * 检查用户版本并发布会话,竞争失败时有限重试 + */ + private void publish(String sessionId, Long userId, LoginContext context, String secret, + Instant createdAt, Instant expiresAt, AccessTokenCodec.IssuedToken access) { + for (int attempt = 0; attempt < 3; attempt++) { + String expectedGeneration = store.currentGeneration(userId); + String generation = properties.getToken().isAllowMultiLogin() + ? expectedGeneration : SecureTokenGenerator.generate(); + var session = new LoginSession(sessionId, generation, loadPrincipal(userId), context, + SecureTokenGenerator.digest(secret), createdAt.toEpochMilli(), expiresAt.toEpochMilli()); + validateSource(session); + Instant now = now(); + if (!access.reference().expiresAt().isAfter(now)) { + throw invalidAccess(); + } + if (store.create(session, expectedGeneration, access, now)) { + return; + } + } + throw new BadCredentialsException("Account state changed during login; retry login"); + } + + /** + * 重新加载有效账号及其权限 + */ + private SessionPrincipal loadPrincipal(Long userId) { + SecurityUser user; + try { + user = users.getObject().getAuthInfoByUserId(userId); + } catch (DataAccessException exception) { + throw new AuthenticationServiceException("Account storage is unavailable", exception); + } + if (user == null || !userId.equals(user.getUserId()) || !Integer.valueOf(1).equals(user.getStatus())) { + throw invalidAccess(); + } + return SessionPrincipal.from(user); + } + + /** + * 校验外部登录来源;对应模块关闭时拒绝认证 + */ + private void validateSource(LoginSession session) { + if (session.context().source() == LoginContext.Source.LOCAL) { + return; + } + var validator = validators.orderedStream() + .filter(candidate -> candidate.source() == session.context().source()) + .findFirst().orElseThrow(SessionService::invalidAccess); + validator.validate(session); + } + + /** + * 计算不超过会话期限的访问凭证截止时间 + */ + private Instant accessExpiry(Instant now, Instant sessionExpiry) { + Instant accessExpiry = now.plus(properties.getToken().getAccessTokenTimeToLive()); + return accessExpiry.isBefore(sessionExpiry) ? accessExpiry : sessionExpiry; + } + + /** + * 组合访问令牌、刷新凭证与剩余有效秒数 + */ + private static TokenPair pair(AccessTokenCodec.IssuedToken access, String sessionId, String secret, Instant now) { + return TokenPair.bearer(access.value(), "rt." + sessionId + "." + secret, + Duration.between(now, access.reference().expiresAt()).toSeconds()); + } + + /** + * 获取精确到秒的认证时间 + */ + private Instant now() { + return clock.instant().truncatedTo(ChronoUnit.SECONDS); + } + + /** + * 事务提交后执行操作,无事务时立即执行 + */ + private static void afterCommit(Runnable operation) { + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + operation.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + operation.run(); + } + }); + } + + /** + * 创建会话无效异常 + */ + private static BadCredentialsException invalidAccess() { + return new BadCredentialsException("Invalid session"); + } + + /** + * 创建刷新凭证无效异常 + */ + private static TokenInvalidException invalidRefresh() { + return new TokenInvalidException(ResultCode.REFRESH_TOKEN_INVALID); + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/session/SessionValidator.java b/src/main/java/com/youlai/boot/framework/security/session/SessionValidator.java new file mode 100644 index 00000000..4ea379fc --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/session/SessionValidator.java @@ -0,0 +1,20 @@ +package com.youlai.boot.framework.security.session; + +/** + * 外部登录来源的会话校验 + * + * @author Ray.Hao + * @since 4.13.0 + */ +public interface SessionValidator { + + /** + * 本校验器负责的登录来源 + */ + LoginContext.Source source(); + + /** + * 校验本实现负责的外部登录来源,失效时拒绝认证 + */ + void validate(LoginSession session); +} diff --git a/src/main/java/com/youlai/boot/framework/security/token/AccessTokenCodec.java b/src/main/java/com/youlai/boot/framework/security/token/AccessTokenCodec.java new file mode 100644 index 00000000..6e75aa91 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/token/AccessTokenCodec.java @@ -0,0 +1,38 @@ +package com.youlai.boot.framework.security.token; + +import java.time.Instant; + +/** + * 访问令牌格式策略;会话生命周期由 SessionService 管理 + * + * @author Ray.Hao + * @since 4.13.0 + */ +public interface AccessTokenCodec { + + /** + * 签发引用指定会话的访问凭证 + */ + IssuedToken issue(String sessionId, Long userId, Instant issuedAt, Instant expiresAt); + + /** + * 校验签名或凭证索引,返回引用;账号与会话状态仍须由 SessionService 校验 + */ + Reference verify(String token); + + /** + * 访问凭证对应的会话、用户与截止时间 + */ + record Reference(String sessionId, Long userId, Instant expiresAt) { + } + + /** + * lookupDigest 仅用于不透明令牌的 Redis 索引 + */ + record IssuedToken(String value, Reference reference, String lookupDigest) { + @Override + public String toString() { + return "IssuedToken[reference=" + reference + ", value=REDACTED]"; + } + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/token/JwtAccessTokenCodec.java b/src/main/java/com/youlai/boot/framework/security/token/JwtAccessTokenCodec.java new file mode 100644 index 00000000..52d2681c --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/token/JwtAccessTokenCodec.java @@ -0,0 +1,96 @@ +package com.youlai.boot.framework.security.token; + +import com.nimbusds.jose.jwk.source.ImmutableSecret; +import com.youlai.boot.framework.security.config.SecurityProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.jwt.JwtClaimValidator; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.JwtException; +import org.springframework.security.oauth2.jwt.JwtIssuerValidator; +import org.springframework.security.oauth2.jwt.JwtTimestampValidator; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.stereotype.Component; + +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.List; + +/** + * JWT 只携带会话引用,角色和登录来源以服务端会话为准 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Component +@ConditionalOnProperty(prefix = "security.token", name = "type", havingValue = "jwt", matchIfMissing = true) +public class JwtAccessTokenCodec implements AccessTokenCodec { + + private static final String ISSUER = "youlai-boot"; + private static final String AUDIENCE = "youlai-api"; + private final JwtEncoder encoder; + private final JwtDecoder decoder; + + public JwtAccessTokenCodec(SecurityProperties properties, Clock clock) { + var secret = new SecretKeySpec(properties.getToken().getJwt().getSecretKey() + .getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + encoder = new NimbusJwtEncoder(new ImmutableSecret<>(secret)); + var nimbus = NimbusJwtDecoder.withSecretKey(secret).macAlgorithm(MacAlgorithm.HS256) + .validateType(false).build(); + var timestamp = new JwtTimestampValidator(Duration.ZERO); + timestamp.setClock(clock); + nimbus.setJwtValidator(new DelegatingOAuth2TokenValidator<>(timestamp, + new JwtIssuerValidator(ISSUER), new JwtClaimValidator>("aud", + audiences -> audiences != null && audiences.contains(AUDIENCE)))); + decoder = nimbus; + } + + @Override + public IssuedToken issue(String sessionId, Long userId, Instant issuedAt, Instant expiresAt) { + var claims = JwtClaimsSet.builder().issuer(ISSUER).audience(List.of(AUDIENCE)) + .subject(userId.toString()).id(SecureTokenGenerator.generate()) + .issuedAt(issuedAt).expiresAt(expiresAt) + .claim("sid", sessionId).claim("token_use", "access").build(); + var header = JwsHeader.with(MacAlgorithm.HS256).type("at+jwt").build(); + String value = encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue(); + return new IssuedToken(value, new Reference(sessionId, userId, expiresAt), null); + } + + @Override + public Reference verify(String token) { + if (token == null || token.length() > 4096) { + throw invalid(); + } + try { + Jwt jwt = decoder.decode(token); + String sessionId = jwt.getClaimAsString("sid"); + if (!"at+jwt".equals(jwt.getHeaders().get("typ")) + || !"access".equals(jwt.getClaimAsString("token_use")) + || sessionId == null || !sessionId.matches("[A-Za-z0-9_-]{43}") + || jwt.getExpiresAt() == null || jwt.getIssuedAt() == null) { + throw invalid(); + } + return new Reference(sessionId, Long.valueOf(jwt.getSubject()), jwt.getExpiresAt()); + } catch (JwtException | IllegalArgumentException exception) { + throw invalid(); + } + } + + /** + * 创建 JWT 凭证无效异常 + */ + private static BadCredentialsException invalid() { + return new BadCredentialsException("Invalid access token"); + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java b/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java deleted file mode 100644 index 566a9369..00000000 --- a/src/main/java/com/youlai/boot/framework/security/token/JwtTokenManager.java +++ /dev/null @@ -1,299 +0,0 @@ -package com.youlai.boot.framework.security.token; - -import cn.hutool.core.convert.Convert; -import cn.hutool.core.date.DateUtil; -import cn.hutool.core.util.IdUtil; -import cn.hutool.core.util.StrUtil; -import cn.hutool.json.JSONArray; -import cn.hutool.json.JSONObject; -import cn.hutool.jwt.JWT; -import cn.hutool.jwt.JWTPayload; -import cn.hutool.jwt.JWTUtil; -import com.youlai.boot.common.constant.JwtClaimConstants; -import com.youlai.boot.common.constant.RedisConstants; -import com.youlai.boot.common.constant.SecurityConstants; -import com.youlai.boot.common.result.ResultCode; -import com.youlai.boot.framework.security.config.SecurityProperties; -import com.youlai.boot.common.exception.TokenInvalidException; -import com.youlai.boot.framework.security.model.AuthenticationToken; -import com.youlai.boot.framework.security.model.RoleDataScope; -import com.youlai.boot.framework.security.model.SecurityUserDetails; -import org.apache.commons.lang3.StringUtils; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.stereotype.Service; - -import java.util.*; -import java.util.concurrent.TimeUnit; -import java.util.stream.Collectors; - -/** - * JWT Token 管理器。 - * - * @author Ray.Hao - * @since 2024/11/15 - */ -@ConditionalOnProperty(value = "security.token.type", havingValue = "jwt") -@Service -public class JwtTokenManager implements TokenManager { - - // 允许的签名算法,防止 alg:none 等算法降级攻击 - private static final String ALLOWED_ALGORITHM = "HS256"; - - private final SecurityProperties securityProperties; - private final RedisTemplate redisTemplate; - private final byte[] secretKey; - - public JwtTokenManager(SecurityProperties securityProperties, RedisTemplate redisTemplate) { - this.securityProperties = securityProperties; - this.redisTemplate = redisTemplate; - this.secretKey = securityProperties.getToken().getJwt().getSecretKey().getBytes(); - } - - @Override - public AuthenticationToken generateToken(Authentication authentication) { - int accessTokenTimeToLive = securityProperties.getToken().getAccessTokenTimeToLive(); - int refreshTokenTimeToLive = securityProperties.getToken().getRefreshTokenTimeToLive(); - - String accessToken = generateToken(authentication, accessTokenTimeToLive); - String refreshToken = generateToken(authentication, refreshTokenTimeToLive, true); - - return AuthenticationToken.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .tokenType("Bearer") - .expiresIn(accessTokenTimeToLive) - .build(); - } - - @Override - public Authentication parseToken(String token) { - JWT jwt = JWTUtil.parseToken(token); - if (!isAlgorithmAllowed(jwt) || !jwt.setKey(secretKey).verify()) { - throw new TokenInvalidException(ResultCode.ACCESS_TOKEN_INVALID); - } - JSONObject payloads = jwt.getPayloads(); - SecurityUserDetails userDetails = new SecurityUserDetails(); - userDetails.setUserId(payloads.getLong(JwtClaimConstants.USER_ID)); - userDetails.setDeptId(payloads.getLong(JwtClaimConstants.DEPT_ID)); - - // 数据权限 - JSONArray dataScopesArray = payloads.getJSONArray(JwtClaimConstants.DATA_SCOPES); - if (dataScopesArray != null && !dataScopesArray.isEmpty()) { - List dataScopes = dataScopesArray.stream() - .map(obj -> { - JSONObject item = (JSONObject) obj; - String roleCode = item.getStr("roleCode"); - Integer dataScope = item.getInt("dataScope"); - JSONArray deptIdsArray = item.getJSONArray("customDeptIds"); - List customDeptIds = null; - if (deptIdsArray != null) { - customDeptIds = deptIdsArray.toList(Long.class); - } - return new RoleDataScope(roleCode, dataScope, customDeptIds); - }) - .collect(Collectors.toList()); - userDetails.setDataScopes(dataScopes); - } - - userDetails.setUsername(payloads.getStr(JWTPayload.SUBJECT)); - - // 角色编码(不带 ROLE_ 前缀) - JSONArray rolesArray = payloads.getJSONArray(JwtClaimConstants.ROLES); - if (rolesArray != null && !rolesArray.isEmpty()) { - Set roles = rolesArray.stream() - .map(Convert::toStr) - .collect(Collectors.toSet()); - userDetails.setRoles(roles); - } - - return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); - } - - @Override - public boolean validateToken(String token) { - return validateToken(token, false); - } - - @Override - public boolean validateRefreshToken(String refreshToken) { - return validateToken(refreshToken, true); - } - - private boolean validateToken(String token, boolean validateRefreshToken) { - JWT jwt = JWTUtil.parseToken(token); - if (!isAlgorithmAllowed(jwt)) { - return false; - } - boolean isValid = jwt.setKey(secretKey).validate(0); - - if (isValid) { - JSONObject payloads = jwt.getPayloads(); - // 刷新令牌类型校验 - String jti = payloads.getStr(JWTPayload.JWT_ID); - if (validateRefreshToken) { - boolean isRefreshToken = payloads.getBool(JwtClaimConstants.TOKEN_TYPE); - if (!isRefreshToken) { - return false; - } - } - - // tokenVersion 校验(用户维度 Token 失效) - Long userId = payloads.getLong(JwtClaimConstants.USER_ID); - if (userId != null) { - Integer tokenVersion = payloads.getInt(JwtClaimConstants.TOKEN_VERSION); - - String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId); - Object currentVersionObj = redisTemplate.opsForValue().get(versionKey); - int currentVersion = currentVersionObj != null ? Convert.toInt(currentVersionObj) : 0; - - if (tokenVersion == null || tokenVersion < currentVersion) { - return false; - } - } - - // jti 黑名单校验 - if (isTokenRevoked(jti)) { - return false; - } - } - return isValid; - } - - @Override - public void invalidateToken(String token) { - if (StringUtils.isBlank(token)) { - return; - } - if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { - token = token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()); - } - JWT jwt = JWTUtil.parseToken(token); - if (!isAlgorithmAllowed(jwt) || !jwt.setKey(secretKey).verify()) { - return; - } - JSONObject payloads = jwt.getPayloads(); - String jti = payloads.getStr(JWTPayload.JWT_ID); - Integer expirationAt = payloads.getInt(JWTPayload.EXPIRES_AT); - revokeTokenByJti(jti, expirationAt); - } - - @Override - public void invalidateUserSessions(Long userId) { - if (userId == null) { - return; - } - String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId); - redisTemplate.opsForValue().increment(versionKey); - } - - @Override - public AuthenticationToken refreshToken(String refreshToken) { - boolean isValid = validateRefreshToken(refreshToken); - if (!isValid) { - throw new TokenInvalidException(ResultCode.REFRESH_TOKEN_INVALID); - } - Authentication authentication = parseToken(refreshToken); - int accessTokenExpiration = securityProperties.getToken().getAccessTokenTimeToLive(); - String newAccessToken = generateToken(authentication, accessTokenExpiration); - return AuthenticationToken.builder() - .accessToken(newAccessToken) - .refreshToken(refreshToken) - .tokenType("Bearer") - .expiresIn(accessTokenExpiration) - .build(); - } - - // ======================== private ======================== - - private String generateToken(Authentication authentication, int ttl) { - return generateToken(authentication, ttl, false); - } - - private String generateToken(Authentication authentication, int ttl, boolean isRefreshToken) { - SecurityUserDetails userDetails = (SecurityUserDetails) authentication.getPrincipal(); - Map payload = new HashMap<>(); - payload.put(JwtClaimConstants.USER_ID, userDetails.getUserId()); - payload.put(JwtClaimConstants.DEPT_ID, userDetails.getDeptId()); - - // 数据权限 - List dataScopes = userDetails.getDataScopes(); - if (dataScopes != null && !dataScopes.isEmpty()) { - List> scopesList = dataScopes.stream() - .map(scope -> { - Map scopeMap = new HashMap<>(); - scopeMap.put("roleCode", scope.getRoleCode()); - scopeMap.put("dataScope", scope.getDataScope()); - scopeMap.put("customDeptIds", scope.getCustomDeptIds()); - return scopeMap; - }) - .collect(Collectors.toList()); - payload.put(JwtClaimConstants.DATA_SCOPES, scopesList); - } - - // 角色编码(不带 ROLE_ 前缀) - Set roles = userDetails.getRoles(); - if (roles != null && !roles.isEmpty()) { - payload.put(JwtClaimConstants.ROLES, roles); - } - - Long userId = userDetails.getUserId(); - int tokenVersion = 0; - if (userId != null) { - String versionKey = StrUtil.format(RedisConstants.Auth.USER_TOKEN_VERSION, userId); - Object versionObj = redisTemplate.opsForValue().get(versionKey); - tokenVersion = versionObj != null ? Convert.toInt(versionObj) : 0; - } - payload.put(JwtClaimConstants.TOKEN_VERSION, tokenVersion); - - Date now = new Date(); - payload.put(JWTPayload.ISSUED_AT, now); - payload.put(JwtClaimConstants.TOKEN_TYPE, isRefreshToken); - - if (ttl != -1) { - Date expiresAt = DateUtil.offsetSecond(now, ttl); - payload.put(JWTPayload.EXPIRES_AT, expiresAt); - } - payload.put(JWTPayload.SUBJECT, authentication.getName()); - payload.put(JWTPayload.JWT_ID, IdUtil.simpleUUID()); - - return JWTUtil.createToken(payload, secretKey); - } - - private boolean isTokenRevoked(String jti) { - if (StringUtils.isBlank(jti)) { - return false; - } - return Boolean.TRUE.equals(redisTemplate.hasKey(StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti))); - } - - private void revokeTokenByJti(String jti, Integer expirationAt) { - if (StringUtils.isBlank(jti)) { - return; - } - String revokedJtiKey = StrUtil.format(RedisConstants.Auth.REVOKED_JTI, jti); - if (expirationAt != null) { - int currentTimeSeconds = Convert.toInt(System.currentTimeMillis() / 1000); - if (expirationAt < currentTimeSeconds) { - return; - } - int expirationIn = expirationAt - currentTimeSeconds; - redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE, expirationIn, TimeUnit.SECONDS); - } else { - redisTemplate.opsForValue().set(revokedJtiKey, Boolean.TRUE); - } - } - - /** - * 校验 JWT 头部算法是否在允许的白名单中,防止 alg:none 等算法降级攻击 - * - * @param jwt JWT 对象 - * @return 是否允许 - */ - private boolean isAlgorithmAllowed(JWT jwt) { - Object algorithm = jwt.getHeader("alg"); - return algorithm != null && ALLOWED_ALGORITHM.equals(algorithm.toString()); - } -} diff --git a/src/main/java/com/youlai/boot/framework/security/token/OpaqueAccessTokenCodec.java b/src/main/java/com/youlai/boot/framework/security/token/OpaqueAccessTokenCodec.java new file mode 100644 index 00000000..ea75e7b3 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/token/OpaqueAccessTokenCodec.java @@ -0,0 +1,48 @@ +package com.youlai.boot.framework.security.token; + +import com.youlai.boot.framework.security.session.RedisSessionStore; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +/** + * Redis Token 是随机凭证,服务端通过摘要查找会话引用 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "security.token", name = "type", havingValue = "redis-token") +public class OpaqueAccessTokenCodec implements AccessTokenCodec { + + private final RedisSessionStore sessions; + + @Override + public IssuedToken issue(String sessionId, Long userId, Instant issuedAt, Instant expiresAt) { + String token = SecureTokenGenerator.generate(); + return new IssuedToken(token, new Reference(sessionId, userId, expiresAt), SecureTokenGenerator.digest(token)); + } + + @Override + public Reference verify(String token) { + if (token == null || !token.matches("[A-Za-z0-9_-]{43}")) { + throw invalid(); + } + Reference reference = sessions.findAccessToken(SecureTokenGenerator.digest(token)); + if (reference == null) { + throw invalid(); + } + return reference; + } + + /** + * 创建随机访问凭证无效异常 + */ + private static BadCredentialsException invalid() { + return new BadCredentialsException("Invalid access token"); + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java b/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java deleted file mode 100644 index 0ae0f707..00000000 --- a/src/main/java/com/youlai/boot/framework/security/token/RedisTokenManager.java +++ /dev/null @@ -1,199 +0,0 @@ -package com.youlai.boot.framework.security.token; - -import cn.hutool.core.util.IdUtil; -import cn.hutool.core.util.StrUtil; -import com.youlai.boot.common.constant.RedisConstants; -import com.youlai.boot.common.constant.SecurityConstants; -import com.youlai.boot.common.result.ResultCode; -import com.youlai.boot.framework.security.config.SecurityProperties; -import com.youlai.boot.common.exception.TokenInvalidException; -import com.youlai.boot.framework.security.model.AuthenticationToken; -import com.youlai.boot.framework.security.model.SecurityUserDetails; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.stereotype.Service; - -import tools.jackson.databind.json.JsonMapper; - -import java.util.Optional; -import java.util.concurrent.TimeUnit; - -/** - * Redis Token 管理器。 - * - * @author Ray.Hao - * @since 2024/11/15 - */ -@ConditionalOnProperty(value = "security.token.type", havingValue = "redis-token") -@Service -public class RedisTokenManager implements TokenManager { - - private final SecurityProperties securityProperties; - private final RedisTemplate redisTemplate; - private final JsonMapper jsonMapper; - - public RedisTokenManager(SecurityProperties securityProperties, - RedisTemplate redisTemplate, - JsonMapper jsonMapper) { - this.securityProperties = securityProperties; - this.redisTemplate = redisTemplate; - this.jsonMapper = jsonMapper; - } - - @Override - public AuthenticationToken generateToken(Authentication authentication) { - SecurityUserDetails user = (SecurityUserDetails) authentication.getPrincipal(); - String accessToken = IdUtil.fastSimpleUUID(); - String refreshToken = IdUtil.fastSimpleUUID(); - - // 构建会话快照(不存密码) - SecurityUserDetails sessionUser = buildSessionUser(user); - - storeTokensInRedis(accessToken, refreshToken, sessionUser); - handleSingleDeviceLogin(user.getUserId(), accessToken); - - return AuthenticationToken.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .expiresIn(securityProperties.getToken().getAccessTokenTimeToLive()) - .build(); - } - - @Override - public Authentication parseToken(String token) { - Object raw = redisTemplate.opsForValue().get(formatTokenKey(token)); - if (raw == null) return null; - SecurityUserDetails userDetails = jsonMapper.convertValue(raw, SecurityUserDetails.class); - return new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); - } - - @Override - public boolean validateToken(String token) { - return redisTemplate.hasKey(formatTokenKey(token)); - } - - @Override - public boolean validateRefreshToken(String refreshToken) { - return redisTemplate.hasKey(formatRefreshTokenKey(refreshToken)); - } - - @Override - public AuthenticationToken refreshToken(String refreshToken) { - Object raw = redisTemplate.opsForValue() - .get(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken)); - if (raw == null) { - throw new TokenInvalidException(ResultCode.REFRESH_TOKEN_INVALID); - } - SecurityUserDetails sessionUser = jsonMapper.convertValue(raw, SecurityUserDetails.class); - - Object oldAccessTokenValue = redisTemplate.opsForValue() - .get(StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, sessionUser.getUserId())); - Optional.of(oldAccessTokenValue) - .map(String.class::cast) - .ifPresent(oldAccessToken -> redisTemplate.delete(formatTokenKey(oldAccessToken))); - - String newAccessToken = IdUtil.fastSimpleUUID(); - storeAccessToken(newAccessToken, sessionUser); - - int accessTtl = securityProperties.getToken().getAccessTokenTimeToLive(); - return AuthenticationToken.builder() - .accessToken(newAccessToken) - .refreshToken(refreshToken) - .expiresIn(accessTtl) - .build(); - } - - @Override - public void invalidateToken(String token) { - String cleanToken = cleanBearerPrefix(token); - redisTemplate.delete(formatTokenKey(cleanToken)); - } - - @Override - public void invalidateUserSessions(Long userId) { - if (userId == null) { - return; - } - String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId); - Object accessTokenValue = redisTemplate.opsForValue().get(userAccessKey); - if (accessTokenValue instanceof String accessToken) { - redisTemplate.delete(formatTokenKey(accessToken)); - } - redisTemplate.delete(userAccessKey); - - String userRefreshKey = StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, userId); - Object refreshTokenValue = redisTemplate.opsForValue().get(userRefreshKey); - if (refreshTokenValue instanceof String refreshToken) { - redisTemplate.delete(StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken)); - } - redisTemplate.delete(userRefreshKey); - } - - // ======================== private ======================== - - private SecurityUserDetails buildSessionUser(SecurityUserDetails user) { - SecurityUserDetails sessionUser = new SecurityUserDetails(); - sessionUser.setUserId(user.getUserId()); - sessionUser.setUsername(user.getUsername()); - sessionUser.setDeptId(user.getDeptId()); - sessionUser.setDataScopes(user.getDataScopes()); - sessionUser.setRoles(user.getRoles()); - sessionUser.setEnabled(user.isEnabled()); - sessionUser.setPassword(null); - return sessionUser; - } - - private void storeTokensInRedis(String accessToken, String refreshToken, SecurityUserDetails sessionUser) { - setRedisValue(formatTokenKey(accessToken), sessionUser, - securityProperties.getToken().getAccessTokenTimeToLive()); - String refreshTokenKey = StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken); - setRedisValue(refreshTokenKey, sessionUser, - securityProperties.getToken().getRefreshTokenTimeToLive()); - setRedisValue(StrUtil.format(RedisConstants.Auth.USER_REFRESH_TOKEN, sessionUser.getUserId()), - refreshToken, securityProperties.getToken().getRefreshTokenTimeToLive()); - } - - private void handleSingleDeviceLogin(Long userId, String accessToken) { - Boolean allowMultiLogin = securityProperties.getToken().getRedisToken().getAllowMultiLogin(); - String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, userId); - if (!allowMultiLogin) { - Object oldAccessTokenValue = redisTemplate.opsForValue().get(userAccessKey); - if (oldAccessTokenValue instanceof String oldAccessToken) { - redisTemplate.delete(formatTokenKey(oldAccessToken)); - } - } - setRedisValue(userAccessKey, accessToken, securityProperties.getToken().getAccessTokenTimeToLive()); - } - - private void storeAccessToken(String newAccessToken, SecurityUserDetails sessionUser) { - setRedisValue(StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, newAccessToken), sessionUser, - securityProperties.getToken().getAccessTokenTimeToLive()); - String userAccessKey = StrUtil.format(RedisConstants.Auth.USER_ACCESS_TOKEN, sessionUser.getUserId()); - setRedisValue(userAccessKey, newAccessToken, securityProperties.getToken().getAccessTokenTimeToLive()); - } - - private String formatTokenKey(String token) { - return StrUtil.format(RedisConstants.Auth.ACCESS_TOKEN_USER, token); - } - - private String formatRefreshTokenKey(String refreshToken) { - return StrUtil.format(RedisConstants.Auth.REFRESH_TOKEN_USER, refreshToken); - } - - private void setRedisValue(String key, Object value, int ttl) { - if (ttl != -1) { - redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.SECONDS); - } else { - redisTemplate.opsForValue().set(key, value); - } - } - - private String cleanBearerPrefix(String token) { - if (token.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { - return token.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()).trim(); - } - return token.trim(); - } -} diff --git a/src/main/java/com/youlai/boot/framework/security/token/SecureTokenGenerator.java b/src/main/java/com/youlai/boot/framework/security/token/SecureTokenGenerator.java new file mode 100644 index 00000000..4ead303d --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/security/token/SecureTokenGenerator.java @@ -0,0 +1,51 @@ +package com.youlai.boot.framework.security.token; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; + +/** + * 生成密码学随机凭证及其 SHA-256 摘要 + * + * @author Ray.Hao + * @since 4.13.0 + */ +public final class SecureTokenGenerator { + + private static final SecureRandom RANDOM = new SecureRandom(); + + private SecureTokenGenerator() { + } + + /** + * 生成 32 字节随机值,并编码为无填充的 Base64URL + */ + public static String generate() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + /** + * 计算凭证的 SHA-256 十六进制摘要 + */ + public static String digest(String value) { + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + + /** + * 以恒定时间比较凭证摘要 + */ + public static boolean matches(String expected, String actual) { + return MessageDigest.isEqual(expected.getBytes(StandardCharsets.US_ASCII), + actual.getBytes(StandardCharsets.US_ASCII)); + } +} diff --git a/src/main/java/com/youlai/boot/framework/security/token/TokenManager.java b/src/main/java/com/youlai/boot/framework/security/token/TokenManager.java deleted file mode 100644 index 81d80a5d..00000000 --- a/src/main/java/com/youlai/boot/framework/security/token/TokenManager.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.youlai.boot.framework.security.token; - - -import com.youlai.boot.framework.security.model.AuthenticationToken; -import org.springframework.security.core.Authentication; - -/** - * Token 管理器 - * - * @author Ray.Hao - * @since 2.16.0 - */ -public interface TokenManager { - - /** - * 生成认证 Token - * - * @param authentication 用户认证信息 - * @return 认证 Token 响应 - */ - AuthenticationToken generateToken(Authentication authentication); - - /** - * 解析 Token 获取认证信息 - * - * @param token Token - * @return 用户认证信息 - */ - Authentication parseToken(String token); - - /** - * 校验 Token 是否有效 - * - * @param token JWT Token - * @return 是否有效 - */ - boolean validateToken(String token); - - /** - * 校验 刷新 Token 是否有效 - * - * @param refreshToken JWT Token - * @return 是否有效 - */ - boolean validateRefreshToken(String refreshToken); - - /** - * 刷新 Token - * - * @param token 刷新令牌 - * @return 认证 Token 响应 - */ - AuthenticationToken refreshToken(String token); - - /** - * 令 Token 失效 - * - * @param token Token - */ - default void invalidateToken(String token) { - } - - /** - * 使指定用户的所有会话失效 - * - * @param userId 用户ID - */ - default void invalidateUserSessions(Long userId) { - // 默认空实现,由具体 TokenManager 决定是否支持按用户下线 - } - -} diff --git a/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java b/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java index 3a25ad59..9e9c9a01 100644 --- a/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java +++ b/src/main/java/com/youlai/boot/framework/security/util/SecurityUtils.java @@ -3,12 +3,8 @@ package com.youlai.boot.framework.security.util; import com.youlai.boot.common.constant.SystemConstants; import com.youlai.boot.framework.security.model.RoleDataScope; import com.youlai.boot.framework.security.model.SecurityUserDetails; -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.http.HttpHeaders; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; import java.util.*; @@ -63,7 +59,7 @@ public class SecurityUtils { } /** - * 获取角色编码集合,不带 ROLE_ 前缀。 + * 获取角色编码集合,不带 ROLE_ 前缀 */ public static Set getRoles() { return getUser().map(SecurityUserDetails::getRoles).orElse(Set.of()); @@ -76,16 +72,4 @@ public class SecurityUtils { return getRoles().contains(SystemConstants.ROOT_ROLE_CODE); } - /** - * 从请求头获取 Token - */ - public static String getAccessToken() { - ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); - if (attributes == null) { - return null; - } - HttpServletRequest request = attributes.getRequest(); - return request.getHeader(HttpHeaders.AUTHORIZATION); - } - } diff --git a/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java b/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java index 751051e6..ce1bb3b7 100644 --- a/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java +++ b/src/main/java/com/youlai/boot/framework/web/advice/GlobalExceptionHandler.java @@ -7,18 +7,21 @@ import com.youlai.boot.common.result.ResultCode; import com.youlai.boot.common.exception.TokenInvalidException; import com.youlai.boot.common.exception.RateLimitException; import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.TypeMismatchException; import org.springframework.context.support.DefaultMessageSourceResolvable; +import org.springframework.context.MessageSourceResolvable; import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.jdbc.BadSqlGrammarException; import org.springframework.security.access.AccessDeniedException; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.authentication.AuthenticationServiceException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; import org.springframework.validation.BindException; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; @@ -26,6 +29,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.servlet.NoHandlerFoundException; import tools.jackson.core.JacksonException; @@ -46,29 +50,43 @@ import java.util.stream.Collectors; public class GlobalExceptionHandler { @ExceptionHandler(BindException.class) - @ResponseStatus(HttpStatus.OK) + @ResponseStatus(HttpStatus.BAD_REQUEST) public Result processException(BindException e) { - log.error("BindException:{}", e.getMessage()); String msg = e.getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.joining(";")); return Result.failed(ResultCode.USER_REQUEST_PARAMETER_ERROR, msg); } @ExceptionHandler(ConstraintViolationException.class) - @ResponseStatus(HttpStatus.OK) + @ResponseStatus(HttpStatus.BAD_REQUEST) public Result processException(ConstraintViolationException e) { - log.error("ConstraintViolationException:{}", e.getMessage()); String msg = e.getConstraintViolations().stream().map(ConstraintViolation::getMessage).collect(Collectors.joining(";")); return Result.failed(ResultCode.INVALID_USER_INPUT, msg); } @ExceptionHandler(MethodArgumentNotValidException.class) - @ResponseStatus(HttpStatus.OK) + @ResponseStatus(HttpStatus.BAD_REQUEST) public Result processException(MethodArgumentNotValidException e) { - log.error("MethodArgumentNotValidException:{}", e.getMessage()); String msg = e.getBindingResult().getAllErrors().stream().map(DefaultMessageSourceResolvable::getDefaultMessage).collect(Collectors.joining(";")); return Result.failed(ResultCode.INVALID_USER_INPUT, msg); } + /** + * 参数约束失败返回400,返回值约束失败保留500 + */ + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity> handleMethodValidationException(HandlerMethodValidationException e) { + if (e.isForReturnValue()) { + log.error("接口返回值校验失败", e); + return ResponseEntity.status(e.getStatusCode()).body(Result.failed(ResultCode.SYSTEM_ERROR)); + } + String message = e.getAllErrors().stream() + .map(MessageSourceResolvable::getDefaultMessage) + .filter(StrUtil::isNotBlank) + .distinct() + .collect(Collectors.joining(";")); + return ResponseEntity.status(e.getStatusCode()).body(Result.failed(ResultCode.INVALID_USER_INPUT, message)); + } + @ExceptionHandler(NoHandlerFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public Result processException(NoHandlerFoundException e) { @@ -77,14 +95,14 @@ public class GlobalExceptionHandler { } @ExceptionHandler(MissingServletRequestParameterException.class) - @ResponseStatus(HttpStatus.OK) + @ResponseStatus(HttpStatus.BAD_REQUEST) public Result processException(MissingServletRequestParameterException e) { log.error(e.getMessage(), e); return Result.failed(ResultCode.REQUEST_REQUIRED_PARAMETER_IS_EMPTY); } @ExceptionHandler(MethodArgumentTypeMismatchException.class) - @ResponseStatus(HttpStatus.OK) + @ResponseStatus(HttpStatus.BAD_REQUEST) public Result processException(MethodArgumentTypeMismatchException e) { log.error(e.getMessage(), e); return Result.failed(ResultCode.PARAMETER_FORMAT_MISMATCH, "类型错误"); @@ -98,23 +116,21 @@ public class GlobalExceptionHandler { } @ExceptionHandler(IllegalArgumentException.class) - @ResponseStatus(HttpStatus.OK) + @ResponseStatus(HttpStatus.BAD_REQUEST) public Result handleIllegalArgumentException(IllegalArgumentException e) { log.error("非法参数异常,异常原因:{}", e.getMessage(), e); return Result.failed(e.getMessage()); } @ExceptionHandler(JacksonException.class) - @ResponseStatus(HttpStatus.OK) + @ResponseStatus(HttpStatus.BAD_REQUEST) public Result handleJacksonException(JacksonException e) { - log.error("Json转换异常,异常原因:{}", e.getMessage(), e); - return Result.failed(e.getMessage()); + return Result.failed(ResultCode.PARAMETER_FORMAT_MISMATCH); } @ExceptionHandler(HttpMessageNotReadableException.class) - @ResponseStatus(HttpStatus.OK) + @ResponseStatus(HttpStatus.BAD_REQUEST) public Result processException(HttpMessageNotReadableException e) { - log.error(e.getMessage(), e); String errorMessage = "请求体不可为空"; Throwable cause = e.getCause(); if (cause != null) { @@ -124,7 +140,7 @@ public class GlobalExceptionHandler { } @ExceptionHandler(TypeMismatchException.class) - @ResponseStatus(HttpStatus.OK) + @ResponseStatus(HttpStatus.BAD_REQUEST) public Result processException(TypeMismatchException e) { log.error(e.getMessage(), e); return Result.failed(e.getMessage()); @@ -160,6 +176,23 @@ public class GlobalExceptionHandler { return Result.failed(e.getResultCode()); } + @ExceptionHandler(AuthenticationServiceException.class) + @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE) + public Result handleAuthenticationServiceException( + AuthenticationServiceException exception) { + log.error("认证服务不可用", exception); + return Result.failed(ResultCode.AUTH_SERVICE_UNAVAILABLE); + } + + @ExceptionHandler(AuthenticationException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public Result handleAuthenticationException(AuthenticationException exception) { + ResultCode code = exception instanceof DisabledException ? ResultCode.ACCOUNT_FROZEN + : exception instanceof BadCredentialsException ? ResultCode.USER_PASSWORD_ERROR + : ResultCode.USER_LOGIN_EXCEPTION; + return Result.failed(code); + } + @ExceptionHandler(RateLimitException.class) @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) public Result handleRateLimitException(RateLimitException e) { @@ -178,17 +211,12 @@ public class GlobalExceptionHandler { @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) - public Result handleException(Exception e, HttpServletRequest request) throws Exception { - if (e instanceof AccessDeniedException - || e instanceof AuthenticationException) { - throw e; - } - String accept = request.getHeader("Accept"); - if (accept != null && accept.contains(MediaType.TEXT_EVENT_STREAM_VALUE)) { + public Result handleException(Exception e) throws Exception { + if (e instanceof AccessDeniedException) { throw e; } log.error("unknown exception", e); - return Result.failed(e.getLocalizedMessage()); + return Result.failed(ResultCode.SYSTEM_ERROR); } private String convertMessage(Throwable throwable) { diff --git a/src/main/java/com/youlai/boot/framework/web/advice/QueryBindingAdvice.java b/src/main/java/com/youlai/boot/framework/web/advice/QueryBindingAdvice.java new file mode 100644 index 00000000..2bd42977 --- /dev/null +++ b/src/main/java/com/youlai/boot/framework/web/advice/QueryBindingAdvice.java @@ -0,0 +1,24 @@ +package com.youlai.boot.framework.web.advice; + +import org.springframework.beans.propertyeditors.StringArrayPropertyEditor; +import org.springframework.web.bind.WebDataBinder; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.InitBinder; + +/** + * 保留重复排序参数中的字段与方向 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@ControllerAdvice +public class QueryBindingAdvice { + + /** + * 将每个 sort 参数作为完整规则,避免按逗号拆成两个数组元素 + */ + @InitBinder + public void bindSort(WebDataBinder binder) { + binder.registerCustomEditor(String[].class, "sort", new StringArrayPropertyEditor(null)); + } +} diff --git a/src/main/java/com/youlai/boot/framework/web/log/OperationLogAspect.java b/src/main/java/com/youlai/boot/framework/web/log/OperationLogAspect.java index 1cc010cf..56c9a706 100644 --- a/src/main/java/com/youlai/boot/framework/web/log/OperationLogAspect.java +++ b/src/main/java/com/youlai/boot/framework/web/log/OperationLogAspect.java @@ -66,6 +66,11 @@ public class OperationLogAspect { } finally { long elapsed = System.currentTimeMillis() - start; try { + // 登录前没有操作者;退出前已取快照,不能在清理上下文后重新读取 + if (logAnnotation.value() == ActionTypeEnum.LOGIN && failure == null) { + userId = SecurityUtils.getUserId(); + username = SecurityUtils.getUsername(); + } OperationLog operationLog = buildOperationLog(logAnnotation, elapsed, failure, userId, username); if (operationLog != null) { operationLogExecutor.execute(() -> { diff --git a/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java b/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java deleted file mode 100644 index 13cc472f..00000000 --- a/src/main/java/com/youlai/boot/framework/web/ratelimit/SlidingWindowScript.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.youlai.boot.framework.web.ratelimit; - -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.data.redis.core.script.DefaultRedisScript; - -import java.util.Collections; -import java.util.UUID; - -/** - * 滑动窗口限流 Lua 脚本(共享工具) - * - * @author Ray.Hao - * @since 4.4.0 - */ -public final class SlidingWindowScript { - - private SlidingWindowScript() { - throw new UnsupportedOperationException("工具类不允许实例化"); - } - - private static final DefaultRedisScript SCRIPT; - - static { - SCRIPT = new DefaultRedisScript<>(); - SCRIPT.setScriptText( - "local key = KEYS[1] " + - "local now = tonumber(ARGV[1]) " + - "local window = tonumber(ARGV[2]) " + - "local member = ARGV[3] " + - "redis.call('ZREMRANGEBYSCORE', key, 0, now - window) " + - "redis.call('ZADD', key, now, member) " + - "redis.call('PEXPIRE', key, window + 1000) " + - "return redis.call('ZCARD', key)" - ); - SCRIPT.setResultType(Long.class); - } - - /** - * 执行滑动窗口计数 - * - * @param redisTemplate Redis 模板 - * @param key 限流 Key(如 {@code rate_limit:ip:192.168.1.1}) - * @param windowMs 窗口大小(毫秒) - * @return 当前窗口内请求数(包含本次) - */ - public static Long execute(RedisTemplate redisTemplate, - String key, - long windowMs) { - // 传 Long 而非 String,避免 Jackson 序列化器加双引号导致 Lua tonumber() 返回 nil - return redisTemplate.execute( - SCRIPT, - Collections.singletonList(key), - System.currentTimeMillis(), - windowMs, - UUID.randomUUID().toString() - ); - } - -} diff --git a/src/main/java/com/youlai/boot/framework/web/util/ResponseWriter.java b/src/main/java/com/youlai/boot/framework/web/util/ResponseWriter.java index 16ce427b..fbfbfa5a 100644 --- a/src/main/java/com/youlai/boot/framework/web/util/ResponseWriter.java +++ b/src/main/java/com/youlai/boot/framework/web/util/ResponseWriter.java @@ -13,6 +13,9 @@ import java.nio.charset.StandardCharsets; /** * 响应写入器 + * + * @author Ray.Hao + * @since 4.13.0 */ @Slf4j public final class ResponseWriter { @@ -54,6 +57,7 @@ public final class ResponseWriter { private static int mapHttpStatus(ResultCode resultCode) { return switch (resultCode) { + case AUTH_SERVICE_UNAVAILABLE -> HttpStatus.SERVICE_UNAVAILABLE.value(); case ACCESS_UNAUTHORIZED, ACCESS_TOKEN_INVALID, REFRESH_TOKEN_INVALID -> HttpStatus.UNAUTHORIZED.value(); case ACCESS_PERMISSION_EXCEPTION -> HttpStatus.FORBIDDEN.value(); case REQUEST_CONCURRENCY_LIMIT_EXCEEDED -> HttpStatus.TOO_MANY_REQUESTS.value(); diff --git a/src/main/java/com/youlai/boot/module/form/controller/FormController.java b/src/main/java/com/youlai/boot/module/form/controller/FormController.java index 552fd6aa..18fc8ff9 100644 --- a/src/main/java/com/youlai/boot/module/form/controller/FormController.java +++ b/src/main/java/com/youlai/boot/module/form/controller/FormController.java @@ -13,8 +13,8 @@ import com.youlai.boot.framework.web.util.IPUtils; import com.youlai.boot.module.form.model.form.FormAiGenerateForm; import com.youlai.boot.module.form.model.form.FormDefinitionForm; import com.youlai.boot.module.form.model.form.FormMenuForm; -import com.youlai.boot.module.form.model.query.FormDataPageQuery; -import com.youlai.boot.module.form.model.query.FormDefinitionPageQuery; +import com.youlai.boot.module.form.model.query.FormDataQuery; +import com.youlai.boot.module.form.model.query.FormDefinitionQuery; import com.youlai.boot.module.form.model.vo.FormDataVO; import com.youlai.boot.module.form.model.vo.FormDefinitionPageVO; import com.youlai.boot.module.form.model.vo.FormMenuVO; @@ -28,6 +28,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import java.util.Arrays; @@ -53,7 +54,7 @@ public class FormController { @GetMapping @Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.LIST) public PageResult getFormDefinitionPage( - @Valid FormDefinitionPageQuery queryParams + @Valid FormDefinitionQuery queryParams ) { Page result = formDefinitionService.getFormDefinitionPage(queryParams); return PageResult.success(result); @@ -79,7 +80,7 @@ public class FormController { @PreAuthorize("@ss.hasPerm('form:definition:create')") @RepeatSubmit @Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.INSERT) - public Result saveFormDefinition(@Valid @RequestBody FormDefinitionForm formData) { + public Result saveFormDefinition(@RequestBody @Validated(FormDefinitionForm.Create.class) FormDefinitionForm formData) { boolean result = formDefinitionService.saveFormDefinition(formData); return Result.judge(result); } @@ -90,7 +91,7 @@ public class FormController { @Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.UPDATE) public Result updateFormDefinition( @Parameter(description = "表单ID") @PathVariable Long formId, - @RequestBody @Valid FormDefinitionForm formData + @RequestBody @Validated(FormDefinitionForm.Update.class) FormDefinitionForm formData ) { boolean result = formDefinitionService.updateFormDefinition(formId, formData); return Result.judge(result); @@ -138,9 +139,6 @@ public class FormController { return Result.success(renderVO); } - //--------------------------------------------------- - // 表单菜单相关接口(发布向导) - //--------------------------------------------------- @Operation(summary = "获取表单菜单配置(向导回显)") @GetMapping("/{formId}/menu") public Result getFormMenu( @@ -163,9 +161,7 @@ public class FormController { return Result.judge(result); } - //--------------------------------------------------- - // 公开表单相关接口(匿名,Security 白名单 + Service 校验 is_public + IP 限流) - //--------------------------------------------------- + // 公开访问须通过表单开关与 IP 限流校验 @Operation(summary = "获取公开表单渲染规则(匿名)") @GetMapping("/public/{formKey}/render") public Result getPublicFormRender( @@ -187,16 +183,13 @@ public class FormController { return Result.judge(result); } - //--------------------------------------------------- - // 表单数据相关接口 - //--------------------------------------------------- @Operation(summary = "表单数据分页列表") @GetMapping("/{formKey}/data") @PreAuthorize("@ss.hasPerm('form:data:list')") @Log(module = LogModuleEnum.FORM, value = ActionTypeEnum.LIST) public PageResult getFormDataPage( @Parameter(description = "表单唯一标识") @PathVariable String formKey, - @Valid FormDataPageQuery queryParams + @Valid FormDataQuery queryParams ) { Page result = formDataService.getFormDataPage(formKey, queryParams); return PageResult.success(result); @@ -241,9 +234,7 @@ public class FormController { return Result.success(); } - //--------------------------------------------------- - // AI 辅助接口(未开启 AI 时调用返回业务异常) - //--------------------------------------------------- + // AI 未启用时返回业务异常 @Operation(summary = "AI 生成表单规则") @PostMapping("/ai-generate") diff --git a/src/main/java/com/youlai/boot/module/form/mapper/FormDataMapper.java b/src/main/java/com/youlai/boot/module/form/mapper/FormDataMapper.java index 16b67a0b..e27ef7f3 100644 --- a/src/main/java/com/youlai/boot/module/form/mapper/FormDataMapper.java +++ b/src/main/java/com/youlai/boot/module/form/mapper/FormDataMapper.java @@ -3,7 +3,7 @@ package com.youlai.boot.module.form.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.youlai.boot.module.form.model.entity.FormData; -import com.youlai.boot.module.form.model.query.FormDataPageQuery; +import com.youlai.boot.module.form.model.query.FormDataQuery; import com.youlai.boot.module.form.model.vo.FormDataVO; import org.apache.ibatis.annotations.Mapper; @@ -24,6 +24,6 @@ public interface FormDataMapper extends BaseMapper { * @param queryParams 查询参数 * @return 表单数据分页列表 */ - Page getFormDataPage(Page page, Long formId, FormDataPageQuery queryParams); + Page getFormDataPage(Page page, Long formId, FormDataQuery queryParams); } diff --git a/src/main/java/com/youlai/boot/module/form/mapper/FormDefinitionMapper.java b/src/main/java/com/youlai/boot/module/form/mapper/FormDefinitionMapper.java index 39196b94..ebbe6260 100644 --- a/src/main/java/com/youlai/boot/module/form/mapper/FormDefinitionMapper.java +++ b/src/main/java/com/youlai/boot/module/form/mapper/FormDefinitionMapper.java @@ -3,7 +3,7 @@ package com.youlai.boot.module.form.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.youlai.boot.module.form.model.entity.FormDefinition; -import com.youlai.boot.module.form.model.query.FormDefinitionPageQuery; +import com.youlai.boot.module.form.model.query.FormDefinitionQuery; import com.youlai.boot.module.form.model.vo.FormDefinitionPageVO; import org.apache.ibatis.annotations.Mapper; @@ -23,6 +23,6 @@ public interface FormDefinitionMapper extends BaseMapper { * @param queryParams 查询参数 * @return 表单定义分页列表 */ - Page getFormDefinitionPage(Page page, FormDefinitionPageQuery queryParams); + Page getFormDefinitionPage(Page page, FormDefinitionQuery queryParams); } diff --git a/src/main/java/com/youlai/boot/module/form/model/form/FormDefinitionForm.java b/src/main/java/com/youlai/boot/module/form/model/form/FormDefinitionForm.java index fbe7f06b..1ec76b5e 100644 --- a/src/main/java/com/youlai/boot/module/form/model/form/FormDefinitionForm.java +++ b/src/main/java/com/youlai/boot/module/form/model/form/FormDefinitionForm.java @@ -16,22 +16,34 @@ import lombok.Data; @Data public class FormDefinitionForm { + /** + * 创建校验组:表单标识、表单名称为必填 + */ + public interface Create { + } + + /** + * 更新校验组:只校验本次提交字段的格式,允许只提交规则或公开开关 + */ + public interface Update { + } + @Schema(description = "表单ID", example = "1") private Long id; @Schema(description = "表单唯一标识(创建后不可修改)", example = "employee_onboarding") - @NotBlank(message = "表单标识不能为空") - @Size(max = 64, message = "表单标识长度不能超过64个字符") - @Pattern(regexp = "^[a-z][a-z0-9_]*$", message = "表单标识须以小写字母开头,仅含小写字母、数字、下划线") + @NotBlank(message = "表单标识不能为空", groups = Create.class) + @Size(max = 64, message = "表单标识长度不能超过64个字符", groups = {Create.class, Update.class}) + @Pattern(regexp = "^[a-z][a-z0-9_]*$", message = "表单标识须以小写字母开头,仅含小写字母、数字、下划线", groups = {Create.class, Update.class}) private String formKey; @Schema(description = "表单名称", example = "新员工入职信息采集") - @NotBlank(message = "表单名称不能为空") - @Size(max = 100, message = "表单名称长度不能超过100个字符") + @NotBlank(message = "表单名称不能为空", groups = Create.class) + @Size(max = 100, message = "表单名称长度不能超过100个字符", groups = {Create.class, Update.class}) private String formName; @Schema(description = "表单描述") - @Size(max = 255, message = "表单描述长度不能超过255个字符") + @Size(max = 255, message = "表单描述长度不能超过255个字符", groups = {Create.class, Update.class}) private String description; @Schema(description = "表单规则(form-create rule 数组 JSON 字符串,由设计器产出)") @@ -44,7 +56,7 @@ public class FormDefinitionForm { private Integer isPublic; @Schema(description = "表单类型(normal通用表单 workflow工作流表单,缺省normal)", example = "normal") - @Pattern(regexp = "^(normal|workflow)$", message = "表单类型不合法") + @Pattern(regexp = "^(normal|workflow)$", message = "表单类型不合法", groups = {Create.class, Update.class}) private String category; } diff --git a/src/main/java/com/youlai/boot/module/form/model/query/FormDataPageQuery.java b/src/main/java/com/youlai/boot/module/form/model/query/FormDataQuery.java similarity index 88% rename from src/main/java/com/youlai/boot/module/form/model/query/FormDataPageQuery.java rename to src/main/java/com/youlai/boot/module/form/model/query/FormDataQuery.java index 577a3c37..64c5c71c 100644 --- a/src/main/java/com/youlai/boot/module/form/model/query/FormDataPageQuery.java +++ b/src/main/java/com/youlai/boot/module/form/model/query/FormDataQuery.java @@ -14,6 +14,6 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = false) @Schema(description = "表单数据分页查询对象") -public class FormDataPageQuery extends BaseQuery { +public class FormDataQuery extends BaseQuery { } diff --git a/src/main/java/com/youlai/boot/module/form/model/query/FormDefinitionPageQuery.java b/src/main/java/com/youlai/boot/module/form/model/query/FormDefinitionQuery.java similarity index 92% rename from src/main/java/com/youlai/boot/module/form/model/query/FormDefinitionPageQuery.java rename to src/main/java/com/youlai/boot/module/form/model/query/FormDefinitionQuery.java index 4f00d428..a62566fe 100644 --- a/src/main/java/com/youlai/boot/module/form/model/query/FormDefinitionPageQuery.java +++ b/src/main/java/com/youlai/boot/module/form/model/query/FormDefinitionQuery.java @@ -14,7 +14,7 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = false) @Schema(description = "表单定义分页查询对象") -public class FormDefinitionPageQuery extends BaseQuery { +public class FormDefinitionQuery extends BaseQuery { @Schema(description = "关键字(表单名称/标识)") private String keywords; diff --git a/src/main/java/com/youlai/boot/module/form/service/FormDataService.java b/src/main/java/com/youlai/boot/module/form/service/FormDataService.java index 437844ec..283e9342 100644 --- a/src/main/java/com/youlai/boot/module/form/service/FormDataService.java +++ b/src/main/java/com/youlai/boot/module/form/service/FormDataService.java @@ -1,7 +1,7 @@ package com.youlai.boot.module.form.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.youlai.boot.module.form.model.query.FormDataPageQuery; +import com.youlai.boot.module.form.model.query.FormDataQuery; import com.youlai.boot.module.form.model.vo.FormDataVO; import java.util.List; @@ -50,7 +50,7 @@ public interface FormDataService { * @param queryParams 查询参数 * @return 表单数据分页列表 */ - Page getFormDataPage(String formKey, FormDataPageQuery queryParams); + Page getFormDataPage(String formKey, FormDataQuery queryParams); /** * 获取表单数据详情(只读回显) diff --git a/src/main/java/com/youlai/boot/module/form/service/FormDefinitionService.java b/src/main/java/com/youlai/boot/module/form/service/FormDefinitionService.java index 9b5f7080..e4063a1c 100644 --- a/src/main/java/com/youlai/boot/module/form/service/FormDefinitionService.java +++ b/src/main/java/com/youlai/boot/module/form/service/FormDefinitionService.java @@ -5,7 +5,7 @@ import com.youlai.boot.common.model.Option; import com.youlai.boot.module.form.model.entity.FormDefinition; import com.youlai.boot.module.form.model.form.FormDefinitionForm; import com.youlai.boot.module.form.model.form.FormMenuForm; -import com.youlai.boot.module.form.model.query.FormDefinitionPageQuery; +import com.youlai.boot.module.form.model.query.FormDefinitionQuery; import com.youlai.boot.module.form.model.vo.FormDefinitionPageVO; import com.youlai.boot.module.form.model.vo.FormMenuVO; import com.youlai.boot.module.form.model.vo.FormRenderVO; @@ -26,7 +26,7 @@ public interface FormDefinitionService { * @param queryParams 查询参数 * @return 表单定义分页列表 */ - Page getFormDefinitionPage(FormDefinitionPageQuery queryParams); + Page getFormDefinitionPage(FormDefinitionQuery queryParams); /** * 按主键查询表单定义(供模块内回退取规则等场景) diff --git a/src/main/java/com/youlai/boot/module/form/service/impl/FormDataServiceImpl.java b/src/main/java/com/youlai/boot/module/form/service/impl/FormDataServiceImpl.java index 7c144fa1..34894fd4 100644 --- a/src/main/java/com/youlai/boot/module/form/service/impl/FormDataServiceImpl.java +++ b/src/main/java/com/youlai/boot/module/form/service/impl/FormDataServiceImpl.java @@ -13,7 +13,7 @@ import com.youlai.boot.module.form.mapper.FormSnapshotMapper; import com.youlai.boot.module.form.model.entity.FormData; import com.youlai.boot.module.form.model.entity.FormDefinition; import com.youlai.boot.module.form.model.entity.FormSnapshot; -import com.youlai.boot.module.form.model.query.FormDataPageQuery; +import com.youlai.boot.module.form.model.query.FormDataQuery; import com.youlai.boot.module.form.model.vo.FormDataVO; import com.youlai.boot.module.form.service.FormDataService; import com.youlai.boot.module.form.service.FormDefinitionService; @@ -143,7 +143,7 @@ public class FormDataServiceImpl extends ServiceImpl i * @param queryParams 查询参数 */ @Override - public Page getFormDataPage(String formKey, FormDataPageQuery queryParams) { + public Page getFormDataPage(String formKey, FormDataQuery queryParams) { FormDefinition form = formDefinitionService.getByFormKey(formKey); int pageNum = queryParams.getPageNum(); int pageSize = queryParams.getPageSize(); diff --git a/src/main/java/com/youlai/boot/module/form/service/impl/FormDefinitionServiceImpl.java b/src/main/java/com/youlai/boot/module/form/service/impl/FormDefinitionServiceImpl.java index 9a0171cd..f1b53241 100644 --- a/src/main/java/com/youlai/boot/module/form/service/impl/FormDefinitionServiceImpl.java +++ b/src/main/java/com/youlai/boot/module/form/service/impl/FormDefinitionServiceImpl.java @@ -24,7 +24,7 @@ import com.youlai.boot.module.form.model.entity.FormDefinition; import com.youlai.boot.module.form.model.entity.FormSnapshot; import com.youlai.boot.module.form.model.form.FormDefinitionForm; import com.youlai.boot.module.form.model.form.FormMenuForm; -import com.youlai.boot.module.form.model.query.FormDefinitionPageQuery; +import com.youlai.boot.module.form.model.query.FormDefinitionQuery; import com.youlai.boot.module.form.model.vo.FormDefinitionPageVO; import com.youlai.boot.module.form.model.vo.FormMenuVO; import com.youlai.boot.module.form.model.vo.FormRenderVO; @@ -82,7 +82,7 @@ public class FormDefinitionServiceImpl extends ServiceImpl getFormDefinitionPage(FormDefinitionPageQuery queryParams) { + public Page getFormDefinitionPage(FormDefinitionQuery queryParams) { int pageNum = queryParams.getPageNum(); int pageSize = queryParams.getPageSize(); return this.baseMapper.getFormDefinitionPage(new Page<>(pageNum, pageSize), queryParams); @@ -409,7 +409,7 @@ public class FormDefinitionServiceImpl extends ServiceImpl() .eq(Menu::getRouteName, routeName) .last("LIMIT 1") @@ -578,9 +580,7 @@ public class FormDefinitionServiceImpl extends ServiceImpl getInstancePage(@Valid WorkflowInstancePageQuery queryParams) { + public PageResult getInstancePage(@Valid WorkflowInstanceQuery queryParams) { Page result = workflowInstanceService.getInstancePage(queryParams); return PageResult.success(result); } diff --git a/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowModelController.java b/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowModelController.java index 0d09af76..4bac3c08 100644 --- a/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowModelController.java +++ b/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowModelController.java @@ -9,7 +9,7 @@ import com.youlai.boot.common.result.PageResult; import com.youlai.boot.common.result.Result; import com.youlai.boot.module.workflow.model.form.ModelForm; import com.youlai.boot.module.workflow.model.form.ModelXmlForm; -import com.youlai.boot.module.workflow.model.query.WorkflowModelPageQuery; +import com.youlai.boot.module.workflow.model.query.WorkflowModelQuery; import com.youlai.boot.module.workflow.model.vo.ModelVO; import com.youlai.boot.module.workflow.model.vo.ModelXmlVO; import com.youlai.boot.module.workflow.service.WorkflowDemoService; @@ -45,7 +45,7 @@ public class WorkflowModelController { @GetMapping @PreAuthorize("@ss.hasPerm('workflow:model:list')") @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.LIST) - public PageResult getModelPage(@Valid WorkflowModelPageQuery queryParams) { + public PageResult getModelPage(@Valid WorkflowModelQuery queryParams) { Page result = workflowModelService.getModelPage(queryParams); return PageResult.success(result); } diff --git a/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowTaskController.java b/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowTaskController.java index 67b125c0..2ea943a2 100644 --- a/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowTaskController.java +++ b/src/main/java/com/youlai/boot/module/workflow/controller/WorkflowTaskController.java @@ -9,7 +9,7 @@ import com.youlai.boot.common.result.PageResult; import com.youlai.boot.common.result.Result; import com.youlai.boot.module.workflow.model.form.CompleteTaskForm; import com.youlai.boot.module.workflow.model.form.RejectTaskForm; -import com.youlai.boot.module.workflow.model.query.WorkflowTaskPageQuery; +import com.youlai.boot.module.workflow.model.query.WorkflowTaskQuery; import com.youlai.boot.module.workflow.model.vo.DoneTaskVO; import com.youlai.boot.module.workflow.model.vo.RejectTargetVO; import com.youlai.boot.module.workflow.model.vo.TaskDetailVO; @@ -43,7 +43,7 @@ public class WorkflowTaskController { @Operation(summary = "待办任务分页列表") @GetMapping("/todo") @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.LIST, title = "待办任务") - public PageResult getTodoTaskPage(@Valid WorkflowTaskPageQuery queryParams) { + public PageResult getTodoTaskPage(@Valid WorkflowTaskQuery queryParams) { Page result = workflowTaskService.getTodoTaskPage(queryParams); return PageResult.success(result); } @@ -51,7 +51,7 @@ public class WorkflowTaskController { @Operation(summary = "已办任务分页列表") @GetMapping("/done") @Log(module = LogModuleEnum.WORKFLOW, value = ActionTypeEnum.LIST, title = "已办任务") - public PageResult getDoneTaskPage(@Valid WorkflowTaskPageQuery queryParams) { + public PageResult getDoneTaskPage(@Valid WorkflowTaskQuery queryParams) { Page result = workflowTaskService.getDoneTaskPage(queryParams); return PageResult.success(result); } diff --git a/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowInstancePageQuery.java b/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowInstanceQuery.java similarity index 89% rename from src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowInstancePageQuery.java rename to src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowInstanceQuery.java index ca3c03ec..7e05221e 100644 --- a/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowInstancePageQuery.java +++ b/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowInstanceQuery.java @@ -14,7 +14,7 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = false) @Schema(description = "流程实例分页查询对象") -public class WorkflowInstancePageQuery extends BaseQuery { +public class WorkflowInstanceQuery extends BaseQuery { @Schema(description = "关键字(流程定义名称)") private String keywords; diff --git a/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowModelPageQuery.java b/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowModelQuery.java similarity index 89% rename from src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowModelPageQuery.java rename to src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowModelQuery.java index b47b0d9d..2ca74c2b 100644 --- a/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowModelPageQuery.java +++ b/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowModelQuery.java @@ -14,7 +14,7 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = false) @Schema(description = "流程模型分页查询对象") -public class WorkflowModelPageQuery extends BaseQuery { +public class WorkflowModelQuery extends BaseQuery { @Schema(description = "关键字(模型名称)") private String keywords; diff --git a/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowTaskPageQuery.java b/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowTaskQuery.java similarity index 89% rename from src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowTaskPageQuery.java rename to src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowTaskQuery.java index 8f7457fa..b578bf04 100644 --- a/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowTaskPageQuery.java +++ b/src/main/java/com/youlai/boot/module/workflow/model/query/WorkflowTaskQuery.java @@ -14,7 +14,7 @@ import lombok.EqualsAndHashCode; @Data @EqualsAndHashCode(callSuper = false) @Schema(description = "任务分页查询对象") -public class WorkflowTaskPageQuery extends BaseQuery { +public class WorkflowTaskQuery extends BaseQuery { @Schema(description = "关键字(任务名称)") private String keywords; diff --git a/src/main/java/com/youlai/boot/module/workflow/service/WorkflowInstanceService.java b/src/main/java/com/youlai/boot/module/workflow/service/WorkflowInstanceService.java index a0e44be5..8a0afe11 100644 --- a/src/main/java/com/youlai/boot/module/workflow/service/WorkflowInstanceService.java +++ b/src/main/java/com/youlai/boot/module/workflow/service/WorkflowInstanceService.java @@ -2,7 +2,7 @@ package com.youlai.boot.module.workflow.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.youlai.boot.module.workflow.model.form.StartProcessForm; -import com.youlai.boot.module.workflow.model.query.WorkflowInstancePageQuery; +import com.youlai.boot.module.workflow.model.query.WorkflowInstanceQuery; import com.youlai.boot.module.workflow.model.vo.InstanceDetailVO; import com.youlai.boot.module.workflow.model.vo.InstancePageVO; import com.youlai.boot.module.workflow.model.vo.ProcessDiagramVO; @@ -29,7 +29,7 @@ public interface WorkflowInstanceService { * @param queryParams 查询参数 * @return 分页结果 */ - Page getInstancePage(WorkflowInstancePageQuery queryParams); + Page getInstancePage(WorkflowInstanceQuery queryParams); /** * 获取流程图数据(BPMN XML + 已办/进行中节点,bpmn-js 高亮渲染) diff --git a/src/main/java/com/youlai/boot/module/workflow/service/WorkflowModelService.java b/src/main/java/com/youlai/boot/module/workflow/service/WorkflowModelService.java index e0ac1b06..6f134fcd 100644 --- a/src/main/java/com/youlai/boot/module/workflow/service/WorkflowModelService.java +++ b/src/main/java/com/youlai/boot/module/workflow/service/WorkflowModelService.java @@ -3,7 +3,7 @@ package com.youlai.boot.module.workflow.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.youlai.boot.module.workflow.model.form.ModelForm; import com.youlai.boot.module.workflow.model.form.ModelXmlForm; -import com.youlai.boot.module.workflow.model.query.WorkflowModelPageQuery; +import com.youlai.boot.module.workflow.model.query.WorkflowModelQuery; import com.youlai.boot.module.workflow.model.vo.ModelVO; import com.youlai.boot.module.workflow.model.vo.ModelXmlVO; @@ -26,7 +26,7 @@ public interface WorkflowModelService { * @param queryParams 查询参数 * @return 分页结果 */ - Page getModelPage(WorkflowModelPageQuery queryParams); + Page getModelPage(WorkflowModelQuery queryParams); /** * 新增流程模型(内置开始-审批-结束模板,设计器开箱即画) diff --git a/src/main/java/com/youlai/boot/module/workflow/service/WorkflowTaskService.java b/src/main/java/com/youlai/boot/module/workflow/service/WorkflowTaskService.java index 22206e42..bde106e9 100644 --- a/src/main/java/com/youlai/boot/module/workflow/service/WorkflowTaskService.java +++ b/src/main/java/com/youlai/boot/module/workflow/service/WorkflowTaskService.java @@ -3,7 +3,7 @@ package com.youlai.boot.module.workflow.service; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.youlai.boot.module.workflow.model.form.CompleteTaskForm; import com.youlai.boot.module.workflow.model.form.RejectTaskForm; -import com.youlai.boot.module.workflow.model.query.WorkflowTaskPageQuery; +import com.youlai.boot.module.workflow.model.query.WorkflowTaskQuery; import com.youlai.boot.module.workflow.model.vo.DoneTaskVO; import com.youlai.boot.module.workflow.model.vo.RejectTargetVO; import com.youlai.boot.module.workflow.model.vo.TaskDetailVO; @@ -25,7 +25,7 @@ public interface WorkflowTaskService { * @param queryParams 查询参数 * @return 分页结果 */ - Page getTodoTaskPage(WorkflowTaskPageQuery queryParams); + Page getTodoTaskPage(WorkflowTaskQuery queryParams); /** * 已办任务分页列表(当前用户已办理的任务) @@ -33,7 +33,7 @@ public interface WorkflowTaskService { * @param queryParams 查询参数 * @return 分页结果 */ - Page getDoneTaskPage(WorkflowTaskPageQuery queryParams); + Page getDoneTaskPage(WorkflowTaskQuery queryParams); /** * 获取待办任务详情(发起表单只读回显 + 审批记录) diff --git a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowInstanceServiceImpl.java b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowInstanceServiceImpl.java index dda6c3d9..13207be2 100644 --- a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowInstanceServiceImpl.java +++ b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowInstanceServiceImpl.java @@ -9,7 +9,7 @@ import com.youlai.boot.module.form.model.vo.FormDataVO; import com.youlai.boot.module.form.service.FormDataService; import com.youlai.boot.module.workflow.enums.InstanceStatusEnum; import com.youlai.boot.module.workflow.model.form.StartProcessForm; -import com.youlai.boot.module.workflow.model.query.WorkflowInstancePageQuery; +import com.youlai.boot.module.workflow.model.query.WorkflowInstanceQuery; import com.youlai.boot.module.workflow.model.vo.InstanceDetailVO; import com.youlai.boot.module.workflow.model.vo.InstancePageVO; import com.youlai.boot.module.workflow.model.vo.ProcessDiagramVO; @@ -109,7 +109,7 @@ public class WorkflowInstanceServiceImpl implements WorkflowInstanceService { * @param queryParams 查询参数 */ @Override - public Page getInstancePage(WorkflowInstancePageQuery queryParams) { + public Page getInstancePage(WorkflowInstanceQuery queryParams) { HistoricProcessInstanceQuery query = historyService.createHistoricProcessInstanceQuery() .startedBy(SecurityUtils.getUsername()); if (StrUtil.isNotBlank(queryParams.getKeywords())) { diff --git a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowModelServiceImpl.java b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowModelServiceImpl.java index 2f530786..c77e8b7c 100644 --- a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowModelServiceImpl.java +++ b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowModelServiceImpl.java @@ -10,7 +10,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.module.workflow.model.form.ModelForm; import com.youlai.boot.module.workflow.model.form.ModelXmlForm; -import com.youlai.boot.module.workflow.model.query.WorkflowModelPageQuery; +import com.youlai.boot.module.workflow.model.query.WorkflowModelQuery; import com.youlai.boot.module.workflow.model.vo.ModelVO; import com.youlai.boot.module.workflow.model.vo.ModelXmlVO; import com.youlai.boot.module.workflow.service.WorkflowModelService; @@ -111,7 +111,7 @@ public class WorkflowModelServiceImpl implements WorkflowModelService { * @param queryParams 查询参数 */ @Override - public Page getModelPage(WorkflowModelPageQuery queryParams) { + public Page getModelPage(WorkflowModelQuery queryParams) { ModelQuery query = repositoryService.createModelQuery(); if (StrUtil.isNotBlank(queryParams.getKeywords())) { query.modelNameLike("%" + queryParams.getKeywords() + "%"); diff --git a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowTaskServiceImpl.java b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowTaskServiceImpl.java index 4b3df71e..9ff67d35 100644 --- a/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowTaskServiceImpl.java +++ b/src/main/java/com/youlai/boot/module/workflow/service/impl/WorkflowTaskServiceImpl.java @@ -14,7 +14,7 @@ import com.youlai.boot.module.form.service.FormDataService; import com.youlai.boot.module.form.util.FormRuleValidator; import com.youlai.boot.module.workflow.model.form.CompleteTaskForm; import com.youlai.boot.module.workflow.model.form.RejectTaskForm; -import com.youlai.boot.module.workflow.model.query.WorkflowTaskPageQuery; +import com.youlai.boot.module.workflow.model.query.WorkflowTaskQuery; import com.youlai.boot.module.workflow.model.vo.ApprovalHistoryVO; import com.youlai.boot.module.workflow.model.vo.DoneTaskVO; import com.youlai.boot.module.workflow.model.vo.RejectTargetVO; @@ -86,7 +86,7 @@ public class WorkflowTaskServiceImpl implements WorkflowTaskService { * @param queryParams 查询参数 */ @Override - public Page getTodoTaskPage(WorkflowTaskPageQuery queryParams) { + public Page getTodoTaskPage(WorkflowTaskQuery queryParams) { TaskQuery query = this.createCandidateTaskQuery(); if (StrUtil.isNotBlank(queryParams.getKeywords())) { query.taskNameLike("%" + queryParams.getKeywords() + "%"); @@ -130,7 +130,7 @@ public class WorkflowTaskServiceImpl implements WorkflowTaskService { * @param queryParams 查询参数 */ @Override - public Page getDoneTaskPage(WorkflowTaskPageQuery queryParams) { + public Page getDoneTaskPage(WorkflowTaskQuery queryParams) { HistoricTaskInstanceQuery query = historyService.createHistoricTaskInstanceQuery() .taskAssignee(SecurityUtils.getUsername()).finished(); if (StrUtil.isNotBlank(queryParams.getKeywords())) { @@ -386,9 +386,7 @@ public class WorkflowTaskServiceImpl implements WorkflowTaskService { .collect(Collectors.toMap(ProcessInstance::getId, instance -> instance)); } - //--------------------------------------------------- - // AI 辅助:未开启 AI 时调用返回业务异常 - //--------------------------------------------------- + // AI 未启用时返回业务异常 /** * 用 AI 汇总表单数据与审批记录,生成审批摘要 diff --git a/src/main/java/com/youlai/boot/support/realtime/RealtimeMaintenanceTask.java b/src/main/java/com/youlai/boot/support/realtime/RealtimeMaintenanceTask.java new file mode 100644 index 00000000..fcab1c98 --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/RealtimeMaintenanceTask.java @@ -0,0 +1,60 @@ +package com.youlai.boot.support.realtime; + +import com.youlai.boot.support.realtime.config.RealtimeProperties; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * 调度实时连接的心跳与授权维护 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Slf4j +@Component +public class RealtimeMaintenanceTask { + private final ScheduledExecutorService scheduler; + private final RealtimeSessionManager sessions; + private final RealtimeProperties properties; + private ScheduledFuture task; + + public RealtimeMaintenanceTask(@Qualifier("realtimeScheduler") ScheduledExecutorService scheduler, + RealtimeSessionManager sessions, RealtimeProperties properties) { + this.scheduler = scheduler; + this.sessions = sessions; + this.properties = properties; + } + + /** + * 启动定期维护任务 + */ + @PostConstruct + public void start() { + if (!properties.isEnabled()) { + return; + } + task = scheduler.scheduleWithFixedDelay(() -> { + try { + sessions.maintain(); + } catch (Exception exception) { + log.error("Realtime maintenance failed", exception); + } + }, 1, 1, TimeUnit.SECONDS); + } + + /** + * 取消维护任务 + */ + @PreDestroy + public void stop() { + if (task != null) { + task.cancel(false); + } + } +} diff --git a/src/main/java/com/youlai/boot/support/realtime/RealtimeMessageSender.java b/src/main/java/com/youlai/boot/support/realtime/RealtimeMessageSender.java new file mode 100644 index 00000000..186cb619 --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/RealtimeMessageSender.java @@ -0,0 +1,129 @@ +package com.youlai.boot.support.realtime; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.TextMessage; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; + +/** + * 每连接一个发送者;排队帧和正在写出的帧都计入内存预算 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Slf4j +@Component +public class RealtimeMessageSender { + private static final int MAX_SESSION_BYTES = 32_768; + private static final int MAX_SESSION_MESSAGES = 32; + private final Executor executor; + + public RealtimeMessageSender(@Qualifier("realtimeSenderExecutor") Executor executor) { + this.executor = executor; + } + + /** + * 待发送文本及其预算占用 + */ + record Frame(String text, int bytes) { + /** + * 按 UTF-8 字节数计算帧的预算占用 + */ + static Frame of(String text) { + return new Frame(text, text.getBytes(StandardCharsets.UTF_8).length); + } + } + + /** + * 在连接预算内入队,同一连接只启动一个发送者 + */ + boolean enqueue(RealtimeSession session, Frame frame, Runnable onFailure) { + boolean start; + synchronized (session) { + if (session.closed) { + return false; + } + if (session.bufferedMessages >= MAX_SESSION_MESSAGES + || session.bufferedBytes + frame.bytes() > MAX_SESSION_BYTES) { + return false; + } + session.queue.addLast(frame); + session.bufferedBytes += frame.bytes(); + session.bufferedMessages++; + start = !session.draining; + session.draining = true; + } + if (start) { + submit(session, onFailure); + } + return true; + } + + /** + * 投递发送任务,线程池拒绝时清理连接 + */ + private void submit(RealtimeSession session, Runnable onFailure) { + try { + executor.execute(() -> drain(session, onFailure)); + } catch (RejectedExecutionException exception) { + synchronized (session) { + session.draining = false; + } + onFailure.run(); + } + } + + /** + * 串行发送连接中的排队帧,并归还预算 + */ + private void drain(RealtimeSession session, Runnable onFailure) { + // 每轮最多发送 16 帧后让出线程,避免单条连接长期占用发送池 + for (int count = 0; count < 16; count++) { + Frame frame; + synchronized (session) { + frame = session.closed ? null : session.queue.pollFirst(); + if (frame == null) { + session.draining = false; + return; + } + } + boolean failed = false; + try { + session.transport.sendMessage(new TextMessage(frame.text())); + } catch (Exception exception) { + failed = true; + log.debug("Realtime write failed: {}", exception.getClass().getSimpleName()); + } finally { + // 正在写出的帧在此结算,关闭连接只释放尚未发送的队列 + synchronized (session) { + session.bufferedBytes -= frame.bytes(); + session.bufferedMessages--; + } + } + if (failed) { + synchronized (session) { + session.draining = false; + } + onFailure.run(); + return; + } + } + submit(session, onFailure); + } + + /** + * 只释放排队帧,正在写出的帧由 drain 的 finally 结算 + */ + void discardQueued(RealtimeSession session) { + synchronized (session) { + Frame frame; + while ((frame = session.queue.pollFirst()) != null) { + session.bufferedBytes -= frame.bytes(); + session.bufferedMessages--; + } + } + } +} diff --git a/src/main/java/com/youlai/boot/support/realtime/RealtimeService.java b/src/main/java/com/youlai/boot/support/realtime/RealtimeService.java new file mode 100644 index 00000000..97c672d5 --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/RealtimeService.java @@ -0,0 +1,79 @@ +package com.youlai.boot.support.realtime; + +import com.youlai.boot.support.realtime.config.RealtimeProperties; +import com.youlai.boot.support.realtime.protocol.RealtimeServerMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import java.util.List; +import java.util.concurrent.Semaphore; +import java.util.function.BiFunction; + +/** + * 异步广播与分批发送业务变更提示 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RealtimeService { + private static final int RECIPIENT_BATCH_SIZE = 100; + private final RealtimeSessionManager sessions; + private final RealtimeProperties properties; + private final Semaphore deliveryJobs = new Semaphore(2); + + /** + * 向订阅指定主题的连接异步广播消息 + */ + public void broadcast(String topic, String type, Object payload) { + if (properties.isEnabled()) { + var message = RealtimeServerMessage.create(type, payload); + sessions.submitMaintenance(() -> sessions.broadcast(topic, message)); + } + } + + /** + * 分批投递失效提示,批次之间重新排队让出线程 + * loadRecipients 按 userId 升序查询游标之后的用户,返回数不得超过传入的 limit + */ + public void sendBatches(String type, Object payload, BiFunction> loadRecipients) { + if (!properties.isEnabled()) { + return; + } + if (!deliveryJobs.tryAcquire()) { + log.warn("Realtime delivery dropped: audience queue full"); + return; + } + submitBatch(loadRecipients, RealtimeServerMessage.create(type, payload), 0); + } + + /** + * 提交一批收件人投递,结束或拒绝时释放任务配额 + */ + private void submitBatch(BiFunction> loader, + RealtimeServerMessage message, long afterUserId) { + if (!sessions.submitMaintenance(() -> { + boolean continued = false; + try { + List recipients = loader.apply(afterUserId, RECIPIENT_BATCH_SIZE); + if (!recipients.isEmpty()) { + sessions.sendToUsers(recipients, message); + } + if (recipients.size() == RECIPIENT_BATCH_SIZE) { + continued = true; + submitBatch(loader, message, recipients.get(recipients.size() - 1)); + } + } catch (Exception exception) { + log.warn("Realtime audience delivery failed: {}", exception.getClass().getSimpleName()); + } finally { + if (!continued) { + deliveryJobs.release(); + } + } + })) { + deliveryJobs.release(); + } + } +} diff --git a/src/main/java/com/youlai/boot/support/realtime/RealtimeSession.java b/src/main/java/com/youlai/boot/support/realtime/RealtimeSession.java new file mode 100644 index 00000000..392d081d --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/RealtimeSession.java @@ -0,0 +1,71 @@ +package com.youlai.boot.support.realtime; + +import com.youlai.boot.support.realtime.security.RealtimeTicketService.TicketGrant; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketSession; +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.Set; + +/** + * 可变连接状态由本对象的监视器保护,锁内不执行网络或存储操作 + * + * @author Ray.Hao + * @since 4.13.0 + */ +final class RealtimeSession { + final WebSocketSession transport; + final Long userId; + final String instanceKey; + final RealtimeSessionManager.Reservation reservation; + final ArrayDeque queue = new ArrayDeque<>(); + private String accessToken; + final Set topics; + final Instant authExpiresAt; + // 间隔判断使用单调时钟纳秒;协议中的绝对到期时间使用 Instant + long lastVerifiedAt = System.nanoTime(); + long lastPongAt = System.nanoTime(); + long nextPingAt = lastPongAt + java.util.concurrent.TimeUnit.SECONDS.toNanos(25); + String pendingPingId; + long nextVerifyAt = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(5) + + java.util.concurrent.ThreadLocalRandom.current().nextLong(java.util.concurrent.TimeUnit.SECONDS.toNanos(10)); + // 预算包含排队和正在写出的帧,由发送者分别结算 + int bufferedBytes; + int bufferedMessages; + int inboundMessages; + long inboundWindow = System.nanoTime(); + boolean draining; + // closed 停止业务收发;terminated 表示底层连接结束,可释放配额 + boolean closed; + boolean terminated; + boolean verifying; + boolean closeRunning; + int closeAttempts; + CloseStatus closeStatus = CloseStatus.NORMAL; + + RealtimeSession(WebSocketSession transport, TicketGrant grant, + RealtimeSessionManager.Reservation reservation, Instant authExpiresAt) { + this.transport = transport; + this.userId = grant.userId(); + this.instanceKey = grant.userId() + ":" + grant.clientInstanceId(); + this.accessToken = grant.accessToken(); + this.topics = grant.topics(); + this.reservation = reservation; + this.authExpiresAt = authExpiresAt; + } + + synchronized String accessToken() { + return accessToken; + } + /** + * 清除连接持有的认证凭证 + */ + synchronized void forgetCredential() { + accessToken = null; + } + + @Override + public String toString() { + return "RealtimeSession[transport=" + transport.getId() + "]"; + } +} diff --git a/src/main/java/com/youlai/boot/support/realtime/RealtimeSessionManager.java b/src/main/java/com/youlai/boot/support/realtime/RealtimeSessionManager.java new file mode 100644 index 00000000..e3ef7510 --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/RealtimeSessionManager.java @@ -0,0 +1,404 @@ +package com.youlai.boot.support.realtime; + +import com.youlai.boot.support.realtime.model.PresenceSnapshot; +import com.youlai.boot.support.realtime.protocol.RealtimeMessageCodec; +import com.youlai.boot.support.realtime.protocol.RealtimeServerMessage; +import com.youlai.boot.support.realtime.security.RealtimeCredentialVerifier; +import com.youlai.boot.support.realtime.security.RealtimeTicketService.VerifiedTicket; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketSession; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executor; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +/** + * 管理节点内连接、连接配额与授权期限 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Slf4j +@Component +public class RealtimeSessionManager { + private static final long PONG_TIMEOUT = TimeUnit.SECONDS.toNanos(60); + private static final long VERIFY_DEADLINE = TimeUnit.SECONDS.toNanos(30); + // transport 保留握手与关闭中连接,active 只保留当前可投递的实例 + private final Map transports = new ConcurrentHashMap<>(); + private final Map active = new ConcurrentHashMap<>(); + private final Semaphore slots = new Semaphore(500); + private final AtomicBoolean stopping = new AtomicBoolean(); + private final AtomicLong rejectedTasks = new AtomicLong(); + private final AtomicLong lastRejectionWarning = new AtomicLong(); + private final String nodeId = UUID.randomUUID().toString(); + private final RealtimeMessageSender sender; + private final RealtimeMessageCodec codec; + private final RealtimeCredentialVerifier verifier; + private final Executor maintenance; + private long lastPresenceAt; + private int lastOnlineUsers = -1; + private int lastOnlineConnections = -1; + + public RealtimeSessionManager(RealtimeMessageSender sender, RealtimeMessageCodec codec, + RealtimeCredentialVerifier verifier, + @Qualifier("realtimeMaintenanceExecutor") Executor maintenance) { + this.sender = sender; + this.codec = codec; + this.verifier = verifier; + this.maintenance = maintenance; + } + + /** + * 握手、活跃和关闭中的 transport 共用节点配额 + */ + public Reservation reserve() { + if (stopping.get() || !slots.tryAcquire()) { + return null; + } + return new Reservation(); + } + + /** + * 连接配额预留,重复关闭只释放一次 + */ + public final class Reservation implements AutoCloseable { + private final AtomicBoolean released = new AtomicBoolean(); + @Override + public void close() { + if (released.compareAndSet(false, true)) { + slots.release(); + } + } + } + + /** + * 注册已验证的连接,替换同一客户端的旧实例 + */ + public void register(WebSocketSession transport, VerifiedTicket ticket, Reservation reservation) { + RealtimeSession session = new RealtimeSession(transport, ticket.grant(), reservation, ticket.credentialExpiresAt()); + transports.put(transport.getId(), session); + try { + RealtimeSession previous; + synchronized (session) { + synchronized (active) { + if (stopping.get()) { + throw new IllegalStateException("Stopping"); + } + previous = active.get(session.instanceKey); + long count = active.values().stream().filter(item -> item.userId.equals(session.userId)).count(); + if (previous == null && count >= 8) { + throw new RejectedExecutionException("User connection limit"); + } + // 先排入 ready,再向广播公开连接;旧连接关闭只能移除其自身 + offer(session, RealtimeServerMessage.create("connection.ready", Map.of("heartbeatTimeoutMs", 60_000))); + if (session.closed) { + return; + } + active.put(session.instanceKey, session); + } + } + if (previous != null) { + requestClose(previous, 4009, "instance replaced"); + } + } catch (Exception exception) { + requestClose(session, 4001, "credential rejected"); + } + } + + /** + * 确认当前心跳并限制接收频率 + */ + public void handlePong(WebSocketSession transport, String pingId) { + RealtimeSession session = transports.get(transport.getId()); + if (session == null) { + return; + } + long now = System.nanoTime(); + synchronized (session) { + if (session.closed) { + return; + } + if (now - session.inboundWindow >= TimeUnit.SECONDS.toNanos(1)) { + session.inboundWindow = now; + session.inboundMessages = 0; + } + if (++session.inboundMessages > 20) { + requestClose(session, 4008, "message rate exceeded"); + return; + } + if (pingId.equals(session.pendingPingId) && now - session.lastPongAt < PONG_TIMEOUT) { + session.pendingPingId = null; + session.lastPongAt = now; + session.nextPingAt = now + TimeUnit.SECONDS.toNanos(25); + } + } + } + + /** + * 向指定用户的活跃连接投递消息 + */ + public void sendToUsers(Collection userIds, RealtimeServerMessage message) { + Set recipients = Set.copyOf(userIds); + for (RealtimeSession session : active.values()) { + if (recipients.contains(session.userId)) { + offer(session, message); + } + } + } + + /** + * 向订阅指定主题的活跃连接投递消息 + */ + public void broadcast(String topic, RealtimeServerMessage message) { + for (RealtimeSession session : active.values()) { + if (!session.topics.contains(topic)) { + continue; + } + offer(session, message); + } + } + + /** + * 检查授权期限后投递消息,超出缓冲预算时关闭连接 + */ + private void offer(RealtimeSession session, RealtimeServerMessage message) { + if (isAuthorizationExpired(session)) { + requestClose(session, 4012, "authorization expired"); + return; + } + var frame = RealtimeMessageSender.Frame.of(codec.encode(message)); + if (!sender.enqueue(session, frame, () -> requestClose(session, 4010, "write unavailable"))) { + requestClose(session, 4010, "slow consumer"); + } + } + + /** + * 统计当前节点的在线用户数与连接数 + */ + public PresenceSnapshot presence() { + var sessions = List.copyOf(active.values()); + return new PresenceSnapshot(Math.toIntExact(sessions.stream().map(item -> item.userId).distinct().count()), + sessions.size(), nodeId, Instant.now().truncatedTo(ChronoUnit.MILLIS).toString()); + } + + /** + * 标记连接关闭并安排底层资源清理 + */ + public void requestClose(WebSocketSession transport, int code, String reason) { + RealtimeSession session = transports.get(transport.getId()); + if (session != null) { + requestClose(session, code, reason); + } + } + + /** + * 标记连接关闭并安排底层资源清理 + */ + private void requestClose(RealtimeSession session, int code, String reason) { + synchronized (session) { + if (session.closed) { + return; + } + session.closed = true; + session.closeStatus = new CloseStatus(code, reason); + } + synchronized (active) { + active.remove(session.instanceKey, session); + } + sender.discardQueued(session); + session.forgetCredential(); + scheduleClose(session); + } + + /** + * 安排底层关闭操作,失败时由维护任务有限重试 + */ + private void scheduleClose(RealtimeSession session) { + synchronized (session) { + if (session.terminated || session.closeRunning || session.closeAttempts >= 3) { + return; + } + session.closeRunning = true; + } + if (!submitMaintenance(() -> { + try { + synchronized (session) { + session.closeAttempts++; + } + session.transport.close(session.closeStatus); + } catch (Exception exception) { + log.warn("Realtime close failed: {}", exception.getClass().getSimpleName()); + } finally { + synchronized (session) { + session.closeRunning = false; + } + } + })) { + synchronized (session) { + session.closeRunning = false; + } + } + } + + /** + * 底层连接确认结束后才释放槽位,逻辑关闭期间仍占配额 + */ + public void terminated(WebSocketSession transport) { + RealtimeSession session = transports.get(transport.getId()); + if (session == null) { + return; + } + requestClose(session, 1000, "transport terminated"); + synchronized (session) { + if (session.terminated) { + return; + } + session.terminated = true; + } + transports.remove(transport.getId(), session); + session.reservation.close(); + } + + /** + * 检查凭证期限与最近一次身份复核是否仍有效 + */ + private boolean isAuthorizationExpired(RealtimeSession session) { + synchronized (session) { + return !Instant.now().isBefore(session.authExpiresAt) + || System.nanoTime() - session.lastVerifiedAt >= VERIFY_DEADLINE; + } + } + + /** + * 仅作内存判断和有界投递,调度线程不访问 Redis、数据库或网络 + */ + public void maintain() { + long now = System.nanoTime(); + for (RealtimeSession session : transports.values()) { + boolean closed; + synchronized (session) { + closed = session.closed; + } + if (closed) { + scheduleClose(session); + continue; + } + if (isAuthorizationExpired(session)) { + requestClose(session, 4012, "authorization expired"); + continue; + } + boolean heartbeatExpired; + boolean verify; + String pingId = null; + synchronized (session) { + heartbeatExpired = now - session.lastPongAt >= PONG_TIMEOUT; + verify = !session.verifying && now >= session.nextVerifyAt; + if (verify) { + session.verifying = true; + } + if (now >= session.nextPingAt && session.pendingPingId == null) { + pingId = UUID.randomUUID().toString(); + session.pendingPingId = pingId; + } + } + if (heartbeatExpired) { + requestClose(session, 4011, "heartbeat timeout"); + continue; + } + if (verify && !submitMaintenance(() -> verify(session))) { + synchronized (session) { + session.verifying = false; + } + } + if (pingId != null) { + offer(session, new RealtimeServerMessage(1, pingId, "heartbeat.ping", Map.of())); + } + } + if (now - lastPresenceAt >= TimeUnit.SECONDS.toNanos(5)) { + lastPresenceAt = now; + PresenceSnapshot snapshot = presence(); + if (snapshot.onlineUsers() != lastOnlineUsers || snapshot.onlineConnections() != lastOnlineConnections) { + lastOnlineUsers = snapshot.onlineUsers(); + lastOnlineConnections = snapshot.onlineConnections(); + broadcast("presence", RealtimeServerMessage.create("presence.snapshot", snapshot)); + } + } + } + + /** + * 复核连接身份及主题权限,并更新校验时间 + */ + private void verify(RealtimeSession session) { + try { + var identity = verifier.verify(session.accessToken()); + synchronized (session) { + if (session.closed) { + return; + } + if (System.nanoTime() - session.lastVerifiedAt >= VERIFY_DEADLINE) { + throw new BadCredentialsException("Authorization check expired"); + } + if (!identity.userId().equals(session.userId) + || (session.topics.contains("presence") && !identity.presenceAllowed())) { + throw new AccessDeniedException("Authorization changed"); + } + session.lastVerifiedAt = System.nanoTime(); + session.nextVerifyAt = session.lastVerifiedAt + TimeUnit.SECONDS.toNanos(15); + } + } catch (Exception exception) { + requestClose(session, 4001, "credential revoked"); + } finally { + synchronized (session) { + session.verifying = false; + } + } + } + + /** + * 拒绝时返回 false,由调用方清理;不借用调用线程执行任务 + */ + public boolean submitMaintenance(Runnable task) { + try { + maintenance.execute(task); + return true; + } catch (RejectedExecutionException exception) { + long rejected = rejectedTasks.incrementAndGet(); + long now = System.nanoTime(); + long previous = lastRejectionWarning.get(); + if (now - previous > TimeUnit.SECONDS.toNanos(10) + && lastRejectionWarning.compareAndSet(previous, now)) { + log.warn("Realtime maintenance queue full: rejected={}, transports={}", rejected, transports.size()); + } + return false; + } + } + + public boolean isStopping() { + return stopping.get(); + } + + /** + * 停止接收新连接,并关闭现有连接 + */ + @PreDestroy + public void shutdown() { + stopping.set(true); + for (RealtimeSession session : transports.values()) requestClose(session, 1001, "server shutdown"); + } +} diff --git a/src/main/java/com/youlai/boot/support/realtime/RealtimeWebSocketHandler.java b/src/main/java/com/youlai/boot/support/realtime/RealtimeWebSocketHandler.java new file mode 100644 index 00000000..a2851d74 --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/RealtimeWebSocketHandler.java @@ -0,0 +1,66 @@ +package com.youlai.boot.support.realtime; + +import com.youlai.boot.support.realtime.protocol.RealtimeMessageCodec; +import com.youlai.boot.support.realtime.security.RealtimeHandshakeInterceptor; +import com.youlai.boot.support.realtime.security.RealtimeTicketService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.BinaryMessage; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.TextWebSocketHandler; +import org.springframework.web.socket.adapter.NativeWebSocketSession; + +/** + * 接收 WebSocket 回调并转交连接管理器 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Component +@RequiredArgsConstructor +public class RealtimeWebSocketHandler extends TextWebSocketHandler { + private final RealtimeSessionManager sessions; + private final RealtimeMessageCodec codec; + + @Override + public void afterConnectionEstablished(WebSocketSession session) { + // Tomcat 的同步发送有独立阻塞上限,不依赖 Future 超时 + if (session instanceof NativeWebSocketSession nativeSession) { + jakarta.websocket.Session delegate = nativeSession.getNativeSession(jakarta.websocket.Session.class); + if (delegate != null) { + delegate.getUserProperties().put("org.apache.tomcat.websocket.BLOCKING_SEND_TIMEOUT", 5_000L); + } + } + var ticket = (RealtimeTicketService.VerifiedTicket) session.getAttributes() + .remove(RealtimeHandshakeInterceptor.TICKET); + var reservation = (RealtimeSessionManager.Reservation) session.getAttributes() + .remove(RealtimeHandshakeInterceptor.RESERVATION); + sessions.register(session, ticket, reservation); + } + + @Override + protected void handleTextMessage(WebSocketSession session, TextMessage message) { + try { + sessions.handlePong(session, codec.decodePong(message.getPayload())); + } catch (RuntimeException exception) { + sessions.requestClose(session, 1008, "invalid protocol"); + } + } + + @Override + protected void handleBinaryMessage(WebSocketSession session, BinaryMessage message) { + sessions.requestClose(session, 1008, "text messages required"); + } + + @Override + public void handleTransportError(WebSocketSession session, Throwable exception) { + sessions.requestClose(session, 1011, "transport error"); + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) { + sessions.terminated(session); + } +} diff --git a/src/main/java/com/youlai/boot/support/realtime/config/RealtimeConfig.java b/src/main/java/com/youlai/boot/support/realtime/config/RealtimeConfig.java new file mode 100644 index 00000000..517cc708 --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/config/RealtimeConfig.java @@ -0,0 +1,106 @@ +package com.youlai.boot.support.realtime.config; + +import com.youlai.boot.support.realtime.RealtimeWebSocketHandler; +import com.youlai.boot.support.realtime.security.RealtimeHandshakeHandler; +import com.youlai.boot.support.realtime.security.RealtimeHandshakeInterceptor; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +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; +import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 注册实时连接端点及专用有界线程池 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Configuration(proxyBeanMethods = false) +@EnableWebSocket +@EnableConfigurationProperties(RealtimeProperties.class) +@RequiredArgsConstructor +public class RealtimeConfig implements WebSocketConfigurer { + private final RealtimeWebSocketHandler handler; + private final RealtimeHandshakeHandler handshakeHandler; + private final RealtimeHandshakeInterceptor interceptor; + private final RealtimeProperties properties; + + @Override + public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { + if (!properties.isEnabled()) { + return; + } + registry.addHandler(handler, "/api/v1/realtime/ws") + .setHandshakeHandler(handshakeHandler::handshake) + .addInterceptors(interceptor) + .setAllowedOrigins(properties.getAllowedOrigins().toArray(String[]::new)); + } + + /** + * 配置 WebSocket 消息上限与写入超时 + */ + @Bean + public static ServletServerContainerFactoryBean realtimeContainer() { + var container = new ServletServerContainerFactoryBean(); + container.setMaxTextMessageBufferSize(16_384); + container.setMaxBinaryMessageBufferSize(1_024); + container.setMaxSessionIdleTimeout(120_000L); + container.setAsyncSendTimeout(5_000L); + return container; + } + + /** + * 创建有界消息发送线程池 + */ + @Bean(name = "realtimeSenderExecutor", destroyMethod = "shutdown") + public static ExecutorService senderExecutor() { + return pool("realtime-send-", 4, 512); + } + + /** + * 创建有界连接维护线程池 + */ + @Bean(name = "realtimeMaintenanceExecutor", destroyMethod = "shutdown") + public static ExecutorService maintenanceExecutor() { + return pool("realtime-maintain-", 4, 256); + } + + /** + * 创建单线程维护调度器 + */ + @Bean(name = "realtimeScheduler", destroyMethod = "shutdown") + public static ScheduledExecutorService scheduler() { + return Executors.newSingleThreadScheduledExecutor(threadFactory("realtime-clock-")); + } + + /** + * 创建队列有界、拒绝时抛出异常的线程池 + */ + private static ExecutorService pool(String prefix, int threads, int capacity) { + return new ThreadPoolExecutor(threads, threads, 0, TimeUnit.MILLISECONDS, + new ArrayBlockingQueue<>(capacity), threadFactory(prefix), new ThreadPoolExecutor.AbortPolicy()); + } + + /** + * 创建带名称的守护线程 + */ + private static ThreadFactory threadFactory(String prefix) { + AtomicInteger sequence = new AtomicInteger(); + return task -> { + Thread thread = new Thread(task, prefix + sequence.incrementAndGet()); + thread.setDaemon(true); + return thread; + }; + } +} diff --git a/src/main/java/com/youlai/boot/support/realtime/config/RealtimeProperties.java b/src/main/java/com/youlai/boot/support/realtime/config/RealtimeProperties.java new file mode 100644 index 00000000..f8619bb0 --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/config/RealtimeProperties.java @@ -0,0 +1,20 @@ +package com.youlai.boot.support.realtime.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import java.util.List; + +/** + * 实时通信开关与额外允许的浏览器来源 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Getter +@Setter +@ConfigurationProperties("realtime") +public class RealtimeProperties { + private boolean enabled = true; + private List allowedOrigins = List.of(); +} diff --git a/src/main/java/com/youlai/boot/support/realtime/controller/RealtimeController.java b/src/main/java/com/youlai/boot/support/realtime/controller/RealtimeController.java new file mode 100644 index 00000000..d88b7afc --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/controller/RealtimeController.java @@ -0,0 +1,94 @@ +package com.youlai.boot.support.realtime.controller; + +import com.youlai.boot.common.constant.SecurityConstants; +import com.youlai.boot.common.result.Result; +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.framework.web.ratelimit.RateLimit; +import com.youlai.boot.support.realtime.RealtimeSessionManager; +import com.youlai.boot.support.realtime.config.RealtimeProperties; +import com.youlai.boot.support.realtime.model.CreateRealtimeTicketRequest; +import com.youlai.boot.support.realtime.model.PresenceSnapshot; +import com.youlai.boot.support.realtime.model.RealtimeTicketResponse; +import com.youlai.boot.support.realtime.security.RealtimeTicketService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataAccessException; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.AuthenticationException; +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.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 提供实时连接票据与在线状态查询 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@RestController +@RequestMapping("/api/v1/realtime") +@RequiredArgsConstructor +public class RealtimeController { + private final RealtimeTicketService tickets; + private final RealtimeSessionManager sessions; + private final RealtimeProperties properties; + + /** + * 验证访问凭证并签发一次性握手票据 + */ + @PostMapping("/tickets") + @RateLimit(limit = 60, window = 60) + public ResponseEntity> createTicket( + @Valid @RequestBody CreateRealtimeTicketRequest request, + @RequestHeader(value = HttpHeaders.AUTHORIZATION, required = false) String authorization) { + if (!properties.isEnabled()) { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).cacheControl(CacheControl.noStore()) + .body(Result.failed(ResultCode.REALTIME_DISABLED)); + } + if (sessions.isStopping()) { + return unavailable(); + } + try { + if (authorization == null || !authorization.startsWith(SecurityConstants.BEARER_TOKEN_PREFIX)) { + throw new BadCredentialsException("Bearer credential required"); + } + String accessToken = authorization.substring(SecurityConstants.BEARER_TOKEN_PREFIX.length()).trim(); + var ticket = tickets.create(accessToken, request); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(Result.success(ticket)); + } catch (AccessDeniedException exception) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).cacheControl(CacheControl.noStore()) + .body(Result.failed(ResultCode.ACCESS_UNAUTHORIZED)); + } catch (AuthenticationException exception) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).cacheControl(CacheControl.noStore()) + .body(Result.failed(ResultCode.ACCESS_TOKEN_INVALID)); + } catch (DataAccessException exception) { + return unavailable(); + } + } + + /** + * 返回实时服务暂不可用响应 + */ + private ResponseEntity> unavailable() { + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).cacheControl(CacheControl.noStore()) + .body(Result.failed("实时通信暂不可用")); + } + + /** + * 查询当前节点在线状态 + */ + @GetMapping("/presence") + @PreAuthorize("@ss.hasPerm('sys:online:view')") + public ResponseEntity> getPresence() { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).body(Result.success(sessions.presence())); + } +} diff --git a/src/main/java/com/youlai/boot/support/realtime/model/CreateRealtimeTicketRequest.java b/src/main/java/com/youlai/boot/support/realtime/model/CreateRealtimeTicketRequest.java new file mode 100644 index 00000000..ea29c35a --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/model/CreateRealtimeTicketRequest.java @@ -0,0 +1,29 @@ +package com.youlai.boot.support.realtime.model; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import java.util.Set; + +/** + * 客户端实例、运行平台与本次连接的订阅主题 + * + * @author Ray.Hao + * @since 4.13.0 + */ +public record CreateRealtimeTicketRequest( + @NotBlank @Pattern(regexp = "[A-Za-z0-9_-]{16,128}") String clientInstanceId, + @NotBlank @Pattern(regexp = "web|mp-weixin|app") String platform, + @NotNull Set topics) { + /** + * 订阅须包含 notice,且只允许已支持的主题 + */ + public CreateRealtimeTicketRequest { + if (topics != null) { + if (!topics.contains("notice") || !Set.of("notice", "dict", "presence").containsAll(topics)) { + throw new IllegalArgumentException("订阅主题须包含 notice,且仅支持 notice、dict、presence"); + } + topics = Set.copyOf(topics); + } + } +} diff --git a/src/main/java/com/youlai/boot/support/realtime/model/PresenceSnapshot.java b/src/main/java/com/youlai/boot/support/realtime/model/PresenceSnapshot.java new file mode 100644 index 00000000..0b7f0ac2 --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/model/PresenceSnapshot.java @@ -0,0 +1,10 @@ +package com.youlai.boot.support.realtime.model; + +/** + * 指定节点在采样时刻的在线统计 + * + * @author Ray.Hao + * @since 4.13.0 + */ +public record PresenceSnapshot(int onlineUsers, int onlineConnections, String nodeId, String observedAt) { +} diff --git a/src/main/java/com/youlai/boot/support/realtime/model/RealtimeTicketResponse.java b/src/main/java/com/youlai/boot/support/realtime/model/RealtimeTicketResponse.java new file mode 100644 index 00000000..4d5cdba3 --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/model/RealtimeTicketResponse.java @@ -0,0 +1,14 @@ +package com.youlai.boot.support.realtime.model; + +/** + * WebSocket 握手的一次性票据 + * + * @author Ray.Hao + * @since 4.13.0 + */ +public record RealtimeTicketResponse(String ticket) { + @Override + public String toString() { + return "RealtimeTicketResponse[REDACTED]"; + } +} diff --git a/src/main/java/com/youlai/boot/support/realtime/protocol/RealtimeMessageCodec.java b/src/main/java/com/youlai/boot/support/realtime/protocol/RealtimeMessageCodec.java new file mode 100644 index 00000000..12c003d1 --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/protocol/RealtimeMessageCodec.java @@ -0,0 +1,54 @@ +package com.youlai.boot.support.realtime.protocol; + +import org.springframework.stereotype.Component; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; +import java.nio.charset.StandardCharsets; + +/** + * 实时消息的 JSON 编解码与字段校验 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Component +public class RealtimeMessageCodec { + public static final int MAX_MESSAGE_BYTES = 16_384; + private final JsonMapper mapper = JsonMapper.builder().build(); + + /** + * 将服务端消息编码为 JSON 文本 + */ + public String encode(RealtimeServerMessage message) { + String text = mapper.writeValueAsString(message); + if (text.getBytes(StandardCharsets.UTF_8).length > MAX_MESSAGE_BYTES) { + throw new IllegalArgumentException("Message too large"); + } + return text; + } + + /** + * 校验客户端心跳响应并返回对应的 ping ID + */ + public String decodePong(String text) { + // 限制 UTF-8 字节数,中文和表情不能按字符数估算帧大小 + if (text.getBytes(StandardCharsets.UTF_8).length > MAX_MESSAGE_BYTES) { + throw new IllegalArgumentException("Message too large"); + } + JsonNode root = mapper.readTree(text); + if (root.isObject() && root.path("version").isIntegralNumber() && root.path("version").asLong(-1) == 1 + && "heartbeat.pong".equals(root.path("type").asText()) + && root.path("payload").isObject() && isText(root.path("payload").path("pingId"))) { + return root.path("payload").path("pingId").asText(); + } + throw new IllegalArgumentException("Unsupported message"); + } + + /** + * 校验文本长度与首尾空白 + */ + private boolean isText(JsonNode value) { + return value.isString() && !value.asText().isBlank() && value.asText().length() <= 128 + && value.asText().equals(value.asText().trim()); + } +} diff --git a/src/main/java/com/youlai/boot/support/realtime/protocol/RealtimeServerMessage.java b/src/main/java/com/youlai/boot/support/realtime/protocol/RealtimeServerMessage.java new file mode 100644 index 00000000..2969ab1b --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/protocol/RealtimeServerMessage.java @@ -0,0 +1,18 @@ +package com.youlai.boot.support.realtime.protocol; + +import java.util.UUID; + +/** + * 带协议版本和消息标识的服务端消息 + * + * @author Ray.Hao + * @since 4.13.0 + */ +public record RealtimeServerMessage(int version, String id, String type, Object payload) { + /** + * 为服务端消息生成唯一标识 + */ + public static RealtimeServerMessage create(String type, Object payload) { + return new RealtimeServerMessage(1, UUID.randomUUID().toString(), type, payload); + } +} diff --git a/src/main/java/com/youlai/boot/support/realtime/security/RealtimeCredentialVerifier.java b/src/main/java/com/youlai/boot/support/realtime/security/RealtimeCredentialVerifier.java new file mode 100644 index 00000000..4c879efa --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/security/RealtimeCredentialVerifier.java @@ -0,0 +1,39 @@ +package com.youlai.boot.support.realtime.security; + +import com.youlai.boot.framework.security.port.PermissionPort; +import com.youlai.boot.framework.security.session.SessionService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.util.PatternMatchUtils; +import java.util.Set; + +/** + * 实时连接与 HTTP 请求共用会话校验,权限以服务端记录为准 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Component +@RequiredArgsConstructor +public class RealtimeCredentialVerifier { + private final SessionService sessionService; + private final PermissionPort permissions; + + /** + * 复用登录会话校验并提取实时连接所需权限 + */ + public VerifiedIdentity verify(String accessToken) { + var session = sessionService.authenticate(accessToken); + Set roles = session.principal().roles(); + Set perms = roles.isEmpty() ? Set.of() : permissions.getRolePerms(roles); + boolean presenceAllowed = roles.contains("ROOT") || perms.stream() + .anyMatch(perm -> PatternMatchUtils.simpleMatch(perm, "sys:online:view")); + return new VerifiedIdentity(session.principal().userId(), presenceAllowed, session.accessExpiresAt()); + } + + /** + * 已验证的用户身份、凭证期限及在线统计权限 + */ + public record VerifiedIdentity(Long userId, boolean presenceAllowed, java.time.Instant credentialExpiresAt) { + } +} diff --git a/src/main/java/com/youlai/boot/support/realtime/security/RealtimeHandshakeHandler.java b/src/main/java/com/youlai/boot/support/realtime/security/RealtimeHandshakeHandler.java new file mode 100644 index 00000000..593865e1 --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/security/RealtimeHandshakeHandler.java @@ -0,0 +1,45 @@ +package com.youlai.boot.support.realtime.security; + +import com.youlai.boot.support.realtime.RealtimeSessionManager; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.support.DefaultHandshakeHandler; +import java.security.Principal; +import java.util.Map; + +/** + * Principal 仅公开用户 ID;认证材料在建立连接后移入私有状态 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Component +public class RealtimeHandshakeHandler extends DefaultHandshakeHandler { + @Override + protected Principal determineUser(ServerHttpRequest request, WebSocketHandler handler, + Map attributes) { + var ticket = (RealtimeTicketService.VerifiedTicket) attributes.get(RealtimeHandshakeInterceptor.TICKET); + return () -> ticket.grant().userId().toString(); + } + + /** + * 执行协议升级,失败时释放连接配额 + */ + public boolean handshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler handler, Map attributes) { + boolean upgraded = false; + try { + upgraded = doHandshake(request, response, handler, attributes); + return upgraded; + } finally { + if (!upgraded) { + var reservation = (RealtimeSessionManager.Reservation) attributes.get(RealtimeHandshakeInterceptor.RESERVATION); + if (reservation != null) { + reservation.close(); + } + } + } + } +} diff --git a/src/main/java/com/youlai/boot/support/realtime/security/RealtimeHandshakeInterceptor.java b/src/main/java/com/youlai/boot/support/realtime/security/RealtimeHandshakeInterceptor.java new file mode 100644 index 00000000..3f15701d --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/security/RealtimeHandshakeInterceptor.java @@ -0,0 +1,93 @@ +package com.youlai.boot.support.realtime.security; + +import com.youlai.boot.support.realtime.RealtimeSessionManager; +import com.youlai.boot.support.realtime.config.RealtimeProperties; +import lombok.RequiredArgsConstructor; +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.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.stereotype.Component; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.server.HandshakeInterceptor; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.WebUtils; +import java.util.Map; + +/** + * 消费票据后将可信身份和配额转交给连接,不在 URL 中传访问令牌 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Component +@RequiredArgsConstructor +public class RealtimeHandshakeInterceptor implements HandshakeInterceptor { + public static final String TICKET = RealtimeHandshakeInterceptor.class.getName() + ".ticket"; + public static final String RESERVATION = RealtimeHandshakeInterceptor.class.getName() + ".reservation"; + private final RealtimeTicketService tickets; + private final RealtimeSessionManager sessions; + private final RealtimeProperties properties; + + @Override + public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler handler, Map attributes) { + String origin = request.getHeaders().getOrigin(); + if (origin != null && !WebUtils.isSameOrigin(request) + && !properties.getAllowedOrigins().contains(origin)) { + response.setStatusCode(HttpStatus.FORBIDDEN); + return false; + } + var reservation = sessions.reserve(); + if (reservation == null) { + response.setStatusCode(HttpStatus.SERVICE_UNAVAILABLE); + return false; + } + try { + var params = UriComponentsBuilder.fromUri(request.getURI()).build().getQueryParams(); + if (params.size() != 1 || params.get("ticket") == null || params.get("ticket").size() != 1) { + throw new BadCredentialsException("Invalid ticket query"); + } + var ticket = tickets.consume(params.getFirst("ticket")); + if (origin == null && "web".equals(ticket.grant().platform())) { + response.setStatusCode(HttpStatus.FORBIDDEN); + reservation.close(); + return false; + } + attributes.put(TICKET, ticket); + attributes.put(RESERVATION, reservation); + if (request instanceof ServletServerHttpRequest servlet) { + servlet.getServletRequest().setAttribute(RESERVATION, reservation); + } + return true; + } catch (AccessDeniedException exception) { + reservation.close(); + response.setStatusCode(HttpStatus.FORBIDDEN); + return false; + } catch (BadCredentialsException exception) { + reservation.close(); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + return false; + } catch (Exception exception) { + reservation.close(); + response.setStatusCode(HttpStatus.SERVICE_UNAVAILABLE); + return false; + } + } + + @Override + public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, + WebSocketHandler handler, Exception exception) { + if (request instanceof ServletServerHttpRequest servlet + && response instanceof ServletServerHttpResponse servletResponse + && servletResponse.getServletResponse().getStatus() != 101) { + Object reserved = servlet.getServletRequest().getAttribute(RESERVATION); + if (reserved instanceof RealtimeSessionManager.Reservation reservation) { + reservation.close(); + } + } + } +} diff --git a/src/main/java/com/youlai/boot/support/realtime/security/RealtimeTicketService.java b/src/main/java/com/youlai/boot/support/realtime/security/RealtimeTicketService.java new file mode 100644 index 00000000..307b40eb --- /dev/null +++ b/src/main/java/com/youlai/boot/support/realtime/security/RealtimeTicketService.java @@ -0,0 +1,119 @@ +package com.youlai.boot.support.realtime.security; + +import com.youlai.boot.framework.security.token.SecureTokenGenerator; +import com.youlai.boot.support.realtime.model.CreateRealtimeTicketRequest; +import com.youlai.boot.support.realtime.model.RealtimeTicketResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.stereotype.Service; +import tools.jackson.databind.json.JsonMapper; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Set; + +/** + * 签发并消费实时连接的一次性票据 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Service +@RequiredArgsConstructor +public class RealtimeTicketService { + private static final Duration TICKET_TTL = Duration.ofSeconds(30); + // 取出即删除,握手票据只能使用一次 + private static final DefaultRedisScript CONSUME = new DefaultRedisScript<>( + "local v=redis.call('GET',KEYS[1]); if v then redis.call('DEL',KEYS[1]); end; return v", + String.class); + private final StringRedisTemplate redis; + private final RealtimeCredentialVerifier verifier; + private final JsonMapper mapper = JsonMapper.builder().build(); + + /** + * 校验访问凭证后签发短期一次性票据 + */ + public RealtimeTicketResponse create(String accessToken, CreateRealtimeTicketRequest request) { + var identity = verifier.verify(accessToken); + checkTopics(request.topics(), identity.presenceAllowed()); + String ticket = SecureTokenGenerator.generate(); + TicketGrant grant = new TicketGrant(accessToken, identity.userId(), request.clientInstanceId(), + request.platform(), request.topics(), Instant.now().plus(TICKET_TTL).toEpochMilli()); + redis.opsForValue().set(key(ticket), mapper.writeValueAsString(grant), TICKET_TTL); + return new RealtimeTicketResponse(ticket); + } + + /** + * 原子取出并删除后复核到期与身份,已取出的票据不回滚 + */ + public VerifiedTicket consume(String ticket) { + if (ticket == null || !ticket.matches("[A-Za-z0-9_-]{43,256}")) { + throw invalid(); + } + String json = redis.execute(CONSUME, List.of(key(ticket))); + if (json == null) { + throw invalid(); + } + TicketGrant grant = mapper.readValue(json, TicketGrant.class); + if (grant.expiresAtMillis() <= System.currentTimeMillis()) { + throw invalid(); + } + var identity = verifier.verify(grant.accessToken()); + if (!grant.userId().equals(identity.userId())) { + throw invalid(); + } + checkTopics(grant.topics(), identity.presenceAllowed()); + return new VerifiedTicket(grant, identity.credentialExpiresAt()); + } + + /** + * 在线统计主题需要对应的查看权限 + */ + private void checkTopics(Set topics, boolean presenceAllowed) { + if (topics.contains("presence") && !presenceAllowed) { + throw new AccessDeniedException("Presence topic forbidden"); + } + } + + /** + * 通过票据摘要构造 Redis 存储键 + */ + private String key(String ticket) { + // Redis 键只保存摘要,不暴露可用于握手的票据 + return "realtime:ticket:" + SecureTokenGenerator.digest(ticket); + } + + /** + * 创建实时连接票据无效异常 + */ + private BadCredentialsException invalid() { + return new BadCredentialsException("Invalid realtime ticket"); + } + + /** + * 仅限服务器内部,禁止把该对象放入响应或日志 + */ + public record TicketGrant(String accessToken, Long userId, String clientInstanceId, + String platform, Set topics, long expiresAtMillis) { + /** + * 固定本次连接的订阅集合 + */ + public TicketGrant { + topics = Set.copyOf(topics); + } + + @Override + public String toString() { + return "TicketGrant[REDACTED]"; + } + } + + /** + * 本次握手已验证的票据及访问凭证到期时间 + */ + public record VerifiedTicket(TicketGrant grant, Instant credentialExpiresAt) { + } +} diff --git a/src/main/java/com/youlai/boot/support/sse/OnlineUserDTO.java b/src/main/java/com/youlai/boot/support/sse/OnlineUserDTO.java deleted file mode 100644 index 480562e0..00000000 --- a/src/main/java/com/youlai/boot/support/sse/OnlineUserDTO.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.youlai.boot.support.sse; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.io.Serial; -import java.io.Serializable; - -/** - * 在线用户信息 DTO。 - * - * @author Ray.Hao - * @since 3.0.0 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -public class OnlineUserDTO implements Serializable { - - @Serial - private static final long serialVersionUID = 1L; - - // 用户名 - private String username; - - // 会话数量,多设备登录时大于 1 - private int sessionCount; - - // 最早登录时间(毫秒时间戳) - private long loginTime; -} diff --git a/src/main/java/com/youlai/boot/support/sse/SseService.java b/src/main/java/com/youlai/boot/support/sse/SseService.java deleted file mode 100644 index 1bc4654f..00000000 --- a/src/main/java/com/youlai/boot/support/sse/SseService.java +++ /dev/null @@ -1,129 +0,0 @@ -package com.youlai.boot.support.sse; - -import com.youlai.boot.support.sse.constant.SseEventConstants; -import com.youlai.boot.support.sse.constant.SseTopics; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -import java.io.IOException; -import java.util.List; -import java.util.Map; - -/** - * SSE 推送服务 - * - * @author Ray.Hao - * @since 3.1.0 - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class SseService { - - private final SseSessionManager sessionManager; - - /** - * 创建 SSE 连接 - * - * @param username 用户名 - * @return SseEmitter - */ - public SseEmitter createConnection(String username) { - SseEmitter emitter = new SseEmitter(SseSessionManager.TIMEOUT); - if (username == null || username.isEmpty()) { - log.warn("创建SSE连接失败:用户名为空"); - emitter.completeWithError(new IllegalArgumentException("用户名为空")); - return emitter; - } - - sessionManager.userConnected(username, emitter); - - // 连接建立后立即发送在线用户数 - try { - emitter.send(SseEmitter.event() - .name(SseEventConstants.ONLINE_USERS) - .data(sessionManager.getOnlineUserCount())); - } catch (IOException e) { - log.warn("发送初始在线用户数失败: {}", e.getMessage()); - } - - log.info("用户[{}]SSE连接已建立,当前在线用户数: {}", username, sessionManager.getOnlineUserCount()); - sendOnlineCount(); - - return emitter; - } - - /** - * 广播事件到所有在线连接 - * - * @param topic 事件主题 - * @param data 数据 - */ - public void broadcast(String topic, Object data) { - sessionManager.broadcast(topic, data); - } - - /** - * 向指定用户发送事件 - * - * @param username 用户名 - * @param topic 事件主题 - * @param data 数据 - */ - public void sendToUser(String username, String topic, Object data) { - sessionManager.sendToUser(username, topic, data); - } - - /** - * 广播在线用户数 - */ - public void sendOnlineCount() { - int count = sessionManager.getOnlineUserCount(); - sessionManager.broadcast(SseEventConstants.ONLINE_USERS, count); - log.debug("已发送在线用户数: {}", count); - } - - /** - * 发送系统消息(全员广播) - * - * @param message 消息内容 - */ - public void sendSystemMessage(String message) { - if (message == null || message.isEmpty()) { - return; - } - Map systemMessage = Map.of( - "sender", "系统通知", - "content", message, - "timestamp", System.currentTimeMillis() - ); - sessionManager.broadcast(SseTopics.SYSTEM, systemMessage); - log.info("已发送系统消息: {}", message); - } - - /** - * 获取在线用户列表 - * - * @return 在线用户列表 - */ - public List getOnlineUsers() { - return sessionManager.getOnlineUsernames().stream() - .map(username -> { - int sessionCount = sessionManager.getUserConnectionCount(username); - long loginTime = sessionManager.getEarliestLoginTime(username); - return new OnlineUserDTO(username, sessionCount, loginTime); - }) - .toList(); - } - - /** - * 获取在线用户数 - * - * @return 在线用户数 - */ - public int getOnlineUserCount() { - return sessionManager.getOnlineUserCount(); - } -} diff --git a/src/main/java/com/youlai/boot/support/sse/SseSessionManager.java b/src/main/java/com/youlai/boot/support/sse/SseSessionManager.java deleted file mode 100644 index 8c38fe2f..00000000 --- a/src/main/java/com/youlai/boot/support/sse/SseSessionManager.java +++ /dev/null @@ -1,261 +0,0 @@ -package com.youlai.boot.support.sse; - -import com.youlai.boot.support.sse.constant.SseEventConstants; -import lombok.extern.slf4j.Slf4j; -import org.springframework.context.event.ContextClosedEvent; -import org.springframework.context.event.EventListener; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -import java.io.IOException; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.ConcurrentHashMap; - -/** - * SSE 会话管理器 - * - * @author Ray.Hao - * @since 3.1.0 - */ -@Slf4j -@Component -public class SseSessionManager { - - // SSE 连接超时时间:30 分钟 - public static final long TIMEOUT = 30 * 60 * 1000L; - - // 用户名 -> SseEmitter 集合(支持多设备) - private final Map> userEmittersMap = new ConcurrentHashMap<>(); - - // SseEmitter -> 用户名(快速定位用户) - private final Map emitterUserMap = new ConcurrentHashMap<>(); - - // SseEmitter -> 连接时间 - private final Map emitterTimeMap = new ConcurrentHashMap<>(); - - /** - * 用户上线(建立 SSE 连接) - * - * @param username 用户名 - * @param emitter SseEmitter - */ - public void userConnected(String username, SseEmitter emitter) { - userEmittersMap.computeIfAbsent(username, k -> ConcurrentHashMap.newKeySet()).add(emitter); - emitterUserMap.put(emitter, username); - emitterTimeMap.put(emitter, System.currentTimeMillis()); - log.debug("用户[{}]SSE连接已建立", username); - - emitter.onCompletion(() -> { - removeEmitter(emitter); - log.debug("用户[{}]SSE连接已完成", username); - }); - emitter.onTimeout(() -> { - removeEmitter(emitter); - emitter.complete(); - log.debug("用户[{}]SSE连接超时", username); - }); - emitter.onError(e -> { - removeEmitter(emitter); - log.debug("用户[{}]SSE连接错误: {}", username, e.getMessage()); - }); - } - - /** - * 移除指定 emitter - */ - private void removeEmitter(SseEmitter emitter) { - String username = emitterUserMap.remove(emitter); - if (username == null) { - return; - } - emitterTimeMap.remove(emitter); - - Set emitters = userEmittersMap.get(username); - if (emitters != null) { - emitters.remove(emitter); - if (emitters.isEmpty()) { - userEmittersMap.remove(username); - log.debug("用户[{}]所有SSE连接已断开", username); - } - } - } - - /** - * 用户下线(断开所有 SSE 连接) - * - * @param username 用户名 - */ - public void userDisconnected(String username) { - Set emitters = userEmittersMap.remove(username); - if (emitters == null) { - return; - } - emitters.forEach(emitter -> { - emitterUserMap.remove(emitter); - emitterTimeMap.remove(emitter); - try { - emitter.complete(); - } catch (Exception ignored) { - } - }); - log.debug("用户[{}]已下线,移除{}个SSE连接", username, emitters.size()); - } - - /** - * 获取在线用户数量 - */ - public int getOnlineUserCount() { - return userEmittersMap.size(); - } - - /** - * 获取总连接数量(包括多设备) - */ - public int getTotalConnectionCount() { - return emitterUserMap.size(); - } - - /** - * 获取指定用户的连接数量 - */ - public int getUserConnectionCount(String username) { - Set emitters = userEmittersMap.get(username); - return emitters != null ? emitters.size() : 0; - } - - /** - * 检查用户是否在线 - */ - public boolean isUserOnline(String username) { - Set emitters = userEmittersMap.get(username); - return emitters != null && !emitters.isEmpty(); - } - - /** - * 获取所有在线用户名 - */ - public Set getOnlineUsernames() { - return userEmittersMap.keySet(); - } - - /** - * 获取指定用户的最早登录时间 - */ - public long getEarliestLoginTime(String username) { - Set emitters = userEmittersMap.get(username); - if (emitters == null || emitters.isEmpty()) { - return System.currentTimeMillis(); - } - return emitters.stream() - .map(emitterTimeMap::get) - .filter(t -> t != null) - .mapToLong(Long::longValue) - .min() - .orElse(System.currentTimeMillis()); - } - - /** - * 获取所有活跃的 SseEmitter - */ - public Set getAllEmitters() { - return emitterUserMap.keySet(); - } - - /** - * 获取指定用户的所有 SseEmitter - */ - public Set getUserEmitters(String username) { - return userEmittersMap.get(username); - } - - /** - * 向指定 emitter 发送事件 - */ - public boolean sendEvent(SseEmitter emitter, String eventName, Object data) { - try { - emitter.send(SseEmitter.event() - .name(eventName) - .data(data)); - return true; - } catch (IOException e) { - log.warn("发送SSE事件失败: {}", e.getMessage()); - emitter.completeWithError(e); - return false; - } - } - - /** - * 向所有连接广播事件 - * - * @param eventName 事件名称 - * @param data 数据 - */ - public void broadcast(String eventName, Object data) { - getAllEmitters().forEach(emitter -> sendEvent(emitter, eventName, data)); - } - - /** - * 向指定用户发送事件 - * - * @param username 用户名 - * @param eventName 事件名称 - * @param data 数据 - */ - public void sendToUser(String username, String eventName, Object data) { - Set emitters = userEmittersMap.get(username); - if (emitters != null) { - emitters.forEach(emitter -> sendEvent(emitter, eventName, data)); - } - } - - /** - * 心跳检测:每 30 秒向所有连接发送 ping 事件,清理已断开的僵尸连接 - */ - @Scheduled(fixedRate = 30000) - public void heartbeat() { - if (emitterUserMap.isEmpty()) { - return; - } - List failedEmitters = new ArrayList<>(); - for (SseEmitter emitter : emitterUserMap.keySet()) { - try { - emitter.send(SseEmitter.event().name(SseEventConstants.PING).data("heartbeat")); - } catch (Exception e) { - failedEmitters.add(emitter); - } - } - if (!failedEmitters.isEmpty()) { - log.debug("心跳检测清理{}个失效SSE连接", failedEmitters.size()); - failedEmitters.forEach(this::removeEmitter); - } - } - - /** - * 容器关闭时主动断开所有 SSE 连接,避免阻塞应用停止 - */ - @Order(Ordered.HIGHEST_PRECEDENCE) - @EventListener(ContextClosedEvent.class) - public void destroy() { - int count = emitterUserMap.size(); - if (count == 0) { - return; - } - log.info("应用关闭,主动断开 {} 个SSE连接...", count); - emitterUserMap.keySet().forEach(emitter -> { - try { - emitter.complete(); - } catch (Exception ignored) { - } - }); - userEmittersMap.clear(); - emitterUserMap.clear(); - emitterTimeMap.clear(); - log.info("所有SSE连接已断开"); - } -} diff --git a/src/main/java/com/youlai/boot/support/sse/constant/SseEventConstants.java b/src/main/java/com/youlai/boot/support/sse/constant/SseEventConstants.java deleted file mode 100644 index 38406081..00000000 --- a/src/main/java/com/youlai/boot/support/sse/constant/SseEventConstants.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.youlai.boot.support.sse.constant; - -/** - * SSE 通用事件常量 - * - * @author Ray.Hao - * @since 3.1.0 - */ -public final class SseEventConstants { - - private SseEventConstants() { - } - - // 心跳事件 - public static final String PING = "ping"; - - // 在线用户数事件 - public static final String ONLINE_USERS = "online-users"; -} diff --git a/src/main/java/com/youlai/boot/support/sse/constant/SseTopics.java b/src/main/java/com/youlai/boot/support/sse/constant/SseTopics.java deleted file mode 100644 index c0695acd..00000000 --- a/src/main/java/com/youlai/boot/support/sse/constant/SseTopics.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.youlai.boot.support.sse.constant; - -/** - * SSE 业务主题常量 - * - * @author Ray.Hao - * @since 3.1.0 - */ -public final class SseTopics { - - private SseTopics() { - } - - // 字典变更事件 - public static final String DICT = "dict"; - - // 系统消息事件 - public static final String SYSTEM = "system"; - - // 通知事件 - public static final String NOTICE = "notice"; - - // 通知撤回事件 - public static final String NOTICE_REVOKE = "notice-revoke"; -} diff --git a/src/main/java/com/youlai/boot/system/adapter/security/UserAuthenticationAdapter.java b/src/main/java/com/youlai/boot/system/adapter/security/UserAuthenticationAdapter.java index fbf1055b..2909a628 100644 --- a/src/main/java/com/youlai/boot/system/adapter/security/UserAuthenticationAdapter.java +++ b/src/main/java/com/youlai/boot/system/adapter/security/UserAuthenticationAdapter.java @@ -1,15 +1,14 @@ package com.youlai.boot.system.adapter.security; -import com.youlai.boot.common.enums.SocialPlatformEnum; import com.youlai.boot.framework.security.model.SecurityUser; import com.youlai.boot.framework.security.port.UserAuthenticationPort; +import com.youlai.boot.system.service.UserIdentityService; import com.youlai.boot.system.service.UserService; -import com.youlai.boot.system.service.UserSocialService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; /** - * 用户认证信息查询适配器。 + * 用户认证信息查询适配器 * * @author Ray.Hao * @since 3.1.0 @@ -20,7 +19,12 @@ import org.springframework.stereotype.Component; public class UserAuthenticationAdapter implements UserAuthenticationPort { private final UserService userService; - private final UserSocialService userSocialService; + private final UserIdentityService userIdentityService; + + @Override + public SecurityUser getAuthInfoByUserId(Long userId) { + return userIdentityService.getAuthInfoByUserId(userId); + } @Override public SecurityUser getAuthInfoByUsername(String username) { @@ -33,7 +37,7 @@ public class UserAuthenticationAdapter implements UserAuthenticationPort { } @Override - public SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid) { - return userSocialService.getAuthInfoByOpenid(platform, openid); + public SecurityUser getAuthInfoByProviderAccount(String provider, String providerAccountId) { + return userIdentityService.getAuthInfoByProviderAccount(provider, providerAccountId); } } diff --git a/src/main/java/com/youlai/boot/system/controller/DictController.java b/src/main/java/com/youlai/boot/system/controller/DictController.java index 008dd958..3ee39e1f 100644 --- a/src/main/java/com/youlai/boot/system/controller/DictController.java +++ b/src/main/java/com/youlai/boot/system/controller/DictController.java @@ -8,9 +8,6 @@ import com.youlai.boot.common.model.Option; import com.youlai.boot.common.result.PageResult; import com.youlai.boot.common.result.Result; import com.youlai.boot.framework.web.repeatsubmit.RepeatSubmit; -import com.youlai.boot.support.sse.SseService; -import com.youlai.boot.support.sse.constant.SseTopics; -import com.youlai.boot.system.model.dto.DictChangeDTO; import com.youlai.boot.system.model.form.DictForm; import com.youlai.boot.system.model.form.DictItemForm; import com.youlai.boot.system.model.query.DictQuery; @@ -45,16 +42,6 @@ public class DictController { private final DictService dictService; private final DictItemService dictItemService; - private final SseService sseService; - - /** - * 广播字典变更事件到所有 SSE 在线连接 - * - * @param dictCode 字典编码 - */ - private void sendDictChange(String dictCode) { - sseService.broadcast(SseTopics.DICT, new DictChangeDTO(dictCode)); - } @Operation(summary = "字典分页列表") @GetMapping @@ -91,10 +78,6 @@ public class DictController { @Log(module = LogModuleEnum.DICT, value = ActionTypeEnum.INSERT) public Result saveDict(@Valid @RequestBody DictForm formData) { boolean result = dictService.saveDict(formData); - // 发送字典更新通知 - if (result) { - sendDictChange(formData.getDictCode()); - } return Result.judge(result); } @@ -107,10 +90,6 @@ public class DictController { @RequestBody @Valid DictForm formData ) { boolean status = dictService.updateDict(dictId, formData); - // 发送字典更新通知 - if (status && formData.getDictCode() != null) { - sendDictChange(formData.getDictCode()); - } return Result.judge(status); } @@ -121,16 +100,8 @@ public class DictController { public Result deleteDictionaries( @Parameter(description = "字典ID,多个以英文逗号(,)拼接") @PathVariable String ids ) { - // 获取字典编码列表,用于发送删除通知 - List dictCodes = dictService.getDictCodesByIds(Arrays.stream(ids.split(",")).toList()); - dictService.deleteDictByIds(Arrays.stream(ids.split(",")).toList()); - // 发送字典删除通知 - for (String dictCode : dictCodes) { - sendDictChange(dictCode); - } - return Result.success(); } @@ -177,11 +148,6 @@ public class DictController { formData.setDictCode(dictCode); boolean result = dictItemService.saveDictItem(formData); - // 发送字典更新通知 - if (result) { - sendDictChange(dictCode); - } - return Result.judge(result); } @@ -199,11 +165,6 @@ public class DictController { formData.setDictCode(dictCode); boolean status = dictItemService.updateDictItem(formData); - // 发送字典更新通知 - if (status) { - sendDictChange(dictCode); - } - return Result.judge(status); } @@ -216,8 +177,6 @@ public class DictController { @Parameter(description = "字典项ID,多个以英文逗号(,)拼接") @PathVariable String itemIds ) { dictItemService.deleteDictItemByIds(itemIds); - // 发送字典更新通知 - sendDictChange(dictCode); return Result.success(); } diff --git a/src/main/java/com/youlai/boot/system/controller/MenuController.java b/src/main/java/com/youlai/boot/system/controller/MenuController.java index 7b59e1d6..c6c1c0f2 100644 --- a/src/main/java/com/youlai/boot/system/controller/MenuController.java +++ b/src/main/java/com/youlai/boot/system/controller/MenuController.java @@ -17,6 +17,8 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.access.prepost.PreAuthorize; @@ -72,6 +74,9 @@ public class MenuController { return Result.success(menu); } + /** + * 新增菜单,由持久化层生成主键 + */ @Operation(summary = "新增菜单") @PostMapping @PreAuthorize("@ss.hasPerm('sys:menu:create')") @@ -82,6 +87,9 @@ public class MenuController { return Result.judge(result); } + /** + * 更新路径指定的菜单,不使用表单主键切换目标 + */ @Operation(summary = "修改菜单") @PutMapping(value = "/{menuId}") @PreAuthorize("@ss.hasPerm('sys:menu:update')") @@ -90,7 +98,7 @@ public class MenuController { @Parameter(description = "菜单ID") @PathVariable Long menuId, @RequestBody @Valid MenuForm menuForm ) { - boolean result = menuService.saveMenu(menuForm); + boolean result = menuService.updateMenu(menuId, menuForm); return Result.judge(result); } @@ -105,14 +113,18 @@ public class MenuController { return Result.judge(result); } + /** + * 修改菜单显示状态,仅接受0或1 + */ @Operation(summary = "修改菜单显示状态") @PatchMapping("/{menuId}") @PreAuthorize("@ss.hasPerm('sys:menu:update')") @Log(module = LogModuleEnum.MENU, value = ActionTypeEnum.UPDATE) public Result updateMenuVisible( @Parameter(description = "菜单ID") @PathVariable Long menuId, - @Parameter(description = "显示状态(1:显示;0:隐藏)") Integer visible - + @Parameter(description = "显示状态(1:显示;0:隐藏)") @RequestParam + @Min(value = 0, message = "菜单显示状态只能为0或1") + @Max(value = 1, message = "菜单显示状态只能为0或1") Integer visible ) { boolean result = menuService.updateMenuVisible(menuId, visible); return Result.judge(result); diff --git a/src/main/java/com/youlai/boot/system/controller/NoticeController.java b/src/main/java/com/youlai/boot/system/controller/NoticeController.java index 2e484a7e..4dfee557 100644 --- a/src/main/java/com/youlai/boot/system/controller/NoticeController.java +++ b/src/main/java/com/youlai/boot/system/controller/NoticeController.java @@ -75,6 +75,13 @@ public class NoticeController { return Result.success(detailVo); } + @Operation(summary = "管理端预览通知") + @GetMapping("/{noticeId}/preview") + @PreAuthorize("@ss.hasPerm('sys:notice:list')") + public Result getNoticePreview(@PathVariable Long noticeId) { + return Result.success(noticeService.getNoticePreview(noticeId)); + } + @Operation(summary = "修改通知公告") @PutMapping(value = "/{noticeId}") @PreAuthorize("@ss.hasPerm('sys:notice:update')") diff --git a/src/main/java/com/youlai/boot/system/controller/RoleController.java b/src/main/java/com/youlai/boot/system/controller/RoleController.java index d0a340c4..6576b336 100644 --- a/src/main/java/com/youlai/boot/system/controller/RoleController.java +++ b/src/main/java/com/youlai/boot/system/controller/RoleController.java @@ -20,6 +20,8 @@ import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import java.util.List; @@ -61,6 +63,9 @@ public class RoleController { return Result.success(roleService.listRoleCodeOptions()); } + /** + * 新增角色,由持久化层生成主键 + */ @Operation(summary = "新增角色") @PostMapping @PreAuthorize("@ss.hasPerm('sys:role:create')") @@ -81,6 +86,9 @@ public class RoleController { return Result.success(roleForm); } + /** + * 更新路径指定的角色,不使用表单主键切换目标 + */ @Operation(summary = "修改角色") @PutMapping(value = "/{roleId}") @PreAuthorize("@ss.hasPerm('sys:role:update')") @@ -88,7 +96,7 @@ public class RoleController { public Result updateRole( @Parameter(description = "角色ID") @PathVariable Long roleId, @Valid @RequestBody RoleForm roleForm) { - boolean result = roleService.saveRole(roleForm); + boolean result = roleService.updateRole(roleId, roleForm); return Result.judge(result); } @@ -103,13 +111,18 @@ public class RoleController { return Result.success(); } + /** + * 修改角色状态,仅接受0或1 + */ @Operation(summary = "修改角色状态") @PutMapping(value = "/{roleId}/status") @PreAuthorize("@ss.hasPerm('sys:role:update')") @Log(module = LogModuleEnum.ROLE, value = ActionTypeEnum.UPDATE) public Result updateRoleStatus( @Parameter(description = "角色ID") @PathVariable Long roleId, - @Parameter(description = "状态(1:启用;0:禁用)") @RequestParam Integer status + @Parameter(description = "状态(1:启用;0:禁用)") @RequestParam + @Min(value = 0, message = "角色状态只能为0或1") + @Max(value = 1, message = "角色状态只能为0或1") Integer status ) { boolean result = roleService.updateRoleStatus(roleId, status); return Result.judge(result); diff --git a/src/main/java/com/youlai/boot/system/controller/SseController.java b/src/main/java/com/youlai/boot/system/controller/SseController.java deleted file mode 100644 index 9fbd1a3b..00000000 --- a/src/main/java/com/youlai/boot/system/controller/SseController.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.youlai.boot.system.controller; - -import com.youlai.boot.support.sse.SseService; -import com.youlai.boot.framework.security.model.SecurityUserDetails; -import com.youlai.boot.framework.security.util.SecurityUtils; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -import java.io.IOException; - -/** - * SSE 连接控制器 - * - * @author Ray.Hao - * @since 3.1.0 - */ -@Tag(name = "14. SSE连接") -@Slf4j -@RestController -@RequestMapping("/api/v1/sse") -@RequiredArgsConstructor -public class SseController { - - private final SseService sseService; - - @Operation(summary = "建立SSE连接") - @GetMapping(value = "/connect", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public SseEmitter connect(HttpServletResponse response) throws IOException { - SecurityUserDetails user = SecurityUtils.getUser().orElse(null); - if (user == null) { - response.setStatus(HttpStatus.UNAUTHORIZED.value()); - response.flushBuffer(); - return null; - } - return sseService.createConnection(user.getUsername()); - } -} diff --git a/src/main/java/com/youlai/boot/system/controller/UserController.java b/src/main/java/com/youlai/boot/system/controller/UserController.java index 2f30d4d1..1eb41a57 100644 --- a/src/main/java/com/youlai/boot/system/controller/UserController.java +++ b/src/main/java/com/youlai/boot/system/controller/UserController.java @@ -6,7 +6,6 @@ import com.youlai.boot.common.annotation.Log; import com.youlai.boot.framework.web.repeatsubmit.RepeatSubmit; import com.youlai.boot.common.enums.ActionTypeEnum; import com.youlai.boot.common.enums.LogModuleEnum; -import com.youlai.boot.common.enums.StatusEnum; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.framework.security.util.SecurityUtils; import com.youlai.boot.common.model.Option; @@ -29,13 +28,14 @@ import com.youlai.boot.system.model.vo.CurrentUserVO; import com.youlai.boot.system.model.vo.UserPageVO; import com.youlai.boot.system.model.vo.UserProfileVO; import com.youlai.boot.system.service.UserService; -import com.youlai.boot.framework.security.token.TokenManager; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.ServletOutputStream; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.validation.annotation.Validated; @@ -70,7 +70,6 @@ import java.util.List; @RequiredArgsConstructor public class UserController { - private final TokenManager tokenManager; private final UserService userService; @@ -129,19 +128,20 @@ public class UserController { return Result.judge(result); } + /** + * 修改用户状态,仅接受0或1 + */ @Operation(summary = "修改用户状态") @PatchMapping(value = "/{userId}/status") @PreAuthorize("@ss.hasPerm('sys:user:update')") @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.UPDATE) public Result updateUserStatus( @Parameter(description = "用户ID") @PathVariable Long userId, - @Parameter(description = "用户状态(1:启用;0:禁用)") @RequestParam Integer status + @Parameter(description = "用户状态(1:启用;0:禁用)") @RequestParam + @Min(value = 0, message = "用户状态只能为0或1") + @Max(value = 1, message = "用户状态只能为0或1") Integer status ) { boolean result = userService.updateUserStatus(userId, status); - // 用户禁用时立即失效其会话 - if (result && StatusEnum.DISABLE.getValue().equals(status)) { - tokenManager.invalidateUserSessions(userId); - } return Result.judge(result); } @@ -198,11 +198,11 @@ public class UserController { @PreAuthorize("@ss.hasPerm('sys:user:export')") @Log(module = LogModuleEnum.USER, value = ActionTypeEnum.EXPORT) public void exportUsers(@Valid UserQuery queryParams, HttpServletResponse response) throws IOException { + List exportUserList = userService.listExportUsers(queryParams); String fileName = "用户列表.xlsx"; response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8)); - List exportUserList = userService.listExportUsers(queryParams); EasyExcel.write(response.getOutputStream(), UserExportVO.class).sheet("用户列表") .doWrite(exportUserList); } diff --git a/src/main/java/com/youlai/boot/system/enums/MenuTypeEnum.java b/src/main/java/com/youlai/boot/system/enums/MenuTypeEnum.java index 0cac4fe9..61fc915b 100644 --- a/src/main/java/com/youlai/boot/system/enums/MenuTypeEnum.java +++ b/src/main/java/com/youlai/boot/system/enums/MenuTypeEnum.java @@ -5,12 +5,10 @@ import com.youlai.boot.common.base.IBaseEnum; import lombok.Getter; /** - * 菜单类型枚举(char) + * 菜单类型枚举 * - * C:目录 - * M:菜单 - * E:外链 - * B:按钮 + * @author Ray.Hao + * @since 4.13.0 */ @Getter public enum MenuTypeEnum implements IBaseEnum { diff --git a/src/main/java/com/youlai/boot/system/event/DictChangedEvent.java b/src/main/java/com/youlai/boot/system/event/DictChangedEvent.java new file mode 100644 index 00000000..652c39c9 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/event/DictChangedEvent.java @@ -0,0 +1,10 @@ +package com.youlai.boot.system.event; + +/** + * 字典数据变更事件 + * + * @author Ray.Hao + * @since 4.13.0 + */ +public record DictChangedEvent(String dictCode) { +} diff --git a/src/main/java/com/youlai/boot/system/event/NoticeChangedEvent.java b/src/main/java/com/youlai/boot/system/event/NoticeChangedEvent.java new file mode 100644 index 00000000..4f13b953 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/event/NoticeChangedEvent.java @@ -0,0 +1,10 @@ +package com.youlai.boot.system.event; + +/** + * 通知变更事件 + * + * @author Ray.Hao + * @since 4.13.0 + */ +public record NoticeChangedEvent(Long noticeId, String action) { +} diff --git a/src/main/java/com/youlai/boot/system/mapper/NoticeMapper.java b/src/main/java/com/youlai/boot/system/mapper/NoticeMapper.java index e26fa359..8316cf78 100644 --- a/src/main/java/com/youlai/boot/system/mapper/NoticeMapper.java +++ b/src/main/java/com/youlai/boot/system/mapper/NoticeMapper.java @@ -33,5 +33,7 @@ public interface NoticeMapper extends BaseMapper { * @param id 通知公告ID * @return 通知公告详情 */ - NoticeDetailVO getNoticeDetail(@Param("id") Long id); + NoticeDetailVO getNoticeDetail(@Param("id") Long id, @Param("userId") Long userId); + + NoticeDetailVO getNoticePreview(@Param("id") Long id); } diff --git a/src/main/java/com/youlai/boot/system/mapper/UserIdentityMapper.java b/src/main/java/com/youlai/boot/system/mapper/UserIdentityMapper.java new file mode 100644 index 00000000..f7886465 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/mapper/UserIdentityMapper.java @@ -0,0 +1,34 @@ +package com.youlai.boot.system.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.system.model.entity.UserIdentity; +import com.youlai.boot.system.model.entity.SysUser; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** + * 用户外部身份绑定持久层 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Mapper +public interface UserIdentityMapper extends BaseMapper { + + /** + * 锁定本地用户行,使没有关联记录时的并发新增也能串行处理 + */ + @Select("SELECT id, status FROM sys_user WHERE id = #{userId} AND is_deleted = 0 FOR UPDATE") + SysUser lockUser(@Param("userId") Long userId); + + /** + * 根据用户ID获取认证信息 + * + * @param userId 用户ID + * @return 认证信息 + */ + SecurityUser getAuthInfoByUserId(Long userId); + +} diff --git a/src/main/java/com/youlai/boot/system/mapper/UserMapper.java b/src/main/java/com/youlai/boot/system/mapper/UserMapper.java index 06b630f1..4ea7c5d6 100644 --- a/src/main/java/com/youlai/boot/system/mapper/UserMapper.java +++ b/src/main/java/com/youlai/boot/system/mapper/UserMapper.java @@ -65,7 +65,7 @@ public interface UserMapper extends BaseMapper { * @return 导出用户列表 */ @DataPermission(deptAlias = "u", userAlias = "u") - List listExportUsers(UserQuery queryParams); + List listExportUsers(Page page, @Param("queryParams") UserQuery queryParams); /** * 获取用户个人中心信息 diff --git a/src/main/java/com/youlai/boot/system/mapper/UserNoticeMapper.java b/src/main/java/com/youlai/boot/system/mapper/UserNoticeMapper.java index 378f0606..7b97e6b1 100644 --- a/src/main/java/com/youlai/boot/system/mapper/UserNoticeMapper.java +++ b/src/main/java/com/youlai/boot/system/mapper/UserNoticeMapper.java @@ -9,6 +9,9 @@ import com.youlai.boot.system.model.vo.NoticePageVO; import com.youlai.boot.system.model.vo.UserNoticePageVO; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Options; +import java.util.List; /** @@ -19,6 +22,15 @@ import org.apache.ibatis.annotations.Param; */ @Mapper public interface UserNoticeMapper extends BaseMapper { + /** + * 按用户 ID 游标分批查询公告接收人 + */ + @Select("SELECT DISTINCT user_id FROM sys_user_notice WHERE notice_id = #{noticeId} " + + "AND is_deleted = 0 AND user_id > #{afterUserId} ORDER BY user_id LIMIT #{limit}") + @Options(timeout = 5) + List findRecipientsAfter(@Param("noticeId") Long noticeId, @Param("afterUserId") long afterUserId, + @Param("limit") int limit); + /** * 分页获取我的通知公告 * @param page 分页对象 diff --git a/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java b/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java deleted file mode 100644 index 0f50ccbb..00000000 --- a/src/main/java/com/youlai/boot/system/mapper/UserSocialMapper.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.youlai.boot.system.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.youlai.boot.framework.security.model.SecurityUser; -import com.youlai.boot.system.model.entity.UserSocial; -import org.apache.ibatis.annotations.Mapper; - -/** - * 用户第三方账号绑定持久层 - */ -@Mapper -public interface UserSocialMapper extends BaseMapper { - - /** - * 根据用户ID获取认证信息 - * - * @param userId 用户ID - * @return 认证信息 - */ - SecurityUser getAuthInfoByUserId(Long userId); - -} diff --git a/src/main/java/com/youlai/boot/system/model/dto/DictChangeDTO.java b/src/main/java/com/youlai/boot/system/model/dto/DictChangeDTO.java deleted file mode 100644 index fc9ced3e..00000000 --- a/src/main/java/com/youlai/boot/system/model/dto/DictChangeDTO.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.youlai.boot.system.model.dto; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -import java.io.Serial; -import java.io.Serializable; - -/** - * 字典变更通知数据 - * - * @author Ray.Hao - * @since 3.0.0 - */ -@Data -@NoArgsConstructor -@AllArgsConstructor -public class DictChangeDTO implements Serializable { - - @Serial - private static final long serialVersionUID = 1L; - - // 字典编码 - private String dictCode; - - // 变更时间戳 - private long timestamp; - - /** - * 创建字典变更通知数据(自动设置当前时间戳) - * - * @param dictCode 字典编码 - */ - public DictChangeDTO(String dictCode) { - this.dictCode = dictCode; - this.timestamp = System.currentTimeMillis(); - } -} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/model/entity/UserIdentity.java b/src/main/java/com/youlai/boot/system/model/entity/UserIdentity.java new file mode 100644 index 00000000..ffadb439 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/model/entity/UserIdentity.java @@ -0,0 +1,49 @@ +package com.youlai.boot.system.model.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Getter; +import lombok.Setter; + +import java.time.LocalDateTime; + +/** + * 本地用户与外部账号的关联 + * + *

provider 是具体认证源的固定配置标识,不是协议类型。例如两个小程序使用不同 provider; + * 同一 provider 不得在保留关联数据的情况下更换 issuer 或 AppID + * + * @author Ray.Hao + * @since 4.13.0 + */ +@TableName("sys_user_identity") +@Getter +@Setter +public class UserIdentity { + + @TableId(type = IdType.AUTO) + private Long id; + + // 承载本地角色、部门与数据权限的用户 ID + private Long userId; + + // 具体认证源标识,例如 oidc-main、wechat-mini-main、github + private String provider; + + // 来源方账号 ID:OIDC sub、微信 OpenID 或 GitHub/Gitee 用户 ID + private String providerAccountId; + + // 外部账号展示名;只作显示,不参与身份匹配 + private String displayName; + + // 外部账号头像;不覆盖本地用户资料 + private String avatarUrl; + + // 最近一次通过该外部账号登录的时间 + private LocalDateTime lastLoginTime; + + private LocalDateTime createTime; + + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/youlai/boot/system/model/entity/UserSocial.java b/src/main/java/com/youlai/boot/system/model/entity/UserSocial.java deleted file mode 100644 index ee640d67..00000000 --- a/src/main/java/com/youlai/boot/system/model/entity/UserSocial.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.youlai.boot.system.model.entity; - -import com.baomidou.mybatisplus.annotation.TableName; -import com.youlai.boot.common.base.BaseEntity; -import com.youlai.boot.common.enums.SocialPlatformEnum; -import lombok.Getter; -import lombok.Setter; - -import java.time.LocalDateTime; - -/** - * 用户第三方账号绑定实体 - */ -@TableName("sys_user_social") -@Getter -@Setter -public class UserSocial { - - // 主键ID - private Long id; - - // 用户ID - private Long userId; - - // 平台类型 - private SocialPlatformEnum platform; - - // 平台openid - private String openid; - - // 微信unionid - private String unionid; - - // 第三方昵称 - private String nickname; - - // 第三方头像URL - private String avatar; - - // 微信session_key - private String sessionKey; - - // 是否已验证(1-已验证 0-未验证) - private Integer verified; - - // 绑定时间 - private LocalDateTime createTime; - - // 更新时间 - private LocalDateTime updateTime; - -} diff --git a/src/main/java/com/youlai/boot/system/model/form/RoleForm.java b/src/main/java/com/youlai/boot/system/model/form/RoleForm.java index 410b5757..14898e55 100644 --- a/src/main/java/com/youlai/boot/system/model/form/RoleForm.java +++ b/src/main/java/com/youlai/boot/system/model/form/RoleForm.java @@ -7,6 +7,12 @@ import org.hibernate.validator.constraints.Range; import java.util.List; +/** + * 新增、编辑和回填共用的角色资料 + * + * @author Ray.Hao + * @since 4.13.0 + */ @Schema(description = "角色表单对象") @Data public class RoleForm { diff --git a/src/main/java/com/youlai/boot/system/model/query/DeptQuery.java b/src/main/java/com/youlai/boot/system/model/query/DeptQuery.java index 3a7afadb..7a47d26d 100644 --- a/src/main/java/com/youlai/boot/system/model/query/DeptQuery.java +++ b/src/main/java/com/youlai/boot/system/model/query/DeptQuery.java @@ -9,7 +9,7 @@ import lombok.Data; * @author haoxr * @since 2022/6/11 */ -@Schema(description ="部门分页查询对象") +@Schema(description ="部门查询条件") @Data public class DeptQuery { diff --git a/src/main/java/com/youlai/boot/system/model/query/UserQuery.java b/src/main/java/com/youlai/boot/system/model/query/UserQuery.java index 2cde9070..d6ff14e5 100644 --- a/src/main/java/com/youlai/boot/system/model/query/UserQuery.java +++ b/src/main/java/com/youlai/boot/system/model/query/UserQuery.java @@ -7,11 +7,20 @@ import lombok.EqualsAndHashCode; import java.util.List; +/** + * 用户列表和导出共用的筛选及排序 + * + * @author Ray.Hao + * @since 4.13.0 + */ @Data @EqualsAndHashCode(callSuper = false) @Schema(description = "用户分页查询对象") public class UserQuery extends BaseQuery { + @Schema(description = "排序规则,重复参数按优先级排列;方向为 asc 或 desc", example = "createTime,desc") + private String[] sort; + @Schema(description = "关键字(用户名/昵称/手机号)") private String keywords; @@ -24,7 +33,7 @@ public class UserQuery extends BaseQuery { @Schema(description = "角色ID") private List roleIds; - // 创建时间范围,下标 0 为开始时间、下标 1 为结束时间,只传日期时自动补全为当天 00:00:00 / 23:59:59 + // 下标 0、1 分别为起止时间,日期值由查询补齐当天边界 @Schema(description = "创建时间范围,格式为 [开始时间, 结束时间]") private List createTime; } diff --git a/src/main/java/com/youlai/boot/system/model/vo/NoticeVO.java b/src/main/java/com/youlai/boot/system/model/vo/NoticeVO.java deleted file mode 100644 index 47f491cd..00000000 --- a/src/main/java/com/youlai/boot/system/model/vo/NoticeVO.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.youlai.boot.system.model.vo; - -import com.fasterxml.jackson.annotation.JsonFormat; -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 通知视图对象 - * - * @author Theo - * @since 2024-9-2 14:32:58 - */ -@Data -public class NoticeVO { - - @Schema(description = "通知ID") - private Long id; - - @Schema(description = "通知类型") - private Integer type; - - @Schema(description = "通知标题") - private String title; - - @Schema(description = "通知时间") - @JsonFormat(pattern = "yyyy-MM-dd HH:mm") - private LocalDateTime publishTime; - -} \ No newline at end of file diff --git a/src/main/java/com/youlai/boot/system/schedule/OnlineUserCountTask.java b/src/main/java/com/youlai/boot/system/schedule/OnlineUserCountTask.java deleted file mode 100644 index e66ecb34..00000000 --- a/src/main/java/com/youlai/boot/system/schedule/OnlineUserCountTask.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.youlai.boot.system.schedule; - -import com.youlai.boot.support.sse.SseService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -/** - * 在线用户统计定时任务 - * - * @author Ray.Hao - * @since 3.1.0 - */ -@Component -@Slf4j -@RequiredArgsConstructor -public class OnlineUserCountTask { - - private final SseService sseService; - - @Scheduled(cron = "0 */3 * * * ?") - public void execute() { - sseService.sendOnlineCount(); - } -} diff --git a/src/main/java/com/youlai/boot/system/service/MenuService.java b/src/main/java/com/youlai/boot/system/service/MenuService.java index 2370b0e2..1a31c019 100644 --- a/src/main/java/com/youlai/boot/system/service/MenuService.java +++ b/src/main/java/com/youlai/boot/system/service/MenuService.java @@ -34,24 +34,22 @@ public interface MenuService extends IService

{ List> listMenuOptions(List types); /** - * 新增菜单 + * 新增菜单,忽略表单中的主键 * * @param menuForm 菜单表单对象 */ boolean saveMenu(MenuForm menuForm); + /** + * 更新指定菜单;目标不存在时拒绝保存 + */ + boolean updateMenu(Long menuId, MenuForm menuForm); + /** * 获取当前用户的菜单路由列表 */ List listCurrentUserRoutes(); - /** - * 获取当前用户的菜单路由列表(指定数据源) - * - * @param datasource 数据源名称,如:master(主库)、naiveui(NaiveUI数据库)、template(模板数据库) - */ - List listCurrentUserRoutes(String datasource); - /** * 修改菜单显示状态 * diff --git a/src/main/java/com/youlai/boot/system/service/NoticeService.java b/src/main/java/com/youlai/boot/system/service/NoticeService.java index 951366c7..e7c78821 100644 --- a/src/main/java/com/youlai/boot/system/service/NoticeService.java +++ b/src/main/java/com/youlai/boot/system/service/NoticeService.java @@ -81,6 +81,11 @@ public interface NoticeService extends IService { */ NoticeDetailVO getNoticeDetail(Long id); + /** + * 管理员预览,不修改接收人的阅读状态 + */ + NoticeDetailVO getNoticePreview(Long id); + /** * 获取我的通知公告分页列表 * diff --git a/src/main/java/com/youlai/boot/system/service/RoleService.java b/src/main/java/com/youlai/boot/system/service/RoleService.java index e211e2da..45292213 100644 --- a/src/main/java/com/youlai/boot/system/service/RoleService.java +++ b/src/main/java/com/youlai/boot/system/service/RoleService.java @@ -45,12 +45,15 @@ public interface RoleService extends IService { List> listRoleCodeOptions(); /** - * - * @param roleForm - * @return + * 新增角色,忽略表单中的主键 */ boolean saveRole(RoleForm roleForm); + /** + * 更新指定角色;目标不存在时拒绝保存 + */ + boolean updateRole(Long roleId, RoleForm roleForm); + /** * 获取角色表单数据 * diff --git a/src/main/java/com/youlai/boot/system/service/UserIdentityService.java b/src/main/java/com/youlai/boot/system/service/UserIdentityService.java new file mode 100644 index 00000000..384c82cf --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/UserIdentityService.java @@ -0,0 +1,57 @@ +package com.youlai.boot.system.service; + +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.system.model.entity.UserIdentity; + +/** + * 外部账号关联服务;只处理已确认的身份,不负责认证、注册或角色分配 + * + * @author Ray.Hao + * @since 4.13.0 + */ +public interface UserIdentityService { + + /** + * 查询用户在指定认证源下的关联;用户不存在时报错,未关联时返回 null + */ + UserIdentity getIdentity(Long userId, String provider); + + /** + * 按认证源和来源方账号 ID 查找本地用户;未关联时返回 null + */ + Long findUserId(String provider, String providerAccountId); + + /** + * 首次关联外部账号;同一关联重复提交可成功,不覆盖任何已有账号归属 + */ + void bindIdentity(Long userId, String provider, String providerAccountId); + + /** + * 设置用户在指定认证源下的账号,可替换当前用户的旧关联 + * + *

其他用户已关联的账号不能转移。按本地用户加行锁,并由两个唯一键兜底并发冲突 + * + * @return 关联是否发生变化;相同账号重复保存返回 false + */ + boolean setIdentity(Long userId, String provider, String providerAccountId); + + /** + * 解除指定用户、指定认证源的关联;重复解除返回 false + */ + boolean removeIdentity(Long userId, String provider); + + /** + * 装载外部账号对应的本地认证信息;未关联或本地用户已删除时返回 null + */ + SecurityUser getAuthInfoByProviderAccount(String provider, String providerAccountId); + + /** + * 按本地用户 ID 装载认证信息及角色数据权限,供票据兑换和扫码登录使用 + */ + SecurityUser getAuthInfoByUserId(Long userId); + + /** + * 只更新时间,不改变账号所属用户 + */ + void markLogin(String provider, String providerAccountId); +} diff --git a/src/main/java/com/youlai/boot/system/service/UserRoleService.java b/src/main/java/com/youlai/boot/system/service/UserRoleService.java index 1117e973..3a826b20 100644 --- a/src/main/java/com/youlai/boot/system/service/UserRoleService.java +++ b/src/main/java/com/youlai/boot/system/service/UserRoleService.java @@ -15,13 +15,13 @@ import java.util.List; public interface UserRoleService extends IService { /** - * 保存用户角色 + * 替换用户角色,空列表表示移除全部角色 * * @param userId 用户ID * @param roleIds 角色ID列表 - * @return + * @return 角色集合是否发生变化 */ - void saveUserRoles(Long userId, List roleIds); + boolean replaceUserRoles(Long userId, List roleIds); /** * 判断角色是否存在绑定的用户 diff --git a/src/main/java/com/youlai/boot/system/service/UserSocialService.java b/src/main/java/com/youlai/boot/system/service/UserSocialService.java deleted file mode 100644 index 6d088626..00000000 --- a/src/main/java/com/youlai/boot/system/service/UserSocialService.java +++ /dev/null @@ -1,78 +0,0 @@ -package com.youlai.boot.system.service; - -import com.baomidou.mybatisplus.extension.service.IService; -import com.youlai.boot.framework.security.model.SecurityUser; -import com.youlai.boot.common.enums.SocialPlatformEnum; -import com.youlai.boot.system.model.entity.UserSocial; - -/** - * 用户第三方账号绑定业务接口 - */ -public interface UserSocialService extends IService { - - /** - * 根据平台和openid查询绑定信息 - * - * @param platform 平台类型 - * @param openid openid - * @return 绑定信息 - */ - UserSocial getByPlatformAndOpenid(SocialPlatformEnum platform, String openid); - - /** - * 根据unionid查询绑定信息 - * - * @param unionid unionid - * @return 绑定信息 - */ - UserSocial getByUnionid(String unionid); - - /** - * 绑定或更新第三方账号 - * - * @param userId 用户ID - * @param platform 平台类型 - * @param openid openid - * @param unionid unionid - * @param nickname 昵称 - * @param avatar 头像 - * @param sessionKey session_key - * @return 绑定信息 - */ - UserSocial bindOrUpdate(Long userId, SocialPlatformEnum platform, String openid, String unionid, String nickname, String avatar, String sessionKey); - - /** - * 解绑第三方账号 - * - * @param userId 用户ID - * @param platform 平台类型 - * @return 是否成功 - */ - boolean unbind(Long userId, SocialPlatformEnum platform); - - /** - * 根据openid获取用户认证信息 - * - * @param platform 平台类型 - * @param openid openid - * @return 用户认证信息 - */ - SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid); - - /** - * 根据用户ID获取用户认证信息 - * - * @param userId 用户ID - * @return 用户认证信息 - */ - SecurityUser getAuthInfoByUserId(Long userId); - - /** - * 更新session_key - * - * @param id 绑定记录ID - * @param sessionKey session_key - */ - void updateSessionKey(Long id, String sessionKey); - -} diff --git a/src/main/java/com/youlai/boot/system/service/impl/DeptServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/DeptServiceImpl.java index c003d5ed..78cc77e7 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/DeptServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/DeptServiceImpl.java @@ -60,18 +60,15 @@ public class DeptServiceImpl extends ServiceImpl implements De return Collections.EMPTY_LIST; } - // 获取所有部门ID Set deptIds = deptList.stream() .map(Dept::getId) .collect(Collectors.toSet()); - // 获取父节点ID Set parentIds = deptList.stream() .map(Dept::getParentId) .collect(Collectors.toSet()); - // 获取根节点ID(递归的起点),即父节点ID中不包含在部门ID中的节点,注意这里不能拿顶级部门 O 作为根节点,因为部门筛选的时候 O 会被过滤掉 + // 从结果集以外的父节点开始构树,避免筛选后遗漏子树 List rootIds = CollectionUtil.subtractToList(parentIds, deptIds); - // 递归生成部门树形列表 return rootIds.stream() .flatMap(rootId -> recurDeptList(rootId, deptList).stream()) .toList(); diff --git a/src/main/java/com/youlai/boot/system/service/impl/DictItemServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/DictItemServiceImpl.java index 4e85259f..c12bd56a 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/DictItemServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/DictItemServiceImpl.java @@ -14,6 +14,9 @@ import com.youlai.boot.system.model.vo.DictItemPageVO; import com.youlai.boot.system.service.DictItemService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.context.ApplicationEventPublisher; +import com.youlai.boot.system.event.DictChangedEvent; import java.util.Arrays; import java.util.List; @@ -29,6 +32,7 @@ import java.util.List; public class DictItemServiceImpl extends ServiceImpl implements DictItemService { private final DictItemConverter dictItemConverter; + private final ApplicationEventPublisher events; /** * 获取字典项分页列表 @@ -87,10 +91,13 @@ public class DictItemServiceImpl extends ServiceImpl i * @return 是否成功 */ @Override + @Transactional public boolean saveDictItem(DictItemForm formData) { validateValueUnique(formData.getDictCode(), formData.getValue(), null); DictItem entity = dictItemConverter.toEntity(formData); - return this.save(entity); + boolean saved = this.save(entity); + if (saved) events.publishEvent(new DictChangedEvent(entity.getDictCode())); + return saved; } /** @@ -100,10 +107,19 @@ public class DictItemServiceImpl extends ServiceImpl i * @return 是否成功 */ @Override + @Transactional public boolean updateDictItem(DictItemForm formData) { + DictItem previous = this.getById(formData.getId()); validateValueUnique(formData.getDictCode(), formData.getValue(), formData.getId()); DictItem entity = dictItemConverter.toEntity(formData); - return this.updateById(entity); + boolean updated = this.updateById(entity); + if (updated) { + events.publishEvent(new DictChangedEvent(entity.getDictCode())); + if (previous != null && !previous.getDictCode().equals(entity.getDictCode())) { + events.publishEvent(new DictChangedEvent(previous.getDictCode())); + } + } + return updated; } /** @@ -112,11 +128,14 @@ public class DictItemServiceImpl extends ServiceImpl i * @param ids 字典项ID集合 */ @Override + @Transactional public void deleteDictItemByIds(String ids) { List idList = Arrays.stream(ids.split(",")) .map(Long::parseLong) .toList(); + List codes = this.listByIds(idList).stream().map(DictItem::getDictCode).distinct().toList(); this.removeByIds(idList); + codes.forEach(code -> events.publishEvent(new DictChangedEvent(code))); } /** diff --git a/src/main/java/com/youlai/boot/system/service/impl/DictServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/DictServiceImpl.java index c128890f..21bef51e 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/DictServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/DictServiceImpl.java @@ -15,6 +15,11 @@ import com.youlai.boot.system.model.query.DictQuery; import com.youlai.boot.system.model.vo.DictPageVO; import com.youlai.boot.system.service.DictItemService; import com.youlai.boot.system.service.DictService; +import com.youlai.boot.system.event.DictChangedEvent; +import com.youlai.boot.support.realtime.RealtimeService; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.event.TransactionalEventListener; +import java.util.Map; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -33,6 +38,8 @@ public class DictServiceImpl extends ServiceImpl implements Di private final DictItemService dictItemService; private final DictConverter dictConverter; + private final ApplicationEventPublisher events; + private final RealtimeService realtime; /** * 字典分页列表 @@ -69,6 +76,7 @@ public class DictServiceImpl extends ServiceImpl implements Di * @param dictForm 字典表单数据 */ @Override + @Transactional public boolean saveDict(DictForm dictForm) { // 保存字典 Dict entity = dictConverter.toEntity(dictForm); @@ -82,7 +90,9 @@ public class DictServiceImpl extends ServiceImpl implements Di Assert.isTrue(count == 0, "字典编码已存在"); - return this.save(entity); + boolean saved = this.save(entity); + if (saved) events.publishEvent(new DictChangedEvent(dictCode)); + return saved; } @@ -128,6 +138,10 @@ public class DictServiceImpl extends ServiceImpl implements Di dict.setId(id); boolean result = this.updateById(dict); if (result) { + events.publishEvent(new DictChangedEvent(entity.getDictCode())); + if (!entity.getDictCode().equals(dictCode)) { + events.publishEvent(new DictChangedEvent(dictCode)); + } // 更新字典数据 List dictItemList = dictItemService.list( new LambdaQueryWrapper() @@ -166,6 +180,7 @@ public class DictServiceImpl extends ServiceImpl implements Di dictItemService.remove(new LambdaQueryWrapper() .in(DictItem::getDictCode, dictCodes) ); + dictCodes.forEach(code -> events.publishEvent(new DictChangedEvent(code))); } } @@ -181,4 +196,12 @@ public class DictServiceImpl extends ServiceImpl implements Di return dictList.stream().map(Dict::getDictCode).toList(); } + /** + * 提交后广播失效提示,事务回滚时不触发客户端刷新 + */ + @TransactionalEventListener + public void onDictChanged(DictChangedEvent event) { + realtime.broadcast("dict", "dict.changed", Map.of("dictCode", event.dictCode())); + } + } diff --git a/src/main/java/com/youlai/boot/system/service/impl/MenuServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/MenuServiceImpl.java index 86f5aa34..136ad149 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/MenuServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/MenuServiceImpl.java @@ -33,6 +33,7 @@ import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.cache.annotation.CacheEvict; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.*; import java.util.stream.Collectors; @@ -69,22 +70,19 @@ public class MenuServiceImpl extends ServiceImpl implements Me .like(StrUtil.isNotBlank(queryParams.getKeywords()), Menu::getName, queryParams.getKeywords()) .orderByAsc(Menu::getSort) ); - // 获取所有菜单ID Set menuIds = menus.stream() .map(Menu::getId) .collect(Collectors.toSet()); - // 获取所有父级ID Set parentIds = menus.stream() .map(Menu::getParentId) .collect(Collectors.toSet()); - // 获取根节点ID(递归的起点),即父节点ID中不包含在部门ID中的节点,注意这里不能拿顶级菜单 O 作为根节点,因为菜单筛选的时候 O 会被过滤掉 + // 从结果集以外的父节点开始构树,避免筛选后遗漏子树 List rootIds = parentIds.stream() .filter(id -> !menuIds.contains(id)) .toList(); - // 使用递归函数来构建菜单树 return rootIds.stream() .flatMap(rootId -> buildMenuTree(rootId, menus).stream()) .collect(Collectors.toList()); @@ -195,20 +193,6 @@ public class MenuServiceImpl extends ServiceImpl implements Me return buildRoutes(SystemConstants.ROOT_NODE_ID, menuList); } - /** - * 获取当前用户的菜单路由列表(指定数据源) - * - * @param datasource 数据源名称 - * - master: 主库菜单数据 - * - naiveui: NaiveUI项目菜单数据 - * - template: 模板项目菜单数据 - */ - @Override - public List listCurrentUserRoutes(String datasource) { - return listCurrentUserRoutes(); - } - - /** * 递归生成菜单路由层级列表 * @@ -234,7 +218,7 @@ public class MenuServiceImpl extends ServiceImpl implements Me } /** - * 根据RouteBO创建RouteVo + * 将菜单转换为前端路由 */ private RouteVO toRouteVo(Menu menu) { RouteVO routeVo = new RouteVO(); @@ -293,12 +277,33 @@ public class MenuServiceImpl extends ServiceImpl implements Me } /** - * 新增/修改菜单 + * 新增菜单,主键由持久化层生成 */ @Override + @Transactional(rollbackFor = Exception.class) @CacheEvict(cacheNames = "menu", key = "'routes'") public boolean saveMenu(MenuForm menuForm) { + menuForm.setId(null); + return persistMenu(menuForm); + } + /** + * 更新指定菜单,忽略表单中原有的主键 + */ + @Override + @Transactional(rollbackFor = Exception.class) + @CacheEvict(cacheNames = "menu", key = "'routes'") + public boolean updateMenu(Long menuId, MenuForm menuForm) { + Assert.notNull(menuId, "菜单ID不能为空"); + Assert.notNull(this.getById(menuId), "菜单不存在"); + menuForm.setId(menuId); + return persistMenu(menuForm); + } + + /** + * 保存菜单及其按钮、子菜单路径 + */ + private boolean persistMenu(MenuForm menuForm) { String menuType = menuForm.getType(); boolean isExternal = MenuTypeEnum.EXTERNAL.getValue().equals(menuType); boolean isEmbedded = isExternal && "iframe".equals(menuForm.getComponent()); @@ -345,7 +350,7 @@ public class MenuServiceImpl extends ServiceImpl implements Me entity.setRouteName(null); } - boolean result = this.saveOrUpdate(entity); + boolean result = menuForm.getId() == null ? this.save(entity) : this.updateById(entity); if (result) { if (menuForm.getId() != null) { // 编辑刷新角色权限缓存 @@ -355,9 +360,9 @@ public class MenuServiceImpl extends ServiceImpl implements Me // 新增页面菜单时按需生成增删改查按钮 saveCrudButtons(entity.getId(), menuForm.getButtonPermPrefix().trim(), treePath); } + // 修改菜单如果有子菜单,则更新子菜单的树路径 + updateChildrenTreePath(entity.getId(), treePath); } - // 修改菜单如果有子菜单,则更新子菜单的树路径 - updateChildrenTreePath(entity.getId(), treePath); return result; } @@ -384,7 +389,7 @@ public class MenuServiceImpl extends ServiceImpl implements Me } /** - * 部门路径生成 + * 生成菜单祖先路径 * * @param parentId 父ID * @return 父节点路径以英文逗号(, )分割,eg: 1,2,3 @@ -474,7 +479,7 @@ public class MenuServiceImpl extends ServiceImpl implements Me * 修改菜单显示状态 * * @param menuId 菜单ID - * @param visible 是否显示(1->显示;2->隐藏) + * @param visible 是否显示(1->显示;0->隐藏) * @return 是否修改成功 */ @Override @@ -572,9 +577,7 @@ public class MenuServiceImpl extends ServiceImpl implements Me } } - //--------------------------------------------------- - // AI 辅助:未开启 AI 时调用返回业务异常 - //--------------------------------------------------- + // AI 未启用时返回业务异常 /** * 用 AI 推断菜单的访问路径、权限标识与配套按钮 diff --git a/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java index b001e69f..20cef67e 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/NoticeServiceImpl.java @@ -9,7 +9,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.framework.security.util.SecurityUtils; -import com.youlai.boot.support.sse.OnlineUserDTO; +import com.youlai.boot.support.realtime.RealtimeService; +import com.youlai.boot.system.event.NoticeChangedEvent; +import com.youlai.boot.system.mapper.UserNoticeMapper; import com.youlai.boot.system.converter.NoticeConverter; import com.youlai.boot.system.enums.NoticePublishStatusEnum; import com.youlai.boot.system.enums.NoticeTargetEnum; @@ -17,7 +19,6 @@ import com.youlai.boot.system.mapper.NoticeMapper; import com.youlai.boot.system.model.vo.NoticePageVO; import com.youlai.boot.system.model.vo.UserNoticePageVO; import com.youlai.boot.system.model.vo.NoticeDetailVO; -import com.youlai.boot.system.model.vo.NoticeVO; import com.youlai.boot.system.model.entity.Notice; import com.youlai.boot.system.model.entity.UserNotice; import com.youlai.boot.system.model.entity.SysUser; @@ -26,17 +27,17 @@ import com.youlai.boot.system.model.query.NoticeQuery; import com.youlai.boot.system.service.NoticeService; import com.youlai.boot.system.service.UserNoticeService; import com.youlai.boot.system.service.UserService; -import com.youlai.boot.support.sse.SseService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.event.TransactionalEventListener; +import org.springframework.transaction.event.TransactionPhase; import java.time.LocalDateTime; import java.util.Arrays; -import java.util.HashSet; import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; +import java.util.Map; /** * 通知公告服务实现类 @@ -51,7 +52,9 @@ public class NoticeServiceImpl extends ServiceImpl impleme private final NoticeConverter noticeConverter; private final UserNoticeService userNoticeService; private final UserService userService; - private final SseService sseService; + private final ApplicationEventPublisher events; + private final RealtimeService realtime; + private final UserNoticeMapper userNoticeMapper; /** * 获取通知公告分页列表 @@ -207,24 +210,7 @@ public class NoticeServiceImpl extends ServiceImpl impleme userNoticeService.saveBatch(userNoticeList); } - Set receivers = targetUserList.stream().map(SysUser::getUsername).collect(Collectors.toSet()); - - // 获取在线用户名集合 - Set allOnlineUsers = sseService.getOnlineUsers().stream() - .map(OnlineUserDTO::getUsername) - .collect(Collectors.toSet()); - - // 找出在线用户的通知接收者 - Set onlineReceivers = new HashSet<>(CollectionUtil.intersection(receivers, allOnlineUsers)); - - NoticeVO noticeVo = new NoticeVO(); - noticeVo.setId(id); - noticeVo.setTitle(notice.getTitle()); - noticeVo.setType(notice.getType()); - noticeVo.setPublishTime(notice.getPublishTime()); - - // 向在线接收者推送通知 - onlineReceivers.forEach(receiver -> sseService.sendToUser(receiver, "notice", noticeVo)); + events.publishEvent(new NoticeChangedEvent(id, "published")); } return publishResult; } @@ -253,23 +239,8 @@ public class NoticeServiceImpl extends ServiceImpl impleme boolean revokeResult = this.updateById(notice); if (revokeResult) { - // 撤回通知公告的同时,需要删除通知公告对应的用户通知状态 - userNoticeService.remove(new LambdaQueryWrapper() - .eq(UserNotice::getNoticeId, id) - ); - - // 通知前端移除该通知 - NoticeVO noticeVo = new NoticeVO(); - noticeVo.setId(id); - - // 获取所有在线用户 - Set allOnlineUsers = sseService.getOnlineUsers().stream() - .map(OnlineUserDTO::getUsername) - .collect(Collectors.toSet()); - - // 向所有在线用户推送撤回通知 - allOnlineUsers.forEach(username -> - sseService.sendToUser(username, "notice-revoke", noticeVo)); + // 保留受众关系,以便定向通知撤回;查询接口按发布状态屏蔽正文 + events.publishEvent(new NoticeChangedEvent(id, "revoked")); } return revokeResult; } @@ -281,9 +252,10 @@ public class NoticeServiceImpl extends ServiceImpl impleme */ @Override public NoticeDetailVO getNoticeDetail(Long id) { - NoticeDetailVO detail = this.baseMapper.getNoticeDetail(id); - // 更新用户通知公告的阅读状态 Long userId = SecurityUtils.getUserId(); + NoticeDetailVO detail = this.baseMapper.getNoticeDetail(id, userId); + if (detail == null) throw new BusinessException("通知不存在或无权查看"); + // 只有已发布通知的接收者可以读取正文和修改阅读状态 userNoticeService.update(new LambdaUpdateWrapper() .eq(UserNotice::getNoticeId, id) .eq(UserNotice::getUserId, userId) @@ -293,6 +265,22 @@ public class NoticeServiceImpl extends ServiceImpl impleme return detail; } + /** + * 事务提交后再通知收件人,避免客户端补拉到尚未提交的状态 + */ + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void onNoticeChanged(NoticeChangedEvent event) { + realtime.sendBatches("notice.changed", Map.of("noticeId", event.noticeId().toString(), "action", event.action()), + (afterUserId, limit) -> userNoticeMapper.findRecipientsAfter(event.noticeId(), afterUserId, limit)); + } + + @Override + public NoticeDetailVO getNoticePreview(Long id) { + NoticeDetailVO detail = this.baseMapper.getNoticePreview(id); + if (detail == null) throw new BusinessException("通知不存在"); + return detail; + } + /** * 获取当前登录用户的通知公告列表 * diff --git a/src/main/java/com/youlai/boot/system/service/impl/RoleMenuServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/RoleMenuServiceImpl.java index 9914f3fc..6328d94e 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/RoleMenuServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/RoleMenuServiceImpl.java @@ -11,6 +11,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.time.Duration; import java.util.*; @@ -27,88 +29,110 @@ import java.util.stream.Collectors; @Slf4j public class RoleMenuServiceImpl extends ServiceImpl implements RoleMenuService { - // 权限缓存 TTL(秒)。 + // 权限缓存 TTL(秒) private static final Duration ROLE_PERMS_TTL = Duration.ofSeconds(600); private final RedisTemplate redisTemplate; /** - * 刷新权限缓存 + * 刷新全部角色权限缓存,有事务时在提交后执行 */ @Override public void refreshRolePermsCache() { - String cacheKey = RedisConstants.System.ROLE_PERMS; + afterCommit(() -> { + String cacheKey = RedisConstants.System.ROLE_PERMS; - // 清理权限缓存 - redisTemplate.delete(cacheKey); + // 清理权限缓存 + redisTemplate.delete(cacheKey); - // 预热权限缓存,避免后续请求触发频繁回源 - List list = this.baseMapper.getRolePermsList(null); - if (CollectionUtil.isNotEmpty(list)) { - list.forEach(item -> { - String roleCode = item.getRoleCode(); - Set perms = item.getPerms(); - if (CollectionUtil.isNotEmpty(perms)) { - redisTemplate.opsForHash().put(cacheKey, roleCode, perms); - } - }); - refreshCacheTtl(cacheKey); - } + // 预热权限缓存,避免后续请求触发频繁回源 + List list = this.baseMapper.getRolePermsList(null); + if (CollectionUtil.isNotEmpty(list)) { + list.forEach(item -> { + String roleCode = item.getRoleCode(); + Set perms = item.getPerms(); + if (CollectionUtil.isNotEmpty(perms)) { + redisTemplate.opsForHash().put(cacheKey, roleCode, perms); + } + }); + refreshCacheTtl(cacheKey); + } - log.info("权限缓存刷新完成"); + log.info("权限缓存刷新完成"); + }); } /** - * 刷新单个角色权限缓存 + * 刷新单个角色权限缓存,有事务时在提交后执行 */ @Override public void refreshRolePermsCache(String roleCode) { - String cacheKey = RedisConstants.System.ROLE_PERMS; + afterCommit(() -> { + String cacheKey = RedisConstants.System.ROLE_PERMS; - // 清理指定角色缓存 - redisTemplate.opsForHash().delete(cacheKey, roleCode); + // 清理指定角色缓存 + redisTemplate.opsForHash().delete(cacheKey, roleCode); - // 回源 DB 并更新缓存 - List list = this.baseMapper.getRolePermsList(roleCode); - if (CollectionUtil.isNotEmpty(list)) { - RolePermsDTO rolePerms = list.get(0); - if (rolePerms != null) { - Set perms = rolePerms.getPerms(); - if (CollectionUtil.isNotEmpty(perms)) { - redisTemplate.opsForHash().put(cacheKey, roleCode, perms); - refreshCacheTtl(cacheKey); + // 回源 DB 并更新缓存 + List list = this.baseMapper.getRolePermsList(roleCode); + if (CollectionUtil.isNotEmpty(list)) { + RolePermsDTO rolePerms = list.get(0); + if (rolePerms != null) { + Set perms = rolePerms.getPerms(); + if (CollectionUtil.isNotEmpty(perms)) { + redisTemplate.opsForHash().put(cacheKey, roleCode, perms); + refreshCacheTtl(cacheKey); + } } } - } - log.info("角色[{}]权限缓存刷新完成", roleCode); + log.info("角色[{}]权限缓存刷新完成", roleCode); + }); } /** - * 刷新权限缓存(角色编码变更时调用) + * 角色编码变更后刷新权限缓存,有事务时在提交后执行 */ @Override public void refreshRolePermsCache(String oldRoleCode, String newRoleCode) { - String cacheKey = RedisConstants.System.ROLE_PERMS; + afterCommit(() -> { + String cacheKey = RedisConstants.System.ROLE_PERMS; - // 清理旧角色权限缓存 - redisTemplate.opsForHash().delete(cacheKey, oldRoleCode); - redisTemplate.opsForHash().delete(cacheKey, newRoleCode); + // 清理旧角色权限缓存 + redisTemplate.opsForHash().delete(cacheKey, oldRoleCode); + redisTemplate.opsForHash().delete(cacheKey, newRoleCode); - // 回源 DB 并更新新角色编码缓存 - List list = this.baseMapper.getRolePermsList(newRoleCode); - if (CollectionUtil.isNotEmpty(list)) { - RolePermsDTO rolePerms = list.get(0); - if (rolePerms != null) { - Set perms = rolePerms.getPerms(); - if (CollectionUtil.isNotEmpty(perms)) { - redisTemplate.opsForHash().put(cacheKey, newRoleCode, perms); - refreshCacheTtl(cacheKey); + // 回源 DB 并更新新角色编码缓存 + List list = this.baseMapper.getRolePermsList(newRoleCode); + if (CollectionUtil.isNotEmpty(list)) { + RolePermsDTO rolePerms = list.get(0); + if (rolePerms != null) { + Set perms = rolePerms.getPerms(); + if (CollectionUtil.isNotEmpty(perms)) { + redisTemplate.opsForHash().put(cacheKey, newRoleCode, perms); + refreshCacheTtl(cacheKey); + } } } - } - log.info("角色编码变更: {} -> {},相关权限缓存刷新完成", oldRoleCode, newRoleCode); + log.info("角色编码变更: {} -> {},相关权限缓存刷新完成", oldRoleCode, newRoleCode); + }); + } + + /** + * 事务提交后执行操作,无事务时立即执行 + */ + private static void afterCommit(Runnable operation) { + if (!TransactionSynchronizationManager.isActualTransactionActive()) { + operation.run(); + return; + } + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + operation.run(); + } + }); } /** diff --git a/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java index 846ad734..04fc8dfd 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/RoleServiceImpl.java @@ -4,7 +4,7 @@ import cn.hutool.core.collection.CollectionUtil; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.ObjectUtil; import cn.hutool.core.util.StrUtil; -import com.youlai.boot.framework.security.token.TokenManager; +import com.youlai.boot.framework.security.session.SessionService; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; @@ -32,6 +32,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.Arrays; import java.util.List; +import java.util.Objects; import java.util.Map; import java.util.Set; @@ -48,7 +49,7 @@ public class RoleServiceImpl extends ServiceImpl implements Ro private final RoleMenuService roleMenuService; private final RoleDeptService roleDeptService; private final UserRoleService userRoleService; - private final TokenManager tokenManager; + private final SessionService sessionService; private final RoleConverter roleConverter; /** @@ -113,15 +114,30 @@ public class RoleServiceImpl extends ServiceImpl implements Ro } /** - * 保存角色 - * - * @param roleForm 角色表单数据 - * @return {@link Boolean} + * 新增角色,主键由持久化层生成 */ @Override @Transactional(rollbackFor = Exception.class) public boolean saveRole(RoleForm roleForm) { + roleForm.setId(null); + return persistRole(roleForm); + } + /** + * 更新指定角色,忽略表单中原有的主键 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean updateRole(Long roleId, RoleForm roleForm) { + Assert.notNull(roleId, "角色ID不能为空"); + roleForm.setId(roleId); + return persistRole(roleForm); + } + + /** + * 保存角色资料及关联部门,并处理权限变更 + */ + private boolean persistRole(RoleForm roleForm) { Long roleId = roleForm.getId(); // 编辑角色时,判断角色是否存在 @@ -147,7 +163,7 @@ public class RoleServiceImpl extends ServiceImpl implements Ro // 实体转换 Role role = roleConverter.toEntity(roleForm); - boolean result = this.saveOrUpdate(role); + boolean result = roleId == null ? this.save(role) : this.updateById(role); if (result) { // 保存自定义数据权限部门 Long savedRoleId = role.getId(); @@ -168,7 +184,7 @@ public class RoleServiceImpl extends ServiceImpl implements Ro roleMenuService.refreshRolePermsCache(oldRole.getCode(), roleCode); } - // 数据权限发生变化时,失效该角色关联用户的登录态(JWT tokenVersion) + // 会话保存角色编码与数据范围,相关字段变更后统一撤销旧快照 if (oldRole != null) { boolean dataScopeChanged = !ObjectUtil.equals(oldRole.getDataScope(), roleForm.getDataScope()); @@ -179,10 +195,12 @@ public class RoleServiceImpl extends ServiceImpl implements Ro customDeptChanged = !new java.util.HashSet<>(oldIds).equals(new java.util.HashSet<>(newDeptIds)); } - if (dataScopeChanged || customDeptChanged) { + boolean identityChanged = !Objects.equals(oldRole.getCode(), role.getCode()) + || !Objects.equals(oldRole.getStatus(), role.getStatus()); + if (dataScopeChanged || customDeptChanged || identityChanged) { List userIds = userRoleService.listUserIdsByRoleId(savedRoleId); if (CollectionUtil.isNotEmpty(userIds)) { - userIds.forEach(tokenManager::invalidateUserSessions); + userIds.forEach(sessionService::revokeUserSessions); } } } @@ -216,6 +234,7 @@ public class RoleServiceImpl extends ServiceImpl implements Ro * @return {@link Boolean} */ @Override + @Transactional(rollbackFor = Exception.class) public boolean updateRoleStatus(Long roleId, Integer status) { Role role = this.getById(roleId); @@ -233,7 +252,7 @@ public class RoleServiceImpl extends ServiceImpl implements Ro if (status == 0) { List userIds = userRoleService.listUserIdsByRoleId(roleId); for (Long userId : userIds) { - tokenManager.invalidateUserSessions(userId); + sessionService.revokeUserSessions(userId); } } } diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserIdentityServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserIdentityServiceImpl.java new file mode 100644 index 00000000..e67ed894 --- /dev/null +++ b/src/main/java/com/youlai/boot/system/service/impl/UserIdentityServiceImpl.java @@ -0,0 +1,173 @@ +package com.youlai.boot.system.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.youlai.boot.common.enums.StatusEnum; +import com.youlai.boot.common.exception.BusinessException; +import com.youlai.boot.framework.security.model.SecurityUser; +import com.youlai.boot.system.mapper.UserIdentityMapper; +import com.youlai.boot.system.mapper.UserMapper; +import com.youlai.boot.system.model.entity.SysUser; +import com.youlai.boot.system.model.entity.UserIdentity; +import com.youlai.boot.system.service.RoleService; +import com.youlai.boot.system.service.UserIdentityService; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Objects; + +/** + * 外部账号关联实现 + * + *

同一用户的写操作先锁用户行,解决“尚无关联记录可锁”的并发新增; + * 不同用户抢占同一外部账号时,由数据库唯一键裁决,冲突不会转移原有归属 + * + * @author Ray.Hao + * @since 4.13.0 + */ +@Service +@RequiredArgsConstructor +public class UserIdentityServiceImpl extends ServiceImpl + implements UserIdentityService { + + private final UserMapper userMapper; + private final RoleService roleService; + + @Override + public UserIdentity getIdentity(Long userId, String provider) { + if (userMapper.selectById(userId) == null) { + throw new BusinessException("用户不存在"); + } + return findIdentity(userId, provider); + } + + @Override + public Long findUserId(String provider, String providerAccountId) { + UserIdentity identity = findByAccount(provider, providerAccountId); + return identity == null ? null : identity.getUserId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void bindIdentity(Long userId, String provider, String providerAccountId) { + writeIdentity(userId, provider, providerAccountId, false); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean setIdentity(Long userId, String provider, String providerAccountId) { + return writeIdentity(userId, provider, providerAccountId, true); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean removeIdentity(Long userId, String provider) { + requireUser(userId); + return remove(new LambdaQueryWrapper() + .eq(UserIdentity::getUserId, userId) + .eq(UserIdentity::getProvider, provider)); + } + + @Override + public SecurityUser getAuthInfoByProviderAccount(String provider, String providerAccountId) { + Long userId = findUserId(provider, providerAccountId); + return userId == null ? null : getAuthInfoByUserId(userId); + } + + @Override + public SecurityUser getAuthInfoByUserId(Long userId) { + SecurityUser user = baseMapper.getAuthInfoByUserId(userId); + if (user != null) { + // 外部登录和扫码登录沿用本地角色的数据权限,不能只装载角色编码 + user.setDataScopes(roleService.getRoleDataScopes(user.getRoles())); + } + return user; + } + + @Override + public void markLogin(String provider, String providerAccountId) { + update(new LambdaUpdateWrapper() + .eq(UserIdentity::getProvider, provider) + .eq(UserIdentity::getProviderAccountId, providerAccountId) + .set(UserIdentity::getLastLoginTime, LocalDateTime.now())); + } + + /** + * replace 仅供明确的管理端设置操作使用;登录绑定不能隐式替换账号 + */ + private boolean writeIdentity(Long userId, String provider, String providerAccountId, boolean replace) { + if (provider == null || !provider.matches("[a-z][a-z0-9-]{0,63}") + || providerAccountId == null || providerAccountId.isBlank() || providerAccountId.length() > 255) { + throw new BusinessException("认证源或外部账号标识不合法"); + } + SysUser user = requireUser(userId); + if (!StatusEnum.ENABLE.getValue().equals(user.getStatus())) { + throw new BusinessException("用户已禁用,不能关联外部账号"); + } + + UserIdentity owner = findByAccount(provider, providerAccountId); + if (owner != null && !Objects.equals(owner.getUserId(), userId)) { + throw new BusinessException("该身份已关联其他用户"); + } + + UserIdentity current = findIdentity(userId, provider); + if (current != null && current.getProviderAccountId().equals(providerAccountId)) { + return false; + } + if (current != null && !replace) { + throw new BusinessException("当前用户已关联该认证源的其他账号,请先解除关联"); + } + + LocalDateTime now = LocalDateTime.now(); + try { + if (current == null) { + UserIdentity identity = new UserIdentity(); + identity.setUserId(userId); + identity.setProvider(provider); + identity.setProviderAccountId(providerAccountId); + identity.setCreateTime(now); + identity.setUpdateTime(now); + save(identity); + } else { + // 外部账号改变后清空旧展示资料和登录时间,不能沿用上一个账号的信息 + update(new LambdaUpdateWrapper() + .eq(UserIdentity::getId, current.getId()) + .set(UserIdentity::getProviderAccountId, providerAccountId) + .set(UserIdentity::getDisplayName, null) + .set(UserIdentity::getAvatarUrl, null) + .set(UserIdentity::getLastLoginTime, null) + .set(UserIdentity::getUpdateTime, now)); + } + } catch (DuplicateKeyException exception) { + throw new BusinessException("该身份已关联其他用户"); + } + return true; + } + + /** + * 必须在事务中调用;锁保持到外层绑定或解除操作提交 + */ + private SysUser requireUser(Long userId) { + SysUser user = baseMapper.lockUser(userId); + if (user == null) { + throw new BusinessException("用户不存在"); + } + return user; + } + + private UserIdentity findIdentity(Long userId, String provider) { + return getOne(new LambdaQueryWrapper() + .eq(UserIdentity::getUserId, userId) + .eq(UserIdentity::getProvider, provider)); + } + + private UserIdentity findByAccount(String provider, String providerAccountId) { + return getOne(new LambdaQueryWrapper() + .eq(UserIdentity::getProvider, provider) + .eq(UserIdentity::getProviderAccountId, providerAccountId)); + } +} diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserRoleServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserRoleServiceImpl.java index c125575e..c0444de3 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/UserRoleServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/UserRoleServiceImpl.java @@ -1,103 +1,62 @@ package com.youlai.boot.system.service.impl; -import cn.hutool.core.collection.CollectionUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.youlai.boot.framework.security.token.TokenManager; import com.youlai.boot.system.mapper.UserRoleMapper; import com.youlai.boot.system.model.entity.UserRole; import com.youlai.boot.system.service.UserRoleService; -import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.HashSet; import java.util.List; +import java.util.Objects; import java.util.Set; -import java.util.stream.Collectors; /** - * 用户角色业务实现类 + * 用户与角色的关联读写 * * @author Ray.Hao - * @since 0.0.1 + * @since 4.13.0 */ @Service -@RequiredArgsConstructor public class UserRoleServiceImpl extends ServiceImpl implements UserRoleService { - private final TokenManager tokenManager; + /** + * 替换角色集合;空列表表示移除全部角色,会话撤销由用户用例负责 + */ + @Override + @Transactional(rollbackFor = Exception.class) + public boolean replaceUserRoles(Long userId, List roleIds) { + Objects.requireNonNull(userId, "userId"); + Set existing = new HashSet<>(list(new LambdaQueryWrapper() + .select(UserRole::getRoleId).eq(UserRole::getUserId, userId)) + .stream().map(UserRole::getRoleId).toList()); + Set requested = roleIds == null ? Set.of() : new HashSet<>(roleIds); + if (existing.equals(requested)) return false; - /** - * 保存用户角色 - * - * @param userId 用户ID - * @param roleIds 选择的角色ID集合 - * @return - */ - @Override - public void saveUserRoles(Long userId, List roleIds) { - if (userId == null || CollectionUtil.isEmpty(roleIds)) { - return ; + Set added = new HashSet<>(requested); + added.removeAll(existing); + Set removed = new HashSet<>(existing); + removed.removeAll(requested); + + if (!added.isEmpty()) { + saveBatch(added.stream().map(roleId -> new UserRole(userId, roleId)).toList()); + } + if (!removed.isEmpty()) { + remove(new LambdaQueryWrapper().eq(UserRole::getUserId, userId) + .in(UserRole::getRoleId, removed)); + } + return true; } - // 获取现有角色 - List userRoleIds = this.list(new LambdaQueryWrapper() - .select(UserRole::getRoleId) - .eq(UserRole::getUserId, userId)) - .parallelStream() - .map(UserRole::getRoleId) - .toList(); - - // 使用Set提升对比效率 - Set oldRoles = new HashSet<>(userRoleIds); - Set newRoles = new HashSet<>(roleIds); - - // 计算变更集 - Set addedRoles = new HashSet<>(newRoles); - addedRoles.removeAll(oldRoles); - - Set removedRoles = new HashSet<>(oldRoles); - removedRoles.removeAll(newRoles); - - boolean rolesChanged = !addedRoles.isEmpty() || !removedRoles.isEmpty(); - - // 批量保存新增角色 - if (!addedRoles.isEmpty()) { - this.saveBatch(addedRoles.stream() - .map(roleId -> new UserRole(userId, roleId)) - .collect(Collectors.toList())); + @Override + public boolean hasAssignedUsers(Long roleId) { + return baseMapper.countUsersByRoleId(roleId) > 0; } - // 删除废弃角色 - if (!removedRoles.isEmpty()) { - this.remove(new LambdaQueryWrapper() - .eq(UserRole::getUserId, userId) - .in(UserRole::getRoleId, removedRoles)); + @Override + public List listUserIdsByRoleId(Long roleId) { + return roleId == null ? List.of() : baseMapper.listUserIdsByRoleId(roleId); } - - // 当权限变更时清除被修改用户的登录态 - if (rolesChanged) { - tokenManager.invalidateUserSessions(userId); - } - } - - /** - * 判断角色是否存在绑定的用户 - * - * @param roleId 角色ID - * @return true:已分配 false:未分配 - */ - @Override - public boolean hasAssignedUsers(Long roleId) { - int count = this.baseMapper.countUsersByRoleId(roleId); - return count > 0; - } - - @Override - public List listUserIdsByRoleId(Long roleId) { - if (roleId == null) { - return List.of(); - } - return this.baseMapper.listUserIdsByRoleId(roleId); - } } diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java index a799774e..2564dc4a 100644 --- a/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java +++ b/src/main/java/com/youlai/boot/system/service/impl/UserServiceImpl.java @@ -6,6 +6,7 @@ import cn.hutool.core.util.StrUtil; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.metadata.OrderItem; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; import com.youlai.boot.common.constant.RedisConstants; @@ -13,8 +14,9 @@ import com.youlai.boot.common.constant.SystemConstants; import com.youlai.boot.common.exception.BusinessException; import com.youlai.boot.common.model.Option; import com.youlai.boot.framework.security.model.RoleDataScope; +import com.youlai.boot.framework.mybatis.QuerySort; import com.youlai.boot.framework.security.model.SecurityUser; -import com.youlai.boot.framework.security.token.TokenManager; +import com.youlai.boot.framework.security.session.SessionService; import com.youlai.boot.framework.security.util.SecurityUtils; import com.youlai.boot.support.mail.service.EmailService; import com.youlai.boot.support.sms.SmsTypeEnum; @@ -22,6 +24,7 @@ import com.youlai.boot.support.sms.service.SmsService; import com.youlai.boot.system.converter.UserConverter; import com.youlai.boot.system.enums.DictCodeEnum; import com.youlai.boot.system.mapper.UserMapper; +import com.youlai.boot.system.mapper.UserIdentityMapper; import com.youlai.boot.system.model.form.EmailUpdateForm; import com.youlai.boot.system.model.form.MobileUpdateForm; import com.youlai.boot.system.model.form.PasswordUpdateForm; @@ -34,6 +37,7 @@ import com.youlai.boot.system.model.entity.Dept; import com.youlai.boot.system.model.entity.DictItem; import com.youlai.boot.system.model.entity.Role; import com.youlai.boot.system.model.entity.SysUser; +import com.youlai.boot.system.model.entity.UserIdentity; import com.youlai.boot.system.model.query.UserQuery; import com.youlai.boot.system.model.vo.UserPageVO; import com.youlai.boot.system.model.vo.UserProfileVO; @@ -66,10 +70,19 @@ import java.util.stream.Collectors; @Slf4j public class UserServiceImpl extends ServiceImpl implements UserService { + private static final Map SORT_FIELDS = Map.of( + "id", "u.id", + "username", "u.username", + "createTime", "u.create_time", + "updateTime", "u.update_time" + ); + private final PasswordEncoder passwordEncoder; private final UserRoleService userRoleService; + private final UserIdentityMapper userIdentityMapper; + private final DeptService deptService; private final RoleService roleService; @@ -81,7 +94,7 @@ public class UserServiceImpl extends ServiceImpl implements private final StringRedisTemplate redisTemplate; - private final TokenManager tokenManager; + private final SessionService sessionService; private final DictItemService dictItemService; @@ -96,16 +109,27 @@ public class UserServiceImpl extends ServiceImpl implements */ @Override public IPage getUserPage(UserQuery queryParams) { - - // 参数构建 - int pageNum = queryParams.getPageNum(); - int pageSize = queryParams.getPageSize(); - Page page = new Page<>(pageNum, pageSize); - - // 查询数据 + Page page = createUserPage(queryParams, queryParams.getPageSize()); return this.baseMapper.getUserPage(page, queryParams); } + /** + * 列表与导出使用相同排序,同值时以用户 ID 确定顺序 + */ + private Page createUserPage(UserQuery queryParams, long pageSize) { + List orders = QuerySort.parse(queryParams.getSort(), SORT_FIELDS); + if (orders.isEmpty()) { + orders.add(OrderItem.desc("u.update_time")); + orders.add(OrderItem.desc("u.create_time")); + } + if (orders.stream().noneMatch(order -> "u.id".equals(order.getColumn()))) { + orders.add(OrderItem.desc("u.id")); + } + Page page = new Page<>(queryParams.getPageNum(), pageSize); + page.addOrder(orders); + return page; + } + /** * 获取用户表单回显数据. * @@ -132,6 +156,8 @@ public class UserServiceImpl extends ServiceImpl implements // 实体转换 form->entity SysUser entity = userConverter.toEntity(userForm); + // 新增不接受客户端主键,主键由数据库生成 + entity.setId(null); // 检查用户名是否已存在 long count = this.count(new LambdaQueryWrapper() @@ -147,7 +173,7 @@ public class UserServiceImpl extends ServiceImpl implements if (result) { // 保存用户角色 - userRoleService.saveUserRoles(entity.getId(), userForm.getRoleIds()); + userRoleService.replaceUserRoles(entity.getId(), userForm.getRoleIds()); } return result; } @@ -179,13 +205,18 @@ public class UserServiceImpl extends ServiceImpl implements // form -> entity SysUser entity = userConverter.toEntity(userForm); + // 更新目标由路径 ID 决定,忽略 Body 传入的 ID + entity.setId(userId); // 修改用户 boolean result = this.updateById(entity); if (result) { - // 保存用户角色 - userRoleService.saveUserRoles(entity.getId(), userForm.getRoleIds()); + boolean rolesChanged = userRoleService.replaceUserRoles(userId, userForm.getRoleIds()); + boolean identityChanged = entity.getUsername() != null && !Objects.equals(oldUser.getUsername(), entity.getUsername()) + || entity.getDeptId() != null && !Objects.equals(oldUser.getDeptId(), entity.getDeptId()) + || entity.getStatus() != null && !Objects.equals(oldUser.getStatus(), entity.getStatus()); + if (rolesChanged || identityChanged) sessionService.revokeUserSessions(userId); } return result; } @@ -207,6 +238,11 @@ public class UserServiceImpl extends ServiceImpl implements .collect(Collectors.toList()); boolean result = this.removeByIds(ids); + if (result) { + // 删除用户同时释放外部账号关联,避免唯一键永久占用已删除用户的身份 + userIdentityMapper.delete(new LambdaQueryWrapper().in(UserIdentity::getUserId, ids)); + ids.forEach(sessionService::revokeUserSessions); + } return result; } @@ -256,7 +292,8 @@ public class UserServiceImpl extends ServiceImpl implements @Override public List listExportUsers(UserQuery queryParams) { - List exportUsers = this.baseMapper.listExportUsers(queryParams); + Page page = createUserPage(queryParams, -1); + List exportUsers = this.baseMapper.listExportUsers(page, queryParams); if (CollectionUtil.isNotEmpty(exportUsers)) { //获取性别的字典项 Map genderMap = dictItemService.list( @@ -422,7 +459,7 @@ public class UserServiceImpl extends ServiceImpl implements if (result) { // 密码变更后,使当前用户的所有会话失效,强制重新登录 - tokenManager.invalidateUserSessions(userId); + sessionService.revokeUserSessions(userId); } return result; } @@ -442,7 +479,7 @@ public class UserServiceImpl extends ServiceImpl implements ); if (result) { // 管理员重置用户密码后,使该用户的所有会话失效 - tokenManager.invalidateUserSessions(userId); + sessionService.revokeUserSessions(userId); } return result; } @@ -692,11 +729,14 @@ public class UserServiceImpl extends ServiceImpl implements * @return 修改成功返回 true */ @Override + @Transactional(rollbackFor = Exception.class) public boolean updateUserStatus(Long userId, Integer status) { - return this.update(new LambdaUpdateWrapper() + boolean updated = this.update(new LambdaUpdateWrapper() .eq(SysUser::getId, userId) .set(SysUser::getStatus, status) ); + if (updated && !Integer.valueOf(1).equals(status)) sessionService.revokeUserSessions(userId); + return updated; } /** diff --git a/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java b/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java deleted file mode 100644 index d0891975..00000000 --- a/src/main/java/com/youlai/boot/system/service/impl/UserSocialServiceImpl.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.youlai.boot.system.service.impl; - -import cn.hutool.core.util.StrUtil; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; -import com.youlai.boot.framework.security.model.SecurityUser; -import com.youlai.boot.common.enums.SocialPlatformEnum; -import com.youlai.boot.system.mapper.UserSocialMapper; -import com.youlai.boot.system.model.entity.UserSocial; -import com.youlai.boot.system.service.UserSocialService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.LocalDateTime; - -/** - * 用户第三方账号绑定业务实现 - */ -@Slf4j -@Service -@RequiredArgsConstructor -public class UserSocialServiceImpl extends ServiceImpl implements UserSocialService { - - @Override - public UserSocial getByPlatformAndOpenid(SocialPlatformEnum platform, String openid) { - return getOne(new LambdaQueryWrapper() - .eq(UserSocial::getPlatform, platform) - .eq(UserSocial::getOpenid, openid)); - } - - @Override - public UserSocial getByUnionid(String unionid) { - if (StrUtil.isBlank(unionid)) { - return null; - } - return getOne(new LambdaQueryWrapper() - .eq(UserSocial::getUnionid, unionid)); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public UserSocial bindOrUpdate(Long userId, SocialPlatformEnum platform, String openid, String unionid, String nickname, String avatar, String sessionKey) { - UserSocial userSocial = getByPlatformAndOpenid(platform, openid); - LocalDateTime now = LocalDateTime.now(); - - if (userSocial == null) { - userSocial = new UserSocial(); - userSocial.setUserId(userId); - userSocial.setPlatform(platform); - userSocial.setOpenid(openid); - userSocial.setUnionid(unionid); - userSocial.setNickname(nickname); - userSocial.setAvatar(avatar); - userSocial.setSessionKey(sessionKey); - userSocial.setVerified(1); - userSocial.setCreateTime(now); - userSocial.setUpdateTime(now); - save(userSocial); - log.info("第三方账号绑定成功:userId={}, platform={}, openid={}", userId, platform, openid); - } else { - userSocial.setUserId(userId); - userSocial.setUnionid(unionid); - userSocial.setNickname(nickname); - userSocial.setAvatar(avatar); - userSocial.setSessionKey(sessionKey); - userSocial.setUpdateTime(now); - updateById(userSocial); - log.info("第三方账号更新成功:userId={}, platform={}, openid={}", userId, platform, openid); - } - - return userSocial; - } - - @Override - @Transactional(rollbackFor = Exception.class) - public boolean unbind(Long userId, SocialPlatformEnum platform) { - boolean removed = remove(new LambdaQueryWrapper() - .eq(UserSocial::getUserId, userId) - .eq(UserSocial::getPlatform, platform)); - if (removed) { - log.info("第三方账号解绑成功:userId={}, platform={}", userId, platform); - } - return removed; - } - - @Override - public SecurityUser getAuthInfoByOpenid(SocialPlatformEnum platform, String openid) { - UserSocial userSocial = getByPlatformAndOpenid(platform, openid); - if (userSocial == null) { - return null; - } - return baseMapper.getAuthInfoByUserId(userSocial.getUserId()); - } - - @Override - public SecurityUser getAuthInfoByUserId(Long userId) { - return baseMapper.getAuthInfoByUserId(userId); - } - - @Override - public void updateSessionKey(Long id, String sessionKey) { - UserSocial userSocial = getById(id); - if (userSocial != null) { - userSocial.setSessionKey(sessionKey); - userSocial.setUpdateTime(LocalDateTime.now()); - updateById(userSocial); - } - } - -} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index b1fdce02..4c707145 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -1,5 +1,6 @@ server: port: 8000 + forward-headers-strategy: framework spring: datasource: @@ -46,8 +47,7 @@ spring: auth: true starttls: enable: true - # 邮件发送者 - from: youlaitech@163.com + from: youlaitech@163.com # AI 对话(本环境无默认密钥,默认关闭;演示环境配置见 application-youlai.yml) ai: # 装配开关:none 关闭,openai 启用 @@ -67,7 +67,6 @@ spring: api-key: ${AI_API_KEY:} chat: options: - # 对话模型标识 model: qwen3.7-flash # 采样温度,取值 0~1,越小输出越稳定 temperature: 0.3 @@ -83,18 +82,13 @@ mybatis-plus: mapper-locations: classpath*:/mapper/**/*.xml global-config: db-config: - # 主键ID类型 id-type: none # 逻辑删除对应的全局属性名(注意:须是对象属性名,不能是表字段名,如 isDeleted 而非 is_deleted,否则逻辑删除失效) logic-delete-field: isDeleted - # 逻辑删除-删除值 logic-delete-value: 1 - # 逻辑删除-未删除值 logic-not-delete-value: 0 configuration: - # 驼峰下划线转换 map-underscore-to-camel-case: true - # 这个配置会将执行的sql打印出来,在开发或测试的时候可以用 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 接口限流 @@ -110,12 +104,11 @@ rate-limit: security: token: type: jwt # 令牌策略 [jwt|redis-token] - access-token-time-to-live: 7200 # 访问令牌 有效期(单位:秒),默认 2 小时,-1 表示永不过期 - refresh-token-time-to-live: 604800 # 刷新令牌有效期(单位:秒),默认 7 天,-1 表示永不过期 + access-token-time-to-live: 2h # 访问令牌有效期 + refresh-token-time-to-live: 7d # 会话最长有效期,刷新不延长 jwt: - secret-key: SecretKey012345678901234567890123456789012345678901234567890123456789 # JWT密钥(HS256算法至少32字符) - redis-token: - allow-multi-login: true # 是否允许多设备登录 + secret-key: SecretKey012345678901234567890123456789012345678901234567890123456789 # HS256,至少 32 字节 UTF-8 + allow-multi-login: true # 两种模式共用的多设备登录策略 # 匿名放行路径,仅跳过认证与授权判断,仍走过滤器链(保留安全响应头) permit-all-urls: - /api/v1/auth/login # 登录接口(账号密码登录) @@ -123,17 +116,27 @@ security: - /api/v1/auth/sms/** # 短信验证码接口(发送验证码、短信登录) - /api/v1/auth/refresh-token # 刷新令牌接口 - /api/v1/auth/wxma/** # 微信小程序认证接口(静默登录/手机号快捷登录/绑定手机号) - - /api/v1/auth/qr-code/** # 扫码登录接口(生成票据/查询状态/换取令牌) + - /api/v1/auth/qr-code/generate # 扫码登录-生成票据 + - /api/v1/auth/qr-code/status # 扫码登录-查询状态 + - /api/v1/auth/qr-code/login # 扫码登录-换取会话令牌 - /api/v1/forms/public/** # 公开表单接口(匿名渲染/提交,Service 校验 is_public 总闸 + IP 限流) - /api/v1/health # 健康检查接口(供探针与限流测试) - ${springdoc.swagger-ui.path} # Swagger UI 路径 - - /doc.html - /swagger-ui/** - /v3/api-docs/** - /webjars/** - /favicon.ico - /error + # OIDC 登录 + oidc: + enabled: false + issuer: http://localhost:8080/realms/youlai + client-id: youlai-admin + client-secret: "" + # 认证成功后的前端回调地址 + frontend-callback-uri: "http://localhost:3000/#/oidc/callback" + # 文件存储 storage: # 存储类型:s3 | aliyun | local @@ -170,43 +173,34 @@ storage: # springdoc 配置文档: https://springdoc.org/properties.html springdoc: swagger-ui: - path: /swagger-ui.html # Swagger UI 访问路径 - operationsSorter: alpha # 接口按方法名排序 - tags-sorter: alpha # 标签按字母排序 + path: /swagger-ui.html + operationsSorter: alpha + tags-sorter: alpha api-docs: - path: /v3/api-docs # OpenAPI JSON 地址 - group-configs: # 分组配置 - - group: "系统管理" # 分组名称 - paths-to-match: "/**" # 匹配的请求路径 - packages-to-scan: # 扫描的 Controller 包,限制只生成指定包的接口文档 + path: /v3/api-docs + group-configs: + - group: "系统管理" + paths-to-match: "/**" + # 只生成这些包下的接口文档 + packages-to-scan: - com.youlai.boot.auth.password.controller - com.youlai.boot.auth.sms.controller - com.youlai.boot.auth.qrcode.controller - com.youlai.boot.auth.wxma.controller - com.youlai.boot.auth.session.controller + - com.youlai.boot.auth.oidc.controller - com.youlai.boot.system.controller - com.youlai.boot.codegen.controller - com.youlai.boot.module.form.controller - com.youlai.boot.module.workflow.controller default-flat-param-object: true -# knife4j 配置 -knife4j: - enable: true - # 生产环境设置为 true 关闭文档 - production: false - setting: - language: zh_cn - # 验证码配置 captcha: # 验证码类型 circle-圆圈干扰验证码|gif-Gif验证码|line-干扰线验证码|shear-扭曲干扰验证码 type: circle - # 验证码宽度 width: 120 - # 验证码高度 height: 40 - # 验证码干扰元素个数 interfere-count: 2 # 文本透明度(0.0-1.0) text-alpha: 0.8 @@ -222,7 +216,6 @@ captcha: name: SansSerif # 字体样式 0-普通|1-粗体|2-斜体 weight: 1 - # 字体大小 size: 24 # 验证码有效期(秒) expire-seconds: 120 @@ -237,17 +230,13 @@ sms: regionId: cn-shanghai signName: 有来技术 templates: - # 注册短信验证码模板 register: SMS_22xxx771 - # 登录短信验证码模板 login: SMS_22xxx772 - # 修改手机号短信验证码模板 change-mobile: SMS_22xxx773 # xxl-job 定时任务配置 xxl: job: - # 定时任务开关 enabled: false admin: # 调度中心地址,多个地址用逗号分割;3.4.1 起调度中心 context-path 为 /,地址不带 /xxl-job-admin 后缀 @@ -264,6 +253,8 @@ xxl: # 微信小程序配置 wx: miniapp: + # 不同 AppID 使用不同认证源标识。 + provider: wechat-mini-main appid: your-app-id secret: your-app-secret diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 55ad1b74..4186e980 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,5 +1,6 @@ server: port: 8000 + forward-headers-strategy: framework spring: datasource: @@ -110,12 +111,11 @@ rate-limit: security: token: type: jwt # 令牌策略 [jwt|redis-token] - access-token-time-to-live: 7200 # 访问令牌 有效期(单位:秒),默认 2 小时,-1 表示永不过期 - refresh-token-time-to-live: 604800 # 刷新令牌有效期(单位:秒),默认 7 天,-1 表示永不过期 + access-token-time-to-live: 2h # 访问令牌有效期 + refresh-token-time-to-live: 7d # 会话最长有效期,刷新不延长 jwt: - secret-key: SecretKey012345678901234567890123456789012345678901234567890123456789 # JWT密钥(HS256算法至少32字符) - redis-token: - allow-multi-login: true # 是否允许多设备登录 + secret-key: SecretKey012345678901234567890123456789012345678901234567890123456789 # HS256,至少 32 字节 UTF-8 + allow-multi-login: true # 两种模式共用的多设备登录策略 # 匿名放行路径,仅跳过认证与授权判断,仍走过滤器链(保留安全响应头) permit-all-urls: - /api/v1/auth/login # 登录接口(账号密码登录) @@ -123,17 +123,27 @@ security: - /api/v1/auth/sms/** # 短信验证码接口(发送验证码、短信登录) - /api/v1/auth/refresh-token # 刷新令牌接口 - /api/v1/auth/wxma/** # 微信小程序认证接口(静默登录/手机号快捷登录/绑定手机号) - - /api/v1/auth/qr-code/** # 扫码登录接口(生成票据/查询状态/换取令牌) + - /api/v1/auth/qr-code/generate # 扫码登录-生成票据 + - /api/v1/auth/qr-code/status # 扫码登录-查询状态 + - /api/v1/auth/qr-code/login # 扫码登录-换取会话令牌 - /api/v1/forms/public/** # 公开表单接口(匿名渲染/提交,Service 校验 is_public 总闸 + IP 限流) - /api/v1/health # 健康检查接口(供探针与限流测试) - ${springdoc.swagger-ui.path} # Swagger UI 路径 - - /doc.html - /swagger-ui/** - /v3/api-docs/** - /webjars/** - /favicon.ico - /error + # OIDC 登录 + oidc: + enabled: ${OIDC_ENABLED:false} + issuer: ${OIDC_ISSUER:} + client-id: ${OIDC_CLIENT_ID:} + client-secret: ${OIDC_CLIENT_SECRET:} + # 认证成功后的前端回调地址 + frontend-callback-uri: "${OIDC_FRONTEND_CALLBACK_URI:}" + # 文件存储 storage: # 存储类型:s3 | aliyun | local @@ -190,13 +200,6 @@ springdoc: - com.youlai.boot.module.workflow.controller default-flat-param-object: true # 将对象参数扁平化显示在文档中 -# knife4j 配置 -knife4j: - enable: true # 是否启用 Knife4j 增强功能 - production: false # 是否启用生产环境保护(true=生产环境隐藏文档,false=开发环境可访问) - setting: - language: zh_cn - # 验证码配置 captcha: # 验证码类型 circle-圆圈干扰验证码|gif-Gif验证码|line-干扰线验证码|shear-扭曲干扰验证码 @@ -263,6 +266,7 @@ xxl: # 微信小程序配置 wx: miniapp: + provider: ${WX_MINIAPP_PROVIDER:wechat-mini-main} appid: your-app-id secret: your-app-secret diff --git a/src/main/resources/codegen/backend/controller.java.vm b/src/main/resources/codegen/backend/controller.java.vm index f0d336a9..3335817a 100644 --- a/src/main/resources/codegen/backend/controller.java.vm +++ b/src/main/resources/codegen/backend/controller.java.vm @@ -35,7 +35,7 @@ public class ${entityName}Controller { @Operation(summary = "$!{businessName}分页列表") @GetMapping @PreAuthorize("@ss.hasPerm('${moduleName}:${entityKebab}:list')") - public PageResult<${entityName}VO> get${entityName}Page(${entityName}Query queryParams ) { + public PageResult<${entityName}VO> get${entityName}Page(@Valid ${entityName}Query queryParams ) { IPage<${entityName}VO> result = ${entityLowerCamel}Service.get${entityName}Page(queryParams); return PageResult.success(result); } diff --git a/src/main/resources/codegen/backend/entity.java.vm b/src/main/resources/codegen/backend/entity.java.vm index 15a8dd43..e28b564d 100644 --- a/src/main/resources/codegen/backend/entity.java.vm +++ b/src/main/resources/codegen/backend/entity.java.vm @@ -2,6 +2,9 @@ package ${packageName}.${moduleName}.${subpackageName}; import lombok.Getter; import lombok.Setter; +#if(${hasLocalDate}) +import java.time.LocalDate; +#end #if(${hasLocalDateTime}) import java.time.LocalDateTime; #end diff --git a/src/main/resources/codegen/backend/form.java.vm b/src/main/resources/codegen/backend/form.java.vm index e157c2aa..deb99731 100644 --- a/src/main/resources/codegen/backend/form.java.vm +++ b/src/main/resources/codegen/backend/form.java.vm @@ -5,6 +5,9 @@ import java.io.Serializable; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; import lombok.Setter; +#if(${hasLocalDate}) +import java.time.LocalDate; +#end #if(${hasLocalDateTime}) import java.time.LocalDateTime; import com.fasterxml.jackson.annotation.JsonFormat; @@ -12,8 +15,14 @@ import com.fasterxml.jackson.annotation.JsonFormat; #if(${hasBigDecimal}) import java.math.BigDecimal; #end -#if(${hasRequiredField}) -import jakarta.validation.constraints.*; +#if(${hasNotBlank}) +import jakarta.validation.constraints.NotBlank; +#end +#if(${hasNotNull}) +import jakarta.validation.constraints.NotNull; +#end +#if(${hasSize}) +import jakarta.validation.constraints.Size; #end /** @@ -33,10 +42,9 @@ public class ${entityName}Form implements Serializable { @Schema(description = "${businessName}ID") private Long id; -## ---------- BEGIN 字段循环遍历 ---------- #if($fieldConfigs) #foreach($fieldConfig in ${fieldConfigs}) - #if($fieldConfig.isShowInForm) + #if($fieldConfig.isShowInForm && $fieldConfig.fieldName != "id") #if("$!fieldConfig.fieldComment" != "") @Schema(description = "${fieldConfig.fieldComment}") #end @@ -47,8 +55,8 @@ public class ${entityName}Form implements Serializable { @NotNull(message = "$fieldConfig.fieldComment不能为空") #end #end - #if($fieldConfig.maxLength) - @Size(max=$fieldConfig.maxLength, message="$fieldConfig.fieldComment长度不能超过${fieldConfig.maxLength}个字符") + #if($fieldConfig.fieldType == 'String' && $fieldConfig.maxLength && $fieldConfig.maxLength > 0) + @Size(max = $fieldConfig.maxLength, message = "$fieldConfig.fieldComment长度不能超过${fieldConfig.maxLength}个字符") #end #if($fieldConfig.fieldType == 'LocalDateTime') @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") diff --git a/src/main/resources/codegen/backend/mapper.xml.vm b/src/main/resources/codegen/backend/mapper.xml.vm index 301bfe4a..69a87799 100644 --- a/src/main/resources/codegen/backend/mapper.xml.vm +++ b/src/main/resources/codegen/backend/mapper.xml.vm @@ -6,9 +6,9 @@ - SELECT t1.id, t1.title, @@ -59,4 +59,15 @@ t1.id = #{id} AND t1.is_deleted = 0 + + diff --git a/src/main/resources/mapper/system/UserSocialMapper.xml b/src/main/resources/mapper/system/UserIdentityMapper.xml similarity index 95% rename from src/main/resources/mapper/system/UserSocialMapper.xml rename to src/main/resources/mapper/system/UserIdentityMapper.xml index 5856c87d..7c2b5133 100644 --- a/src/main/resources/mapper/system/UserSocialMapper.xml +++ b/src/main/resources/mapper/system/UserIdentityMapper.xml @@ -2,7 +2,7 @@ - + diff --git a/src/main/resources/mapper/system/UserMapper.xml b/src/main/resources/mapper/system/UserMapper.xml index 46d4caf6..4aa53d50 100644 --- a/src/main/resources/mapper/system/UserMapper.xml +++ b/src/main/resources/mapper/system/UserMapper.xml @@ -5,20 +5,8 @@ - - + SELECT + u.id, + u.username, + u.nickname, + u.mobile, + u.gender, + u.avatar, + u.STATUS, + u.email, + d.NAME AS dept_name, + GROUP_CONCAT( r.NAME ) AS roleNames, + u.create_time + @@ -178,33 +174,7 @@ u.gender, d.NAME AS dept_name, u.create_time - FROM - sys_user u - LEFT JOIN sys_dept d ON u.dept_id = d.id - - u.is_deleted = 0 - AND NOT EXISTS ( - SELECT - 1 - FROM sys_user_role sur - INNER JOIN sys_role r ON sur.role_id = r.id - WHERE - sur.user_id = u.id - AND r.code = '${@com.youlai.boot.common.constant.SystemConstants@ROOT_ROLE_CODE}' - ) - - AND (u.username LIKE CONCAT('%',#{keywords},'%') - OR u.nickname LIKE CONCAT('%',#{keywords},'%') - OR u.mobile LIKE CONCAT('%',#{keywords},'%')) - - - AND u.status = #{status} - - - AND concat(',',concat(d.tree_path,',',d.id),',') like concat('%,',#{deptId},',%') - - - GROUP BY u.id + diff --git a/src/test/java/com/youlai/boot/ArchitectureTest.java b/src/test/java/com/youlai/boot/architecture/ArchitectureTest.java similarity index 93% rename from src/test/java/com/youlai/boot/ArchitectureTest.java rename to src/test/java/com/youlai/boot/architecture/ArchitectureTest.java index 3761b750..2fa7f8b2 100644 --- a/src/test/java/com/youlai/boot/ArchitectureTest.java +++ b/src/test/java/com/youlai/boot/architecture/ArchitectureTest.java @@ -1,8 +1,9 @@ -package com.youlai.boot; +package com.youlai.boot.architecture; import com.tngtech.archunit.junit.AnalyzeClasses; import com.tngtech.archunit.junit.ArchTest; import com.tngtech.archunit.lang.ArchRule; +import com.tngtech.archunit.core.importer.ImportOption; import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.slices; @@ -13,7 +14,7 @@ import static com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.sli * @author Ray.Hao * @since 4.8.0 */ -@AnalyzeClasses(packages = "com.youlai.boot") +@AnalyzeClasses(packages = "com.youlai.boot", importOptions = ImportOption.DoNotIncludeTests.class) class ArchitectureTest { @ArchTest diff --git a/src/test/java/com/youlai/boot/AuthControllerTest.java b/src/test/java/com/youlai/boot/auth/AuthControllerTest.java similarity index 82% rename from src/test/java/com/youlai/boot/AuthControllerTest.java rename to src/test/java/com/youlai/boot/auth/AuthControllerTest.java index 7892c0ae..bbd57465 100644 --- a/src/test/java/com/youlai/boot/AuthControllerTest.java +++ b/src/test/java/com/youlai/boot/auth/AuthControllerTest.java @@ -1,33 +1,36 @@ -package com.youlai.boot; +package com.youlai.boot.auth; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.youlai.boot.YouLaiBootApplication; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.MethodOrderer; import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.http.MediaType; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; -import static org.junit.jupiter.api.Assertions.*; +import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; /** - * 认证 + 当前用户接口单元测试 + * 认证接口的外部环境集成测试,依赖 dev 配置中的数据库与 Redis * * @author Ray.Hao * @since 4.6.0 */ -@SpringBootTest +@SpringBootTest(classes = YouLaiBootApplication.class) +@Tag("external") @AutoConfigureMockMvc @ActiveProfiles("dev") @TestMethodOrder(MethodOrderer.OrderAnnotation.class) @@ -37,14 +40,14 @@ class AuthControllerTest { private MockMvc mockMvc; @Autowired - private ObjectMapper objectMapper; + private JsonMapper objectMapper; private static String accessToken; @Test @Order(1) @DisplayName("登录成功") - void loginSuccess() throws Exception { + void loginShouldReturnTokenPair() throws Exception { String body = """ { "username": "admin", @@ -66,14 +69,13 @@ class AuthControllerTest { JsonNode root = objectMapper.readTree(result.getResponse().getContentAsString()); accessToken = root.path("data").path("accessToken").asText(); - assertNotNull(accessToken, "登录后应返回 accessToken"); - assertFalse(accessToken.isBlank(), "accessToken 不能为空"); + assertThat(accessToken).as("登录后应返回 accessToken").isNotBlank(); } @Test @Order(2) @DisplayName("密码错误登录失败") - void loginWithWrongPassword() throws Exception { + void wrongPasswordLoginShouldFail() throws Exception { String body = """ { "username": "admin", @@ -92,7 +94,7 @@ class AuthControllerTest { @Test @Order(3) @DisplayName("空用户名登录失败") - void loginWithEmptyUsername() throws Exception { + void emptyUsernameLoginShouldFail() throws Exception { String body = """ { "username": "", @@ -110,8 +112,8 @@ class AuthControllerTest { @Test @Order(4) @DisplayName("登录后获取当前用户信息") - void getCurrentUserWithToken() throws Exception { - assertNotNull(accessToken, "accessToken 应由登录测试先行填充"); + void authenticatedUserShouldGetProfile() throws Exception { + assertThat(accessToken).as("accessToken 应由登录测试先行填充").isNotBlank(); mockMvc.perform(get("/api/v1/users/me") .header("Authorization", "Bearer " + accessToken)) @@ -127,7 +129,7 @@ class AuthControllerTest { @Test @Order(5) @DisplayName("无 Token 请求 /me 返回令牌无效") - void getCurrentUserWithoutToken() throws Exception { + void missingTokenShouldBeRejected() throws Exception { mockMvc.perform(get("/api/v1/users/me")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value("A0230")) @@ -137,7 +139,7 @@ class AuthControllerTest { @Test @Order(6) @DisplayName("伪造 Token 请求 /me 返回令牌无效") - void getCurrentUserWithInvalidToken() throws Exception { + void forgedTokenShouldBeRejected() throws Exception { mockMvc.perform(get("/api/v1/users/me") .header("Authorization", "Bearer invalid_token_xxx")) .andExpect(status().isOk()) diff --git a/src/test/java/com/youlai/boot/support/realtime/RealtimeControllerTest.java b/src/test/java/com/youlai/boot/support/realtime/RealtimeControllerTest.java new file mode 100644 index 00000000..59ae2f99 --- /dev/null +++ b/src/test/java/com/youlai/boot/support/realtime/RealtimeControllerTest.java @@ -0,0 +1,104 @@ +package com.youlai.boot.support.realtime; + +import com.youlai.boot.common.result.ResultCode; +import com.youlai.boot.support.realtime.config.RealtimeProperties; +import com.youlai.boot.support.realtime.controller.RealtimeController; +import com.youlai.boot.support.realtime.model.CreateRealtimeTicketRequest; +import com.youlai.boot.support.realtime.model.PresenceSnapshot; +import com.youlai.boot.support.realtime.security.RealtimeTicketService; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataAccessResourceFailureException; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 关闭实时通信与临时故障的响应语义 + * + * @since 4.13.0 + */ +class RealtimeControllerTest { + private final RealtimeTicketService tickets = mock(RealtimeTicketService.class); + private final RealtimeSessionManager sessions = mock(RealtimeSessionManager.class); + private final RealtimeProperties properties = new RealtimeProperties(); + private final RealtimeController controller = new RealtimeController(tickets, sessions, properties); + private final CreateRealtimeTicketRequest request = + new CreateRealtimeTicketRequest("client-instance-01", "web", Set.of("notice")); + + @Test + void disabledFeatureReturnsDedicatedCodeWithoutIssuingTicket() { + properties.setEnabled(false); + var response = controller.createTicket(request, "Bearer token"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(response.getBody().getCode()).isEqualTo("B0102"); + assertThat(response.getHeaders().getCacheControl()).isEqualTo("no-store"); + verifyNoInteractions(tickets); + } + + @Test + void stoppingServerRemainsRetryable() { + when(sessions.isStopping()).thenReturn(true); + var response = controller.createTicket(request, "Bearer token"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(response.getBody().getCode()).isNotEqualTo(ResultCode.REALTIME_DISABLED.getCode()); + verifyNoInteractions(tickets); + } + + @Test + void storageFailureRemainsRetryable() { + when(tickets.create("token", request)).thenThrow(new DataAccessResourceFailureException("offline")); + var response = controller.createTicket(request, "Bearer token"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); + assertThat(response.getBody().getCode()).isNotEqualTo(ResultCode.REALTIME_DISABLED.getCode()); + } + + @Test + void missingCredentialIsUnauthorized() { + assertThat(controller.createTicket(request, null).getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + verifyNoInteractions(tickets); + } + + @Test + void forbiddenTopicReturns403WithoutRetryableCode() { + when(tickets.create("token", request)).thenThrow(new AccessDeniedException("forbidden")); + var response = controller.createTicket(request, "Bearer token"); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody().getCode()).isEqualTo(ResultCode.ACCESS_UNAUTHORIZED.getCode()); + } + + @Test + void ticketTopicsAreRequiredAndLimitedToSupportedTopics() { + assertThatThrownBy(() -> new CreateRealtimeTicketRequest("client-instance-01", "web", Set.of("dict"))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new CreateRealtimeTicketRequest("client-instance-01", "web", Set.of("notice", "unknown"))) + .isInstanceOf(IllegalArgumentException.class); + try (var factory = Validation.buildDefaultValidatorFactory()) { + assertThat(factory.getValidator().validate(new CreateRealtimeTicketRequest("client-instance-01", "web", null))) + .isNotEmpty(); + } + } + + @Test + void disabledFeatureStillAllowsPresenceCalibration() { + properties.setEnabled(false); + var snapshot = new PresenceSnapshot(0, 0, "node-1", "2026-01-01T00:00:00Z"); + when(sessions.presence()).thenReturn(snapshot); + + var response = controller.getPresence(); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody().getData()).isEqualTo(snapshot); + } +} diff --git a/src/test/java/com/youlai/boot/support/realtime/RealtimeMessageCodecTest.java b/src/test/java/com/youlai/boot/support/realtime/RealtimeMessageCodecTest.java new file mode 100644 index 00000000..8a6f4c84 --- /dev/null +++ b/src/test/java/com/youlai/boot/support/realtime/RealtimeMessageCodecTest.java @@ -0,0 +1,48 @@ +package com.youlai.boot.support.realtime; + +import com.youlai.boot.support.realtime.protocol.RealtimeMessageCodec; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * 客户端协议的最小字段与输入边界 + * + * @since 4.13.0 + */ +class RealtimeMessageCodecTest { + private final RealtimeMessageCodec codec = new RealtimeMessageCodec(); + + @Test + void acceptsPongWithoutMessageIdOrTimestamp() { + var pingId = codec.decodePong(""" + {"version":1,"type":"heartbeat.pong","payload":{"pingId":"ping-1"}} + """); + assertThat(pingId).isEqualTo("ping-1"); + } + + @ParameterizedTest + @ValueSource(strings = { + "null", + "[]", + "{\"version\":2,\"type\":\"heartbeat.pong\",\"payload\":{\"pingId\":\"p\"}}", + "{\"version\":1,\"type\":\"heartbeat.pong\",\"payload\":{}}", + "{\"version\":1,\"type\":\"heartbeat.pong\",\"payload\":{\"pingId\":\" \"}}", + "{\"version\":1,\"type\":\"session.revoked\",\"payload\":{}}", + "{\"version\":1,\"type\":\"subscription.replace\",\"payload\":{\"topics\":[\"notice\",\"dict\"]}}" + }) + void rejectsInvalidMessages(String text) { + assertThatThrownBy(() -> codec.decodePong(text)).isInstanceOf(IllegalArgumentException.class); + } + + @Test + void limitsUtf8BytesInsteadOfCharacterCount() { + String text = "{\"version\":1,\"type\":\"heartbeat.pong\",\"payload\":{\"pingId\":\"p\"},\"extra\":\"" + + "字".repeat(6_000) + "\"}"; + assertThat(text.length()).isLessThan(RealtimeMessageCodec.MAX_MESSAGE_BYTES); + assertThatThrownBy(() -> codec.decodePong(text)).hasMessage("Message too large"); + } +} diff --git a/src/test/java/com/youlai/boot/support/realtime/RealtimeServiceTest.java b/src/test/java/com/youlai/boot/support/realtime/RealtimeServiceTest.java new file mode 100644 index 00000000..9020eb71 --- /dev/null +++ b/src/test/java/com/youlai/boot/support/realtime/RealtimeServiceTest.java @@ -0,0 +1,104 @@ +package com.youlai.boot.support.realtime; + +import com.youlai.boot.support.realtime.config.RealtimeProperties; +import com.youlai.boot.support.realtime.protocol.RealtimeServerMessage; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.BiFunction; +import java.util.stream.LongStream; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 业务消息封装、游标分批与投递配额释放 + * + * @since 4.13.0 + */ +class RealtimeServiceTest { + private final RealtimeSessionManager sessions = mock(RealtimeSessionManager.class); + private final RealtimeProperties properties = new RealtimeProperties(); + private final RealtimeService service = new RealtimeService(sessions, properties); + private final ArrayDeque tasks = new ArrayDeque<>(); + + @BeforeEach + void captureMaintenanceTasks() { + when(sessions.submitMaintenance(any())).thenAnswer(call -> tasks.offer(call.getArgument(0))); + } + + @Test + void createsServerEnvelopeInsideService() { + Map payload = Map.of("dictCode", "gender"); + service.broadcast("dict", "dict.changed", payload); + tasks.remove().run(); + + var message = ArgumentCaptor.forClass(RealtimeServerMessage.class); + verify(sessions).broadcast(eq("dict"), message.capture()); + assertThat(message.getValue().version()).isEqualTo(1); + assertThat(message.getValue().id()).isNotBlank(); + assertThat(message.getValue().payload()).isSameAs(payload); + } + + @Test + void passesBatchLimitAndAdvancesCursorBetweenTasks() { + List cursors = new ArrayList<>(); + List limits = new ArrayList<>(); + service.sendBatches("notice.changed", Map.of("noticeId", "1"), (after, limit) -> { + cursors.add(after); + limits.add(limit); + return after == 0 ? LongStream.rangeClosed(1, limit).boxed().toList() : List.of(101L); + }); + + tasks.remove().run(); + assertThat(cursors).containsExactly(0L); + assertThat(tasks).hasSize(1); + tasks.remove().run(); + + assertThat(cursors).containsExactly(0L, 100L); + assertThat(limits).containsExactly(100, 100); + assertThat(tasks).isEmpty(); + verify(sessions, times(2)).sendToUsers(anyCollection(), any()); + } + + @Test + void releasesJobSlotAfterQueueRejectionOrLoaderFailure() { + doReturn(false).when(sessions).submitMaintenance(any()); + service.sendBatches("notice.changed", Map.of(), (after, limit) -> List.of()); + service.sendBatches("notice.changed", Map.of(), (after, limit) -> List.of()); + when(sessions.submitMaintenance(any())).thenAnswer(call -> tasks.offer(call.getArgument(0))); + service.sendBatches("notice.changed", Map.of(), (after, limit) -> { + throw new IllegalStateException("query failed"); + }); + tasks.remove().run(); + + service.sendBatches("notice.changed", Map.of(), (after, limit) -> List.of()); + service.sendBatches("notice.changed", Map.of(), (after, limit) -> List.of()); + assertThat(tasks).hasSize(2); + } + + @Test + void disabledFeatureDoesNotLoadRecipientsOrSubmitWork() { + properties.setEnabled(false); + BiFunction> loader = (after, limit) -> { + throw new AssertionError("实时通信关闭时不应查询接收人"); + }; + service.broadcast("dict", "dict.changed", Map.of()); + service.sendBatches("notice.changed", Map.of(), loader); + + verify(sessions, never()).submitMaintenance(any()); + } +} diff --git a/src/test/java/com/youlai/boot/support/realtime/RealtimeSessionManagerTest.java b/src/test/java/com/youlai/boot/support/realtime/RealtimeSessionManagerTest.java new file mode 100644 index 00000000..47748ce7 --- /dev/null +++ b/src/test/java/com/youlai/boot/support/realtime/RealtimeSessionManagerTest.java @@ -0,0 +1,214 @@ +package com.youlai.boot.support.realtime; + +import com.youlai.boot.support.realtime.protocol.RealtimeMessageCodec; +import com.youlai.boot.support.realtime.security.RealtimeCredentialVerifier; +import com.youlai.boot.support.realtime.security.RealtimeCredentialVerifier.VerifiedIdentity; +import com.youlai.boot.support.realtime.security.RealtimeTicketService.TicketGrant; +import com.youlai.boot.support.realtime.security.RealtimeTicketService.VerifiedTicket; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.TextMessage; +import org.springframework.web.socket.WebSocketSession; +import tools.jackson.databind.json.JsonMapper; + +import java.time.Instant; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 连接期限、权限复核、在线统计与配额生命周期 + * + * @since 4.13.0 + */ +class RealtimeSessionManagerTest { + private final RealtimeCredentialVerifier verifier = mock(RealtimeCredentialVerifier.class); + private final RealtimeMessageCodec codec = new RealtimeMessageCodec(); + private final RealtimeMessageSender sender = new RealtimeMessageSender(Runnable::run); + private final RealtimeSessionManager sessions = new RealtimeSessionManager(sender, codec, verifier, Runnable::run); + private final JsonMapper mapper = JsonMapper.builder().build(); + + @Test + void registrationUsesVerifiedTicketAndSendsMinimalReady() throws Exception { + List messages = new ArrayList<>(); + var transport = transport("socket-1", messages); + Instant expiresAt = Instant.now().plusSeconds(3_600).truncatedTo(java.time.temporal.ChronoUnit.MILLIS); + sessions.register(transport, ticket(1L, "client-1", expiresAt, Set.of("notice")), sessions.reserve()); + + var ready = mapper.readTree(messages.get(0)); + assertThat(ready.path("type").asText()).isEqualTo("connection.ready"); + assertThat(ready.path("payload")).isEqualTo(mapper.readTree("{\"heartbeatTimeoutMs\":60000}")); + assertThat(ready.has("occurredAt")).isFalse(); + assertThat(state("socket-1").authExpiresAt).isEqualTo(expiresAt); + verifyNoInteractions(verifier); + } + + @Test + void presenceChangesWhenUserCountChangesWithSameConnectionCount() throws Exception { + List messages = new ArrayList<>(); + var first = transport("first", messages); + var second = transport("second", new ArrayList<>()); + sessions.register(first, ticket(1L, "a", Instant.now().plusSeconds(3_600), Set.of("notice", "presence")), sessions.reserve()); + sessions.register(second, ticket(1L, "b", Instant.now().plusSeconds(3_600), Set.of("notice")), sessions.reserve()); + when(verifier.verify("token-1")).thenReturn(new VerifiedIdentity(1L, true, Instant.now().plusSeconds(3_600))); + sessions.maintain(); + messages.clear(); + + sessions.terminated(second); + var replacement = transport("replacement", new ArrayList<>()); + sessions.register(replacement, ticket(2L, "c", Instant.now().plusSeconds(3_600), Set.of("notice")), sessions.reserve()); + ReflectionTestUtils.setField(sessions, "lastPresenceAt", System.nanoTime() - TimeUnit.SECONDS.toNanos(6)); + sessions.maintain(); + + var snapshots = messages.stream().map(mapper::readTree) + .filter(message -> "presence.snapshot".equals(message.path("type").asText())).toList(); + assertThat(snapshots).hasSize(1); + assertThat(snapshots.get(0).path("payload").path("onlineUsers").asInt()).isEqualTo(2); + assertThat(snapshots.get(0).path("payload").path("onlineConnections").asInt()).isEqualTo(2); + } + + @Test + void periodicVerificationStillClosesRevokedCredentials() throws Exception { + var transport = transport("socket-1", new ArrayList<>()); + sessions.register(transport, ticket(1L, "a", Instant.now().plusSeconds(3_600), Set.of("notice")), sessions.reserve()); + state("socket-1").nextVerifyAt = 0; + when(verifier.verify("token-1")).thenThrow(new BadCredentialsException("revoked")); + + sessions.maintain(); + + verify(transport).close(new CloseStatus(4001, "credential revoked")); + assertThat(sessions.presence().onlineConnections()).isZero(); + assertThat(state("socket-1").accessToken()).isNull(); + } + + @Test + void periodicVerificationStillChecksPresencePermission() throws Exception { + var transport = transport("socket-1", new ArrayList<>()); + sessions.register(transport, ticket(1L, "a", Instant.now().plusSeconds(3_600), Set.of("notice", "presence")), sessions.reserve()); + when(verifier.verify("token-1")).thenReturn(new VerifiedIdentity(1L, false, Instant.now().plusSeconds(3_600))); + state("socket-1").nextVerifyAt = 0; + + sessions.maintain(); + + verify(transport).close(new CloseStatus(4001, "credential revoked")); + } + + @Test + void expiredCredentialsNeverReceiveReadyAndReleaseQuotaOnlyAfterTermination() throws Exception { + List messages = new ArrayList<>(); + var transport = transport("socket-1", messages); + var reservation = sessions.reserve(); + sessions.register(transport, ticket(1L, "a", Instant.now().minusSeconds(1), Set.of("notice")), reservation); + List otherReservations = new ArrayList<>(); + for (int index = 0; index < 499; index++) { + otherReservations.add(sessions.reserve()); + } + + assertThat(messages).isEmpty(); + assertThat(sessions.reserve()).isNull(); + sessions.terminated(transport); + sessions.terminated(transport); + reservation.close(); + var released = sessions.reserve(); + assertThat(released).isNotNull(); + assertThat(sessions.reserve()).isNull(); + released.close(); + otherReservations.forEach(RealtimeSessionManager.Reservation::close); + } + + @Test + void keepsOneOutstandingPingUntilMatchingPongOrTimeout() throws Exception { + List messages = new ArrayList<>(); + var transport = transport("socket-1", messages); + sessions.register(transport, ticket(1L, "a", Instant.now().plusSeconds(3_600), Set.of("notice")), sessions.reserve()); + RealtimeSession state = state("socket-1"); + state.nextPingAt = 0; + + sessions.maintain(); + String pingId = state.pendingPingId; + assertThat(pingId).isNotBlank(); + sessions.maintain(); + sessions.handlePong(transport, "wrong-ping"); + assertThat(state.pendingPingId).isEqualTo(pingId); + assertThat(messages).hasSize(2); + + sessions.handlePong(transport, pingId); + assertThat(state.pendingPingId).isNull(); + assertThat(state.nextPingAt - state.lastPongAt).isEqualTo(TimeUnit.SECONDS.toNanos(25)); + state.lastPongAt = System.nanoTime() - TimeUnit.SECONDS.toNanos(61); + sessions.maintain(); + verify(transport).close(new CloseStatus(4011, "heartbeat timeout")); + } + + @Test + void queuedAndInflightFramesShareBudgetAndReleaseItOnce() throws Exception { + var tasks = new ArrayDeque(); + var queuedSender = new RealtimeMessageSender(tasks::add); + var transport = mock(WebSocketSession.class); + var ticket = ticket(1L, "a", Instant.now().plusSeconds(3_600), Set.of("notice")); + var state = new RealtimeSession(transport, ticket.grant(), sessions.reserve(), ticket.credentialExpiresAt()); + var frame = RealtimeMessageSender.Frame.of("x".repeat(16_384)); + Runnable failure = () -> { throw new AssertionError("发送不应失败"); }; + assertThat(queuedSender.enqueue(state, frame, failure)).isTrue(); + assertThat(queuedSender.enqueue(state, frame, failure)).isTrue(); + assertThat(queuedSender.enqueue(state, frame, failure)).isFalse(); + assertThat(state.bufferedBytes).isEqualTo(32_768); + + doAnswer(call -> { + state.closed = true; + queuedSender.discardQueued(state); + assertThat(state.bufferedBytes).isEqualTo(16_384); + assertThat(state.bufferedMessages).isEqualTo(1); + return null; + }).when(transport).sendMessage(any()); + tasks.remove().run(); + + assertThat(state.bufferedBytes).isZero(); + assertThat(state.bufferedMessages).isZero(); + assertThat(state.queue).isEmpty(); + state.reservation.close(); + } + + /** + * 创建握手完成的测试票据 + */ + private VerifiedTicket ticket(long userId, String clientId, Instant expiresAt, Set topics) { + var grant = new TicketGrant("token-" + userId, userId, clientId, "web", + topics, Instant.now().plusSeconds(30).toEpochMilli()); + return new VerifiedTicket(grant, expiresAt); + } + + /** + * 记录连接实际发送的文本帧 + */ + private WebSocketSession transport(String id, List messages) throws Exception { + var transport = mock(WebSocketSession.class); + when(transport.getId()).thenReturn(id); + doAnswer(call -> { + messages.add(((TextMessage) call.getArgument(0)).getPayload()); + return null; + }).when(transport).sendMessage(any()); + return transport; + } + + /** + * 取得连接状态以推进测试时钟,不等待真实心跳间隔 + */ + @SuppressWarnings("unchecked") + private RealtimeSession state(String id) { + return ((Map) ReflectionTestUtils.getField(sessions, "transports")).get(id); + } +} diff --git a/src/test/java/com/youlai/boot/support/realtime/RealtimeWebSocketTest.java b/src/test/java/com/youlai/boot/support/realtime/RealtimeWebSocketTest.java new file mode 100644 index 00000000..994e26b2 --- /dev/null +++ b/src/test/java/com/youlai/boot/support/realtime/RealtimeWebSocketTest.java @@ -0,0 +1,282 @@ +package com.youlai.boot.support.realtime; + +import com.youlai.boot.support.realtime.config.RealtimeConfig; +import com.youlai.boot.support.realtime.model.CreateRealtimeTicketRequest; +import com.youlai.boot.support.realtime.protocol.RealtimeMessageCodec; +import com.youlai.boot.support.realtime.protocol.RealtimeServerMessage; +import com.youlai.boot.support.realtime.security.RealtimeCredentialVerifier; +import com.youlai.boot.support.realtime.security.RealtimeCredentialVerifier.VerifiedIdentity; +import com.youlai.boot.support.realtime.security.RealtimeHandshakeHandler; +import com.youlai.boot.support.realtime.security.RealtimeHandshakeInterceptor; +import com.youlai.boot.support.realtime.security.RealtimeTicketService; +import org.apache.tomcat.websocket.server.WsSci; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.servlet.context.AnnotationConfigServletWebServerApplicationContext; +import org.springframework.boot.web.servlet.ServletRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.servlet.DispatcherServlet; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.json.JsonMapper; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.WebSocket; +import java.net.http.WebSocketHandshakeException; +import java.time.Duration; +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 本地 Tomcat 上的票据授权、消息投递与心跳闭环,不连接外部存储 + * + * @since 4.13.0 + */ +class RealtimeWebSocketTest { + private AnnotationConfigServletWebServerApplicationContext context; + private final HttpClient http = HttpClient.newHttpClient(); + private final JsonMapper mapper = JsonMapper.builder().build(); + private final Instant expiresAt = Instant.now().plusSeconds(3_600).truncatedTo(java.time.temporal.ChronoUnit.MILLIS); + + @BeforeEach + void startLocalServer() { + context = new AnnotationConfigServletWebServerApplicationContext(TestConfiguration.class); + when(context.getBean(RealtimeCredentialVerifier.class).verify("access-token")) + .thenReturn(new VerifiedIdentity(1L, true, expiresAt)); + } + + @AfterEach + void stopLocalServer() { + if (context != null) { + context.close(); + } + } + + @Test + void connectsWithTicketTopicsAndAnswersHeartbeat() throws Exception { + String ticket = createTicket(); + var listener = new Messages(); + WebSocket socket = connect(ticket, listener).get(5, TimeUnit.SECONDS); + try { + JsonNode ready = mapper.readTree(listener.next()); + assertThat(ready.path("type").asText()).isEqualTo("connection.ready"); + assertThat(ready.path("payload")).isEqualTo(mapper.readTree("{\"heartbeatTimeoutMs\":60000}")); + assertThat(ready.size()).isEqualTo(4); + verify(context.getBean(RealtimeCredentialVerifier.class), times(2)).verify("access-token"); + + var manager = context.getBean(RealtimeSessionManager.class); + manager.broadcast("dict", RealtimeServerMessage.create("dict.changed", Map.of("dictCode", "gender"))); + assertThat(mapper.readTree(listener.next()).path("type").asText()).isEqualTo("dict.changed"); + RealtimeSession state = currentSession(manager); + synchronized (state) { + state.nextPingAt = 0; + } + manager.maintain(); + JsonNode ping = mapper.readTree(listener.next()); + assertThat(ping.path("type").asText()).isEqualTo("heartbeat.ping"); + socket.sendText("{\"version\":1,\"type\":\"heartbeat.pong\",\"payload\":{\"pingId\":\"" + + ping.path("id").asText() + "\"}}", true).get(5, TimeUnit.SECONDS); + await().atMost(Duration.ofSeconds(5)).until(() -> { + synchronized (state) { + return state.pendingPingId == null; + } + }); + + assertThatThrownBy(() -> connect(ticket, new Messages()).join()) + .hasCauseInstanceOf(WebSocketHandshakeException.class) + .satisfies(failure -> assertThat(((WebSocketHandshakeException) failure.getCause()) + .getResponse().statusCode()).isEqualTo(401)); + assertThat(manager.presence().onlineConnections()).isEqualTo(1); + } finally { + socket.sendClose(WebSocket.NORMAL_CLOSURE, "done").get(5, TimeUnit.SECONDS); + } + await().atMost(Duration.ofSeconds(5)) + .until(() -> context.getBean(RealtimeSessionManager.class).presence().onlineConnections() == 0); + } + + @Test + void revokedCredentialRejectsHandshakeAndReleasesReservation() { + String ticket = createTicket(); + when(context.getBean(RealtimeCredentialVerifier.class).verify("access-token")) + .thenThrow(new BadCredentialsException("revoked")); + + assertThatThrownBy(() -> connect(ticket, new Messages()).join()) + .hasCauseInstanceOf(WebSocketHandshakeException.class) + .satisfies(failure -> assertThat(((WebSocketHandshakeException) failure.getCause()) + .getResponse().statusCode()).isEqualTo(401)); + var manager = context.getBean(RealtimeSessionManager.class); + for (int index = 0; index < 500; index++) { + assertThat(manager.reserve()).isNotNull(); + } + assertThat(manager.reserve()).isNull(); + } + + @Test + void ticketCreationRejectsUnauthorizedPresenceTopic() { + when(context.getBean(RealtimeCredentialVerifier.class).verify("access-token")) + .thenReturn(new VerifiedIdentity(1L, false, expiresAt)); + + assertThatThrownBy(() -> context.getBean(RealtimeTicketService.class).create("access-token", + new CreateRealtimeTicketRequest("client-instance-01", "web", Set.of("notice", "presence")))) + .isInstanceOf(AccessDeniedException.class); + } + + @Test + void handshakeRechecksTopicPermissionAndConsumesRejectedTicket() { + String ticket = context.getBean(RealtimeTicketService.class).create("access-token", + new CreateRealtimeTicketRequest("client-instance-01", "web", Set.of("notice", "presence"))).ticket(); + when(context.getBean(RealtimeCredentialVerifier.class).verify("access-token")) + .thenReturn(new VerifiedIdentity(1L, false, expiresAt)); + + assertThatThrownBy(() -> connect(ticket, new Messages()).join()) + .hasCauseInstanceOf(WebSocketHandshakeException.class) + .satisfies(failure -> assertThat(((WebSocketHandshakeException) failure.getCause()) + .getResponse().statusCode()).isEqualTo(403)); + assertThatThrownBy(() -> connect(ticket, new Messages()).join()) + .hasCauseInstanceOf(WebSocketHandshakeException.class) + .satisfies(failure -> assertThat(((WebSocketHandshakeException) failure.getCause()) + .getResponse().statusCode()).isEqualTo(401)); + } + + /** + * 模拟已登录客户端领取一次性票据 + */ + private String createTicket() { + return context.getBean(RealtimeTicketService.class) + .create("access-token", new CreateRealtimeTicketRequest("client-instance-01", "web", Set.of("notice", "dict"))) + .ticket(); + } + + /** + * 使用真实 HTTP Upgrade 建立本地连接 + */ + private java.util.concurrent.CompletableFuture connect(String ticket, Messages listener) { + int port = context.getWebServer().getPort(); + return http.newWebSocketBuilder().header("Origin", "http://localhost:" + port) + .connectTimeout(Duration.ofSeconds(5)) + .buildAsync(URI.create("ws://localhost:" + port + "/api/v1/realtime/ws?ticket=" + ticket), listener); + } + + /** + * 读取连接以推进心跳时钟,避免测试等待 25 秒 + */ + @SuppressWarnings("unchecked") + private RealtimeSession currentSession(RealtimeSessionManager manager) { + return ((Map) ReflectionTestUtils.getField(manager, "transports")) + .values().iterator().next(); + } + + /** + * 只注册实时通信与本地 Servlet 容器 + */ + @Configuration(proxyBeanMethods = false) + @EnableWebMvc + @Import({RealtimeConfig.class, RealtimeWebSocketHandler.class, RealtimeSessionManager.class, + RealtimeMessageSender.class, RealtimeMessageCodec.class, RealtimeHandshakeHandler.class, + RealtimeHandshakeInterceptor.class, RealtimeTicketService.class}) + static class TestConfiguration { + /** + * 使用随机本地端口启动带 WebSocket 支持的 Tomcat + */ + @Bean + TomcatServletWebServerFactory webServerFactory() { + var factory = new TomcatServletWebServerFactory(0); + factory.addContextCustomizers(servletContext -> + servletContext.addServletContainerInitializer(new WsSci(), Set.of())); + return factory; + } + + /** + * 将请求交给测试上下文中的 Spring MVC + */ + @Bean + ServletRegistrationBean dispatcherServlet(WebApplicationContext applicationContext) { + return new ServletRegistrationBean<>(new DispatcherServlet(applicationContext), "/"); + } + + /** + * 提供可控制登录态的身份校验替身 + */ + @Bean + RealtimeCredentialVerifier verifier() { + return mock(RealtimeCredentialVerifier.class); + } + + /** + * 用内存 Map 模拟票据写入与原子消费,校验仍走真实 TicketService + */ + @Bean + @SuppressWarnings("unchecked") + StringRedisTemplate redis() { + var redis = mock(StringRedisTemplate.class); + ValueOperations values = mock(ValueOperations.class); + Map tickets = new ConcurrentHashMap<>(); + when(redis.opsForValue()).thenReturn(values); + doAnswer(call -> { + tickets.put(call.getArgument(0), call.getArgument(1)); + return null; + }).when(values).set(anyString(), anyString(), any(Duration.class)); + when(redis.execute(any(RedisScript.class), anyList())) + .thenAnswer(call -> tickets.remove(((java.util.List) call.getArgument(1)).get(0))); + return redis; + } + } + + /** + * 收集完整文本帧,保留客户端流量控制 + */ + private static class Messages implements WebSocket.Listener { + private final BlockingQueue messages = new LinkedBlockingQueue<>(); + private final StringBuilder partial = new StringBuilder(); + + @Override + public CompletionStage onText(WebSocket socket, CharSequence data, boolean last) { + partial.append(data); + if (last) { + messages.add(partial.toString()); + partial.setLength(0); + } + socket.request(1); + return null; + } + + /** + * 等待一条完整消息,超时即失败 + */ + String next() throws InterruptedException { + String message = messages.poll(5, TimeUnit.SECONDS); + assertThat(message).as("服务器应在超时前返回消息").isNotNull(); + return message; + } + } +} diff --git a/src/test/java/com/youlai/boot/validation/ValidationTest.java b/src/test/java/com/youlai/boot/validation/ValidationTest.java index 7b8cb677..dbee9de2 100644 --- a/src/test/java/com/youlai/boot/validation/ValidationTest.java +++ b/src/test/java/com/youlai/boot/validation/ValidationTest.java @@ -1,23 +1,26 @@ package com.youlai.boot.validation; +import com.youlai.boot.codegen.model.form.GenConfigForm; +import com.youlai.boot.module.form.model.form.FormDefinitionForm; +import com.youlai.boot.system.model.form.PasswordResetForm; +import com.youlai.boot.system.model.form.PasswordUpdateForm; +import com.youlai.boot.system.model.query.UserQuery; +import jakarta.validation.Valid; import jakarta.validation.Validation; import jakarta.validation.Validator; -import jakarta.validation.constraints.NotNull; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.core.type.classreading.MetadataReader; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; -import jakarta.validation.Valid; -import org.springframework.validation.annotation.Validated; - import java.lang.reflect.Method; import java.lang.reflect.Parameter; import java.util.ArrayList; import java.util.List; -import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -37,11 +40,12 @@ class ValidationTest { } @Test + @DisplayName("密码重置要求非空且长度达标") void passwordResetFormShouldRejectBlankAndShortPassword() { - var blank = new com.youlai.boot.system.model.form.PasswordResetForm(); + var blank = new PasswordResetForm(); assertThat(validator.validate(blank)).isNotEmpty(); - var form = new com.youlai.boot.system.model.form.PasswordResetForm(); + var form = new PasswordResetForm(); form.setPassword("12345"); assertThat(validator.validate(form)).isNotEmpty(); @@ -50,8 +54,9 @@ class ValidationTest { } @Test + @DisplayName("修改密码三个字段都必填") void passwordUpdateFormShouldRequireAllThreeFields() { - var form = new com.youlai.boot.system.model.form.PasswordUpdateForm(); + var form = new PasswordUpdateForm(); assertThat(validator.validate(form)).hasSize(3); form.setOldPassword("123456"); @@ -61,8 +66,9 @@ class ValidationTest { } @Test + @DisplayName("分页参数超出范围被拒绝") void baseQueryShouldRejectOutOfRangePaging() { - var query = new com.youlai.boot.system.model.query.UserQuery(); + var query = new UserQuery(); assertThat(validator.validate(query)).isEmpty(); query.setPageNum(0); @@ -73,16 +79,39 @@ class ValidationTest { assertThat(validator.validate(query)).isNotEmpty(); query.setPageSize(200); - query.setOrder("DROP TABLE"); - assertThat(validator.validate(query)).isNotEmpty(); - - query.setOrder("DESC"); assertThat(validator.validate(query)).isEmpty(); } @Test + @DisplayName("表单定义按创建与更新分组校验") + void formDefinitionFormShouldValidateByCreateAndUpdateGroups() { + var form = new FormDefinitionForm(); + // 创建:标识与名称为必填 + assertThat(validator.validate(form, FormDefinitionForm.Create.class)).hasSize(2); + + form.setFormKey("EmployeeOnboarding"); + form.setFormName("入职信息采集"); + // 标识格式非法仍被拦截 + assertThat(validator.validate(form, FormDefinitionForm.Create.class)).hasSize(1); + + form.setFormKey("employee_onboarding"); + assertThat(validator.validate(form, FormDefinitionForm.Create.class)).isEmpty(); + + // 更新:只保存规则的部分更新载荷不触发必填校验 + var ruleOnly = new FormDefinitionForm(); + ruleOnly.setFormJson("[]"); + ruleOnly.setOptionsJson("{}"); + assertThat(validator.validate(ruleOnly, FormDefinitionForm.Update.class)).isEmpty(); + + // 更新:显式提交的字段格式仍被校验 + ruleOnly.setFormName("x".repeat(101)); + assertThat(validator.validate(ruleOnly, FormDefinitionForm.Update.class)).hasSize(1); + } + + @Test + @DisplayName("代码生成配置要求核心字段") void genConfigFormShouldRequireCoreFields() { - var form = new com.youlai.boot.codegen.model.form.GenConfigForm(); + var form = new GenConfigForm(); assertThat(validator.validate(form)).isNotEmpty(); form.setTableName("sys_user"); @@ -94,8 +123,9 @@ class ValidationTest { } @Test + @DisplayName("每个 @RequestBody 参数都必须触发校验") void everyRequestBodyParameterShouldTriggerValidation() throws Exception { - var scanner = new org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider(false); + var scanner = new ClassPathScanningCandidateComponentProvider(false); scanner.addIncludeFilter(new AnnotationTypeFilter(RestController.class)); List violations = new ArrayList<>();