From 58c73134fb1ce10c1ea1d95ee66d8c89557d7dee Mon Sep 17 00:00:00 2001
From: longz <longzhaoz@qq.com>
Date: 星期二, 21 四月 2026 14:10:54 +0800
Subject: [PATCH] java 后端
---
backend/src/main/java/com/sinata/appointment/dto/StaffUpdateRequest.java | 36
backend/src/main/java/com/sinata/appointment/mapper/MpStaffGroupMemberMapper.java | 17
backend/src/main/java/com/sinata/appointment/constants/BizErrorCode.java | 42
backend/src/main/resources/db/migration/V21__approval_node_level_shift.sql | 58
backend/src/main/java/com/sinata/appointment/dto/ApprovalNodeEnabledPatchRequest.java | 21
backend/src/main/java/com/sinata/appointment/controller/DeptManageController.java | 76
backend/src/main/java/com/sinata/appointment/dto/LocalUnitUpdateRequest.java | 26
backend/src/main/java/com/sinata/appointment/mapper/ApprovalInstanceMapper.java | 8
backend/src/main/java/com/sinata/appointment/dto/LocalUnitDetailVo.java | 31
backend/src/main/java/com/sinata/appointment/constants/AppointmentBizConstants.java | 23
backend/src/main/java/com/sinata/appointment/constants/HttpBizCode.java | 14
backend/src/main/java/com/sinata/appointment/dto/StaffGroupMemberVo.java | 14
backend/src/main/java/com/sinata/appointment/entity/SysDept.java | 26
backend/src/main/java/com/sinata/appointment/dto/VisitorUserListVo.java | 20
backend/src/main/java/com/sinata/appointment/entity/BizVisitReason.java | 25
backend/src/main/java/com/sinata/appointment/entity/MpUser.java | 50
backend/src/main/resources/db/migration/V24__rename_submitter_open_id_to_submitter_id.sql | 2
backend/src/main/java/com/sinata/appointment/dto/MiniHandlerApprovalActionRequest.java | 38
backend/src/main/java/com/sinata/appointment/dto/ApprovalFlowGroupVo.java | 26
backend/src/main/java/com/sinata/appointment/util/PhoneNormalize.java | 20
backend/src/main/java/com/sinata/appointment/config/SaTokenConfigure.java | 31
backend/src/main/java/com/sinata/appointment/controller/LocalUnitManageController.java | 79
backend/src/main/java/com/sinata/appointment/dto/LocalUnitMemberItemVo.java | 12
backend/src/main/java/com/sinata/appointment/service/IntegrationHikvisionService.java | 156
backend/src/main/java/com/sinata/appointment/constants/MiniLoginIntent.java | 10
backend/src/main/java/com/sinata/appointment/dto/MiniLoginResponse.java | 35
backend/src/main/java/com/sinata/appointment/dto/MiniApprovalActionRequest.java | 26
backend/src/main/java/com/sinata/appointment/dto/StaffGroupCreateRequest.java | 25
backend/src/main/java/com/sinata/appointment/dto/PassAreaDetailVo.java | 24
backend/src/main/java/com/sinata/appointment/dto/DeptUpdateRequest.java | 27
backend/src/main/java/com/sinata/appointment/entity/BizFieldConfig.java | 28
backend/src/main/java/com/sinata/appointment/controller/SysMenuController.java | 33
backend/src/main/java/com/sinata/appointment/dto/BookingSettingsSaveRequest.java | 24
backend/src/main/resources/mapper/BizHolidayDateMapper.xml | 16
backend/src/main/java/com/sinata/appointment/dto/PassAreaListVo.java | 23
backend/src/main/java/com/sinata/appointment/dto/ContentSettingsSaveRequest.java | 23
backend/src/main/java/com/sinata/appointment/integration/WechatMiniGateway.java | 180
backend/src/main/java/com/sinata/appointment/dto/UserStatusPatchRequest.java | 18
backend/src/main/java/com/sinata/appointment/service/MiniAppointmentApplicationService.java | 1260 +++
backend/src/main/resources/db/migration/V14__content_settings.sql | 12
backend/src/main/java/com/sinata/appointment/dto/StaffListVo.java | 23
backend/src/main/java/com/sinata/appointment/mapper/AppointmentVisitorMapper.java | 8
backend/src/main/java/com/sinata/appointment/entity/ApprovalTask.java | 31
backend/src/main/java/com/sinata/appointment/mapper/MpUserMapper.java | 8
backend/src/main/java/com/sinata/appointment/controller/ConfigController.java | 187
backend/src/main/java/com/sinata/appointment/common/ResultUtil.java | 78
backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java | 282
backend/src/main/resources/mapper/MpPassLocalUnitMemberMapper.xml | 12
backend/src/main/java/com/sinata/appointment/AppointmentApplication.java | 22
backend/src/main/java/com/sinata/appointment/dto/VisitorItemRequest.java | 26
backend/src/main/resources/db/migration/V11__booking_settings.sql | 119
backend/src/main/java/com/sinata/appointment/dto/AppointmentAdminDetailVo.java | 27
backend/src/main/java/com/sinata/appointment/entity/AppointmentVisitor.java | 34
backend/src/main/java/com/sinata/appointment/util/IdCardUtil.java | 29
backend/src/main/java/com/sinata/appointment/dto/RoleManageListVo.java | 17
backend/src/main/java/com/sinata/appointment/dto/MiniMyVisitorCodeTicketVo.java | 31
backend/src/main/java/com/sinata/appointment/validation/DeleteGroup.java | 4
backend/src/main/java/com/sinata/appointment/mapper/BizFieldConfigMapper.java | 8
backend/src/main/java/com/sinata/appointment/integration/WxCode2SessionResult.java | 22
backend/src/main/java/com/sinata/appointment/dto/CaptchaResponse.java | 17
backend/src/main/java/com/sinata/appointment/dto/LoginRequest.java | 26
backend/src/main/resources/db/migration/V26__mp_user_add_visitor_profile_fields.sql | 7
backend/src/main/java/com/sinata/appointment/controller/AppointmentController.java | 107
backend/src/main/java/com/sinata/appointment/mapper/AppointmentVehicleMapper.java | 9
backend/src/main/resources/db/migration/V16__mini_visitor.sql | 19
backend/src/main/resources/db/migration/V17__column_comments.sql | 347 +
backend/src/main/resources/db/migration/V19__table_comments.sql | 35
backend/src/main/java/com/sinata/appointment/mapper/BizOpLogMapper.java | 8
backend/src/main/java/com/sinata/appointment/dto/MiniVisitRecordListVo.java | 36
backend/src/main/java/com/sinata/appointment/dto/StaffDetailVo.java | 23
backend/src/main/java/com/sinata/appointment/controller/MiniAppointmentController.java | 101
backend/src/main/java/com/sinata/appointment/dto/StaffImportExcelRow.java | 27
backend/src/main/java/com/sinata/appointment/service/ApprovalFlowViewApplicationService.java | 368 +
backend/src/main/java/com/sinata/appointment/service/MiniVisitorApplicationService.java | 250
backend/src/main/java/com/sinata/appointment/dto/FieldConfigPatchRequest.java | 24
backend/src/main/java/com/sinata/appointment/dto/VisitorCompetentDeptSaveRequest.java | 11
backend/src/main/java/com/sinata/appointment/controller/StaffGroupManageController.java | 105
backend/src/main/java/com/sinata/appointment/validation/UpdateGroup.java | 4
backend/src/main/java/com/sinata/appointment/constants/IntegrationConstant.java | 11
backend/src/main/java/com/sinata/appointment/service/MiniLookupApplicationService.java | 183
backend/src/main/java/com/sinata/appointment/dto/MiniFieldConfigVo.java | 29
backend/src/main/java/com/sinata/appointment/mapper/MpVisitorMapper.java | 9
backend/src/main/java/com/sinata/appointment/entity/QrcodeTicket.java | 35
backend/src/main/resources/db/migration/V3__user_mp_dept.sql | 27
backend/src/main/java/com/sinata/appointment/config/TraceIdFilter.java | 36
backend/src/main/java/com/sinata/appointment/service/SysBizSettingCacheService.java | 31
backend/src/main/resources/db/migration/V13__visit_reason_manage.sql | 4
backend/src/main/resources/db/migration/V4__menu_role_menu.sql | 73
backend/src/main/java/com/sinata/appointment/dto/MiniApprovalTodoVo.java | 47
backend/src/main/java/com/sinata/appointment/controller/ApprovalController.java | 57
backend/src/main/java/com/sinata/appointment/mapper/SysUserRoleMapper.java | 14
.gitignore | 2
backend/src/main/java/com/sinata/appointment/dto/RoleManageUpdateRequest.java | 27
backend/src/main/java/com/sinata/appointment/service/SysUserApplicationService.java | 259
backend/src/main/resources/mapper/SysUserMapper.xml | 43
backend/src/main/java/com/sinata/appointment/entity/MpPassAccessArea.java | 25
backend/src/main/java/com/sinata/appointment/validation/CreateGroup.java | 4
backend/src/main/java/com/sinata/appointment/service/LocalUnitManageApplicationService.java | 419 +
backend/src/main/java/com/sinata/appointment/entity/MpPassLocalUnitMember.java | 19
backend/src/main/java/com/sinata/appointment/mapper/MpStaffGroupMapper.java | 9
backend/src/main/java/com/sinata/appointment/mapper/MpPassLocalUnitMemberMapper.java | 12
backend/src/main/java/com/sinata/appointment/dto/AppointmentCreateRequest.java | 51
backend/src/main/java/com/sinata/appointment/config/MybatisPlusConfig.java | 53
backend/src/main/resources/db/migration/V15__visit_reason_backfill_uuid.sql | 5
backend/src/main/java/com/sinata/appointment/dto/PassAreaCreateRequest.java | 22
backend/src/main/java/com/sinata/appointment/common/Result.java | 41
backend/src/main/java/com/sinata/appointment/config/WebCorsConfig.java | 19
backend/src/main/java/com/sinata/appointment/util/SensitiveDataUtil.java | 42
backend/src/main/java/com/sinata/appointment/mapper/AppointmentOrderMapper.java | 8
backend/src/main/resources/db/migration/V7__mp_staff_group.sql | 37
backend/src/main/resources/mapper/MpStaffGroupMemberMapper.xml | 17
backend/src/main/java/com/sinata/appointment/service/DeptManageApplicationService.java | 279
backend/src/main/java/com/sinata/appointment/service/MiniApprovalDetailApplicationService.java | 461 +
backend/src/main/resources/db/migration/V27__admin_permission_catalog.sql | 391 +
backend/src/main/java/com/sinata/appointment/dto/RoleUserCountRow.java | 10
backend/src/main/java/com/sinata/appointment/dto/MiniVisitorCreateRequest.java | 24
backend/src/main/java/com/sinata/appointment/dto/MiniVisitedUserVo.java | 19
backend/src/main/java/com/sinata/appointment/dto/MiniApprovalDetailVisitorVo.java | 30
backend/src/main/java/com/sinata/appointment/entity/ApprovalInstance.java | 26
backend/src/main/java/com/sinata/appointment/mapper/BizHolidayDateMapper.java | 15
backend/src/main/java/com/sinata/appointment/mapper/SysDeptMapper.java | 8
backend/src/main/java/com/sinata/appointment/exception/BizException.java | 14
backend/src/main/java/com/sinata/appointment/audit/OpLogAuditAspect.java | 41
backend/src/main/java/com/sinata/appointment/validation/ChangePasswordGroup.java | 4
backend/src/main/java/com/sinata/appointment/dto/BookingSettingsVo.java | 22
backend/src/main/java/com/sinata/appointment/dto/MiniVisitorUpdateRequest.java | 24
backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java | 72
backend/src/main/java/com/sinata/appointment/dto/ResetPasswordRequest.java | 22
backend/src/main/resources/db/migration/V1__init.sql | 313
backend/src/main/java/com/sinata/appointment/dto/StaffCreateRequest.java | 35
backend/src/main/java/com/sinata/appointment/mapper/SysRoleMenuMapper.java | 8
backend/src/main/java/com/sinata/appointment/entity/MpStaffGroupMember.java | 20
backend/src/main/java/com/sinata/appointment/controller/ExportController.java | 38
backend/src/main/java/com/sinata/appointment/dto/PassAreaUpdateRequest.java | 26
backend/src/main/java/com/sinata/appointment/dto/StaffGroupListVo.java | 27
backend/src/main/java/com/sinata/appointment/mapper/BizApprovalNodeConfigMapper.java | 8
backend/src/main/java/com/sinata/appointment/config/OpenApiConfig.java | 39
backend/src/main/java/com/sinata/appointment/util/OrderNoGenerator.java | 17
backend/src/main/java/com/sinata/appointment/service/MiniHandlerApprovalApplicationService.java | 453 +
backend/src/main/java/com/sinata/appointment/entity/MpStaffGroup.java | 20
backend/src/main/java/com/sinata/appointment/dto/LocalUnitCreateRequest.java | 31
backend/src/main/java/com/sinata/appointment/config/MiniStpUtil.java | 1217 +++
backend/src/main/java/com/sinata/appointment/dto/MiniVisitorDetailVo.java | 30
backend/src/main/java/com/sinata/appointment/dto/VisitReasonListVo.java | 18
backend/src/main/java/com/sinata/appointment/service/ApprovalApplicationService.java | 85
backend/src/main/java/com/sinata/appointment/dto/StaffGroupDetailVo.java | 32
backend/src/main/java/com/sinata/appointment/dto/StaffStatusPatchRequest.java | 17
backend/src/main/java/com/sinata/appointment/constants/AppointmentFieldCodes.java | 19
backend/src/main/java/com/sinata/appointment/constants/MiniUserType.java | 13
backend/src/main/java/com/sinata/appointment/dto/LoginResponse.java | 30
backend/src/main/java/com/sinata/appointment/dto/MiniApprovalDetailVo.java | 83
backend/src/main/java/com/sinata/appointment/constants/QrcodeTicketStatus.java | 11
backend/src/main/java/com/sinata/appointment/dto/MiniAppointmentVehicleItemRequest.java | 20
backend/src/main/java/com/sinata/appointment/dto/ContentSettingsVo.java | 21
backend/src/main/java/com/sinata/appointment/entity/MpPassLocalUnit.java | 28
backend/src/main/java/com/sinata/appointment/config/SaTokenMiniStpConfig.java | 39
backend/src/main/java/com/sinata/appointment/dto/DeptCreateRequest.java | 23
backend/src/main/java/com/sinata/appointment/dto/ApprovalFlowTaskUserVo.java | 31
backend/src/main/java/com/sinata/appointment/service/PostApprovalOrchestrationService.java | 277
backend/src/main/java/com/sinata/appointment/dto/StaffImportResultVo.java | 13
backend/src/main/java/com/sinata/appointment/service/AuthApplicationService.java | 59
backend/src/main/java/com/sinata/appointment/controller/MiniSystemSettingController.java | 30
backend/src/main/java/com/sinata/appointment/dto/SysUserListVo.java | 28
backend/src/main/java/com/sinata/appointment/mapper/SysRoleMapper.java | 8
backend/src/main/java/com/sinata/appointment/mapper/SysUserMapper.java | 21
backend/src/main/java/com/sinata/appointment/dto/AppointmentAdminDetailVisitorVo.java | 16
backend/src/main/java/com/sinata/appointment/dto/RoleManageCreateRequest.java | 23
backend/src/main/java/com/sinata/appointment/mapper/BizVisitReasonMapper.java | 8
backend/src/main/java/com/sinata/appointment/util/UuidAssert.java | 23
backend/src/main/java/com/sinata/appointment/entity/BaseEntity.java | 65
backend/src/main/java/com/sinata/appointment/service/StaffGroupManageApplicationService.java | 407 +
backend/src/main/java/com/sinata/appointment/service/VisitorUserManageApplicationService.java | 294
backend/src/main/java/com/sinata/appointment/entity/MpVisitor.java | 36
backend/src/main/resources/mapper/SysUserRoleMapper.xml | 21
backend/src/main/java/com/sinata/appointment/service/PassAreaManageApplicationService.java | 192
backend/src/main/resources/db/migration/V2__mp_user.sql | 21
backend/src/main/java/com/sinata/appointment/controller/MiniLookupController.java | 59
backend/src/main/java/com/sinata/appointment/mapper/SysBizSettingMapper.java | 8
backend/src/main/java/com/sinata/appointment/dto/MenuTreeNodeVo.java | 29
backend/src/main/java/com/sinata/appointment/dto/MiniPhoneLoginRequest.java | 14
backend/src/main/java/com/sinata/appointment/dto/AppointmentAdminListVo.java | 29
backend/src/main/java/com/sinata/appointment/dto/VisitorUserStatusPatchRequest.java | 18
backend/src/main/java/com/sinata/appointment/controller/AuthController.java | 47
backend/src/main/java/com/sinata/appointment/dto/LocalUnitListVo.java | 23
backend/src/main/java/com/sinata/appointment/controller/StaffManageController.java | 146
backend/src/main/java/com/sinata/appointment/config/SecurityBeansConfig.java | 14
backend/src/main/java/com/sinata/appointment/dto/MiniPassAccessAreaTreeVo.java | 25
backend/src/main/java/com/sinata/appointment/entity/BizApprovalNodeConfig.java | 25
backend/src/main/java/com/sinata/appointment/controller/SysLookupController.java | 63
backend/src/main/java/com/sinata/appointment/controller/MiniApprovalController.java | 94
backend/src/main/java/com/sinata/appointment/mapper/MpPassLocalUnitMapper.java | 8
backend/src/main/java/com/sinata/appointment/service/AppointmentApplicationService.java | 930 ++
backend/src/main/java/com/sinata/appointment/service/MiniSystemSettingApplicationService.java | 43
backend/src/main/java/com/sinata/appointment/constants/LocalUnitApproverType.java | 11
backend/src/main/java/com/sinata/appointment/dto/MiniMyVisitorCodeOrderVo.java | 36
backend/src/main/java/com/sinata/appointment/constants/ApprovalNodeCode.java | 13
backend/src/main/resources/application.yml | 100
backend/src/main/resources/db/migration/COMMENT_RULES.md | 9
backend/src/main/resources/db/migration/V12__booking_holiday_table.sql | 18
backend/src/main/java/com/sinata/appointment/dto/DeptTreeNodeVo.java | 23
backend/src/main/java/com/sinata/appointment/dto/StaffGroupAppendMembersRequest.java | 19
backend/src/main/java/com/sinata/appointment/entity/SysUser.java | 31
backend/src/main/java/com/sinata/appointment/dto/MiniVisitorListVo.java | 31
backend/src/main/java/com/sinata/appointment/entity/SysRole.java | 22
backend/src/main/java/com/sinata/appointment/constants/AppointmentOrderStatus.java | 15
backend/src/main/java/com/sinata/appointment/entity/SysRoleMenu.java | 19
backend/src/main/resources/application-local.yml | 17
backend/src/main/java/com/sinata/appointment/constants/FieldConfigLockedCodes.java | 17
backend/src/main/java/com/sinata/appointment/constants/SystemSettingKeys.java | 33
backend/src/main/java/com/sinata/appointment/controller/SysRoleManageController.java | 79
backend/src/main/java/com/sinata/appointment/dto/VisitorCompetentDeptVo.java | 11
backend/src/main/java/com/sinata/appointment/entity/SysUserRole.java | 19
backend/src/main/java/com/sinata/appointment/service/StaffManageApplicationService.java | 860 ++
backend/src/main/java/com/sinata/appointment/entity/AppointmentOrder.java | 83
backend/src/main/resources/db/migration/V6__mp_staff.sql | 7
backend/src/main/java/com/sinata/appointment/controller/MiniVisitorController.java | 63
backend/src/main/java/com/sinata/appointment/util/MobileMaskUtil.java | 19
backend/src/main/java/com/sinata/appointment/constants/ApprovalInstanceStatus.java | 11
backend/src/main/java/com/sinata/appointment/entity/SysBizSetting.java | 18
backend/src/main/resources/db/migration/V10__sys_biz_setting.sql | 10
backend/src/main/java/com/sinata/appointment/dto/ApprovalActionRequest.java | 26
backend/src/main/java/com/sinata/appointment/mapper/ApprovalTaskMapper.java | 8
backend/src/main/java/com/sinata/appointment/controller/PassAreaManageController.java | 81
backend/src/main/java/com/sinata/appointment/constants/VisitType.java | 10
backend/src/main/java/com/sinata/appointment/config/AdminLoginCryptoProperties.java | 24
backend/src/main/resources/db/migration/V22__mini_handler_approval_fields.sql | 6
backend/src/main/java/com/sinata/appointment/constants/RoleCode.java | 14
backend/src/main/java/com/sinata/appointment/dto/AppointmentAdminExportRow.java | 68
backend/src/main/java/com/sinata/appointment/dto/SysUserCreateRequest.java | 35
backend/src/main/java/com/sinata/appointment/dto/VisitReasonCreateRequest.java | 16
backend/src/main/java/com/sinata/appointment/dto/MiniMyAppointmentDetailVo.java | 66
backend/src/main/java/com/sinata/appointment/constants/CacheNames.java | 14
backend/src/main/java/com/sinata/appointment/service/BizHolidayDateCacheService.java | 38
backend/src/main/java/com/sinata/appointment/service/ExportApplicationService.java | 35
backend/src/main/java/com/sinata/appointment/config/RestTemplateConfig.java | 16
backend/src/main/resources/db/migration/V20__delete_visitor_org_field_config.sql | 3
backend/src/main/java/com/sinata/appointment/entity/BizOpLog.java | 38
backend/src/main/java/com/sinata/appointment/dto/VisitReasonUpdateRequest.java | 20
backend/src/main/java/com/sinata/appointment/config/LocalUploadResourceConfig.java | 21
backend/src/main/java/com/sinata/appointment/dto/MiniVisitReasonVo.java | 22
backend/src/main/java/com/sinata/appointment/service/IntegrationWechatService.java | 22
backend/src/main/java/com/sinata/appointment/service/ApprovalEngineService.java | 717 ++
backend/src/main/resources/db/migration/V8__pass_access_area.sql | 41
backend/src/main/java/com/sinata/appointment/dto/MiniHandlerApprovalTodoVo.java | 41
backend/src/main/java/com/sinata/appointment/dto/StaffGroupUpdateRequest.java | 29
backend/src/main/java/com/sinata/appointment/mapper/QrcodeTicketMapper.java | 8
backend/src/main/java/com/sinata/appointment/config/WechatMiniAppProperties.java | 26
backend/src/main/java/com/sinata/appointment/service/AdminLoginCryptoService.java | 91
backend/src/main/java/com/sinata/appointment/constants/MpStaffConstants.java | 17
backend/src/main/java/com/sinata/appointment/controller/SysUserController.java | 105
backend/src/main/java/com/sinata/appointment/constants/SecurityConstant.java | 14
backend/src/main/java/com/sinata/appointment/entity/SysMenu.java | 32
backend/src/main/resources/db/migration/V5__dept_status.sql | 4
backend/src/main/java/com/sinata/appointment/dto/MiniAppointmentListVo.java | 56
backend/src/main/java/com/sinata/appointment/common/GlobalExceptionHandler.java | 50
backend/src/main/java/com/sinata/appointment/dto/MiniLocalUnitVo.java | 16
backend/src/main/java/com/sinata/appointment/mapper/MpPassAccessAreaMapper.java | 8
backend/src/main/java/com/sinata/appointment/controller/VisitorUserManageController.java | 116
backend/src/main/java/com/sinata/appointment/mapper/SysMenuMapper.java | 8
backend/src/main/resources/db/migration/V23__drop_appointment_key_area_flag.sql | 3
backend/src/main/resources/db/migration/V18__mini_appointment_enhance.sql | 27
backend/src/main/java/com/sinata/appointment/dto/MiniAppointmentCreateRequest.java | 55
backend/src/main/java/com/sinata/appointment/service/CaptchaService.java | 97
backend/src/main/java/com/sinata/appointment/dto/UserRoleLabelRow.java | 11
backend/src/main/java/com/sinata/appointment/config/StpInterfaceImpl.java | 46
backend/src/main/java/com/sinata/appointment/dto/MiniWxLoginRequest.java | 22
backend/src/main/java/com/sinata/appointment/dto/SysUserUpdateRequest.java | 29
backend/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports | 1
backend/src/main/java/com/sinata/appointment/service/SysRoleManageApplicationService.java | 249
backend/src/main/java/com/sinata/appointment/dto/VisitorUserDetailVo.java | 17
backend/src/main/java/com/sinata/appointment/constants/SaRealmConstants.java | 21
backend/src/main/java/com/sinata/appointment/dto/VisitorUserExportRow.java | 31
backend/src/main/java/com/sinata/appointment/service/OpLogApplicationService.java | 38
backend/pom.xml | 114
backend/src/main/java/com/sinata/appointment/dto/VisitorUserUpdateRequest.java | 28
backend/src/main/java/com/sinata/appointment/dto/ApprovalFlowNodeVo.java | 23
backend/src/main/resources/db/migration/V9__pass_local_unit.sql | 38
backend/src/main/java/com/sinata/appointment/constants/ApprovalTaskStatus.java | 13
backend/src/main/java/com/sinata/appointment/entity/BizHolidayDate.java | 18
backend/src/main/java/com/sinata/appointment/dto/RoleManageDetailVo.java | 21
backend/src/main/java/com/sinata/appointment/dto/SysUserDetailVo.java | 24
backend/src/main/java/com/sinata/appointment/service/ConfigApplicationService.java | 447 +
backend/src/main/resources/db/migration/V25__qrcode_ticket_visitor_fields.sql | 7
backend/src/main/java/com/sinata/appointment/entity/AppointmentVehicle.java | 26
284 files changed, 19,069 insertions(+), 1 deletions(-)
diff --git a/.gitignore b/.gitignore
index cd3a904..6df5f4e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,5 @@
backend/target/
-.idea/
+backend/.idea/
*.iml
.DS_Store
*/node_modules/
diff --git a/backend/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/backend/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
new file mode 100644
index 0000000..6a35303
--- /dev/null
+++ b/backend/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -0,0 +1 @@
+cn.dev33.satoken.spring.SaTokenContextRegister
\ No newline at end of file
diff --git a/backend/pom.xml b/backend/pom.xml
new file mode 100644
index 0000000..fa6f84a
--- /dev/null
+++ b/backend/pom.xml
@@ -0,0 +1,114 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-parent</artifactId>
+ <version>3.2.5</version>
+ <relativePath/>
+ </parent>
+
+ <groupId>com.sinata</groupId>
+ <artifactId>appointment-backend</artifactId>
+ <version>1.0.0-SNAPSHOT</version>
+ <name>appointment-backend</name>
+ <description>访客预约系统后端(模块化单体)</description>
+
+ <properties>
+ <java.version>17</java.version>
+ <mybatis-plus.version>3.5.5</mybatis-plus.version>
+ <sa-token.version>1.37.0</sa-token.version>
+ <easyexcel.version>3.3.4</easyexcel.version>
+ </properties>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-web</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-validation</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-aop</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-amqp</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-quartz</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-data-redis</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>com.baomidou</groupId>
+ <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
+ <version>${mybatis-plus.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>com.mysql</groupId>
+ <artifactId>mysql-connector-j</artifactId>
+ <scope>runtime</scope>
+ </dependency>
+ <dependency>
+ <groupId>cn.dev33</groupId>
+ <artifactId>sa-token-spring-boot3-starter</artifactId>
+ <version>${sa-token.version}</version>
+ </dependency>
+ <!-- Sa-Token 独立 Redis:与会话/权限数据走专用连接,可与业务 spring.data.redis 分离 -->
+ <dependency>
+ <groupId>cn.dev33</groupId>
+ <artifactId>sa-token-alone-redis</artifactId>
+ <version>${sa-token.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.commons</groupId>
+ <artifactId>commons-pool2</artifactId>
+ </dependency>
+
+ <dependency>
+ <groupId>org.springdoc</groupId>
+ <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
+ <version>2.5.0</version>
+ </dependency>
+
+ <dependency>
+ <groupId>org.projectlombok</groupId>
+ <artifactId>lombok</artifactId>
+ <optional>true</optional>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-starter-test</artifactId>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.springframework.security</groupId>
+ <artifactId>spring-security-crypto</artifactId>
+ </dependency>
+ <dependency>
+ <groupId>com.alibaba</groupId>
+ <artifactId>easyexcel</artifactId>
+ <version>${easyexcel.version}</version>
+ </dependency>
+ </dependencies>
+
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.springframework.boot</groupId>
+ <artifactId>spring-boot-maven-plugin</artifactId>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git a/backend/src/main/java/com/sinata/appointment/AppointmentApplication.java b/backend/src/main/java/com/sinata/appointment/AppointmentApplication.java
new file mode 100644
index 0000000..180cc62
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/AppointmentApplication.java
@@ -0,0 +1,22 @@
+package com.sinata.appointment;
+
+import com.sinata.appointment.config.AdminLoginCryptoProperties;
+import com.sinata.appointment.config.WechatMiniAppProperties;
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.scheduling.annotation.EnableAsync;
+
+@SpringBootApplication
+@MapperScan("com.sinata.appointment.mapper")
+@EnableAsync
+@EnableCaching
+@EnableConfigurationProperties({WechatMiniAppProperties.class, AdminLoginCryptoProperties.class})
+public class AppointmentApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(AppointmentApplication.class, args);
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/audit/OpLogAuditAspect.java b/backend/src/main/java/com/sinata/appointment/audit/OpLogAuditAspect.java
new file mode 100644
index 0000000..1572151
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/audit/OpLogAuditAspect.java
@@ -0,0 +1,41 @@
+package com.sinata.appointment.audit;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.sinata.appointment.service.OpLogApplicationService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.annotation.AfterReturning;
+import org.aspectj.lang.annotation.Aspect;
+import org.springframework.stereotype.Component;
+
+/**
+ * 配置类写操作审计(示例):复杂场景可在 ApplicationService 内显式记录。
+ */
+@Slf4j
+@Aspect
+@Component
+@RequiredArgsConstructor
+public class OpLogAuditAspect {
+
+ private final OpLogApplicationService opLogApplicationService;
+
+ @AfterReturning(
+ pointcut =
+ "execution(* com.sinata.appointment.service.ConfigApplicationService.save*(..))",
+ returning = "ret")
+ public void afterConfigSave(JoinPoint jp, Object ret) {
+ try {
+ Long op = StpUtil.isLogin() ? StpUtil.getLoginIdAsLong() : null;
+ opLogApplicationService.recordAsync(
+ op,
+ "CONFIG",
+ jp.getSignature().getName(),
+ jp.getArgs().length > 0 ? jp.getArgs()[0].getClass().getSimpleName() : "UNKNOWN",
+ "",
+ "{}");
+ } catch (Exception e) {
+ log.debug("audit skip: {}", e.getMessage());
+ }
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/common/GlobalExceptionHandler.java b/backend/src/main/java/com/sinata/appointment/common/GlobalExceptionHandler.java
new file mode 100644
index 0000000..aac0091
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/common/GlobalExceptionHandler.java
@@ -0,0 +1,50 @@
+package com.sinata.appointment.common;
+
+import cn.dev33.satoken.exception.NotLoginException;
+import com.sinata.appointment.constants.HttpBizCode;
+import com.sinata.appointment.exception.BizException;
+
+import cn.dev33.satoken.exception.SaTokenException;
+import jakarta.validation.ConstraintViolationException;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(BizException.class)
+ public Result<Void> handleBiz(BizException e) {
+ return ResultUtil.error(e.getCode(), e.getMessage());
+ }
+
+ @ExceptionHandler({MethodArgumentNotValidException.class, ConstraintViolationException.class})
+ public Result<Void> handleValidation(Exception e) {
+ String msg =
+ e instanceof MethodArgumentNotValidException mn
+ ? mn.getBindingResult().getFieldError() != null
+ ? mn.getBindingResult().getFieldError().getDefaultMessage()
+ : e.getMessage()
+ : e.getMessage();
+ return ResultUtil.error(HttpBizCode.BAD_REQUEST, msg != null ? msg : "参数错误");
+ }
+
+
+ @ExceptionHandler(SaTokenException.class)
+ public Result<Void> handleSaToken(SaTokenException e) {
+ log.error("satoken error", e);
+ if (e instanceof NotLoginException notLogin
+ && NotLoginException.KICK_OUT.equals(notLogin.getType())) {
+ return ResultUtil.error(HttpBizCode.UNAUTHORIZED, "账号已被禁用,请重新登录");
+ }
+ return ResultUtil.error(HttpBizCode.UNAUTHORIZED, "账号信息已过期,请重新登录");
+ }
+
+ @ExceptionHandler(Exception.class)
+ public Result<Void> handleOther(Exception e) {
+ log.error("Unhandled error", e);
+ return ResultUtil.error(HttpBizCode.INTERNAL_ERROR, "系统繁忙,请稍后重试");
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/common/Result.java b/backend/src/main/java/com/sinata/appointment/common/Result.java
new file mode 100644
index 0000000..1e85a64
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/common/Result.java
@@ -0,0 +1,41 @@
+package com.sinata.appointment.common;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.io.Serializable;
+import lombok.Data;
+
+@Schema(
+ description =
+ "统一 HTTP 响应包装。泛型 T 为 result 载荷类型;分页接口 result 为当前页列表,page/size/total/pages 有效。")
+@Data
+public class Result<T> implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "是否成功", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
+ private boolean success;
+
+ @Schema(description = "提示信息", example = "success")
+ private String message;
+
+ @Schema(description = "状态码,成功一般为 200", example = "200")
+ private Integer code;
+
+ @Schema(description = "服务端响应时间戳(毫秒)", example = "1711363200000")
+ private long timestamp = System.currentTimeMillis();
+
+ @Schema(description = "业务数据载荷")
+ private T result;
+
+ @Schema(description = "当前页码(分页从 1 开始;非分页为 0)", example = "1")
+ private long page;
+
+ @Schema(description = "每页条数(非分页为 0)", example = "10")
+ private long size;
+
+ @Schema(description = "总记录数(非分页为 0)", example = "100")
+ private long total;
+
+ @Schema(description = "总页数(非分页为 0)", example = "10")
+ private long pages;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/common/ResultUtil.java b/backend/src/main/java/com/sinata/appointment/common/ResultUtil.java
new file mode 100644
index 0000000..0e68677
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/common/ResultUtil.java
@@ -0,0 +1,78 @@
+package com.sinata.appointment.common;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.sinata.appointment.constants.HttpBizCode;
+import java.util.List;
+
+public final class ResultUtil {
+
+ private ResultUtil() {}
+
+ public static <T> Result<T> data(T t) {
+ Result<T> r = baseOk();
+ r.setResult(t);
+ r.setCode(HttpBizCode.SUCCESS);
+ return r;
+ }
+
+ public static <T> Result<T> data(T t, String msg) {
+ Result<T> r = baseOk();
+ r.setResult(t);
+ r.setMessage(msg);
+ r.setCode(HttpBizCode.SUCCESS);
+ return r;
+ }
+
+ public static <T> Result<List<T>> page(IPage<T> page) {
+ Result<List<T>> r = new Result<>();
+ r.setPage(page.getCurrent());
+ r.setSize(page.getSize());
+ r.setTotal(page.getTotal());
+ r.setPages(page.getPages());
+ r.setMessage("操作成功");
+ r.setSuccess(true);
+ r.setCode(HttpBizCode.SUCCESS);
+ r.setResult(page.getRecords());
+ return r;
+ }
+
+ public static <T> Result<T> success(String msg) {
+ Result<T> r = baseOk();
+ r.setMessage(msg);
+ r.setResult(null);
+ r.setCode(HttpBizCode.SUCCESS);
+ return r;
+ }
+
+ public static <T> Result<T> success() {
+ return success("操作成功");
+ }
+
+ public static <T> Result<T> error(String msg) {
+ Result<T> r = new Result<>();
+ r.setSuccess(false);
+ r.setMessage(msg);
+ r.setCode(HttpBizCode.INTERNAL_ERROR);
+ return r;
+ }
+
+ public static <T> Result<T> error(Integer code, String msg) {
+ Result<T> r = new Result<>();
+ r.setSuccess(false);
+ r.setMessage(msg);
+ r.setCode(code);
+ return r;
+ }
+
+ public static <T> Result<T> error() {
+ return error("操作失败");
+ }
+
+ private static <T> Result<T> baseOk() {
+ Result<T> r = new Result<>();
+ r.setSuccess(true);
+ r.setMessage("success");
+ r.setCode(HttpBizCode.SUCCESS);
+ return r;
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/config/AdminLoginCryptoProperties.java b/backend/src/main/java/com/sinata/appointment/config/AdminLoginCryptoProperties.java
new file mode 100644
index 0000000..558711d
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/config/AdminLoginCryptoProperties.java
@@ -0,0 +1,24 @@
+package com.sinata.appointment.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@Data
+@ConfigurationProperties(prefix = "security.admin-login-crypto")
+public class AdminLoginCryptoProperties {
+
+ /**
+ * AES 密钥,16位字符。
+ */
+ private String key = "SinataAdminKey01";
+
+ /**
+ * AES CBC 偏移量,16位字符。
+ */
+ private String iv = "SinataAdminIv001";
+
+ /**
+ * 时间戳允许偏差,单位毫秒。
+ */
+ private long maxSkewMillis = 60_000L;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/config/LocalUploadResourceConfig.java b/backend/src/main/java/com/sinata/appointment/config/LocalUploadResourceConfig.java
new file mode 100644
index 0000000..1cc4940
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/config/LocalUploadResourceConfig.java
@@ -0,0 +1,21 @@
+package com.sinata.appointment.config;
+
+import java.nio.file.Path;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class LocalUploadResourceConfig implements WebMvcConfigurer {
+
+ @Override
+ public void addResourceHandlers(ResourceHandlerRegistry registry) {
+ String location =
+ Path.of(System.getProperty("user.dir"), "uploads")
+ .toAbsolutePath()
+ .normalize()
+ .toUri()
+ .toString();
+ registry.addResourceHandler("/uploads/**").addResourceLocations(location);
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/config/MiniStpUtil.java b/backend/src/main/java/com/sinata/appointment/config/MiniStpUtil.java
new file mode 100644
index 0000000..f63af40
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/config/MiniStpUtil.java
@@ -0,0 +1,1217 @@
+package com.sinata.appointment.config;
+
+import java.util.List;
+
+import cn.dev33.satoken.SaManager;
+import cn.dev33.satoken.fun.SaFunction;
+import cn.dev33.satoken.listener.SaTokenEventCenter;
+import cn.dev33.satoken.session.SaSession;
+import cn.dev33.satoken.session.TokenSign;
+import cn.dev33.satoken.stp.SaLoginModel;
+import cn.dev33.satoken.stp.SaTokenInfo;
+import cn.dev33.satoken.stp.StpLogic;
+import com.sinata.appointment.constants.SaRealmConstants;
+
+
+public class MiniStpUtil {
+
+ private MiniStpUtil() {}
+
+ /**
+ * 多账号体系下的类型标识
+ */
+ public static final String TYPE = SaRealmConstants.MINI_PROGRAM;
+
+ /**
+ * 底层使用的 StpLogic 对象
+ */
+ public static StpLogic stpLogic = new StpLogic(TYPE);
+
+ /**
+ * 获取当前 StpLogic 的账号类型
+ *
+ * @return /
+ */
+ public static String getLoginType(){
+ return stpLogic.getLoginType();
+ }
+
+ /**
+ * 安全的重置 StpLogic 对象
+ *
+ * <br> 1、更改此账户的 StpLogic 对象
+ * <br> 2、put 到全局 StpLogic 集合中
+ * <br> 3、发送日志
+ *
+ * @param newStpLogic /
+ */
+ public static void setStpLogic(StpLogic newStpLogic) {
+ // 1、重置此账户的 StpLogic 对象
+ stpLogic = newStpLogic;
+
+ // 2、添加到全局 StpLogic 集合中
+ // 以便可以通过 SaManager.getStpLogic(type) 的方式来全局获取到这个 StpLogic
+ SaManager.putStpLogic(newStpLogic);
+
+ // 3、$$ 发布事件:更新了 stpLogic 对象
+ SaTokenEventCenter.doSetStpLogic(stpLogic);
+ }
+
+ /**
+ * 获取 StpLogic 对象
+ *
+ * @return /
+ */
+ public static StpLogic getStpLogic() {
+ return stpLogic;
+ }
+
+
+ // ------------------- 获取 token 相关 -------------------
+
+ /**
+ * 返回 token 名称,此名称在以下地方体现:Cookie 保存 token 时的名称、提交 token 时参数的名称、存储 token 时的 key 前缀
+ *
+ * @return /
+ */
+ public static String getTokenName() {
+ return stpLogic.getTokenName();
+ }
+
+ /**
+ * 在当前会话写入指定 token 值
+ *
+ * @param tokenValue token 值
+ */
+ public static void setTokenValue(String tokenValue){
+ stpLogic.setTokenValue(tokenValue);
+ }
+
+ /**
+ * 在当前会话写入指定 token 值
+ *
+ * @param tokenValue token 值
+ * @param cookieTimeout Cookie存活时间(秒)
+ */
+ public static void setTokenValue(String tokenValue, int cookieTimeout){
+ stpLogic.setTokenValue(tokenValue, cookieTimeout);
+ }
+
+ /**
+ * 在当前会话写入指定 token 值
+ *
+ * @param tokenValue token 值
+ * @param loginModel 登录参数
+ */
+ public static void setTokenValue(String tokenValue, SaLoginModel loginModel){
+ stpLogic.setTokenValue(tokenValue, loginModel);
+ }
+
+ /**
+ * 获取当前请求的 token 值
+ *
+ * @return 当前tokenValue
+ */
+ public static String getTokenValue() {
+ return stpLogic.getTokenValue();
+ }
+
+ /**
+ * 获取当前请求的 token 值 (不裁剪前缀)
+ *
+ * @return /
+ */
+ public static String getTokenValueNotCut(){
+ return stpLogic.getTokenValueNotCut();
+ }
+
+ /**
+ * 获取当前会话的 token 参数信息
+ *
+ * @return token 参数信息
+ */
+ public static SaTokenInfo getTokenInfo() {
+ return stpLogic.getTokenInfo();
+ }
+
+
+ // ------------------- 登录相关操作 -------------------
+
+ // --- 登录
+
+ /**
+ * 会话登录
+ *
+ * @param id 账号id,建议的类型:(long | int | String)
+ */
+ public static void login(Object id) {
+ stpLogic.login(id);
+ }
+
+ /**
+ * 会话登录,并指定登录设备类型
+ *
+ * @param id 账号id,建议的类型:(long | int | String)
+ * @param device 设备类型
+ */
+ public static void login(Object id, String device) {
+ stpLogic.login(id, device);
+ }
+
+ /**
+ * 会话登录,并指定是否 [记住我]
+ *
+ * @param id 账号id,建议的类型:(long | int | String)
+ * @param isLastingCookie 是否为持久Cookie,值为 true 时记住我,值为 false 时关闭浏览器需要重新登录
+ */
+ public static void login(Object id, boolean isLastingCookie) {
+ stpLogic.login(id, isLastingCookie);
+ }
+
+ /**
+ * 会话登录,并指定此次登录 token 的有效期, 单位:秒
+ *
+ * @param id 账号id,建议的类型:(long | int | String)
+ * @param timeout 此次登录 token 的有效期, 单位:秒
+ */
+ public static void login(Object id, long timeout) {
+ stpLogic.login(id, timeout);
+ }
+
+ /**
+ * 会话登录,并指定所有登录参数 Model
+ *
+ * @param id 账号id,建议的类型:(long | int | String)
+ * @param loginModel 此次登录的参数Model
+ */
+ public static void login(Object id, SaLoginModel loginModel) {
+ stpLogic.login(id, loginModel);
+ }
+
+ /**
+ * 创建指定账号 id 的登录会话数据
+ *
+ * @param id 账号id,建议的类型:(long | int | String)
+ * @return 返回会话令牌
+ */
+ public static String createLoginSession(Object id) {
+ return stpLogic.createLoginSession(id);
+ }
+
+ /**
+ * 创建指定账号 id 的登录会话数据
+ *
+ * @param id 账号id,建议的类型:(long | int | String)
+ * @param loginModel 此次登录的参数Model
+ * @return 返回会话令牌
+ */
+ public static String createLoginSession(Object id, SaLoginModel loginModel) {
+ return stpLogic.createLoginSession(id, loginModel);
+ }
+
+ // --- 注销
+
+ /**
+ * 在当前客户端会话注销
+ */
+ public static void logout() {
+ stpLogic.logout();
+ }
+
+ /**
+ * 会话注销,根据账号id
+ *
+ * @param loginId 账号id
+ */
+ public static void logout(Object loginId) {
+ stpLogic.logout(loginId);
+ }
+
+ /**
+ * 会话注销,根据账号id 和 设备类型
+ *
+ * @param loginId 账号id
+ * @param device 设备类型 (填 null 代表注销该账号的所有设备类型)
+ */
+ public static void logout(Object loginId, String device) {
+ stpLogic.logout(loginId, device);
+ }
+
+ /**
+ * 会话注销,根据指定 Token
+ *
+ * @param tokenValue 指定 token
+ */
+ public static void logoutByTokenValue(String tokenValue) {
+ stpLogic.logoutByTokenValue(tokenValue);
+ }
+
+ /**
+ * 踢人下线,根据账号id
+ * <p> 当对方再次访问系统时,会抛出 NotLoginException 异常,场景值=-5 </p>
+ *
+ * @param loginId 账号id
+ */
+ public static void kickout(Object loginId) {
+ stpLogic.kickout(loginId);
+ }
+
+ /**
+ * 踢人下线,根据账号id 和 设备类型
+ * <p> 当对方再次访问系统时,会抛出 NotLoginException 异常,场景值=-5 </p>
+ *
+ * @param loginId 账号id
+ * @param device 设备类型 (填 null 代表踢出该账号的所有设备类型)
+ */
+ public static void kickout(Object loginId, String device) {
+ stpLogic.kickout(loginId, device);
+ }
+
+ /**
+ * 踢人下线,根据指定 token
+ * <p> 当对方再次访问系统时,会抛出 NotLoginException 异常,场景值=-5 </p>
+ *
+ * @param tokenValue 指定 token
+ */
+ public static void kickoutByTokenValue(String tokenValue) {
+ stpLogic.kickoutByTokenValue(tokenValue);
+ }
+
+ /**
+ * 顶人下线,根据账号id 和 设备类型
+ * <p> 当对方再次访问系统时,会抛出 NotLoginException 异常,场景值=-4 </p>
+ *
+ * @param loginId 账号id
+ * @param device 设备类型 (填 null 代表顶替该账号的所有设备类型)
+ */
+ public static void replaced(Object loginId, String device) {
+ stpLogic.replaced(loginId, device);
+ }
+
+ // 会话查询
+
+ /**
+ * 判断当前会话是否已经登录
+ *
+ * @return 已登录返回 true,未登录返回 false
+ */
+ public static boolean isLogin() {
+ return stpLogic.isLogin();
+ }
+
+ /**
+ * 判断指定账号是否已经登录
+ *
+ * @return 已登录返回 true,未登录返回 false
+ */
+ public static boolean isLogin(Object loginId) {
+ return stpLogic.isLogin(loginId);
+ }
+
+ /**
+ * 检验当前会话是否已经登录,如未登录,则抛出异常
+ */
+ public static void checkLogin() {
+ stpLogic.checkLogin();
+ }
+
+ /**
+ * 获取当前会话账号id,如果未登录,则抛出异常
+ *
+ * @return 账号id
+ */
+ public static Object getLoginId() {
+ return stpLogic.getLoginId();
+ }
+
+ /**
+ * 获取当前会话账号id, 如果未登录,则返回默认值
+ *
+ * @param <T> 返回类型
+ * @param defaultValue 默认值
+ * @return 登录id
+ */
+ public static <T> T getLoginId(T defaultValue) {
+ return stpLogic.getLoginId(defaultValue);
+ }
+
+ /**
+ * 获取当前会话账号id, 如果未登录,则返回null
+ *
+ * @return 账号id
+ */
+ public static Object getLoginIdDefaultNull() {
+ return stpLogic.getLoginIdDefaultNull();
+ }
+
+ /**
+ * 获取当前会话账号id, 并转换为 String 类型
+ *
+ * @return 账号id
+ */
+ public static String getLoginIdAsString() {
+ return stpLogic.getLoginIdAsString();
+ }
+
+ /**
+ * 获取当前会话账号id, 并转换为 int 类型
+ *
+ * @return 账号id
+ */
+ public static int getLoginIdAsInt() {
+ return stpLogic.getLoginIdAsInt();
+ }
+
+ /**
+ * 获取当前会话账号id, 并转换为 long 类型
+ *
+ * @return 账号id
+ */
+ public static long getLoginIdAsLong() {
+ return stpLogic.getLoginIdAsLong();
+ }
+
+ /**
+ * 获取指定 token 对应的账号id,如果未登录,则返回 null
+ *
+ * @param tokenValue token
+ * @return 账号id
+ */
+ public static Object getLoginIdByToken(String tokenValue) {
+ return stpLogic.getLoginIdByToken(tokenValue);
+ }
+
+ /**
+ * 获取当前 Token 的扩展信息(此函数只在jwt模式下生效)
+ *
+ * @param key 键值
+ * @return 对应的扩展数据
+ */
+ public static Object getExtra(String key) {
+ return stpLogic.getExtra(key);
+ }
+
+ /**
+ * 获取指定 Token 的扩展信息(此函数只在jwt模式下生效)
+ *
+ * @param tokenValue 指定的 Token 值
+ * @param key 键值
+ * @return 对应的扩展数据
+ */
+ public static Object getExtra(String tokenValue, String key) {
+ return stpLogic.getExtra(tokenValue, key);
+ }
+
+
+ // ------------------- Account-Session 相关 -------------------
+
+ /**
+ * 获取指定账号 id 的 Account-Session, 如果该 SaSession 尚未创建,isCreate=是否新建并返回
+ *
+ * @param loginId 账号id
+ * @param isCreate 是否新建
+ * @return SaSession 对象
+ */
+ public static SaSession getSessionByLoginId(Object loginId, boolean isCreate) {
+ return stpLogic.getSessionByLoginId(loginId, isCreate);
+ }
+
+ /**
+ * 获取指定 key 的 SaSession, 如果该 SaSession 尚未创建,则返回 null
+ *
+ * @param sessionId SessionId
+ * @return Session对象
+ */
+ public static SaSession getSessionBySessionId(String sessionId) {
+ return stpLogic.getSessionBySessionId(sessionId);
+ }
+
+ /**
+ * 获取指定账号 id 的 Account-Session,如果该 SaSession 尚未创建,则新建并返回
+ *
+ * @param loginId 账号id
+ * @return SaSession 对象
+ */
+ public static SaSession getSessionByLoginId(Object loginId) {
+ return stpLogic.getSessionByLoginId(loginId);
+ }
+
+ /**
+ * 获取当前已登录账号的 Account-Session, 如果该 SaSession 尚未创建,isCreate=是否新建并返回
+ *
+ * @param isCreate 是否新建
+ * @return Session对象
+ */
+ public static SaSession getSession(boolean isCreate) {
+ return stpLogic.getSession(isCreate);
+ }
+
+ /**
+ * 获取当前已登录账号的 Account-Session,如果该 SaSession 尚未创建,则新建并返回
+ *
+ * @return Session对象
+ */
+ public static SaSession getSession() {
+ return stpLogic.getSession();
+ }
+
+
+ // ------------------- Token-Session 相关 -------------------
+
+ /**
+ * 获取指定 token 的 Token-Session,如果该 SaSession 尚未创建,则新建并返回
+ *
+ * @param tokenValue Token值
+ * @return Session对象
+ */
+ public static SaSession getTokenSessionByToken(String tokenValue) {
+ return stpLogic.getTokenSessionByToken(tokenValue);
+ }
+
+ /**
+ * 获取当前 token 的 Token-Session,如果该 SaSession 尚未创建,则新建并返回
+ *
+ * @return Session对象
+ */
+ public static SaSession getTokenSession() {
+ return stpLogic.getTokenSession();
+ }
+
+ /**
+ * 获取当前匿名 Token-Session (可在未登录情况下使用的Token-Session)
+ *
+ * @return Token-Session 对象
+ */
+ public static SaSession getAnonTokenSession() {
+ return stpLogic.getAnonTokenSession();
+ }
+
+
+ // ------------------- Active-Timeout token 最低活跃度 验证相关 -------------------
+
+ /**
+ * 续签当前 token:(将 [最后操作时间] 更新为当前时间戳)
+ * <h2>
+ * 请注意: 即使 token 已被冻结 也可续签成功,
+ * 如果此场景下需要提示续签失败,可在此之前调用 checkActiveTimeout() 强制检查是否冻结即可
+ * </h2>
+ */
+ public static void updateLastActiveToNow() {
+ stpLogic.updateLastActiveToNow();
+ }
+
+ /**
+ * 检查当前 token 是否已被冻结,如果是则抛出异常
+ */
+ public static void checkActiveTimeout() {
+ stpLogic.checkActiveTimeout();
+ }
+
+
+ // ------------------- 过期时间相关 -------------------
+
+ /**
+ * 获取当前会话 token 剩余有效时间(单位: 秒,返回 -1 代表永久有效,-2 代表没有这个值)
+ *
+ * @return token剩余有效时间
+ */
+ public static long getTokenTimeout() {
+ return stpLogic.getTokenTimeout();
+ }
+
+ /**
+ * 获取指定 token 剩余有效时间(单位: 秒,返回 -1 代表永久有效,-2 代表没有这个值)
+ *
+ * @param token 指定token
+ * @return token剩余有效时间
+ */
+ public static long getTokenTimeout(String token) {
+ return stpLogic.getTokenTimeout(token);
+ }
+
+ /**
+ * 获取当前登录账号的 Account-Session 剩余有效时间(单位: 秒,返回 -1 代表永久有效,-2 代表没有这个值)
+ *
+ * @return token剩余有效时间
+ */
+ public static long getSessionTimeout() {
+ return stpLogic.getSessionTimeout();
+ }
+
+ /**
+ * 获取当前 token 的 Token-Session 剩余有效时间(单位: 秒,返回 -1 代表永久有效,-2 代表没有这个值)
+ *
+ * @return token剩余有效时间
+ */
+ public static long getTokenSessionTimeout() {
+ return stpLogic.getTokenSessionTimeout();
+ }
+
+ /**
+ * 获取当前 token 剩余活跃有效期:当前 token 距离被冻结还剩多少时间(单位: 秒,返回 -1 代表永不冻结,-2 代表没有这个值或 token 已被冻结了)
+ *
+ * @return /
+ */
+ public static long getTokenActiveTimeout() {
+ return stpLogic.getTokenActiveTimeout();
+ }
+
+ /**
+ * 对当前 token 的 timeout 值进行续期
+ *
+ * @param timeout 要修改成为的有效时间 (单位: 秒)
+ */
+ public static void renewTimeout(long timeout) {
+ stpLogic.renewTimeout(timeout);
+ }
+
+ /**
+ * 对指定 token 的 timeout 值进行续期
+ *
+ * @param tokenValue 指定 token
+ * @param timeout 要修改成为的有效时间 (单位: 秒,填 -1 代表要续为永久有效)
+ */
+ public static void renewTimeout(String tokenValue, long timeout) {
+ stpLogic.renewTimeout(tokenValue, timeout);
+ }
+
+
+ // ------------------- 角色认证操作 -------------------
+
+ /**
+ * 获取:当前账号的角色集合
+ *
+ * @return /
+ */
+ public static List<String> getRoleList() {
+ return stpLogic.getRoleList();
+ }
+
+ /**
+ * 获取:指定账号的角色集合
+ *
+ * @param loginId 指定账号id
+ * @return /
+ */
+ public static List<String> getRoleList(Object loginId) {
+ return stpLogic.getRoleList(loginId);
+ }
+
+ /**
+ * 判断:当前账号是否拥有指定角色, 返回 true 或 false
+ *
+ * @param role 角色
+ * @return /
+ */
+ public static boolean hasRole(String role) {
+ return stpLogic.hasRole(role);
+ }
+
+ /**
+ * 判断:指定账号是否含有指定角色标识, 返回 true 或 false
+ *
+ * @param loginId 账号id
+ * @param role 角色标识
+ * @return 是否含有指定角色标识
+ */
+ public static boolean hasRole(Object loginId, String role) {
+ return stpLogic.hasRole(loginId, role);
+ }
+
+ /**
+ * 判断:当前账号是否含有指定角色标识 [ 指定多个,必须全部验证通过 ]
+ *
+ * @param roleArray 角色标识数组
+ * @return true或false
+ */
+ public static boolean hasRoleAnd(String... roleArray){
+ return stpLogic.hasRoleAnd(roleArray);
+ }
+
+ /**
+ * 判断:当前账号是否含有指定角色标识 [ 指定多个,只要其一验证通过即可 ]
+ *
+ * @param roleArray 角色标识数组
+ * @return true或false
+ */
+ public static boolean hasRoleOr(String... roleArray){
+ return stpLogic.hasRoleOr(roleArray);
+ }
+
+ /**
+ * 校验:当前账号是否含有指定角色标识, 如果验证未通过,则抛出异常: NotRoleException
+ *
+ * @param role 角色标识
+ */
+ public static void checkRole(String role) {
+ stpLogic.checkRole(role);
+ }
+
+ /**
+ * 校验:当前账号是否含有指定角色标识 [ 指定多个,必须全部验证通过 ]
+ *
+ * @param roleArray 角色标识数组
+ */
+ public static void checkRoleAnd(String... roleArray){
+ stpLogic.checkRoleAnd(roleArray);
+ }
+
+ /**
+ * 校验:当前账号是否含有指定角色标识 [ 指定多个,只要其一验证通过即可 ]
+ *
+ * @param roleArray 角色标识数组
+ */
+ public static void checkRoleOr(String... roleArray){
+ stpLogic.checkRoleOr(roleArray);
+ }
+
+
+ // ------------------- 权限认证操作 -------------------
+
+ /**
+ * 获取:当前账号的权限码集合
+ *
+ * @return /
+ */
+ public static List<String> getPermissionList() {
+ return stpLogic.getPermissionList();
+ }
+
+ /**
+ * 获取:指定账号的权限码集合
+ *
+ * @param loginId 指定账号id
+ * @return /
+ */
+ public static List<String> getPermissionList(Object loginId) {
+ return stpLogic.getPermissionList(loginId);
+ }
+
+ /**
+ * 判断:当前账号是否含有指定权限, 返回 true 或 false
+ *
+ * @param permission 权限码
+ * @return 是否含有指定权限
+ */
+ public static boolean hasPermission(String permission) {
+ return stpLogic.hasPermission(permission);
+ }
+
+ /**
+ * 判断:指定账号 id 是否含有指定权限, 返回 true 或 false
+ *
+ * @param loginId 账号 id
+ * @param permission 权限码
+ * @return 是否含有指定权限
+ */
+ public static boolean hasPermission(Object loginId, String permission) {
+ return stpLogic.hasPermission(loginId, permission);
+ }
+
+ /**
+ * 判断:当前账号是否含有指定权限 [ 指定多个,必须全部具有 ]
+ *
+ * @param permissionArray 权限码数组
+ * @return true 或 false
+ */
+ public static boolean hasPermissionAnd(String... permissionArray){
+ return stpLogic.hasPermissionAnd(permissionArray);
+ }
+
+ /**
+ * 判断:当前账号是否含有指定权限 [ 指定多个,只要其一验证通过即可 ]
+ *
+ * @param permissionArray 权限码数组
+ * @return true 或 false
+ */
+ public static boolean hasPermissionOr(String... permissionArray){
+ return stpLogic.hasPermissionOr(permissionArray);
+ }
+
+ /**
+ * 校验:当前账号是否含有指定权限, 如果验证未通过,则抛出异常: NotPermissionException
+ *
+ * @param permission 权限码
+ */
+ public static void checkPermission(String permission) {
+ stpLogic.checkPermission(permission);
+ }
+
+ /**
+ * 校验:当前账号是否含有指定权限 [ 指定多个,必须全部验证通过 ]
+ *
+ * @param permissionArray 权限码数组
+ */
+ public static void checkPermissionAnd(String... permissionArray) {
+ stpLogic.checkPermissionAnd(permissionArray);
+ }
+
+ /**
+ * 校验:当前账号是否含有指定权限 [ 指定多个,只要其一验证通过即可 ]
+ *
+ * @param permissionArray 权限码数组
+ */
+ public static void checkPermissionOr(String... permissionArray) {
+ stpLogic.checkPermissionOr(permissionArray);
+ }
+
+
+ // ------------------- id 反查 token 相关操作 -------------------
+
+ /**
+ * 获取指定账号 id 的 token
+ * <p>
+ * 在配置为允许并发登录时,此方法只会返回队列的最后一个 token,
+ * 如果你需要返回此账号 id 的所有 token,请调用 getTokenValueListByLoginId
+ * </p>
+ *
+ * @param loginId 账号id
+ * @return token值
+ */
+ public static String getTokenValueByLoginId(Object loginId) {
+ return stpLogic.getTokenValueByLoginId(loginId);
+ }
+
+ /**
+ * 获取指定账号 id 指定设备类型端的 token
+ * <p>
+ * 在配置为允许并发登录时,此方法只会返回队列的最后一个 token,
+ * 如果你需要返回此账号 id 的所有 token,请调用 getTokenValueListByLoginId
+ * </p>
+ *
+ * @param loginId 账号id
+ * @param device 设备类型,填 null 代表不限设备类型
+ * @return token值
+ */
+ public static String getTokenValueByLoginId(Object loginId, String device) {
+ return stpLogic.getTokenValueByLoginId(loginId, device);
+ }
+
+ /**
+ * 获取指定账号 id 的 token 集合
+ *
+ * @param loginId 账号id
+ * @return 此 loginId 的所有相关 token
+ */
+ public static List<String> getTokenValueListByLoginId(Object loginId) {
+ return stpLogic.getTokenValueListByLoginId(loginId);
+ }
+
+ /**
+ * 获取指定账号 id 指定设备类型端的 token 集合
+ *
+ * @param loginId 账号id
+ * @param device 设备类型,填 null 代表不限设备类型
+ * @return 此 loginId 的所有登录 token
+ */
+ public static List<String> getTokenValueListByLoginId(Object loginId, String device) {
+ return stpLogic.getTokenValueListByLoginId(loginId, device);
+ }
+
+ /**
+ * 获取指定账号 id 指定设备类型端的 tokenSign 集合
+ *
+ * @param loginId 账号id
+ * @param device 设备类型,填 null 代表不限设备类型
+ * @return 此 loginId 的所有登录 tokenSign
+ */
+ public static List<TokenSign> getTokenSignListByLoginId(Object loginId, String device) {
+ return stpLogic.getTokenSignListByLoginId(loginId, device);
+ }
+
+ /**
+ * 返回当前会话的登录设备类型
+ *
+ * @return 当前令牌的登录设备类型
+ */
+ public static String getLoginDevice() {
+ return stpLogic.getLoginDevice();
+ }
+
+
+ // ------------------- 会话管理 -------------------
+
+ /**
+ * 根据条件查询缓存中所有的 token
+ *
+ * @param keyword 关键字
+ * @param start 开始处索引
+ * @param size 获取数量 (-1代表一直获取到末尾)
+ * @param sortType 排序类型(true=正序,false=反序)
+ *
+ * @return token集合
+ */
+ public static List<String> searchTokenValue(String keyword, int start, int size, boolean sortType) {
+ return stpLogic.searchTokenValue(keyword, start, size, sortType);
+ }
+
+ /**
+ * 根据条件查询缓存中所有的 SessionId
+ *
+ * @param keyword 关键字
+ * @param start 开始处索引
+ * @param size 获取数量 (-1代表一直获取到末尾)
+ * @param sortType 排序类型(true=正序,false=反序)
+ *
+ * @return sessionId集合
+ */
+ public static List<String> searchSessionId(String keyword, int start, int size, boolean sortType) {
+ return stpLogic.searchSessionId(keyword, start, size, sortType);
+ }
+
+ /**
+ * 根据条件查询缓存中所有的 Token-Session-Id
+ *
+ * @param keyword 关键字
+ * @param start 开始处索引
+ * @param size 获取数量 (-1代表一直获取到末尾)
+ * @param sortType 排序类型(true=正序,false=反序)
+ *
+ * @return sessionId集合
+ */
+ public static List<String> searchTokenSessionId(String keyword, int start, int size, boolean sortType) {
+ return stpLogic.searchTokenSessionId(keyword, start, size, sortType);
+ }
+
+
+ // ------------------- 账号封禁 -------------------
+
+ /**
+ * 封禁:指定账号
+ * <p> 此方法不会直接将此账号id踢下线,如需封禁后立即掉线,请追加调用 StpUtil.logout(id)
+ *
+ * @param loginId 指定账号id
+ * @param time 封禁时间, 单位: 秒 (-1=永久封禁)
+ */
+ public static void disable(Object loginId, long time) {
+ stpLogic.disable(loginId, time);
+ }
+
+ /**
+ * 判断:指定账号是否已被封禁 (true=已被封禁, false=未被封禁)
+ *
+ * @param loginId 账号id
+ * @return /
+ */
+ public static boolean isDisable(Object loginId) {
+ return stpLogic.isDisable(loginId);
+ }
+
+ /**
+ * 校验:指定账号是否已被封禁,如果被封禁则抛出异常
+ *
+ * @param loginId 账号id
+ */
+ public static void checkDisable(Object loginId) {
+ stpLogic.checkDisable(loginId);
+ }
+
+ /**
+ * 获取:指定账号剩余封禁时间,单位:秒(-1=永久封禁,-2=未被封禁)
+ *
+ * @param loginId 账号id
+ * @return /
+ */
+ public static long getDisableTime(Object loginId) {
+ return stpLogic.getDisableTime(loginId);
+ }
+
+ /**
+ * 解封:指定账号
+ *
+ * @param loginId 账号id
+ */
+ public static void untieDisable(Object loginId) {
+ stpLogic.untieDisable(loginId);
+ }
+
+
+ // ------------------- 分类封禁 -------------------
+
+ /**
+ * 封禁:指定账号的指定服务
+ * <p> 此方法不会直接将此账号id踢下线,如需封禁后立即掉线,请追加调用 StpUtil.logout(id)
+ *
+ * @param loginId 指定账号id
+ * @param service 指定服务
+ * @param time 封禁时间, 单位: 秒 (-1=永久封禁)
+ */
+ public static void disable(Object loginId, String service, long time) {
+ stpLogic.disable(loginId, service, time);
+ }
+
+ /**
+ * 判断:指定账号的指定服务 是否已被封禁(true=已被封禁, false=未被封禁)
+ *
+ * @param loginId 账号id
+ * @param service 指定服务
+ * @return /
+ */
+ public static boolean isDisable(Object loginId, String service) {
+ return stpLogic.isDisable(loginId, service);
+ }
+
+ /**
+ * 校验:指定账号 指定服务 是否已被封禁,如果被封禁则抛出异常
+ *
+ * @param loginId 账号id
+ * @param services 指定服务,可以指定多个
+ */
+ public static void checkDisable(Object loginId, String... services) {
+ stpLogic.checkDisable(loginId, services);
+ }
+
+ /**
+ * 获取:指定账号 指定服务 剩余封禁时间,单位:秒(-1=永久封禁,-2=未被封禁)
+ *
+ * @param loginId 账号id
+ * @param service 指定服务
+ * @return see note
+ */
+ public static long getDisableTime(Object loginId, String service) {
+ return stpLogic.getDisableTime(loginId, service);
+ }
+
+ /**
+ * 解封:指定账号、指定服务
+ *
+ * @param loginId 账号id
+ * @param services 指定服务,可以指定多个
+ */
+ public static void untieDisable(Object loginId, String... services) {
+ stpLogic.untieDisable(loginId, services);
+ }
+
+
+ // ------------------- 阶梯封禁 -------------------
+
+ /**
+ * 封禁:指定账号,并指定封禁等级
+ *
+ * @param loginId 指定账号id
+ * @param level 指定封禁等级
+ * @param time 封禁时间, 单位: 秒 (-1=永久封禁)
+ */
+ public static void disableLevel(Object loginId, int level, long time) {
+ stpLogic.disableLevel(loginId, level, time);
+ }
+
+ /**
+ * 封禁:指定账号的指定服务,并指定封禁等级
+ *
+ * @param loginId 指定账号id
+ * @param service 指定封禁服务
+ * @param level 指定封禁等级
+ * @param time 封禁时间, 单位: 秒 (-1=永久封禁)
+ */
+ public static void disableLevel(Object loginId, String service, int level, long time) {
+ stpLogic.disableLevel(loginId, service, level, time);
+ }
+
+ /**
+ * 判断:指定账号是否已被封禁到指定等级
+ *
+ * @param loginId 指定账号id
+ * @param level 指定封禁等级
+ * @return /
+ */
+ public static boolean isDisableLevel(Object loginId, int level) {
+ return stpLogic.isDisableLevel(loginId, level);
+ }
+
+ /**
+ * 判断:指定账号的指定服务,是否已被封禁到指定等级
+ *
+ * @param loginId 指定账号id
+ * @param service 指定封禁服务
+ * @param level 指定封禁等级
+ * @return /
+ */
+ public static boolean isDisableLevel(Object loginId, String service, int level) {
+ return stpLogic.isDisableLevel(loginId, service, level);
+ }
+
+ /**
+ * 校验:指定账号是否已被封禁到指定等级(如果已经达到,则抛出异常)
+ *
+ * @param loginId 指定账号id
+ * @param level 封禁等级 (只有 封禁等级 ≥ 此值 才会抛出异常)
+ */
+ public static void checkDisableLevel(Object loginId, int level) {
+ stpLogic.checkDisableLevel(loginId, level);
+ }
+
+ /**
+ * 校验:指定账号的指定服务,是否已被封禁到指定等级(如果已经达到,则抛出异常)
+ *
+ * @param loginId 指定账号id
+ * @param service 指定封禁服务
+ * @param level 封禁等级 (只有 封禁等级 ≥ 此值 才会抛出异常)
+ */
+ public static void checkDisableLevel(Object loginId, String service, int level) {
+ stpLogic.checkDisableLevel(loginId, service, level);
+ }
+
+ /**
+ * 获取:指定账号被封禁的等级,如果未被封禁则返回-2
+ *
+ * @param loginId 指定账号id
+ * @return /
+ */
+ public static int getDisableLevel(Object loginId) {
+ return stpLogic.getDisableLevel(loginId);
+ }
+
+ /**
+ * 获取:指定账号的 指定服务 被封禁的等级,如果未被封禁则返回-2
+ *
+ * @param loginId 指定账号id
+ * @param service 指定封禁服务
+ * @return /
+ */
+ public static int getDisableLevel(Object loginId, String service) {
+ return stpLogic.getDisableLevel(loginId, service);
+ }
+
+
+ // ------------------- 临时身份切换 -------------------
+
+ /**
+ * 临时切换身份为指定账号id
+ *
+ * @param loginId 指定loginId
+ */
+ public static void switchTo(Object loginId) {
+ stpLogic.switchTo(loginId);
+ }
+
+ /**
+ * 结束临时切换身份
+ */
+ public static void endSwitch() {
+ stpLogic.endSwitch();
+ }
+
+ /**
+ * 判断当前请求是否正处于 [ 身份临时切换 ] 中
+ *
+ * @return /
+ */
+ public static boolean isSwitch() {
+ return stpLogic.isSwitch();
+ }
+
+ /**
+ * 在一个 lambda 代码段里,临时切换身份为指定账号id,lambda 结束后自动恢复
+ *
+ * @param loginId 指定账号id
+ * @param function 要执行的方法
+ */
+ public static void switchTo(Object loginId, SaFunction function) {
+ stpLogic.switchTo(loginId, function);
+ }
+
+
+ // ------------------- 二级认证 -------------------
+
+ /**
+ * 在当前会话 开启二级认证
+ *
+ * @param safeTime 维持时间 (单位: 秒)
+ */
+ public static void openSafe(long safeTime) {
+ stpLogic.openSafe(safeTime);
+ }
+
+ /**
+ * 在当前会话 开启二级认证
+ *
+ * @param service 业务标识
+ * @param safeTime 维持时间 (单位: 秒)
+ */
+ public static void openSafe(String service, long safeTime) {
+ stpLogic.openSafe(service, safeTime);
+ }
+
+ /**
+ * 判断:当前会话是否处于二级认证时间内
+ *
+ * @return true=二级认证已通过, false=尚未进行二级认证或认证已超时
+ */
+ public static boolean isSafe() {
+ return stpLogic.isSafe();
+ }
+
+ /**
+ * 判断:当前会话 是否处于指定业务的二级认证时间内
+ *
+ * @param service 业务标识
+ * @return true=二级认证已通过, false=尚未进行二级认证或认证已超时
+ */
+ public static boolean isSafe(String service) {
+ return stpLogic.isSafe(service);
+ }
+
+ /**
+ * 判断:指定 token 是否处于二级认证时间内
+ *
+ * @param tokenValue Token 值
+ * @param service 业务标识
+ * @return true=二级认证已通过, false=尚未进行二级认证或认证已超时
+ */
+ public static boolean isSafe(String tokenValue, String service) {
+ return stpLogic.isSafe(tokenValue, service);
+ }
+
+ /**
+ * 校验:当前会话是否已通过二级认证,如未通过则抛出异常
+ */
+ public static void checkSafe() {
+ stpLogic.checkSafe();
+ }
+
+ /**
+ * 校验:检查当前会话是否已通过指定业务的二级认证,如未通过则抛出异常
+ *
+ * @param service 业务标识
+ */
+ public static void checkSafe(String service) {
+ stpLogic.checkSafe(service);
+ }
+
+ /**
+ * 获取:当前会话的二级认证剩余有效时间(单位: 秒, 返回-2代表尚未通过二级认证)
+ *
+ * @return 剩余有效时间
+ */
+ public static long getSafeTime() {
+ return stpLogic.getSafeTime();
+ }
+
+ /**
+ * 获取:当前会话的二级认证剩余有效时间(单位: 秒, 返回-2代表尚未通过二级认证)
+ *
+ * @param service 业务标识
+ * @return 剩余有效时间
+ */
+ public static long getSafeTime(String service) {
+ return stpLogic.getSafeTime(service);
+ }
+
+ /**
+ * 在当前会话 结束二级认证
+ */
+ public static void closeSafe() {
+ stpLogic.closeSafe();
+ }
+
+ /**
+ * 在当前会话 结束指定业务标识的二级认证
+ *
+ * @param service 业务标识
+ */
+ public static void closeSafe(String service) {
+ stpLogic.closeSafe(service);
+ }
+
+}
diff --git a/backend/src/main/java/com/sinata/appointment/config/MybatisPlusConfig.java b/backend/src/main/java/com/sinata/appointment/config/MybatisPlusConfig.java
new file mode 100644
index 0000000..83d11a5
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/config/MybatisPlusConfig.java
@@ -0,0 +1,53 @@
+package com.sinata.appointment.config;
+
+import com.baomidou.mybatisplus.annotation.DbType;
+import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
+import java.time.LocalDateTime;
+import java.util.UUID;
+import org.apache.ibatis.reflection.MetaObject;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class MybatisPlusConfig {
+
+ @Bean
+ public MybatisPlusInterceptor mybatisPlusInterceptor() {
+ MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
+ interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
+ interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
+ return interceptor;
+ }
+
+ @Bean
+ public MetaObjectHandler metaObjectHandler() {
+ return new MetaObjectHandler() {
+ @Override
+ public void insertFill(MetaObject metaObject) {
+ LocalDateTime now = LocalDateTime.now();
+ strictInsertFill(metaObject, "createTime", LocalDateTime.class, now);
+ strictInsertFill(metaObject, "updateTime", LocalDateTime.class, now);
+ Object uuid = getFieldValByName("uuid", metaObject);
+ if (uuid == null || (uuid instanceof String s && s.isBlank())) {
+ setFieldValByName("uuid", UUID.randomUUID().toString().replace("-", ""), metaObject);
+ }
+ Object version = getFieldValByName("version", metaObject);
+ if (version == null) {
+ setFieldValByName("version", 1L, metaObject);
+ }
+ Object delFlag = getFieldValByName("delFlag", metaObject);
+ if (delFlag == null) {
+ setFieldValByName("delFlag", 0, metaObject);
+ }
+ }
+
+ @Override
+ public void updateFill(MetaObject metaObject) {
+ strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
+ }
+ };
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/config/OpenApiConfig.java b/backend/src/main/java/com/sinata/appointment/config/OpenApiConfig.java
new file mode 100644
index 0000000..4c2739e
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/config/OpenApiConfig.java
@@ -0,0 +1,39 @@
+package com.sinata.appointment.config;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Info;
+import io.swagger.v3.oas.models.security.SecurityScheme;
+import io.swagger.v3.oas.models.Components;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class OpenApiConfig {
+
+ @Bean
+ public OpenAPI openAPI() {
+ return new OpenAPI()
+ .info(
+ new Info()
+ .title("访客预约 API")
+ .version("1.0.0")
+ .description(
+ "双登录体系:① 平台管理 `/api/auth/*` + 受保护业务(StpUtil),请求头 "
+ + "`Authorization`;② 小程序 `/api/mini/**`(StpLogic mini),请求头 "
+ + "`Mini-Authorization`。二者不可混用,亦勿用同一本地存储 key 混存两类 token。"))
+ .components(
+ new Components()
+ .addSecuritySchemes(
+ "satoken",
+ new SecurityScheme()
+ .type(SecurityScheme.Type.APIKEY)
+ .in(SecurityScheme.In.HEADER)
+ .name("Authorization"))
+ .addSecuritySchemes(
+ "satoken-mini",
+ new SecurityScheme()
+ .type(SecurityScheme.Type.APIKEY)
+ .in(SecurityScheme.In.HEADER)
+ .name("Mini-Authorization")));
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/config/RestTemplateConfig.java b/backend/src/main/java/com/sinata/appointment/config/RestTemplateConfig.java
new file mode 100644
index 0000000..c524cf7
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/config/RestTemplateConfig.java
@@ -0,0 +1,16 @@
+package com.sinata.appointment.config;
+
+import java.time.Duration;
+import org.springframework.boot.web.client.RestTemplateBuilder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.client.RestTemplate;
+
+@Configuration
+public class RestTemplateConfig {
+
+ @Bean
+ public RestTemplate wechatRestTemplate(RestTemplateBuilder builder) {
+ return builder.setConnectTimeout(Duration.ofSeconds(5)).setReadTimeout(Duration.ofSeconds(10)).build();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/config/SaTokenConfigure.java b/backend/src/main/java/com/sinata/appointment/config/SaTokenConfigure.java
new file mode 100644
index 0000000..6667019
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/config/SaTokenConfigure.java
@@ -0,0 +1,31 @@
+package com.sinata.appointment.config;
+
+import cn.dev33.satoken.interceptor.SaInterceptor;
+import cn.dev33.satoken.stp.StpUtil;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.DependsOn;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+@DependsOn("miniStpUtilInit")
+public class SaTokenConfigure implements WebMvcConfigurer {
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ // 小程序域:StpLogic("mini"),与后台 StpUtil 完全隔离(密码仅后台有效)
+ registry.addInterceptor(
+ new SaInterceptor(
+ handle ->
+ MiniStpUtil.checkLogin()))
+ .addPathPatterns("/api/mini/**")
+ .excludePathPatterns("/api/mini/auth/login", "/api/mini/auth/phone-login")
+ .order(0);
+
+ // 平台管理端:sys_user 账密
+ registry.addInterceptor(new SaInterceptor(handle -> StpUtil.checkLogin()))
+ .addPathPatterns("/api/**")
+ .excludePathPatterns("/api/auth/login", "/api/auth/captcha", "/api/mini/**")
+ .order(1);
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/config/SaTokenMiniStpConfig.java b/backend/src/main/java/com/sinata/appointment/config/SaTokenMiniStpConfig.java
new file mode 100644
index 0000000..c6e13fe
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/config/SaTokenMiniStpConfig.java
@@ -0,0 +1,39 @@
+package com.sinata.appointment.config;
+
+import cn.dev33.satoken.config.SaTokenConfig;
+import cn.dev33.satoken.stp.StpLogic;
+import cn.dev33.satoken.SaManager;
+import com.sinata.appointment.constants.SaRealmConstants;
+import org.springframework.beans.BeanUtils;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * 小程序域 StpLogic,与默认 {@link cn.dev33.satoken.stp.StpUtil}(管理端)隔离。
+ *
+ * <p>独立 {@link SaRealmConstants#MINI_TOKEN_HEADER},避免与管理端共用 {@code Authorization} 时串 token。
+ */
+@Configuration
+public class SaTokenMiniStpConfig {
+
+ /**
+ * 初始化小程序域 StpLogic,并注册到 {@link MiniStpUtil} 与 Sa-Token 全局 StpLogicMap。
+ *
+ * <p>返回值仅用于 Spring 启动顺序控制(供 {@code @DependsOn} 使用)。
+ */
+ @Bean("miniStpUtilInit")
+ public Object miniStpUtilInit() {
+ StpLogic logic = new StpLogic(SaRealmConstants.MINI_PROGRAM);
+
+ SaTokenConfig global = SaManager.getConfig();
+ if (global != null) {
+ SaTokenConfig mini = new SaTokenConfig();
+ BeanUtils.copyProperties(global, mini);
+ mini.setTokenName(SaRealmConstants.MINI_TOKEN_HEADER);
+ logic.setConfig(mini);
+ }
+
+ MiniStpUtil.setStpLogic(logic);
+ return new Object();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/config/SecurityBeansConfig.java b/backend/src/main/java/com/sinata/appointment/config/SecurityBeansConfig.java
new file mode 100644
index 0000000..c076cf6
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/config/SecurityBeansConfig.java
@@ -0,0 +1,14 @@
+package com.sinata.appointment.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+
+@Configuration
+public class SecurityBeansConfig {
+
+ @Bean
+ public BCryptPasswordEncoder bcryptPasswordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/config/StpInterfaceImpl.java b/backend/src/main/java/com/sinata/appointment/config/StpInterfaceImpl.java
new file mode 100644
index 0000000..5d397e2
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/config/StpInterfaceImpl.java
@@ -0,0 +1,46 @@
+package com.sinata.appointment.config;
+
+import cn.dev33.satoken.stp.StpInterface;
+import com.sinata.appointment.mapper.SysUserMapper;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+@Component
+@RequiredArgsConstructor
+public class StpInterfaceImpl implements StpInterface {
+
+ private final SysUserMapper sysUserMapper;
+
+ @Override
+ public List<String> getPermissionList(Object loginId, String loginType) {
+ if ("mini".equals(loginType)) {
+ return List.of();
+ }
+ Long userId = parseLoginId(loginId);
+ return userId == null ? List.of() : sysUserMapper.listPermissionCodesByUserId(userId);
+ }
+
+ @Override
+ public List<String> getRoleList(Object loginId, String loginType) {
+ if ("mini".equals(loginType)) {
+ return List.of();
+ }
+ Long userId = parseLoginId(loginId);
+ return userId == null ? List.of() : sysUserMapper.listRoleCodesByUserId(userId);
+ }
+
+ private Long parseLoginId(Object loginId) {
+ if (loginId == null) {
+ return null;
+ }
+ if (loginId instanceof Long value) {
+ return value;
+ }
+ try {
+ return Long.valueOf(String.valueOf(loginId));
+ } catch (Exception ignore) {
+ return null;
+ }
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/config/TraceIdFilter.java b/backend/src/main/java/com/sinata/appointment/config/TraceIdFilter.java
new file mode 100644
index 0000000..d255a7d
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/config/TraceIdFilter.java
@@ -0,0 +1,36 @@
+package com.sinata.appointment.config;
+
+import com.sinata.appointment.service.OpLogApplicationService;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.UUID;
+import org.slf4j.MDC;
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.Order;
+import org.springframework.stereotype.Component;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+@Component
+@Order(Ordered.HIGHEST_PRECEDENCE)
+public class TraceIdFilter extends OncePerRequestFilter {
+
+ @Override
+ protected void doFilterInternal(
+ HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+ String traceId = request.getHeader("X-Trace-Id");
+ if (traceId == null || traceId.isBlank()) {
+ traceId = UUID.randomUUID().toString().replace("-", "");
+ }
+ MDC.put(OpLogApplicationService.MDC_TRACE, traceId);
+ response.setHeader("X-Trace-Id", traceId);
+ try {
+ filterChain.doFilter(request, response);
+ } finally {
+ MDC.remove(OpLogApplicationService.MDC_TRACE);
+ }
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/config/WebCorsConfig.java b/backend/src/main/java/com/sinata/appointment/config/WebCorsConfig.java
new file mode 100644
index 0000000..4f7062d
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/config/WebCorsConfig.java
@@ -0,0 +1,19 @@
+package com.sinata.appointment.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class WebCorsConfig implements WebMvcConfigurer {
+
+ @Override
+ public void addCorsMappings(CorsRegistry registry) {
+ registry.addMapping("/api/**")
+ .allowedOriginPatterns("*")
+ .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
+ .allowedHeaders("*")
+ .allowCredentials(true)
+ .maxAge(3600);
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/config/WechatMiniAppProperties.java b/backend/src/main/java/com/sinata/appointment/config/WechatMiniAppProperties.java
new file mode 100644
index 0000000..c498fd9
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/config/WechatMiniAppProperties.java
@@ -0,0 +1,26 @@
+package com.sinata.appointment.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+
+@Data
+@ConfigurationProperties(prefix = "wechat.miniapp")
+public class WechatMiniAppProperties {
+
+ private String appId = "";
+
+ private String appSecret = "";
+
+ /**
+ * true 或未配置 appId/secret 时使用桩实现(联调不上微信服务器)。
+ */
+ private boolean stub = true;
+
+ public boolean useStub() {
+ return stub
+ || appId == null
+ || appId.isBlank()
+ || appSecret == null
+ || appSecret.isBlank();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/AppointmentBizConstants.java b/backend/src/main/java/com/sinata/appointment/constants/AppointmentBizConstants.java
new file mode 100644
index 0000000..0943635
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/AppointmentBizConstants.java
@@ -0,0 +1,23 @@
+package com.sinata.appointment.constants;
+
+public final class AppointmentBizConstants {
+
+ private AppointmentBizConstants() {}
+
+ /** 访问类型 */
+ public static final String VISIT_TYPE_VISITOR = "VISITOR";
+ public static final String VISIT_TYPE_VEHICLE = "VEHICLE";
+
+ /** 拜访时间模式 */
+ public static final String TIME_MODE_SINGLE = "SINGLE";
+ public static final String TIME_MODE_MULTI = "MULTI";
+
+ /** 单日时段 */
+ public static final String TIME_SLOT_ALL_DAY = "ALL_DAY";
+ public static final String TIME_SLOT_AM = "AM";
+ public static final String TIME_SLOT_PM = "PM";
+
+ /** 事由“其他”编码 */
+ public static final String VISIT_REASON_OTHER = "OTHER";
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/constants/AppointmentFieldCodes.java b/backend/src/main/java/com/sinata/appointment/constants/AppointmentFieldCodes.java
new file mode 100644
index 0000000..5ad3913
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/AppointmentFieldCodes.java
@@ -0,0 +1,19 @@
+package com.sinata.appointment.constants;
+
+public final class AppointmentFieldCodes {
+
+ private AppointmentFieldCodes() {}
+
+ public static final String RESPONDENT = "respondent";
+ public static final String VISIT_REASON = "visit_reason";
+ public static final String VISIT_START_TIME = "visit_start_time";
+ public static final String VISIT_END_TIME = "visit_end_time";
+ public static final String VISIT_TYPE = "visit_type";
+ public static final String LICENSE_PLATE = "license_plate";
+ public static final String DRIVER = "driver";
+ public static final String VISITOR_NAME = "visitor_name";
+ public static final String ID_CARD = "idCard";
+ public static final String GENDER = "gender";
+ public static final String COMPANY = "company";
+ public static final String MOBILE = "mobile";
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/AppointmentOrderStatus.java b/backend/src/main/java/com/sinata/appointment/constants/AppointmentOrderStatus.java
new file mode 100644
index 0000000..05be298
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/AppointmentOrderStatus.java
@@ -0,0 +1,15 @@
+package com.sinata.appointment.constants;
+
+/** 预约单状态 */
+public final class AppointmentOrderStatus {
+
+ private AppointmentOrderStatus() {}
+
+ public static final String DRAFT = "DRAFT";
+ public static final String PENDING_APPROVAL = "PENDING_APPROVAL";
+ public static final String APPROVED = "APPROVED";
+ public static final String REJECTED = "REJECTED";
+ public static final String CANCELLED = "CANCELLED";
+ public static final String VISITED = "VISITED";
+ public static final String EXPIRED = "EXPIRED";
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/ApprovalInstanceStatus.java b/backend/src/main/java/com/sinata/appointment/constants/ApprovalInstanceStatus.java
new file mode 100644
index 0000000..95bf448
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/ApprovalInstanceStatus.java
@@ -0,0 +1,11 @@
+package com.sinata.appointment.constants;
+
+/** 审批实例状态 */
+public final class ApprovalInstanceStatus {
+
+ private ApprovalInstanceStatus() {}
+
+ public static final String IN_PROGRESS = "IN_PROGRESS";
+ public static final String APPROVED = "APPROVED";
+ public static final String REJECTED = "REJECTED";
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/ApprovalNodeCode.java b/backend/src/main/java/com/sinata/appointment/constants/ApprovalNodeCode.java
new file mode 100644
index 0000000..c9f7f4d
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/ApprovalNodeCode.java
@@ -0,0 +1,13 @@
+package com.sinata.appointment.constants;
+
+/** 审批节点编码(与功能清单 2~5 级对应) */
+public final class ApprovalNodeCode {
+
+ private ApprovalNodeCode() {}
+
+ public static final String LEVEL1_HANDLER = "LEVEL1_HANDLER";
+ public static final String LEVEL2_DEPT_LEADER = "LEVEL2_DEPT_LEADER";
+ public static final String LEVEL3_LOCAL_DEPT_LEADER = "LEVEL3_LOCAL_DEPT_LEADER";
+ public static final String LEVEL4_VISIT_DEPT = "LEVEL4_VISIT_DEPT";
+ public static final String LEVEL5_VISIT_DEPT_LEADER = "LEVEL5_VISIT_DEPT_LEADER";
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/ApprovalTaskStatus.java b/backend/src/main/java/com/sinata/appointment/constants/ApprovalTaskStatus.java
new file mode 100644
index 0000000..0c1b4ff
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/ApprovalTaskStatus.java
@@ -0,0 +1,13 @@
+package com.sinata.appointment.constants;
+
+/** 审批任务状态 */
+public final class ApprovalTaskStatus {
+
+ private ApprovalTaskStatus() {}
+
+ public static final String PENDING = "PENDING";
+ public static final String WAITING = "WAITING";
+ public static final String APPROVED = "APPROVED";
+ public static final String REJECTED = "REJECTED";
+ public static final String SKIPPED = "SKIPPED";
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/BizErrorCode.java b/backend/src/main/java/com/sinata/appointment/constants/BizErrorCode.java
new file mode 100644
index 0000000..43a9da9
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/BizErrorCode.java
@@ -0,0 +1,42 @@
+package com.sinata.appointment.constants;
+
+/** 业务错误码(与 message 配合,禁止魔法数字散落) */
+public final class BizErrorCode {
+
+ private BizErrorCode() {}
+
+ public static final int AUTH_INVALID_CREDENTIALS = 10001;
+ public static final int AUTH_FORBIDDEN = 10002;
+ /** 管理端登录图形验证码错误或已失效 */
+ public static final int AUTH_CAPTCHA_INVALID = 10007;
+ public static final int MINI_STAFF_NOT_BOUND = 10003;
+ public static final int MINI_LOGIN_INTENT_MISMATCH = 10004;
+ public static final int MINI_PHONE_MISMATCH = 10005;
+ public static final int MINI_WX_API_ERROR = 10006;
+ public static final int ORDER_NOT_FOUND = 20001;
+ public static final int ORDER_STATE_INVALID = 20002;
+ public static final int APPROVAL_TASK_NOT_FOUND = 30001;
+ public static final int APPROVAL_CONFLICT = 30002;
+ public static final int CONFIG_NOT_FOUND = 40001;
+
+ public static final int USER_SELF_FORBIDDEN = 10011;
+ public static final int PASSWORD_CONFIRM_MISMATCH = 10012;
+ public static final int DUPLICATE_USERNAME = 10013;
+
+ /** id+uuid 校验失败或资源不可访问(防水平越权) */
+ public static final int RESOURCE_VERIFY_FAILED = 10015;
+
+ /** 角色下仍有关联账号,禁止删除 */
+ public static final int ROLE_HAS_BOUND_USERS = 10016;
+
+ public static final int DEPT_MAX_DEPTH = 10021;
+ public static final int DEPT_HAS_CHILDREN = 10022;
+ public static final int DEPT_PARENT_INVALID = 10023;
+ public static final int DEPT_REORDER_CYCLE = 10024;
+
+ /** 员工手机号已被其他员工占用 */
+ public static final int MP_STAFF_MOBILE_EXISTS = 10031;
+
+ /** 员工编号已被占用 */
+ public static final int MP_STAFF_NO_EXISTS = 10032;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/CacheNames.java b/backend/src/main/java/com/sinata/appointment/constants/CacheNames.java
new file mode 100644
index 0000000..5cffafd
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/CacheNames.java
@@ -0,0 +1,14 @@
+package com.sinata.appointment.constants;
+
+public final class CacheNames {
+
+ private CacheNames() {}
+
+ public static final String SYS_BIZ_SETTING = "sysBizSetting";
+ public static final String MINI_LOOKUP = "miniLookup";
+ public static final String MINI_LOOKUP_VISIT_REASONS_KEY = "visitReasons";
+ public static final String MINI_LOOKUP_FIELD_CONFIGS_KEY = "fieldConfigs";
+ public static final String MINI_LOOKUP_PASS_ACCESS_AREA_TREE_KEY = "passAccessAreaTree";
+ public static final String BIZ_HOLIDAY_DATE = "bizHolidayDate";
+ public static final String BIZ_HOLIDAY_DATE_ALL_KEY = "all";
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/FieldConfigLockedCodes.java b/backend/src/main/java/com/sinata/appointment/constants/FieldConfigLockedCodes.java
new file mode 100644
index 0000000..e5e23ef
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/FieldConfigLockedCodes.java
@@ -0,0 +1,17 @@
+package com.sinata.appointment.constants;
+
+/** 预约字段:不可关闭「是否启用」且不可关闭「是否必填」 */
+public final class FieldConfigLockedCodes {
+
+ private FieldConfigLockedCodes() {}
+
+ public static final String RESPONDENT = "respondent";
+ public static final String VISIT_START_TIME = "visit_start_time";
+
+ public static boolean isLocked(String fieldCode) {
+ if (fieldCode == null) {
+ return false;
+ }
+ return RESPONDENT.equals(fieldCode) || VISIT_START_TIME.equals(fieldCode);
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/HttpBizCode.java b/backend/src/main/java/com/sinata/appointment/constants/HttpBizCode.java
new file mode 100644
index 0000000..2212f92
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/HttpBizCode.java
@@ -0,0 +1,14 @@
+package com.sinata.appointment.constants;
+
+/** HTTP 层业务约定状态码(与 Result.code 对齐) */
+public final class HttpBizCode {
+
+ private HttpBizCode() {}
+
+ public static final int SUCCESS = 200;
+ public static final int BAD_REQUEST = 400;
+ public static final int UNAUTHORIZED = 401;
+ public static final int FORBIDDEN = 403;
+ public static final int NOT_FOUND = 404;
+ public static final int INTERNAL_ERROR = 500;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/IntegrationConstant.java b/backend/src/main/java/com/sinata/appointment/constants/IntegrationConstant.java
new file mode 100644
index 0000000..c60fb0f
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/IntegrationConstant.java
@@ -0,0 +1,11 @@
+package com.sinata.appointment.constants;
+
+/** 第三方集成相关键名(配置、队列、日志场景) */
+public final class IntegrationConstant {
+
+ private IntegrationConstant() {}
+
+ public static final String HIKVISION_QRCODE_EVENT = "integration.hikvision.qrcode";
+ public static final String WECHAT_NOTIFY_EVENT = "integration.wechat.notify";
+ public static final String EXPORT_TASK_QUEUE = "appointment.export.tasks";
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/LocalUnitApproverType.java b/backend/src/main/java/com/sinata/appointment/constants/LocalUnitApproverType.java
new file mode 100644
index 0000000..84e4d77
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/LocalUnitApproverType.java
@@ -0,0 +1,11 @@
+package com.sinata.appointment.constants;
+
+/** 属地单位审批人员类型 */
+public final class LocalUnitApproverType {
+
+ private LocalUnitApproverType() {}
+
+ public static final int DEPT = 1;
+ public static final int STAFF_GROUP = 2;
+ public static final int STAFF = 3;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/MiniLoginIntent.java b/backend/src/main/java/com/sinata/appointment/constants/MiniLoginIntent.java
new file mode 100644
index 0000000..c5baf1c
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/MiniLoginIntent.java
@@ -0,0 +1,10 @@
+package com.sinata.appointment.constants;
+
+/** 小程序登录意图(决定校验规则) */
+public final class MiniLoginIntent {
+
+ private MiniLoginIntent() {}
+
+ public static final String VISITOR = "VISITOR";
+ public static final String EMPLOYEE = "EMPLOYEE";
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/MiniUserType.java b/backend/src/main/java/com/sinata/appointment/constants/MiniUserType.java
new file mode 100644
index 0000000..537fc63
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/MiniUserType.java
@@ -0,0 +1,13 @@
+package com.sinata.appointment.constants;
+
+/** 小程序用户类型 */
+public final class MiniUserType {
+
+ private MiniUserType() {}
+
+ /** 普通访客(预约、查看自己的单) */
+ public static final String VISITOR = "VISITOR";
+
+ /** 员工(须在 mp_user 登记部门,与平台 sys_user 无绑定) */
+ public static final String EMPLOYEE = "EMPLOYEE";
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/MpStaffConstants.java b/backend/src/main/java/com/sinata/appointment/constants/MpStaffConstants.java
new file mode 100644
index 0000000..be160aa
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/MpStaffConstants.java
@@ -0,0 +1,17 @@
+package com.sinata.appointment.constants;
+
+/** 管理端预建档员工:微信 openId 占位前缀,员工端首次登录按手机号绑定真实 openId */
+public final class MpStaffConstants {
+
+ private MpStaffConstants() {}
+
+ public static final String PLACEHOLDER_OPEN_ID_PREFIX = "PLHD_";
+
+ public static boolean isPlaceholderOpenId(String openId) {
+ return openId != null && openId.startsWith(PLACEHOLDER_OPEN_ID_PREFIX);
+ }
+
+ public static String newPlaceholderOpenId() {
+ return PLACEHOLDER_OPEN_ID_PREFIX + java.util.UUID.randomUUID().toString().replace("-", "");
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/QrcodeTicketStatus.java b/backend/src/main/java/com/sinata/appointment/constants/QrcodeTicketStatus.java
new file mode 100644
index 0000000..4ee0bd8
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/QrcodeTicketStatus.java
@@ -0,0 +1,11 @@
+package com.sinata.appointment.constants;
+
+/** 二维码票据状态 */
+public final class QrcodeTicketStatus {
+
+ private QrcodeTicketStatus() {}
+
+ public static final String UNUSED = "UNUSED";
+ public static final String USED = "USED";
+ public static final String EXPIRED = "EXPIRED";
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/RoleCode.java b/backend/src/main/java/com/sinata/appointment/constants/RoleCode.java
new file mode 100644
index 0000000..4646818
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/RoleCode.java
@@ -0,0 +1,14 @@
+package com.sinata.appointment.constants;
+
+/** 角色编码(与 sys_role.role_code 一致) */
+public final class RoleCode {
+
+ private RoleCode() {}
+
+ public static final String ADMIN = "ADMIN";
+ public static final String HANDLER = "HANDLER";
+ public static final String DEPT_LEADER = "DEPT_LEADER";
+ public static final String VISIT_DEPT = "VISIT_DEPT";
+ public static final String VISIT_DEPT_LEADER = "VISIT_DEPT_LEADER";
+ public static final String KEY_DEPT_LEADER = "KEY_DEPT_LEADER";
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/SaRealmConstants.java b/backend/src/main/java/com/sinata/appointment/constants/SaRealmConstants.java
new file mode 100644
index 0000000..f6f9efc
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/SaRealmConstants.java
@@ -0,0 +1,21 @@
+package com.sinata.appointment.constants;
+
+/**
+ * Sa-Token 多账号体系:管理端与小程序端隔离,token 在 Redis 中前缀不同,互不影响。
+ */
+public final class SaRealmConstants {
+
+ private SaRealmConstants() {}
+
+ /** 平台管理后台(sys_user + 账密),对应默认 StpUtil */
+ public static final String ADMIN_CONSOLE = "login";
+
+ /** 小程序端(mp_user + 微信等),对应 StpLogic("mini") */
+ public static final String MINI_PROGRAM = "mini";
+
+ /**
+ * 小程序端 token 请求头,必须与管理端默认的 {@code Authorization} 不同,否则同一浏览器/客户端会把 mini token
+ * 带到 {@code /api/**},触发 {@code StpUtil.checkLogin} 报「token 无效」。
+ */
+ public static final String MINI_TOKEN_HEADER = "Mini-Authorization";
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/SecurityConstant.java b/backend/src/main/java/com/sinata/appointment/constants/SecurityConstant.java
new file mode 100644
index 0000000..57cd586
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/SecurityConstant.java
@@ -0,0 +1,14 @@
+package com.sinata.appointment.constants;
+
+/** 权限/安全相关常量 */
+public final class SecurityConstant {
+
+ private SecurityConstant() {}
+
+ public static final String ROLE_ADMIN = "ROLE_ADMIN";
+ public static final String ROLE_HANDLER = "ROLE_HANDLER";
+ public static final String ROLE_DEPT_LEADER = "ROLE_DEPT_LEADER";
+ public static final String PERM_APPOINTMENT_READ = "appointment:read";
+ public static final String PERM_APPOINTMENT_APPROVE = "appointment:approve";
+ public static final String PERM_CONFIG_WRITE = "config:write";
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/SystemSettingKeys.java b/backend/src/main/java/com/sinata/appointment/constants/SystemSettingKeys.java
new file mode 100644
index 0000000..888a10b
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/SystemSettingKeys.java
@@ -0,0 +1,33 @@
+package com.sinata.appointment.constants;
+
+public final class SystemSettingKeys {
+
+ private SystemSettingKeys() {}
+
+ /** 访客主管部门 sys_dept.id,存于 sys_biz_setting.setting_value */
+ public static final String VISIT_COMPETENT_DEPT_ID = "VISIT_COMPETENT_DEPT_ID";
+
+ /** 预约时段:SINGLE 单日 / MULTI 多日 */
+ public static final String BOOKING_RANGE_MODE = "BOOKING_RANGE_MODE";
+
+ /** 最长连续预约天数(正整数,仅多日模式使用) */
+ public static final String BOOKING_MAX_CONSECUTIVE_DAYS = "BOOKING_MAX_CONSECUTIVE_DAYS";
+
+ /** 节假日/高温假日期列表 JSON 数组,元素为 yyyy-MM-dd */
+ public static final String BOOKING_HOLIDAY_DATES = "BOOKING_HOLIDAY_DATES";
+
+ /** 车辆道闸下放权限 1/0 */
+ public static final String VEHICLE_BARRIER_GATE_ENABLED = "VEHICLE_BARRIER_GATE_ENABLED";
+
+ /** 内容设置:关于我们(HTML) */
+ public static final String CONTENT_ABOUT_US_HTML = "CONTENT_ABOUT_US_HTML";
+
+ /** 内容设置:入园须知(HTML) */
+ public static final String CONTENT_PARK_NOTICE_HTML = "CONTENT_PARK_NOTICE_HTML";
+
+ /** 内容设置:入园流程(HTML) */
+ public static final String CONTENT_PARK_FLOW_HTML = "CONTENT_PARK_FLOW_HTML";
+
+ /** 内容设置:关于我们是否启用(1/0) */
+ public static final String CONTENT_ABOUT_US_ENABLED = "CONTENT_ABOUT_US_ENABLED";
+}
diff --git a/backend/src/main/java/com/sinata/appointment/constants/VisitType.java b/backend/src/main/java/com/sinata/appointment/constants/VisitType.java
new file mode 100644
index 0000000..243583f
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/constants/VisitType.java
@@ -0,0 +1,10 @@
+package com.sinata.appointment.constants;
+
+/** 访问类型 */
+public final class VisitType {
+
+ private VisitType() {}
+
+ public static final String VISITOR = "VISITOR";
+ public static final String VEHICLE = "VEHICLE";
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/AppointmentController.java b/backend/src/main/java/com/sinata/appointment/controller/AppointmentController.java
new file mode 100644
index 0000000..6df190a
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/AppointmentController.java
@@ -0,0 +1,107 @@
+package com.sinata.appointment.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.AppointmentAdminDetailVo;
+import com.sinata.appointment.dto.AppointmentAdminListVo;
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.service.AppointmentApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.constraints.NotBlank;
+import lombok.RequiredArgsConstructor;
+import java.time.LocalDate;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "预约")
+@RestController
+@RequestMapping("/api/appointments")
+@RequiredArgsConstructor
+public class AppointmentController {
+
+ private final AppointmentApplicationService appointmentApplicationService;
+
+ @Operation(summary = "分页查询")
+ @GetMapping
+ @SaCheckPermission("appointments:page")
+ public Result<java.util.List<AppointmentAdminListVo>> page(
+ @RequestParam(defaultValue = "1") long page,
+ @RequestParam(defaultValue = "10") long size,
+ @RequestParam(required = false) String visitedUserName,
+ @RequestParam(required = false) Long visitedDeptId,
+ @RequestParam(required = false) String accessArea,
+ @RequestParam(required = false) String localUnit,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate visitDateStart,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate visitDateEnd,
+ @RequestParam(required = false) String displayStatus,
+ @RequestParam(required = false) String approvalStatus,
+ @RequestParam(required = false) String visitorKeyword,
+ @RequestParam(required = false) String visitReasonCode) {
+ return ResultUtil.page(
+ appointmentApplicationService.pageOrders(
+ page,
+ size,
+ visitedUserName,
+ visitedDeptId,
+ accessArea,
+ localUnit,
+ visitDateStart,
+ visitDateEnd,
+ displayStatus,
+ approvalStatus,
+ visitorKeyword,
+ visitReasonCode));
+ }
+
+ @Operation(summary = "导出 Excel")
+ @GetMapping("/export")
+ @SaCheckPermission("appointments:export")
+ public ResponseEntity<byte[]> export(
+ @RequestParam(required = false) String visitedUserName,
+ @RequestParam(required = false) Long visitedDeptId,
+ @RequestParam(required = false) String accessArea,
+ @RequestParam(required = false) String localUnit,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate visitDateStart,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate visitDateEnd,
+ @RequestParam(required = false) String displayStatus,
+ @RequestParam(required = false) String approvalStatus,
+ @RequestParam(required = false) String visitorKeyword,
+ @RequestParam(required = false) String visitReasonCode) {
+ byte[] body =
+ appointmentApplicationService.exportExcel(
+ visitedUserName,
+ visitedDeptId,
+ accessArea,
+ localUnit,
+ visitDateStart,
+ visitDateEnd,
+ displayStatus,
+ approvalStatus,
+ visitorKeyword,
+ visitReasonCode);
+ return ResponseEntity.ok()
+ .contentType(
+ MediaType.parseMediaType(
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"appointments.xlsx\"")
+ .body(body);
+ }
+
+ @Operation(summary = "详情含访客")
+ @GetMapping("/{id}")
+ @SaCheckPermission("appointments:detail")
+ public Result<AppointmentAdminDetailVo> detail(
+ @PathVariable Long id,
+ @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ return ResultUtil.data(appointmentApplicationService.detail(id, uuid));
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/ApprovalController.java b/backend/src/main/java/com/sinata/appointment/controller/ApprovalController.java
new file mode 100644
index 0000000..116571a
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/ApprovalController.java
@@ -0,0 +1,57 @@
+package com.sinata.appointment.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.dev33.satoken.annotation.SaMode;
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.ApprovalFlowNodeVo;
+import com.sinata.appointment.dto.ApprovalActionRequest;
+import com.sinata.appointment.entity.ApprovalTask;
+import com.sinata.appointment.service.ApprovalFlowViewApplicationService;
+import com.sinata.appointment.service.ApprovalApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "审批")
+@RestController
+@RequestMapping("/api/approvals")
+@RequiredArgsConstructor
+public class ApprovalController {
+
+ private final ApprovalApplicationService approvalApplicationService;
+ private final ApprovalFlowViewApplicationService approvalFlowViewApplicationService;
+
+ @Operation(summary = "我的待办")
+ @GetMapping("/todo")
+ @SaCheckPermission("approvals:page")
+ public Result<java.util.List<ApprovalTask>> todo(
+ @RequestParam(defaultValue = "1") long page, @RequestParam(defaultValue = "10") long size) {
+ return ResultUtil.page(approvalApplicationService.pageMyTodo(page, size));
+ }
+
+ @Operation(summary = "处理审批")
+ @PostMapping("/action")
+ @SaCheckPermission("approvals:action")
+ public Result<Void> action(@Valid @RequestBody ApprovalActionRequest request) {
+ approvalApplicationService.handleAction(request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "预约审批流程树")
+ @GetMapping("/flow-tree")
+ @SaCheckPermission(value = {"appointments:detail", "approvals:page"}, mode = SaMode.OR)
+ public Result<java.util.List<ApprovalFlowNodeVo>> flowTree(
+ @RequestParam Long orderId,
+ @RequestParam @NotBlank(message = "orderUuid 不能为空") String orderUuid) {
+ return ResultUtil.data(approvalFlowViewApplicationService.treeByOrderId(orderId, orderUuid));
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/AuthController.java b/backend/src/main/java/com/sinata/appointment/controller/AuthController.java
new file mode 100644
index 0000000..2647fee
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/AuthController.java
@@ -0,0 +1,47 @@
+package com.sinata.appointment.controller;
+
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.CaptchaResponse;
+import com.sinata.appointment.dto.LoginRequest;
+import com.sinata.appointment.dto.LoginResponse;
+import com.sinata.appointment.service.AuthApplicationService;
+import com.sinata.appointment.service.CaptchaService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "认证")
+@RestController
+@RequestMapping("/api/auth")
+@RequiredArgsConstructor
+public class AuthController {
+
+ private final AuthApplicationService authApplicationService;
+ private final CaptchaService captchaService;
+
+ @Operation(summary = "图形验证码(管理端登录前获取)")
+ @GetMapping("/captcha")
+ public Result<CaptchaResponse> captcha() {
+ return ResultUtil.data(captchaService.generate());
+ }
+
+ @Operation(summary = "登录")
+ @PostMapping("/login")
+ public Result<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
+ return ResultUtil.data(authApplicationService.login(request));
+ }
+
+ @Operation(summary = "登出")
+ @PostMapping("/logout")
+ public Result<Void> logout() {
+ authApplicationService.logout();
+ return ResultUtil.success();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/ConfigController.java b/backend/src/main/java/com/sinata/appointment/controller/ConfigController.java
new file mode 100644
index 0000000..d9aadcd
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/ConfigController.java
@@ -0,0 +1,187 @@
+package com.sinata.appointment.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.dev33.satoken.annotation.SaMode;
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.ApprovalNodeEnabledPatchRequest;
+import com.sinata.appointment.dto.BookingSettingsSaveRequest;
+import com.sinata.appointment.dto.BookingSettingsVo;
+import com.sinata.appointment.dto.ContentSettingsSaveRequest;
+import com.sinata.appointment.dto.ContentSettingsVo;
+import com.sinata.appointment.dto.FieldConfigPatchRequest;
+import com.sinata.appointment.dto.VisitReasonCreateRequest;
+import com.sinata.appointment.dto.VisitReasonListVo;
+import com.sinata.appointment.dto.VisitReasonUpdateRequest;
+import com.sinata.appointment.dto.VisitorCompetentDeptSaveRequest;
+import com.sinata.appointment.dto.VisitorCompetentDeptVo;
+import com.sinata.appointment.entity.BizApprovalNodeConfig;
+import com.sinata.appointment.entity.BizFieldConfig;
+import com.sinata.appointment.entity.BizVisitReason;
+import com.sinata.appointment.service.ConfigApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "系统配置")
+@RestController
+@RequestMapping("/api/config")
+@RequiredArgsConstructor
+public class ConfigController {
+
+ private final ConfigApplicationService configApplicationService;
+
+ @GetMapping("/visit-reasons")
+ @SaCheckPermission(
+ value = {"config:visit-reasons:page", "appointments:page"},
+ mode = SaMode.OR)
+ public Result<List<BizVisitReason>> listReasons() {
+ return ResultUtil.data(configApplicationService.listVisitReasons());
+ }
+
+ @Operation(summary = "保存来访事由")
+ @PostMapping("/visit-reasons")
+ @SaCheckPermission("config:visit-reasons:update")
+ public Result<Void> saveReason(@RequestBody BizVisitReason entity) {
+ configApplicationService.saveVisitReason(entity);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "来访事由管理-分页")
+ @GetMapping("/visit-reasons/manage")
+ @SaCheckPermission("config:visit-reasons:page")
+ public Result<List<VisitReasonListVo>> pageVisitReasons(
+ @RequestParam(defaultValue = "1") long page, @RequestParam(defaultValue = "10") long size) {
+ return ResultUtil.page(configApplicationService.pageVisitReasons(page, size));
+ }
+
+ @Operation(summary = "来访事由管理-新增")
+ @PostMapping("/visit-reasons/manage")
+ @SaCheckPermission("config:visit-reasons:create")
+ public Result<Void> createVisitReason(@Valid @RequestBody VisitReasonCreateRequest request) {
+ configApplicationService.createVisitReason(request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "来访事由管理-编辑")
+ @PutMapping("/visit-reasons/manage/{id}")
+ @SaCheckPermission("config:visit-reasons:update")
+ public Result<Void> updateVisitReason(@PathVariable Long id, @Valid @RequestBody VisitReasonUpdateRequest request) {
+ configApplicationService.updateVisitReason(id, request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "来访事由管理-删除")
+ @DeleteMapping("/visit-reasons/manage/{id}")
+ @SaCheckPermission("config:visit-reasons:delete")
+ public Result<Void> deleteVisitReason(
+ @PathVariable Long id, @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ configApplicationService.deleteVisitReason(id, uuid);
+ return ResultUtil.success();
+ }
+
+ @GetMapping("/fields")
+ @SaCheckPermission("config:booking-settings:page")
+ public Result<List<BizFieldConfig>> listFields() {
+ return ResultUtil.data(configApplicationService.listFieldConfigs());
+ }
+
+ @Operation(summary = "保存字段配置")
+ @PostMapping("/fields")
+ @SaCheckPermission("config:booking-settings:update")
+ public Result<Void> saveField(@RequestBody BizFieldConfig entity) {
+ configApplicationService.saveFieldConfig(entity);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "更新预约字段是否启用/是否必填(须 uuid;受访人、来访时间不可关闭)")
+ @PatchMapping("/fields/{id}")
+ @SaCheckPermission("config:booking-settings:update")
+ public Result<Void> patchField(@PathVariable Long id, @Valid @RequestBody FieldConfigPatchRequest request) {
+ configApplicationService.patchFieldConfig(id, request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "读取预约策略(时段、节假日、道闸)")
+ @GetMapping("/booking-settings")
+ @SaCheckPermission("config:booking-settings:page")
+ public Result<BookingSettingsVo> getBookingSettings() {
+ return ResultUtil.data(configApplicationService.getBookingSettings());
+ }
+
+ @Operation(summary = "保存预约策略")
+ @PutMapping("/booking-settings")
+ @SaCheckPermission("config:booking-settings:update")
+ public Result<Void> saveBookingSettings(@Valid @RequestBody BookingSettingsSaveRequest request) {
+ configApplicationService.saveBookingSettings(request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "读取内容设置")
+ @GetMapping("/content-settings")
+ @SaCheckPermission("config:content-settings:page")
+ public Result<ContentSettingsVo> getContentSettings() {
+ return ResultUtil.data(configApplicationService.getContentSettings());
+ }
+
+ @Operation(summary = "保存内容设置")
+ @PutMapping("/content-settings")
+ @SaCheckPermission("config:content-settings:update")
+ public Result<Void> saveContentSettings(@Valid @RequestBody ContentSettingsSaveRequest request) {
+ configApplicationService.saveContentSettings(request);
+ return ResultUtil.success();
+ }
+
+ @GetMapping("/approval-nodes")
+ @SaCheckPermission("config:approval-settings:page")
+ public Result<List<BizApprovalNodeConfig>> listNodes() {
+ return ResultUtil.data(configApplicationService.listApprovalNodes());
+ }
+
+ @Operation(summary = "保存审批节点配置")
+ @PostMapping("/approval-nodes")
+ @SaCheckPermission("config:approval-settings:update")
+ public Result<Void> saveNode(@RequestBody BizApprovalNodeConfig entity) {
+ configApplicationService.saveApprovalNode(entity);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "更新审批节点是否开启(须 uuid)")
+ @PatchMapping("/approval-nodes/{id}/enabled")
+ @SaCheckPermission("config:approval-settings:update")
+ public Result<Void> patchApprovalNodeEnabled(
+ @PathVariable Long id, @Valid @RequestBody ApprovalNodeEnabledPatchRequest request) {
+ configApplicationService.updateApprovalNodeEnabled(id, request.getUuid(), request.getEnabled());
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "读取访客主管部门配置")
+ @GetMapping("/visitor-competent-dept")
+ @SaCheckPermission("config:approval-settings:page")
+ public Result<VisitorCompetentDeptVo> getVisitorCompetentDept() {
+ VisitorCompetentDeptVo vo = new VisitorCompetentDeptVo();
+ vo.setDeptId(configApplicationService.getVisitCompetentDeptId());
+ return ResultUtil.data(vo);
+ }
+
+ @Operation(summary = "保存访客主管部门(传 deptId=null 清空)")
+ @PutMapping("/visitor-competent-dept")
+ @SaCheckPermission("config:approval-settings:update")
+ public Result<Void> saveVisitorCompetentDept(@RequestBody VisitorCompetentDeptSaveRequest request) {
+ configApplicationService.saveVisitCompetentDept(request.getDeptId());
+ return ResultUtil.success();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/DeptManageController.java b/backend/src/main/java/com/sinata/appointment/controller/DeptManageController.java
new file mode 100644
index 0000000..cb509f2
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/DeptManageController.java
@@ -0,0 +1,76 @@
+package com.sinata.appointment.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.dev33.satoken.annotation.SaMode;
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.DeptCreateRequest;
+import com.sinata.appointment.dto.DeptTreeNodeVo;
+import com.sinata.appointment.dto.DeptUpdateRequest;
+import com.sinata.appointment.service.DeptManageApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import java.util.List;
+import java.util.Map;
+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.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "部门管理")
+@RestController
+@RequestMapping("/api/sys/dept-manage")
+@RequiredArgsConstructor
+@Validated
+public class DeptManageController {
+
+ private final DeptManageApplicationService deptManageApplicationService;
+
+ @Operation(summary = "部门树(含禁用部门,最多五级)")
+ @GetMapping("/tree")
+ @SaCheckPermission(
+ value = {
+ "config:approval-settings:page",
+ "sys:depts:page",
+ "sys:staff:page",
+ "sys:staff-groups:page",
+ "sys:local-units:page"
+ },
+ mode = SaMode.OR)
+ public Result<List<DeptTreeNodeVo>> tree() {
+ return ResultUtil.data(deptManageApplicationService.tree());
+ }
+
+ @Operation(summary = "新增部门")
+ @PostMapping
+ @SaCheckPermission("sys:depts:create")
+ public Result<Map<String, Object>> create(@Valid @RequestBody DeptCreateRequest request) {
+ return ResultUtil.data(deptManageApplicationService.create(request));
+ }
+
+ @Operation(summary = "编辑部门(body 须含 uuid)")
+ @PutMapping("/{id}")
+ @SaCheckPermission("sys:depts:update")
+ public Result<Void> update(@PathVariable Long id, @Valid @RequestBody DeptUpdateRequest request) {
+ deptManageApplicationService.update(id, request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "删除(逻辑删除,无下级时允许)")
+ @DeleteMapping("/{id}")
+ @SaCheckPermission("sys:depts:delete")
+ public Result<Void> delete(
+ @PathVariable Long id, @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ deptManageApplicationService.delete(id, uuid);
+ return ResultUtil.success();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/ExportController.java b/backend/src/main/java/com/sinata/appointment/controller/ExportController.java
new file mode 100644
index 0000000..2cfd592
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/ExportController.java
@@ -0,0 +1,38 @@
+package com.sinata.appointment.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.service.ExportApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.util.HashMap;
+import java.util.Map;
+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;
+
+@Tag(name = "导出")
+@RestController
+@RequestMapping("/api/exports")
+@RequiredArgsConstructor
+public class ExportController {
+
+ private final ExportApplicationService exportApplicationService;
+
+ @Operation(summary = "异步导出任务入队")
+ @PostMapping("/enqueue")
+ @SaCheckPermission("exports:enqueue")
+ public Result<Map<String, String>> enqueue(@RequestBody Map<String, Object> body) {
+ String type = body.get("type") != null ? body.get("type").toString() : "appointment";
+ @SuppressWarnings("unchecked")
+ Map<String, Object> criteria =
+ body.get("criteria") instanceof Map ? (Map<String, Object>) body.get("criteria") : Map.of();
+ String taskId = exportApplicationService.enqueueExport(type, criteria);
+ Map<String, String> res = new HashMap<>();
+ res.put("taskId", taskId);
+ return ResultUtil.data(res);
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/LocalUnitManageController.java b/backend/src/main/java/com/sinata/appointment/controller/LocalUnitManageController.java
new file mode 100644
index 0000000..f0141ca
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/LocalUnitManageController.java
@@ -0,0 +1,79 @@
+package com.sinata.appointment.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.dev33.satoken.annotation.SaMode;
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.LocalUnitCreateRequest;
+import com.sinata.appointment.dto.LocalUnitDetailVo;
+import com.sinata.appointment.dto.LocalUnitListVo;
+import com.sinata.appointment.dto.LocalUnitUpdateRequest;
+import com.sinata.appointment.service.LocalUnitManageApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import java.util.Map;
+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.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "属地单位管理")
+@RestController
+@RequestMapping("/api/sys/local-units")
+@RequiredArgsConstructor
+@Validated
+public class LocalUnitManageController {
+
+ private final LocalUnitManageApplicationService localUnitManageApplicationService;
+
+ @Operation(summary = "分页列表")
+ @GetMapping
+ @SaCheckPermission(value = {"sys:local-units:page", "appointments:page"}, mode = SaMode.OR)
+ public Result<java.util.List<LocalUnitListVo>> page(
+ @RequestParam(defaultValue = "1") long page,
+ @RequestParam(defaultValue = "10") long size,
+ @RequestParam(required = false) String unitName) {
+ return ResultUtil.page(localUnitManageApplicationService.page(page, size, unitName));
+ }
+
+ @Operation(summary = "详情(须 uuid)")
+ @GetMapping("/{id}")
+ @SaCheckPermission("sys:local-units:detail")
+ public Result<LocalUnitDetailVo> detail(
+ @PathVariable Long id, @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ return ResultUtil.data(localUnitManageApplicationService.detail(id, uuid));
+ }
+
+ @Operation(summary = "新增")
+ @PostMapping
+ @SaCheckPermission("sys:local-units:create")
+ public Result<Map<String, Object>> create(@Valid @RequestBody LocalUnitCreateRequest request) {
+ return ResultUtil.data(localUnitManageApplicationService.create(request));
+ }
+
+ @Operation(summary = "编辑")
+ @PutMapping("/{id}")
+ @SaCheckPermission("sys:local-units:update")
+ public Result<Void> update(@PathVariable Long id, @Valid @RequestBody LocalUnitUpdateRequest request) {
+ localUnitManageApplicationService.update(id, request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "删除(逻辑删除)")
+ @DeleteMapping("/{id}")
+ @SaCheckPermission("sys:local-units:delete")
+ public Result<Void> delete(
+ @PathVariable Long id, @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ localUnitManageApplicationService.delete(id, uuid);
+ return ResultUtil.success();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/MiniAppointmentController.java b/backend/src/main/java/com/sinata/appointment/controller/MiniAppointmentController.java
new file mode 100644
index 0000000..928bd18
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/MiniAppointmentController.java
@@ -0,0 +1,101 @@
+package com.sinata.appointment.controller;
+
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.MiniAppointmentCreateRequest;
+import com.sinata.appointment.dto.MiniAppointmentListVo;
+import com.sinata.appointment.dto.MiniMyAppointmentDetailVo;
+import com.sinata.appointment.dto.MiniMyVisitorCodeOrderVo;
+import com.sinata.appointment.dto.MiniVisitRecordListVo;
+import com.sinata.appointment.service.MiniAppointmentApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import java.time.LocalDate;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.PathVariable;
+
+@Tag(name = "小程序预约")
+@RestController
+@RequestMapping("/api/mini/appointments")
+@RequiredArgsConstructor
+public class MiniAppointmentController {
+
+ private final MiniAppointmentApplicationService miniAppointmentApplicationService;
+
+ @Operation(summary = "小程序发起预约")
+ @PostMapping
+ public Result<Map<String, Long>> create(@Valid @RequestBody MiniAppointmentCreateRequest request) {
+ return ResultUtil.data(Map.of("id", miniAppointmentApplicationService.create(request)));
+ }
+
+ @Operation(summary = "小程序取消预约(预约人可在完成/拒绝前取消)")
+ @PostMapping("/{id}/cancel")
+ public Result<Void> cancel(
+ @PathVariable Long id,
+ @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ miniAppointmentApplicationService.cancelBySubmitter(id, uuid);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "小程序我的预约详情")
+ @GetMapping("/{id}")
+ public Result<MiniMyAppointmentDetailVo> detail(
+ @PathVariable Long id,
+ @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ return ResultUtil.data(miniAppointmentApplicationService.detailMyOrder(id, uuid));
+ }
+
+ @Operation(summary = "小程序审批人已处理预约列表(支持状态/姓名关键词/来访日期)")
+ @GetMapping("/mine")
+ public Result<List<MiniAppointmentListVo>> mine(
+ @RequestParam(defaultValue = "1") long page,
+ @RequestParam(defaultValue = "10") long size,
+ @RequestParam(required = false) String status,
+ @RequestParam(required = false) String visitedUserNameKeyword,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate visitDate) {
+ return ResultUtil.page(
+ miniAppointmentApplicationService.pageMine(
+ page, size, status, visitedUserNameKeyword, visitDate));
+ }
+
+ @Operation(summary = "小程序来访记录列表(已通过且被访人为当前用户)")
+ @GetMapping("/visit-records")
+ public Result<List<MiniVisitRecordListVo>> visitRecords(
+ @RequestParam(defaultValue = "1") long page,
+ @RequestParam(defaultValue = "10") long size,
+ @RequestParam(required = false) String visitorNameKeyword,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate visitDate) {
+ return ResultUtil.page(
+ miniAppointmentApplicationService.pageVisitRecords(
+ page, size, visitorNameKeyword, visitDate));
+ }
+
+ @Operation(summary = "小程序我的预约列表(发起人视角)")
+ @GetMapping("/my-orders")
+ public Result<List<MiniAppointmentListVo>> myOrders(
+ @RequestParam(defaultValue = "1") long page,
+ @RequestParam(defaultValue = "10") long size,
+ @RequestParam(required = false) String status,
+ @RequestParam(required = false) String keyword) {
+ return ResultUtil.page(
+ miniAppointmentApplicationService.pageMyOrders(
+ page, size, status, keyword));
+ }
+
+ @Operation(summary = "小程序首页我的访客码列表")
+ @GetMapping("/my-visitor-codes")
+ public Result<List<MiniMyVisitorCodeOrderVo>> myVisitorCodes() {
+ return ResultUtil.data(miniAppointmentApplicationService.listMyVisitorCodes());
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/MiniApprovalController.java b/backend/src/main/java/com/sinata/appointment/controller/MiniApprovalController.java
new file mode 100644
index 0000000..b274706
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/MiniApprovalController.java
@@ -0,0 +1,94 @@
+package com.sinata.appointment.controller;
+
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.ApprovalFlowNodeVo;
+import com.sinata.appointment.dto.MiniApprovalActionRequest;
+import com.sinata.appointment.dto.MiniApprovalDetailVo;
+import com.sinata.appointment.dto.MiniApprovalTodoVo;
+import com.sinata.appointment.dto.MiniHandlerApprovalActionRequest;
+import com.sinata.appointment.dto.MiniHandlerApprovalTodoVo;
+import com.sinata.appointment.service.ApprovalFlowViewApplicationService;
+import com.sinata.appointment.service.MiniApprovalDetailApplicationService;
+import com.sinata.appointment.service.MiniHandlerApprovalApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import java.time.LocalDate;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.format.annotation.DateTimeFormat;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "小程序审批")
+@RestController
+@RequestMapping("/api/mini/approvals")
+@RequiredArgsConstructor
+public class MiniApprovalController {
+
+ private final MiniHandlerApprovalApplicationService miniHandlerApprovalApplicationService;
+ private final MiniApprovalDetailApplicationService miniApprovalDetailApplicationService;
+ private final ApprovalFlowViewApplicationService approvalFlowViewApplicationService;
+
+ @Operation(summary = "小程序可审批待办列表")
+ @GetMapping("/todo")
+ public Result<List<MiniApprovalTodoVo>> myTodo(
+ @RequestParam(defaultValue = "1") long page,
+ @RequestParam(defaultValue = "10") long size,
+ @RequestParam(required = false) String visitedUserNameKeyword,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate visitDate) {
+ return ResultUtil.page(
+ miniHandlerApprovalApplicationService.pageMyTodoAll(
+ page, size, visitedUserNameKeyword, visitDate));
+ }
+
+ @Operation(summary = "经办人一级审批待办")
+ @GetMapping("/handler/todo")
+ public Result<List<MiniHandlerApprovalTodoVo>> myHandlerTodo(
+ @RequestParam(defaultValue = "1") long page,
+ @RequestParam(defaultValue = "10") long size,
+ @RequestParam(required = false) String visitedUserNameKeyword,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate visitDate) {
+ return ResultUtil.page(
+ miniHandlerApprovalApplicationService.pageMyTodo(
+ page, size, visitedUserNameKeyword, visitDate));
+ }
+
+ @Operation(summary = "经办人一级审批处理")
+ @PostMapping("/handler/action")
+ public Result<Void> handlerAction(@Valid @RequestBody MiniHandlerApprovalActionRequest request) {
+ miniHandlerApprovalApplicationService.handleAction(request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "小程序其他节点审批处理(非经办人一级)")
+ @PostMapping("/action")
+ public Result<Void> action(@Valid @RequestBody MiniApprovalActionRequest request) {
+ miniHandlerApprovalApplicationService.handleOtherAction(request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "预约审批流程树")
+ @GetMapping("/flow-tree")
+ public Result<List<ApprovalFlowNodeVo>> flowTree(
+ @RequestParam Long orderId,
+ @RequestParam @NotBlank(message = "orderUuid 不能为空") String orderUuid) {
+ return ResultUtil.data(approvalFlowViewApplicationService.treeByOrderId(orderId, orderUuid));
+ }
+
+ @Operation(summary = "小程序审批详情")
+ @GetMapping("/detail")
+ public Result<MiniApprovalDetailVo> detail(
+ @RequestParam(required = false) Long taskId,
+ @RequestParam(required = false) String taskUuid,
+ @RequestParam(required = false) Long orderId,
+ @RequestParam(required = false) String orderUuid) {
+ return ResultUtil.data(miniApprovalDetailApplicationService.detail(taskId, taskUuid, orderId, orderUuid));
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java b/backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java
new file mode 100644
index 0000000..ac32cde
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/MiniAuthController.java
@@ -0,0 +1,72 @@
+package com.sinata.appointment.controller;
+
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.MiniLoginResponse;
+import com.sinata.appointment.dto.MiniPhoneLoginRequest;
+import com.sinata.appointment.dto.MiniWxLoginRequest;
+import com.sinata.appointment.entity.MpUser;
+import com.sinata.appointment.entity.SysDept;
+import com.sinata.appointment.service.MiniAuthApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import java.util.HashMap;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+import com.sinata.appointment.mapper.SysDeptMapper;
+
+@Tag(name = "小程序认证")
+@RestController
+@RequestMapping("/api/mini/auth")
+@RequiredArgsConstructor
+public class MiniAuthController {
+
+ private final MiniAuthApplicationService miniAuthApplicationService;
+ private final SysDeptMapper sysDeptMapper;
+
+ @Operation(
+ summary = "微信登录",
+ description =
+ "需同时传 wx.login 的 js_code 与 getPhoneNumber 返回的 phoneCode;"
+ + "员工须预先在 mp_user 登记且分配部门,手机号与登记一致;与平台 sys_user 无关联。")
+ @PostMapping("/login")
+ public Result<MiniLoginResponse> login(@Valid @RequestBody MiniWxLoginRequest request) {
+ return ResultUtil.data(miniAuthApplicationService.loginByWx(request));
+ }
+
+ @Operation(summary = "临时手机号登录(H5联调)")
+ @PostMapping("/phone-login")
+ public Result<MiniLoginResponse> phoneLogin(@Valid @RequestBody MiniPhoneLoginRequest request) {
+ return ResultUtil.data(miniAuthApplicationService.loginByPhone(request.getMobile()));
+ }
+
+ @Operation(summary = "当前小程序用户")
+ @GetMapping("/me")
+ public Result<Map<String, Object>> me() {
+ MpUser u = miniAuthApplicationService.currentMpUser();
+ Map<String, Object> m = new HashMap<>();
+ m.put("mpUserId", u.getId());
+ m.put("userType", u.getUserType());
+ m.put("deptId", u.getDeptId());
+ SysDept dept = u.getDeptId() == null ? null : sysDeptMapper.selectById(u.getDeptId());
+ m.put("deptName", dept == null ? null : dept.getDeptName());
+ m.put("deptLeaderFlag", Boolean.TRUE.equals(u.getDeptLeaderFlag()));
+ m.put("nickName", u.getNickName());
+ m.put("avatarUrl", u.getAvatarUrl());
+ m.put("mobile", u.getMobile());
+ return ResultUtil.data(m);
+ }
+
+ @Operation(summary = "退出")
+ @PostMapping("/logout")
+ public Result<Void> logout() {
+ miniAuthApplicationService.logout();
+ return ResultUtil.success();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/MiniLookupController.java b/backend/src/main/java/com/sinata/appointment/controller/MiniLookupController.java
new file mode 100644
index 0000000..d8ee0b9
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/MiniLookupController.java
@@ -0,0 +1,59 @@
+package com.sinata.appointment.controller;
+
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.MiniFieldConfigVo;
+import com.sinata.appointment.dto.MiniLocalUnitVo;
+import com.sinata.appointment.dto.MiniPassAccessAreaTreeVo;
+import com.sinata.appointment.dto.MiniVisitReasonVo;
+import com.sinata.appointment.dto.MiniVisitedUserVo;
+import com.sinata.appointment.service.MiniLookupApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "小程序字典")
+@RestController
+@RequestMapping("/api/mini/lookups")
+@RequiredArgsConstructor
+public class MiniLookupController {
+
+ private final MiniLookupApplicationService miniLookupApplicationService;
+
+ @Operation(summary = "获取预约字段配置列表(优先缓存)")
+ @GetMapping("/field-configs")
+ public Result<List<MiniFieldConfigVo>> listFieldConfigs() {
+ return ResultUtil.data(miniLookupApplicationService.listFieldConfigs());
+ }
+
+ @Operation(summary = "获取来访事由列表(优先缓存)")
+ @GetMapping("/visit-reasons")
+ public Result<List<MiniVisitReasonVo>> listVisitReasons() {
+ return ResultUtil.data(miniLookupApplicationService.listVisitReasons());
+ }
+
+ @Operation(summary = "按被访人姓名精确匹配搜索")
+ @GetMapping("/visited-users")
+ public Result<List<MiniVisitedUserVo>> searchVisitedUsers(
+ @RequestParam(required = false) String realName) {
+ return ResultUtil.data(miniLookupApplicationService.searchVisitedUsersByExactName(realName));
+ }
+
+ @Operation(summary = "按属地单位名称精确匹配搜索")
+ @GetMapping("/local-units")
+ public Result<List<MiniLocalUnitVo>> searchLocalUnits(
+ @RequestParam(required = false) String unitName) {
+ return ResultUtil.data(miniLookupApplicationService.searchLocalUnitsByExactName(unitName));
+ }
+
+ @Operation(summary = "获取通行区域树")
+ @GetMapping("/pass-access-area-tree")
+ public Result<List<MiniPassAccessAreaTreeVo>> passAccessAreaTree() {
+ return ResultUtil.data(miniLookupApplicationService.listPassAccessAreaTree());
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/MiniSystemSettingController.java b/backend/src/main/java/com/sinata/appointment/controller/MiniSystemSettingController.java
new file mode 100644
index 0000000..ed693d5
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/MiniSystemSettingController.java
@@ -0,0 +1,30 @@
+package com.sinata.appointment.controller;
+
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.service.MiniSystemSettingApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "小程序系统配置")
+@RestController
+@RequestMapping("/api/mini/system-settings")
+@RequiredArgsConstructor
+public class MiniSystemSettingController {
+
+ private final MiniSystemSettingApplicationService miniSystemSettingApplicationService;
+
+ @Operation(summary = "按 code 或 codes 获取系统配置(优先缓存)")
+ @GetMapping
+ public Result<Map<String, String>> getSettings(
+ @RequestParam(required = false) String code, @RequestParam(required = false) String codes) {
+ return ResultUtil.data(miniSystemSettingApplicationService.getByCodeOrCodes(code, codes));
+ }
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/controller/MiniVisitorController.java b/backend/src/main/java/com/sinata/appointment/controller/MiniVisitorController.java
new file mode 100644
index 0000000..aab8e98
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/MiniVisitorController.java
@@ -0,0 +1,63 @@
+package com.sinata.appointment.controller;
+
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.MiniVisitorCreateRequest;
+import com.sinata.appointment.dto.MiniVisitorDetailVo;
+import com.sinata.appointment.dto.MiniVisitorListVo;
+import com.sinata.appointment.dto.MiniVisitorUpdateRequest;
+import com.sinata.appointment.service.MiniVisitorApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "小程序访客")
+@RestController
+@RequestMapping("/api/mini/visitors")
+@RequiredArgsConstructor
+public class MiniVisitorController {
+
+ private final MiniVisitorApplicationService miniVisitorApplicationService;
+
+ @Operation(summary = "新增访客")
+ @PostMapping
+ public Result<Void> create(@Valid @RequestBody MiniVisitorCreateRequest request) {
+ miniVisitorApplicationService.create(request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "我的访客列表")
+ @GetMapping
+ public Result<List<MiniVisitorListVo>> listMine() {
+ return ResultUtil.data(miniVisitorApplicationService.listMine());
+ }
+
+ @Operation(summary = "访客详情")
+ @GetMapping("/{id}")
+ public Result<MiniVisitorDetailVo> detail(
+ @PathVariable Long id,
+ @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ return ResultUtil.data(miniVisitorApplicationService.detail(id, uuid));
+ }
+
+ @Operation(summary = "编辑访客")
+ @PutMapping("/{id}")
+ public Result<Void> update(
+ @PathVariable Long id,
+ @RequestParam @NotBlank(message = "uuid 不能为空") String uuid,
+ @Valid @RequestBody MiniVisitorUpdateRequest request) {
+ miniVisitorApplicationService.update(id, uuid, request);
+ return ResultUtil.success();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/PassAreaManageController.java b/backend/src/main/java/com/sinata/appointment/controller/PassAreaManageController.java
new file mode 100644
index 0000000..058dfa3
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/PassAreaManageController.java
@@ -0,0 +1,81 @@
+package com.sinata.appointment.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.dev33.satoken.annotation.SaMode;
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.PassAreaCreateRequest;
+import com.sinata.appointment.dto.PassAreaDetailVo;
+import com.sinata.appointment.dto.PassAreaListVo;
+import com.sinata.appointment.dto.PassAreaUpdateRequest;
+import com.sinata.appointment.service.PassAreaManageApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import java.util.Map;
+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.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "通行区域管理")
+@RestController
+@RequestMapping("/api/sys/pass-access-areas")
+@RequiredArgsConstructor
+@Validated
+public class PassAreaManageController {
+
+ private final PassAreaManageApplicationService passAreaManageApplicationService;
+
+ @Operation(summary = "分页列表(pid=0 一级;传父级 id 查子区域)")
+ @GetMapping
+ @SaCheckPermission(value = {"sys:pass-areas:page", "appointments:page"}, mode = SaMode.OR)
+ public Result<java.util.List<PassAreaListVo>> page(
+ @RequestParam(defaultValue = "1") long page,
+ @RequestParam(defaultValue = "10") long size,
+ @RequestParam(defaultValue = "0") long pid,
+ @RequestParam(required = false) String areaName) {
+ return ResultUtil.page(
+ passAreaManageApplicationService.page(page, size, pid, areaName));
+ }
+
+ @Operation(summary = "详情(须 uuid)")
+ @GetMapping("/{id}")
+ @SaCheckPermission("sys:pass-areas:detail")
+ public Result<PassAreaDetailVo> detail(
+ @PathVariable Long id, @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ return ResultUtil.data(passAreaManageApplicationService.detail(id, uuid));
+ }
+
+ @Operation(summary = "新增")
+ @PostMapping
+ @SaCheckPermission("sys:pass-areas:create")
+ public Result<Map<String, Object>> create(@Valid @RequestBody PassAreaCreateRequest request) {
+ return ResultUtil.data(passAreaManageApplicationService.create(request));
+ }
+
+ @Operation(summary = "编辑")
+ @PutMapping("/{id}")
+ @SaCheckPermission("sys:pass-areas:update")
+ public Result<Void> update(@PathVariable Long id, @Valid @RequestBody PassAreaUpdateRequest request) {
+ passAreaManageApplicationService.update(id, request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "删除(逻辑删除;有子区域时不允许)")
+ @DeleteMapping("/{id}")
+ @SaCheckPermission("sys:pass-areas:delete")
+ public Result<Void> delete(
+ @PathVariable Long id, @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ passAreaManageApplicationService.delete(id, uuid);
+ return ResultUtil.success();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/StaffGroupManageController.java b/backend/src/main/java/com/sinata/appointment/controller/StaffGroupManageController.java
new file mode 100644
index 0000000..f9e82a4
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/StaffGroupManageController.java
@@ -0,0 +1,105 @@
+package com.sinata.appointment.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.dev33.satoken.annotation.SaMode;
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.StaffGroupAppendMembersRequest;
+import com.sinata.appointment.dto.StaffGroupCreateRequest;
+import com.sinata.appointment.dto.StaffGroupDetailVo;
+import com.sinata.appointment.dto.StaffGroupListVo;
+import com.sinata.appointment.dto.StaffGroupUpdateRequest;
+import com.sinata.appointment.service.StaffGroupManageApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import java.util.Map;
+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.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "人员分组")
+@RestController
+@RequestMapping("/api/sys/staff-groups")
+@RequiredArgsConstructor
+@Validated
+public class StaffGroupManageController {
+
+ private final StaffGroupManageApplicationService staffGroupManageApplicationService;
+
+ @Operation(summary = "分页列表")
+ @GetMapping
+ @SaCheckPermission(
+ value = {"sys:staff-groups:page", "sys:staff:page", "sys:local-units:page"},
+ mode = SaMode.OR)
+ public Result<java.util.List<StaffGroupListVo>> page(
+ @RequestParam(defaultValue = "1") long page,
+ @RequestParam(defaultValue = "10") long size,
+ @RequestParam(required = false) String groupName,
+ @RequestParam(required = false) String leaderMobile) {
+ return ResultUtil.page(
+ staffGroupManageApplicationService.page(page, size, groupName, leaderMobile));
+ }
+
+ @Operation(summary = "详情(须 uuid)")
+ @GetMapping("/{id}")
+ @SaCheckPermission("sys:staff-groups:detail")
+ public Result<StaffGroupDetailVo> detail(
+ @PathVariable Long id, @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ return ResultUtil.data(staffGroupManageApplicationService.detail(id, uuid));
+ }
+
+ @Operation(summary = "新增分组")
+ @PostMapping
+ @SaCheckPermission("sys:staff-groups:create")
+ public Result<Map<String, Object>> create(@Valid @RequestBody StaffGroupCreateRequest request) {
+ return ResultUtil.data(staffGroupManageApplicationService.create(request));
+ }
+
+ @Operation(summary = "编辑分组")
+ @PutMapping("/{id}")
+ @SaCheckPermission("sys:staff-groups:update")
+ public Result<Void> update(@PathVariable Long id, @Valid @RequestBody StaffGroupUpdateRequest request) {
+ staffGroupManageApplicationService.update(id, request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "追加成员(不替换原有关联)")
+ @PostMapping("/{id}/members")
+ @SaCheckPermission("sys:staff-groups:members")
+ public Result<Void> appendMembers(
+ @PathVariable Long id, @Valid @RequestBody StaffGroupAppendMembersRequest request) {
+ staffGroupManageApplicationService.appendMembers(id, request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "移除成员(不能移除负责人)")
+ @DeleteMapping("/{id}/members/{mpUserId}")
+ @SaCheckPermission("sys:staff-groups:members")
+ public Result<Void> removeMember(
+ @PathVariable Long id,
+ @PathVariable Long mpUserId,
+ @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ staffGroupManageApplicationService.removeMember(id, uuid, mpUserId);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "删除分组(逻辑删除)")
+ @DeleteMapping("/{id}")
+ @SaCheckPermission("sys:staff-groups:delete")
+ public Result<Void> delete(
+ @PathVariable Long id, @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ staffGroupManageApplicationService.delete(id, uuid);
+ return ResultUtil.success();
+ }
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/controller/StaffManageController.java b/backend/src/main/java/com/sinata/appointment/controller/StaffManageController.java
new file mode 100644
index 0000000..582afc3
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/StaffManageController.java
@@ -0,0 +1,146 @@
+package com.sinata.appointment.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.dev33.satoken.annotation.SaMode;
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.StaffCreateRequest;
+import com.sinata.appointment.dto.StaffDetailVo;
+import com.sinata.appointment.dto.StaffImportResultVo;
+import com.sinata.appointment.dto.StaffListVo;
+import com.sinata.appointment.dto.StaffStatusPatchRequest;
+import com.sinata.appointment.dto.StaffUpdateRequest;
+import com.sinata.appointment.service.StaffManageApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+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.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+@Tag(name = "员工管理")
+@RestController
+@RequestMapping("/api/sys/staff")
+@RequiredArgsConstructor
+@Validated
+public class StaffManageController {
+
+ private final StaffManageApplicationService staffManageApplicationService;
+
+ @Operation(summary = "分页列表")
+ @GetMapping
+ @SaCheckPermission(
+ value = {"sys:staff:page", "sys:staff-groups:page", "sys:local-units:page"},
+ mode = SaMode.OR)
+ public Result<java.util.List<StaffListVo>> page(
+ @RequestParam(defaultValue = "1") long page,
+ @RequestParam(defaultValue = "10") long size,
+ @RequestParam(required = false) Long deptId,
+ @RequestParam(required = false) String realName,
+ @RequestParam(required = false) String staffNo,
+ @RequestParam(required = false) String mobile,
+ @RequestParam(required = false) String position,
+ @RequestParam(required = false) Integer status,
+ @RequestParam(required = false) Long excludeStaffGroupId,
+ @RequestParam(required = false) Long staffGroupId) {
+ return ResultUtil.page(
+ staffManageApplicationService.page(
+ page,
+ size,
+ deptId,
+ realName,
+ staffNo,
+ mobile,
+ position,
+ status,
+ excludeStaffGroupId,
+ staffGroupId));
+ }
+
+ @Operation(summary = "详情(须 uuid)")
+ @GetMapping("/{id}")
+ @SaCheckPermission(
+ value = {"sys:staff:detail", "sys:staff-groups:detail"},
+ mode = SaMode.OR)
+ public Result<StaffDetailVo> detail(
+ @PathVariable Long id, @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ return ResultUtil.data(staffManageApplicationService.detail(id, uuid));
+ }
+
+ @Operation(summary = "新增(访客同号可升级为员工)")
+ @PostMapping
+ @SaCheckPermission("sys:staff:create")
+ public Result<Map<String, Object>> create(@Valid @RequestBody StaffCreateRequest request) {
+ return ResultUtil.data(staffManageApplicationService.create(request));
+ }
+
+ @Operation(summary = "编辑")
+ @PutMapping("/{id}")
+ @SaCheckPermission("sys:staff:update")
+ public Result<Void> update(@PathVariable Long id, @Valid @RequestBody StaffUpdateRequest request) {
+ staffManageApplicationService.update(id, request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "启停")
+ @PatchMapping("/{id}/status")
+ @SaCheckPermission("sys:staff:status")
+ public Result<Void> status(
+ @PathVariable Long id, @Valid @RequestBody StaffStatusPatchRequest request) {
+ staffManageApplicationService.patchStatus(id, request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "删除(员工降级为访客)")
+ @DeleteMapping("/{id}")
+ @SaCheckPermission("sys:staff:delete")
+ public Result<Void> delete(
+ @PathVariable Long id, @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ staffManageApplicationService.delete(id, uuid);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "下载 XLSX 导入模板")
+ @GetMapping("/import-template")
+ @SaCheckPermission("sys:staff:import")
+ public ResponseEntity<byte[]> importTemplate() {
+ byte[] body = staffManageApplicationService.importTemplateExcel();
+ return ResponseEntity.ok()
+ .contentType(
+ MediaType.parseMediaType(
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
+ .header(
+ HttpHeaders.CONTENT_DISPOSITION,
+ "attachment; filename=\"staff-import-template.xlsx\"")
+ .body(body);
+ }
+
+ @Operation(summary = "批量导入 XLSX")
+ @PostMapping("/import")
+ @SaCheckPermission("sys:staff:import")
+ public Result<StaffImportResultVo> importCsv(@RequestParam("file") MultipartFile file) {
+ return ResultUtil.data(staffManageApplicationService.importExcel(file));
+ }
+
+ @Operation(summary = "上传员工头像")
+ @PostMapping("/avatar")
+ @SaCheckPermission(value = {"sys:staff:create", "sys:staff:update"}, mode = SaMode.OR)
+ public Result<Map<String, String>> uploadAvatar(@RequestParam("file") MultipartFile file) {
+ return ResultUtil.data(Map.of("avatarUrl", staffManageApplicationService.uploadAvatar(file)));
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/SysLookupController.java b/backend/src/main/java/com/sinata/appointment/controller/SysLookupController.java
new file mode 100644
index 0000000..c73dfa4
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/SysLookupController.java
@@ -0,0 +1,63 @@
+package com.sinata.appointment.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.dev33.satoken.annotation.SaMode;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.entity.SysDept;
+import com.sinata.appointment.entity.SysRole;
+import com.sinata.appointment.mapper.SysDeptMapper;
+import com.sinata.appointment.mapper.SysRoleMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "系统字典")
+@RestController
+@RequestMapping("/api/sys")
+@RequiredArgsConstructor
+public class SysLookupController {
+
+ private final SysDeptMapper sysDeptMapper;
+ private final SysRoleMapper sysRoleMapper;
+
+ @Operation(summary = "部门列表")
+ @GetMapping("/depts")
+ @SaCheckPermission(
+ value = {
+ "appointments:page",
+ "config:approval-settings:page",
+ "sys:depts:page",
+ "sys:staff:page",
+ "sys:staff-groups:page",
+ "sys:local-units:page"
+ },
+ mode = SaMode.OR)
+ public Result<List<SysDept>> depts() {
+ List<SysDept> list =
+ sysDeptMapper.selectList(
+ new LambdaQueryWrapper<SysDept>()
+ .eq(SysDept::getDelFlag, 0)
+ .eq(SysDept::getStatus, 1)
+ .orderByAsc(SysDept::getSortOrder)
+ .orderByAsc(SysDept::getId));
+ return ResultUtil.data(list);
+ }
+
+ @Operation(summary = "角色列表")
+ @GetMapping("/roles")
+ @SaCheckPermission(value = {"sys:users:page", "sys:roles:page"}, mode = SaMode.OR)
+ public Result<List<SysRole>> roles() {
+ List<SysRole> list =
+ sysRoleMapper.selectList(
+ new LambdaQueryWrapper<SysRole>()
+ .eq(SysRole::getDelFlag, 0)
+ .orderByAsc(SysRole::getId));
+ return ResultUtil.data(list);
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/SysMenuController.java b/backend/src/main/java/com/sinata/appointment/controller/SysMenuController.java
new file mode 100644
index 0000000..b703881
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/SysMenuController.java
@@ -0,0 +1,33 @@
+package com.sinata.appointment.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.dev33.satoken.annotation.SaMode;
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.MenuTreeNodeVo;
+import com.sinata.appointment.service.SysRoleManageApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "系统菜单")
+@RestController
+@RequestMapping("/api/sys/menus")
+@RequiredArgsConstructor
+public class SysMenuController {
+
+ private final SysRoleManageApplicationService sysRoleManageApplicationService;
+
+ @Operation(summary = "菜单树(板块→页面→权限,数据由库维护)")
+ @GetMapping("/tree")
+ @SaCheckPermission(
+ value = {"sys:roles:page", "sys:roles:create", "sys:roles:update", "sys:roles:detail"},
+ mode = SaMode.OR)
+ public Result<List<MenuTreeNodeVo>> tree() {
+ return ResultUtil.data(sysRoleManageApplicationService.menuTree());
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/SysRoleManageController.java b/backend/src/main/java/com/sinata/appointment/controller/SysRoleManageController.java
new file mode 100644
index 0000000..c310e12
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/SysRoleManageController.java
@@ -0,0 +1,79 @@
+package com.sinata.appointment.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.RoleManageCreateRequest;
+import com.sinata.appointment.dto.RoleManageDetailVo;
+import com.sinata.appointment.dto.RoleManageListVo;
+import com.sinata.appointment.dto.RoleManageUpdateRequest;
+import com.sinata.appointment.service.SysRoleManageApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import java.util.Map;
+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.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "角色管理")
+@RestController
+@RequestMapping("/api/sys/role-manage")
+@RequiredArgsConstructor
+@Validated
+public class SysRoleManageController {
+
+ private final SysRoleManageApplicationService sysRoleManageApplicationService;
+
+ @Operation(summary = "分页(角色名称模糊筛选)")
+ @GetMapping
+ @SaCheckPermission("sys:roles:page")
+ public Result<java.util.List<RoleManageListVo>> page(
+ @RequestParam(defaultValue = "1") long page,
+ @RequestParam(defaultValue = "10") long size,
+ @RequestParam(required = false) String roleName) {
+ return ResultUtil.page(sysRoleManageApplicationService.pageRoles(page, size, roleName));
+ }
+
+ @Operation(summary = "详情(须 uuid)")
+ @GetMapping("/{id}")
+ @SaCheckPermission("sys:roles:detail")
+ public Result<RoleManageDetailVo> detail(
+ @PathVariable Long id, @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ return ResultUtil.data(sysRoleManageApplicationService.getDetail(id, uuid));
+ }
+
+ @Operation(summary = "新增(角色编码后台自动生成)")
+ @PostMapping
+ @SaCheckPermission("sys:roles:create")
+ public Result<Map<String, Object>> create(@Valid @RequestBody RoleManageCreateRequest request) {
+ return ResultUtil.data(sysRoleManageApplicationService.create(request));
+ }
+
+ @Operation(summary = "编辑(body 须含 uuid)")
+ @PutMapping("/{id}")
+ @SaCheckPermission("sys:roles:update")
+ public Result<Void> update(
+ @PathVariable Long id, @Valid @RequestBody RoleManageUpdateRequest request) {
+ sysRoleManageApplicationService.update(id, request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "删除(逻辑删除;仅当关联账号数为 0)")
+ @DeleteMapping("/{id}")
+ @SaCheckPermission("sys:roles:delete")
+ public Result<Void> delete(
+ @PathVariable Long id, @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ sysRoleManageApplicationService.delete(id, uuid);
+ return ResultUtil.success();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/SysUserController.java b/backend/src/main/java/com/sinata/appointment/controller/SysUserController.java
new file mode 100644
index 0000000..611d068
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/SysUserController.java
@@ -0,0 +1,105 @@
+package com.sinata.appointment.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import cn.dev33.satoken.stp.StpUtil;
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.ResetPasswordRequest;
+import com.sinata.appointment.dto.SysUserCreateRequest;
+import com.sinata.appointment.dto.SysUserDetailVo;
+import com.sinata.appointment.dto.SysUserListVo;
+import com.sinata.appointment.dto.SysUserUpdateRequest;
+import com.sinata.appointment.dto.UserStatusPatchRequest;
+import com.sinata.appointment.service.SysUserApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import java.util.Map;
+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.PatchMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "系统用户")
+@RestController
+@RequestMapping("/api/sys/users")
+@RequiredArgsConstructor
+@Validated
+public class SysUserController {
+
+ private final SysUserApplicationService sysUserApplicationService;
+
+ @Operation(summary = "分页(手机号为脱敏字段)")
+ @GetMapping
+ @SaCheckPermission("sys:users:page")
+ public Result<java.util.List<SysUserListVo>> page(
+ @RequestParam(defaultValue = "1") long page,
+ @RequestParam(defaultValue = "10") long size,
+ @RequestParam(required = false) String realName,
+ @RequestParam(required = false) String mobile,
+ @RequestParam(required = false) Long roleId,
+ @RequestParam(required = false) Integer status) {
+ return ResultUtil.page(
+ sysUserApplicationService.pageUsers(page, size, realName, mobile, roleId, status));
+ }
+
+ @Operation(summary = "详情(须同时传 uuid 防越权)")
+ @GetMapping("/{id}")
+ @SaCheckPermission("sys:users:detail")
+ public Result<SysUserDetailVo> detail(
+ @PathVariable Long id, @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ return ResultUtil.data(sysUserApplicationService.getDetail(id, uuid));
+ }
+
+ @Operation(summary = "新增")
+ @PostMapping
+ @SaCheckPermission("sys:users:create")
+ public Result<Map<String, Object>> create(@Valid @RequestBody SysUserCreateRequest request) {
+ return ResultUtil.data(sysUserApplicationService.createUser(request));
+ }
+
+ @Operation(summary = "修改(body 须含 uuid,不可改登录名与密码)")
+ @PutMapping("/{id}")
+ @SaCheckPermission("sys:users:update")
+ public Result<Void> update(@PathVariable Long id, @Valid @RequestBody SysUserUpdateRequest request) {
+ sysUserApplicationService.updateUser(id, request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "重置密码(body 须含 uuid)")
+ @PostMapping("/{id}/password")
+ @SaCheckPermission("sys:users:password")
+ public Result<Void> resetPassword(
+ @PathVariable Long id, @Valid @RequestBody ResetPasswordRequest request) {
+ sysUserApplicationService.resetPassword(id, request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "启用/禁用(body 须含 uuid)")
+ @PatchMapping("/{id}/status")
+ @SaCheckPermission("sys:users:status")
+ public Result<Void> status(
+ @PathVariable Long id, @Valid @RequestBody UserStatusPatchRequest request) {
+ sysUserApplicationService.patchStatus(id, request, StpUtil.getLoginIdAsLong());
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "删除(逻辑删除,须传 uuid)")
+ @DeleteMapping("/{id}")
+ @SaCheckPermission("sys:users:delete")
+ public Result<Void> delete(
+ @PathVariable Long id,
+ @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ sysUserApplicationService.deleteUser(id, uuid, StpUtil.getLoginIdAsLong());
+ return ResultUtil.success();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/controller/VisitorUserManageController.java b/backend/src/main/java/com/sinata/appointment/controller/VisitorUserManageController.java
new file mode 100644
index 0000000..4871501
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/controller/VisitorUserManageController.java
@@ -0,0 +1,116 @@
+package com.sinata.appointment.controller;
+
+import cn.dev33.satoken.annotation.SaCheckPermission;
+import com.sinata.appointment.common.Result;
+import com.sinata.appointment.common.ResultUtil;
+import com.sinata.appointment.dto.VisitorUserDetailVo;
+import com.sinata.appointment.dto.VisitorUserListVo;
+import com.sinata.appointment.dto.VisitorUserStatusPatchRequest;
+import com.sinata.appointment.dto.VisitorUserUpdateRequest;
+import com.sinata.appointment.service.VisitorUserManageApplicationService;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import java.time.LocalDate;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.format.annotation.DateTimeFormat;
+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.PatchMapping;
+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.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@Tag(name = "访客信息管理")
+@RestController
+@RequestMapping("/api/sys/visitor-users")
+@RequiredArgsConstructor
+@Validated
+public class VisitorUserManageController {
+
+ private final VisitorUserManageApplicationService visitorUserManageApplicationService;
+
+ @Operation(summary = "分页列表")
+ @GetMapping
+ @SaCheckPermission("sys:visitor-users:page")
+ public Result<java.util.List<VisitorUserListVo>> page(
+ @RequestParam(defaultValue = "1") long page,
+ @RequestParam(defaultValue = "10") long size,
+ @RequestParam(required = false) String userName,
+ @RequestParam(required = false) String mobile,
+ @RequestParam(required = false) String companyName,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate registerStartDate,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate registerEndDate,
+ @RequestParam(required = false) Integer status) {
+ return ResultUtil.page(
+ visitorUserManageApplicationService.page(
+ page, size, userName, mobile, companyName, registerStartDate, registerEndDate, status));
+ }
+
+ @Operation(summary = "导出 Excel")
+ @GetMapping("/export")
+ @SaCheckPermission("sys:visitor-users:export")
+ public ResponseEntity<byte[]> export(
+ @RequestParam(required = false) String userName,
+ @RequestParam(required = false) String mobile,
+ @RequestParam(required = false) String companyName,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate registerStartDate,
+ @RequestParam(required = false) @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate registerEndDate,
+ @RequestParam(required = false) Integer status) {
+ byte[] body =
+ visitorUserManageApplicationService.exportExcel(
+ userName, mobile, companyName, registerStartDate, registerEndDate, status);
+ return ResponseEntity.ok()
+ .contentType(
+ MediaType.parseMediaType(
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"))
+ .header(
+ HttpHeaders.CONTENT_DISPOSITION,
+ "attachment; filename=\"visitor-users.xlsx\"")
+ .body(body);
+ }
+
+ @Operation(summary = "详情(须 uuid)")
+ @GetMapping("/{id}")
+ @SaCheckPermission("sys:visitor-users:detail")
+ public Result<VisitorUserDetailVo> detail(
+ @PathVariable Long id, @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ return ResultUtil.data(visitorUserManageApplicationService.detail(id, uuid));
+ }
+
+ @Operation(summary = "编辑")
+ @PutMapping("/{id}")
+ @SaCheckPermission("sys:visitor-users:update")
+ public Result<Void> update(
+ @PathVariable Long id, @Valid @RequestBody VisitorUserUpdateRequest request) {
+ visitorUserManageApplicationService.update(id, request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "启用/禁用")
+ @PatchMapping("/{id}/status")
+ @SaCheckPermission("sys:visitor-users:status")
+ public Result<Void> status(
+ @PathVariable Long id, @Valid @RequestBody VisitorUserStatusPatchRequest request) {
+ visitorUserManageApplicationService.patchStatus(id, request);
+ return ResultUtil.success();
+ }
+
+ @Operation(summary = "删除(逻辑删除,须传 uuid)")
+ @DeleteMapping("/{id}")
+ @SaCheckPermission("sys:visitor-users:delete")
+ public Result<Void> delete(
+ @PathVariable Long id,
+ @RequestParam @NotBlank(message = "uuid 不能为空") String uuid) {
+ visitorUserManageApplicationService.delete(id, uuid);
+ return ResultUtil.success();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/AppointmentAdminDetailVisitorVo.java b/backend/src/main/java/com/sinata/appointment/dto/AppointmentAdminDetailVisitorVo.java
new file mode 100644
index 0000000..97cc8d7
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/AppointmentAdminDetailVisitorVo.java
@@ -0,0 +1,16 @@
+package com.sinata.appointment.dto;
+
+import lombok.Data;
+
+@Data
+public class AppointmentAdminDetailVisitorVo {
+
+ private Long visitorId;
+ private String name;
+ private String gender;
+ private String idCardMasked;
+ private String mobileMasked;
+ private String companyName;
+ private String plateNo;
+ private String driverName;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/AppointmentAdminDetailVo.java b/backend/src/main/java/com/sinata/appointment/dto/AppointmentAdminDetailVo.java
new file mode 100644
index 0000000..6b1f276
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/AppointmentAdminDetailVo.java
@@ -0,0 +1,27 @@
+package com.sinata.appointment.dto;
+
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+
+@Data
+public class AppointmentAdminDetailVo {
+
+ private Long id;
+ private String uuid;
+ private String orderNo;
+ private String visitTypeText;
+ private String visitedUserName;
+ private String visitedDeptName;
+ private String accessAreaText;
+ private String localUnitText;
+ private String plateNoText;
+ private String visitReasonText;
+ private String visitStartText;
+ private String visitEndText;
+ private String displayStatusCode;
+ private String displayStatusText;
+ private String approvalStatusCode;
+ private String approvalStatusText;
+ private List<AppointmentAdminDetailVisitorVo> visitors = new ArrayList<>();
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/AppointmentAdminExportRow.java b/backend/src/main/java/com/sinata/appointment/dto/AppointmentAdminExportRow.java
new file mode 100644
index 0000000..b97ae31
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/AppointmentAdminExportRow.java
@@ -0,0 +1,68 @@
+package com.sinata.appointment.dto;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+import lombok.Data;
+
+@Data
+public class AppointmentAdminExportRow {
+
+ @ExcelProperty({"受访信息", "来访类型"})
+ private String visitTypeText;
+
+ @ExcelProperty({"受访信息", "被访人"})
+ private String visitedUserName;
+
+ @ExcelProperty({"受访信息", "受访区域"})
+ private String accessAreaText;
+
+ @ExcelProperty({"受访信息", "涉及属地单位"})
+ private String localUnitText;
+
+ @ExcelProperty({"受访信息", "车牌号"})
+ private String plateNoText;
+
+ @ExcelProperty({"受访信息", "来访事由"})
+ private String visitReasonText;
+
+ @ExcelProperty({"受访信息", "来访时间"})
+ private String visitTimeText;
+
+ @ExcelProperty({"受访信息", "状态"})
+ private String displayStatusText;
+
+ @ExcelProperty({"访客", "访客"})
+ private String visitorLabel;
+
+ @ExcelProperty({"访客", "姓名"})
+ private String visitorName;
+
+ @ExcelProperty({"访客", "身份证"})
+ private String idCardText;
+
+ @ExcelProperty({"访客", "联系电话"})
+ private String mobileText;
+
+ @ExcelProperty({"访客", "公司"})
+ private String companyName;
+
+ @ExcelProperty({"审核", "经办人"})
+ private String handlerApproverNames;
+
+ @ExcelProperty({"审核", "部门负责人"})
+ private String deptLeaderApproverNames;
+
+ @ExcelProperty({"审核", "访客主管部门"})
+ private String visitDeptApproverNames;
+
+ @ExcelProperty({"审核", "访客主管部门领导"})
+ private String visitDeptLeaderApproverNames;
+
+ @ExcelProperty({"审核", "属地单位领导"})
+ private String localDeptLeaderApproverNames;
+
+ @ExcelProperty({"提交人", "电话"})
+ private String submitterMobile;
+
+ @ExcelProperty({"提交人", "提交时间"})
+ private String submitTimeText;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/AppointmentAdminListVo.java b/backend/src/main/java/com/sinata/appointment/dto/AppointmentAdminListVo.java
new file mode 100644
index 0000000..403ef07
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/AppointmentAdminListVo.java
@@ -0,0 +1,29 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import lombok.Data;
+
+@Data
+@Schema(description = "后台预约记录列表项")
+public class AppointmentAdminListVo {
+
+ private Long id;
+ private String uuid;
+ private String orderNo;
+ private String visitTypeText;
+ private String visitedUserName;
+ private String visitedDeptName;
+ private String accessAreaText;
+ private String localUnitText;
+ private String plateNoText;
+ private String visitReasonText;
+ private String visitTimeText;
+ private String displayStatusCode;
+ private String displayStatusText;
+ private String approvalStatusCode;
+ private String approvalStatusText;
+ private String visitorNamesText;
+ private String status;
+ private LocalDateTime createTime;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/AppointmentCreateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/AppointmentCreateRequest.java
new file mode 100644
index 0000000..42b1996
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/AppointmentCreateRequest.java
@@ -0,0 +1,51 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDate;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "创建预约单")
+public class AppointmentCreateRequest {
+
+ @NotNull
+ @Schema(description = "访问类型 VISITOR / VEHICLE")
+ private String visitType;
+
+ @NotNull
+ @Schema(description = "被访人用户 id")
+ private Long visitedUserId;
+
+ @NotNull
+ @Schema(description = "被访人所属部门 id(用于审批二级节点匹配部门负责人)")
+ private Long visitedDeptId;
+
+ @Schema(description = "来访事由编码")
+ private String visitReasonCode;
+
+ @Schema(description = "来访单位")
+ private String visitorOrg;
+
+ @NotNull
+ @Schema(description = "拜访开始")
+ private LocalDate visitStartDate;
+
+ @NotNull
+ @Schema(description = "拜访结束")
+ private LocalDate visitEndDate;
+
+ @Schema(description = "是否节假日访问")
+ private Boolean holidayFlag;
+
+ @Schema(description = "提交人用户ID(mp_user.id)")
+ private Long submitterId;
+
+ @NotEmpty
+ @Valid
+ @Schema(description = "访客列表")
+ private List<VisitorItemRequest> visitors;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/ApprovalActionRequest.java b/backend/src/main/java/com/sinata/appointment/dto/ApprovalActionRequest.java
new file mode 100644
index 0000000..cdfbcea
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/ApprovalActionRequest.java
@@ -0,0 +1,26 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Data
+@Schema(description = "审批操作")
+public class ApprovalActionRequest {
+
+ @NotNull
+ @Schema(description = "任务 id")
+ private Long taskId;
+
+ @NotBlank
+ @Schema(description = "任务 uuid")
+ private String taskUuid;
+
+ @NotNull
+ @Schema(description = "是否同意")
+ private Boolean approve;
+
+ @Schema(description = "意见/拒绝原因")
+ private String comment;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/ApprovalFlowGroupVo.java b/backend/src/main/java/com/sinata/appointment/dto/ApprovalFlowGroupVo.java
new file mode 100644
index 0000000..ca94762
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/ApprovalFlowGroupVo.java
@@ -0,0 +1,26 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "审批流程树-审批组")
+public class ApprovalFlowGroupVo {
+
+ @Schema(description = "组key")
+ private String groupKey;
+
+ @Schema(description = "组类型 DEFAULT/DEPT/STAFF_GROUP/TEMP_GROUP")
+ private String groupType;
+
+ @Schema(description = "组名称")
+ private String groupName;
+
+ @Schema(description = "组状态")
+ private String groupStatus;
+
+ @Schema(description = "审批人列表")
+ private List<ApprovalFlowTaskUserVo> users = new ArrayList<>();
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/ApprovalFlowNodeVo.java b/backend/src/main/java/com/sinata/appointment/dto/ApprovalFlowNodeVo.java
new file mode 100644
index 0000000..bbbb1e6
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/ApprovalFlowNodeVo.java
@@ -0,0 +1,23 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "审批流程树-节点")
+public class ApprovalFlowNodeVo {
+
+ @Schema(description = "节点编码")
+ private String nodeCode;
+
+ @Schema(description = "节点名称")
+ private String nodeName;
+
+ @Schema(description = "节点状态")
+ private String nodeStatus;
+
+ @Schema(description = "审批组列表")
+ private List<ApprovalFlowGroupVo> groups = new ArrayList<>();
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/ApprovalFlowTaskUserVo.java b/backend/src/main/java/com/sinata/appointment/dto/ApprovalFlowTaskUserVo.java
new file mode 100644
index 0000000..532059d
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/ApprovalFlowTaskUserVo.java
@@ -0,0 +1,31 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import lombok.Data;
+
+@Data
+@Schema(description = "审批流程树-审批人")
+public class ApprovalFlowTaskUserVo {
+
+ @Schema(description = "审批任务ID")
+ private Long taskId;
+
+ @Schema(description = "审批人ID")
+ private Long userId;
+
+ @Schema(description = "审批人姓名")
+ private String userName;
+
+ @Schema(description = "审批人头像")
+ private String avatarUrl;
+
+ @Schema(description = "任务状态")
+ private String taskStatus;
+
+ @Schema(description = "审批意见")
+ private String comment;
+
+ @Schema(description = "处理时间")
+ private LocalDateTime actionTime;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/ApprovalNodeEnabledPatchRequest.java b/backend/src/main/java/com/sinata/appointment/dto/ApprovalNodeEnabledPatchRequest.java
new file mode 100644
index 0000000..1592e1e
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/ApprovalNodeEnabledPatchRequest.java
@@ -0,0 +1,21 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Data
+public class ApprovalNodeEnabledPatchRequest {
+
+ @NotBlank(message = "uuid 不能为空")
+ private String uuid;
+
+ @Schema(description = "1 开启 0 关闭")
+ @NotNull(message = "enabled 不能为空")
+ @Min(0)
+ @Max(1)
+ private Integer enabled;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/BookingSettingsSaveRequest.java b/backend/src/main/java/com/sinata/appointment/dto/BookingSettingsSaveRequest.java
new file mode 100644
index 0000000..c8cf0fe
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/BookingSettingsSaveRequest.java
@@ -0,0 +1,24 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.util.List;
+import lombok.Data;
+
+@Data
+public class BookingSettingsSaveRequest {
+
+ @NotBlank(message = "rangeMode 不能为空")
+ @Schema(description = "SINGLE 或 MULTI")
+ private String rangeMode;
+
+ @Schema(description = "多日模式下最长连续天数,正整数")
+ private Integer maxConsecutiveDays;
+
+ @Schema(description = "节假日日期 yyyy-MM-dd")
+ private List<String> holidayDates;
+
+ @NotNull(message = "vehicleBarrierEnabled 不能为空")
+ private Boolean vehicleBarrierEnabled;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/BookingSettingsVo.java b/backend/src/main/java/com/sinata/appointment/dto/BookingSettingsVo.java
new file mode 100644
index 0000000..28454d9
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/BookingSettingsVo.java
@@ -0,0 +1,22 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+
+@Data
+public class BookingSettingsVo {
+
+ @Schema(description = "SINGLE 单日 / MULTI 多日")
+ private String rangeMode = "SINGLE";
+
+ @Schema(description = "最长连续预约天数(多日模式)")
+ private Integer maxConsecutiveDays = 7;
+
+ @Schema(description = "节假日/高温假日期 yyyy-MM-dd")
+ private List<String> holidayDates = new ArrayList<>();
+
+ @Schema(description = "车辆道闸下放权限")
+ private Boolean vehicleBarrierEnabled = false;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/CaptchaResponse.java b/backend/src/main/java/com/sinata/appointment/dto/CaptchaResponse.java
new file mode 100644
index 0000000..8f1ce3b
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/CaptchaResponse.java
@@ -0,0 +1,17 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+@Schema(description = "图形验证码")
+public class CaptchaResponse {
+
+ @Schema(description = "验证码标识,登录时原样回传")
+ private String captchaId;
+
+ @Schema(description = "PNG 图片 Base64(不含 data: 前缀)")
+ private String imageBase64;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/ContentSettingsSaveRequest.java b/backend/src/main/java/com/sinata/appointment/dto/ContentSettingsSaveRequest.java
new file mode 100644
index 0000000..c578258
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/ContentSettingsSaveRequest.java
@@ -0,0 +1,23 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Data
+public class ContentSettingsSaveRequest {
+
+ @Schema(description = "关于我们(HTML)")
+ private String aboutUsHtml;
+
+ @Schema(description = "入园须知(HTML)")
+ private String parkNoticeHtml;
+
+ @Schema(description = "入园流程(HTML)")
+ private String parkFlowHtml;
+
+ @Schema(description = "关于我们是否启用")
+ @NotNull(message = "关于我们启用状态不能为空")
+ private Boolean aboutUsEnabled;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/dto/ContentSettingsVo.java b/backend/src/main/java/com/sinata/appointment/dto/ContentSettingsVo.java
new file mode 100644
index 0000000..f139b45
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/ContentSettingsVo.java
@@ -0,0 +1,21 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+public class ContentSettingsVo {
+
+ @Schema(description = "关于我们(HTML)")
+ private String aboutUsHtml;
+
+ @Schema(description = "入园须知(HTML)")
+ private String parkNoticeHtml;
+
+ @Schema(description = "入园流程(HTML)")
+ private String parkFlowHtml;
+
+ @Schema(description = "关于我们是否启用")
+ private Boolean aboutUsEnabled;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/dto/DeptCreateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/DeptCreateRequest.java
new file mode 100644
index 0000000..482d788
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/DeptCreateRequest.java
@@ -0,0 +1,23 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+@Schema(description = "新增部门,parentId 为 0 或省略表示一级部门")
+public class DeptCreateRequest {
+
+ @Schema(description = "上级部门 id,0 表示一级部门")
+ private Long parentId;
+
+ @NotBlank
+ @Size(max = 128)
+ private String deptName;
+
+ @NotNull
+ @Schema(description = "1 启用 0 禁用")
+ private Integer status;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/DeptTreeNodeVo.java b/backend/src/main/java/com/sinata/appointment/dto/DeptTreeNodeVo.java
new file mode 100644
index 0000000..27130fd
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/DeptTreeNodeVo.java
@@ -0,0 +1,23 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "部门树节点(管理端)")
+public class DeptTreeNodeVo {
+
+ private Long id;
+ private String uuid;
+ private Long parentId;
+ private String deptName;
+ private Integer sortOrder;
+ private Integer status;
+
+ @Schema(description = "从根算起层级,根为 1,最大 5")
+ private int level;
+
+ private List<DeptTreeNodeVo> children = new ArrayList<>();
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/DeptUpdateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/DeptUpdateRequest.java
new file mode 100644
index 0000000..6da7510
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/DeptUpdateRequest.java
@@ -0,0 +1,27 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+@Schema(description = "编辑部门;一级部门不传 parentId;非一级可改上级")
+public class DeptUpdateRequest {
+
+ @NotBlank
+ @Schema(description = "与路径 id 对应,防越权")
+ private String uuid;
+
+ @NotBlank
+ @Size(max = 128)
+ private String deptName;
+
+ @NotNull
+ @Schema(description = "1 启用 0 禁用")
+ private Integer status;
+
+ @Schema(description = "上级部门 id(仅非一级部门有效;不传则保持原上级)")
+ private Long parentId;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/FieldConfigPatchRequest.java b/backend/src/main/java/com/sinata/appointment/dto/FieldConfigPatchRequest.java
new file mode 100644
index 0000000..bc42673
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/FieldConfigPatchRequest.java
@@ -0,0 +1,24 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.Max;
+import jakarta.validation.constraints.Min;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+public class FieldConfigPatchRequest {
+
+ @NotBlank(message = "uuid 不能为空")
+ private String uuid;
+
+ @Schema(description = "是否启用 1/0,不传则不修改")
+ @Min(0)
+ @Max(1)
+ private Integer visible;
+
+ @Schema(description = "是否必填 1/0,不传则不修改")
+ @Min(0)
+ @Max(1)
+ private Integer requiredFlag;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/LocalUnitCreateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/LocalUnitCreateRequest.java
new file mode 100644
index 0000000..936ad8e
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/LocalUnitCreateRequest.java
@@ -0,0 +1,31 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.util.List;
+import lombok.Data;
+
+@Data
+public class LocalUnitCreateRequest {
+
+ @Schema(description = "单位名称")
+ @NotBlank(message = "单位名称不能为空")
+ private String unitName;
+
+ @Schema(description = "备注")
+ private String remark;
+
+ @Schema(description = "审批类型:1部门 2人员分组 3人员")
+ @NotNull(message = "审批人员类型不能为空")
+ private Integer approverType;
+
+ @Schema(description = "部门 id 列表(类型为部门时必填)")
+ private List<Long> approverDeptIds;
+
+ @Schema(description = "人员分组 id(类型为人员分组时必填)")
+ private Long approverGroupId;
+
+ @Schema(description = "人员 mpUserId 列表(类型为人员时必填)")
+ private List<Long> memberIds;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/LocalUnitDetailVo.java b/backend/src/main/java/com/sinata/appointment/dto/LocalUnitDetailVo.java
new file mode 100644
index 0000000..332499d
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/LocalUnitDetailVo.java
@@ -0,0 +1,31 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+
+@Data
+public class LocalUnitDetailVo {
+
+ private Long id;
+ private String uuid;
+
+ @Schema(description = "单位名称")
+ private String unitName;
+
+ @Schema(description = "备注")
+ private String remark;
+
+ @Schema(description = "审批类型:1部门 2人员分组 3人员")
+ private Integer approverType;
+
+ @Schema(description = "部门 id 列表(类型为部门时)")
+ private List<Long> approverDeptIds = new ArrayList<>();
+
+ @Schema(description = "人员分组 id")
+ private Long approverGroupId;
+
+ @Schema(description = "已选人员(类型为人员时)")
+ private List<LocalUnitMemberItemVo> members = new ArrayList<>();
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/LocalUnitListVo.java b/backend/src/main/java/com/sinata/appointment/dto/LocalUnitListVo.java
new file mode 100644
index 0000000..7f646d3
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/LocalUnitListVo.java
@@ -0,0 +1,23 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import lombok.Data;
+
+@Data
+public class LocalUnitListVo {
+
+ private Long id;
+ private String uuid;
+
+ @Schema(description = "单位名称")
+ private String unitName;
+
+ @Schema(description = "审批人员摘要")
+ private String approverSummary;
+
+ @Schema(description = "备注")
+ private String remark;
+
+ private LocalDateTime createTime;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/LocalUnitMemberItemVo.java b/backend/src/main/java/com/sinata/appointment/dto/LocalUnitMemberItemVo.java
new file mode 100644
index 0000000..f760dca
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/LocalUnitMemberItemVo.java
@@ -0,0 +1,12 @@
+package com.sinata.appointment.dto;
+
+import lombok.Data;
+
+@Data
+public class LocalUnitMemberItemVo {
+
+ private Long mpUserId;
+ private String uuid;
+ private String realName;
+ private String deptName;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/LocalUnitUpdateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/LocalUnitUpdateRequest.java
new file mode 100644
index 0000000..63b5485
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/LocalUnitUpdateRequest.java
@@ -0,0 +1,26 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.util.List;
+import lombok.Data;
+
+@Data
+public class LocalUnitUpdateRequest {
+
+ @NotBlank(message = "uuid 不能为空")
+ private String uuid;
+
+ @NotBlank(message = "单位名称不能为空")
+ private String unitName;
+
+ private String remark;
+
+ @NotNull(message = "审批人员类型不能为空")
+ private Integer approverType;
+
+ private List<Long> approverDeptIds;
+ private Long approverGroupId;
+ private List<Long> memberIds;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/LoginRequest.java b/backend/src/main/java/com/sinata/appointment/dto/LoginRequest.java
new file mode 100644
index 0000000..d9a9930
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/LoginRequest.java
@@ -0,0 +1,26 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+@Schema(description = "登录请求")
+public class LoginRequest {
+
+ @NotBlank
+ @Schema(description = "用户名")
+ private String username;
+
+ @NotBlank
+ @Schema(description = "密码")
+ private String password;
+
+ @NotBlank
+ @Schema(description = "图形验证码标识(GET /api/auth/captcha 返回)")
+ private String captchaId;
+
+ @NotBlank
+ @Schema(description = "用户输入的验证码")
+ private String captchaCode;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/LoginResponse.java b/backend/src/main/java/com/sinata/appointment/dto/LoginResponse.java
new file mode 100644
index 0000000..0ce26df
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/LoginResponse.java
@@ -0,0 +1,30 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.List;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+@Schema(description = "登录响应")
+public class LoginResponse {
+
+ @Schema(description = "Sa-Token token")
+ private String token;
+
+ @Schema(description = "用户 id")
+ private Long userId;
+
+ @Schema(description = "显示名")
+ private String displayName;
+
+ @Schema(description = "角色编码列表")
+ private List<String> roles;
+
+ @Schema(description = "权限编码列表")
+ private List<String> permissions;
+
+ @Schema(description = "头像 URL")
+ private String avatarUrl;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MenuTreeNodeVo.java b/backend/src/main/java/com/sinata/appointment/dto/MenuTreeNodeVo.java
new file mode 100644
index 0000000..e25565e
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MenuTreeNodeVo.java
@@ -0,0 +1,29 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "菜单树节点(供前端 el-tree)")
+public class MenuTreeNodeVo {
+
+ @Schema(description = "菜单 id")
+ private Long id;
+
+ @Schema(description = "展示名称")
+ private String label;
+
+ @Schema(description = "1板块 2页面 3权限")
+ private Integer menuType;
+
+ @Schema(description = "权限编码")
+ private String permCode;
+
+ @Schema(description = "路由")
+ private String routePath;
+
+ @Schema(description = "子节点")
+ private List<MenuTreeNodeVo> children = new ArrayList<>();
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniAppointmentCreateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/MiniAppointmentCreateRequest.java
new file mode 100644
index 0000000..31ab9ae
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniAppointmentCreateRequest.java
@@ -0,0 +1,55 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.time.LocalDate;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序发起预约")
+public class MiniAppointmentCreateRequest {
+
+ @NotNull(message = "被访人不能为空")
+ @Schema(description = "被访人id(mp_user.id)")
+ private Long visitedUserId;
+
+ @NotBlank(message = "拜访事由不能为空")
+ @Schema(description = "拜访事由编码(选择其他时传 OTHER)")
+ private String visitReasonCode;
+
+ @Schema(description = "拜访事由自定义内容(visitReasonCode=OTHER 时必填)")
+ private String visitReasonOther;
+
+ @NotBlank(message = "拜访时间类型不能为空")
+ @Schema(description = "拜访时间类型:SINGLE 单日 / MULTI 多日")
+ private String visitTimeMode;
+
+ @Schema(description = "单日拜访日期(visitTimeMode=SINGLE 时必填)")
+ private LocalDate visitDate;
+
+ @Schema(description = "单日时段:ALL_DAY/AM/PM(visitTimeMode=SINGLE 时必填)")
+ private String visitTimeSlot;
+
+ @Schema(description = "多日开始日期(visitTimeMode=MULTI 时必填)")
+ private LocalDate visitStartDate;
+
+ @Schema(description = "多日结束日期(visitTimeMode=MULTI 时必填)")
+ private LocalDate visitEndDate;
+
+ @Schema(description = "有人员进场(默认true)")
+ private Boolean personEntry;
+
+ @Schema(description = "来访人员id列表(mp_visitor.id)")
+ private List<Long> visitorIds;
+
+ @Schema(description = "有车辆进场(默认false)")
+ private Boolean vehicleEntry;
+
+ @Valid
+ @Schema(description = "进场车辆列表")
+ private List<MiniAppointmentVehicleItemRequest> vehicles;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniAppointmentListVo.java b/backend/src/main/java/com/sinata/appointment/dto/MiniAppointmentListVo.java
new file mode 100644
index 0000000..e0aaa12
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniAppointmentListVo.java
@@ -0,0 +1,56 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序我的预约列表项")
+public class MiniAppointmentListVo {
+
+ @Schema(description = "预约单ID")
+ private Long id;
+
+ @Schema(description = "预约单UUID")
+ private String uuid;
+
+ @Schema(description = "预约单号")
+ private String orderNo;
+
+ @Schema(description = "状态")
+ private String status;
+
+ @Schema(description = "列表展示状态")
+ private String displayStatus;
+
+ @Schema(description = "被访人姓名")
+ private String visitedUserName;
+
+ @Schema(description = "被访人部门名称")
+ private String visitedDeptName;
+
+ @Schema(description = "被访人展示文案(姓名+部门)")
+ private String visitedUserDisplay;
+
+ @Schema(description = "拜访开始日期")
+ private LocalDate visitStartDate;
+
+ @Schema(description = "拜访结束日期")
+ private LocalDate visitEndDate;
+
+ @Schema(description = "拜访时间文案")
+ private String visitTimeText;
+
+ @Schema(description = "来访事由编码")
+ private String visitReasonCode;
+
+ @Schema(description = "来访事由文案")
+ private String visitReasonText;
+
+ @Schema(description = "访问区域文案")
+ private String accessAreaText;
+
+ @Schema(description = "创建时间")
+ private LocalDateTime createTime;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniAppointmentVehicleItemRequest.java b/backend/src/main/java/com/sinata/appointment/dto/MiniAppointmentVehicleItemRequest.java
new file mode 100644
index 0000000..d9ea302
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniAppointmentVehicleItemRequest.java
@@ -0,0 +1,20 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序预约-进场车辆项")
+public class MiniAppointmentVehicleItemRequest {
+
+ @NotBlank(message = "车牌号不能为空")
+ @Schema(description = "车牌号")
+ private String plateNo;
+
+ @NotNull(message = "驾驶员id不能为空")
+ @Schema(description = "驾驶员id(mp_visitor.id)")
+ private Long driverVisitorId;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalActionRequest.java b/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalActionRequest.java
new file mode 100644
index 0000000..5054d4d
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalActionRequest.java
@@ -0,0 +1,26 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序通用审批操作(非经办人一级)")
+public class MiniApprovalActionRequest {
+
+ @NotNull
+ @Schema(description = "任务ID")
+ private Long taskId;
+
+ @NotBlank
+ @Schema(description = "任务UUID")
+ private String taskUuid;
+
+ @NotNull
+ @Schema(description = "是否通过")
+ private Boolean approve;
+
+ @Schema(description = "审批意见/拒绝原因")
+ private String comment;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalDetailVisitorVo.java b/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalDetailVisitorVo.java
new file mode 100644
index 0000000..56ce75c
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalDetailVisitorVo.java
@@ -0,0 +1,30 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序审批详情-来访人")
+public class MiniApprovalDetailVisitorVo {
+
+ @Schema(description = "访客ID")
+ private Long visitorId;
+
+ @Schema(description = "来访人姓名")
+ private String name;
+
+ @Schema(description = "性别")
+ private String gender;
+
+ @Schema(description = "身份证脱敏")
+ private String idCardMasked;
+
+ @Schema(description = "手机号脱敏")
+ private String mobileMasked;
+
+ @Schema(description = "单位名称")
+ private String companyName;
+
+ @Schema(description = "关联车牌号")
+ private String plateNo;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalDetailVo.java b/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalDetailVo.java
new file mode 100644
index 0000000..1b7523b
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalDetailVo.java
@@ -0,0 +1,83 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序审批详情")
+public class MiniApprovalDetailVo {
+
+ @Schema(description = "页面模式 HANDLER_PENDING/GENERAL_PENDING/GENERAL_READONLY")
+ private String pageMode;
+
+ @Schema(description = "是否可审批")
+ private Boolean canApprove;
+
+ @Schema(description = "任务ID")
+ private Long taskId;
+
+ @Schema(description = "任务UUID")
+ private String taskUuid;
+
+ @Schema(description = "预约单ID")
+ private Long orderId;
+
+ @Schema(description = "预约单UUID")
+ private String orderUuid;
+
+ @Schema(description = "预约单号")
+ private String orderNo;
+
+ @Schema(description = "任务状态")
+ private String taskStatus;
+
+ @Schema(description = "审批状态文案")
+ private String approvalStatusText;
+
+ @Schema(description = "当前节点编码")
+ private String nodeCode;
+
+ @Schema(description = "当前节点名称")
+ private String nodeName;
+
+ @Schema(description = "被访人ID")
+ private Long visitedUserId;
+
+ @Schema(description = "被访人姓名")
+ private String visitedUserName;
+
+ @Schema(description = "被访人部门")
+ private String visitedDeptName;
+
+ @Schema(description = "可访问区域展示文案")
+ private String accessAreaText;
+
+ @Schema(description = "来访类型文案")
+ private String visitTypeText;
+
+ @Schema(description = "来访事由文案")
+ private String visitReasonText;
+
+ @Schema(description = "来访时间文案")
+ private String visitTimeText;
+
+ @Schema(description = "已选通行区域ID")
+ private List<Long> passAccessAreaIds = new ArrayList<>();
+
+ @Schema(description = "已选通行区域名称")
+ private List<String> passAccessAreaNames = new ArrayList<>();
+
+ @Schema(description = "是否涉及其他属地单位")
+ private Boolean involveOtherLocalUnit;
+
+ @Schema(description = "已选其他属地单位")
+ private List<MiniLocalUnitVo> localUnits = new ArrayList<>();
+
+ @Schema(description = "来访人列表")
+ private List<MiniApprovalDetailVisitorVo> visitors = new ArrayList<>();
+
+ @Schema(description = "审批流程树")
+ private List<ApprovalFlowNodeVo> flowTree = new ArrayList<>();
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalTodoVo.java b/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalTodoVo.java
new file mode 100644
index 0000000..1e44f73
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniApprovalTodoVo.java
@@ -0,0 +1,47 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序可审批待办")
+public class MiniApprovalTodoVo {
+
+ @Schema(description = "任务ID")
+ private Long taskId;
+
+ @Schema(description = "任务UUID")
+ private String taskUuid;
+
+ @Schema(description = "预约单ID")
+ private Long orderId;
+
+ @Schema(description = "预约单UUID")
+ private String orderUuid;
+
+ @Schema(description = "预约单号")
+ private String orderNo;
+
+ @Schema(description = "节点编码")
+ private String nodeCode;
+
+ @Schema(description = "节点名称")
+ private String nodeName;
+
+ @Schema(description = "被访人姓名")
+ private String visitedUserName;
+
+ @Schema(description = "拜访开始日期")
+ private LocalDate visitStartDate;
+
+ @Schema(description = "拜访结束日期")
+ private LocalDate visitEndDate;
+
+ @Schema(description = "来访事由编码")
+ private String visitReasonCode;
+
+ @Schema(description = "任务创建时间")
+ private LocalDateTime createTime;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniFieldConfigVo.java b/backend/src/main/java/com/sinata/appointment/dto/MiniFieldConfigVo.java
new file mode 100644
index 0000000..ef945b5
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniFieldConfigVo.java
@@ -0,0 +1,29 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.io.Serial;
+import java.io.Serializable;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序预约字段配置")
+public class MiniFieldConfigVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "字段编码")
+ private String fieldCode;
+
+ @Schema(description = "字段名称")
+ private String fieldName;
+
+ @Schema(description = "是否显示(1是 0否)")
+ private Integer visible;
+
+ @Schema(description = "是否必填(1是 0否)")
+ private Integer requiredFlag;
+
+ @Schema(description = "排序值")
+ private Integer sortOrder;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniHandlerApprovalActionRequest.java b/backend/src/main/java/com/sinata/appointment/dto/MiniHandlerApprovalActionRequest.java
new file mode 100644
index 0000000..e64f3c5
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniHandlerApprovalActionRequest.java
@@ -0,0 +1,38 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序经办人审批操作")
+public class MiniHandlerApprovalActionRequest {
+
+ @NotNull
+ @Schema(description = "审批任务ID")
+ private Long taskId;
+
+ @NotBlank
+ @Schema(description = "审批任务UUID")
+ private String taskUuid;
+
+ @NotNull
+ @Schema(description = "是否通过")
+ private Boolean approve;
+
+ @NotBlank
+ @Schema(description = "审批意见")
+ private String comment;
+
+ @Schema(description = "通行区域ID列表(多选)")
+ private List<Long> passAccessAreaIds;
+
+ @NotNull
+ @Schema(description = "是否涉及其他属地单位")
+ private Boolean involveOtherLocalUnit;
+
+ @Schema(description = "其他属地单位ID列表")
+ private List<Long> passLocalUnitIds;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniHandlerApprovalTodoVo.java b/backend/src/main/java/com/sinata/appointment/dto/MiniHandlerApprovalTodoVo.java
new file mode 100644
index 0000000..00d86b5
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniHandlerApprovalTodoVo.java
@@ -0,0 +1,41 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序经办人审批待办")
+public class MiniHandlerApprovalTodoVo {
+
+ @Schema(description = "任务ID")
+ private Long taskId;
+
+ @Schema(description = "任务UUID")
+ private String taskUuid;
+
+ @Schema(description = "预约单ID")
+ private Long orderId;
+
+ @Schema(description = "预约单UUID")
+ private String orderUuid;
+
+ @Schema(description = "预约单号")
+ private String orderNo;
+
+ @Schema(description = "被访人姓名")
+ private String visitedUserName;
+
+ @Schema(description = "拜访开始日期")
+ private LocalDate visitStartDate;
+
+ @Schema(description = "拜访结束日期")
+ private LocalDate visitEndDate;
+
+ @Schema(description = "来访事由编码")
+ private String visitReasonCode;
+
+ @Schema(description = "创建时间")
+ private LocalDateTime createTime;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniLocalUnitVo.java b/backend/src/main/java/com/sinata/appointment/dto/MiniLocalUnitVo.java
new file mode 100644
index 0000000..e1a5785
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniLocalUnitVo.java
@@ -0,0 +1,16 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序属地单位")
+public class MiniLocalUnitVo {
+
+ @Schema(description = "属地单位ID")
+ private Long id;
+
+ @Schema(description = "属地单位名称")
+ private String unitName;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniLoginResponse.java b/backend/src/main/java/com/sinata/appointment/dto/MiniLoginResponse.java
new file mode 100644
index 0000000..d3cd7fe
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniLoginResponse.java
@@ -0,0 +1,35 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+@Schema(description = "小程序登录响应(token 与后台 Authorization 同属请求头名,但为不同体系签发)")
+public class MiniLoginResponse {
+
+ @Schema(description = "Sa-Token,请求头 Authorization 携带")
+ private String token;
+
+ @Schema(description = "小程序用户 id")
+ private Long mpUserId;
+
+ @Schema(description = "VISITOR / EMPLOYEE")
+ private String userType;
+
+ @Schema(description = "员工所属部门 id,访客为 null")
+ private Long deptId;
+
+ @Schema(description = "员工所属部门名称,访客为 null")
+ private String deptName;
+
+ @Schema(description = "是否部门负责人,访客为 null")
+ private Boolean deptLeader;
+
+ @Schema(description = "头像 URL")
+ private String avatarUrl;
+
+ @Schema(description = "展示名")
+ private String displayName;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniMyAppointmentDetailVo.java b/backend/src/main/java/com/sinata/appointment/dto/MiniMyAppointmentDetailVo.java
new file mode 100644
index 0000000..3f13b99
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniMyAppointmentDetailVo.java
@@ -0,0 +1,66 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序我的预约详情")
+public class MiniMyAppointmentDetailVo {
+
+ @Schema(description = "预约单ID")
+ private Long orderId;
+
+ @Schema(description = "预约单UUID")
+ private String orderUuid;
+
+ @Schema(description = "预约单号")
+ private String orderNo;
+
+ @Schema(description = "预约单状态")
+ private String status;
+
+ @Schema(description = "列表展示状态")
+ private String displayStatus;
+
+ @Schema(description = "状态文案")
+ private String statusText;
+
+ @Schema(description = "是否可取消")
+ private Boolean cancelable;
+
+ @Schema(description = "被访人ID")
+ private Long visitedUserId;
+
+ @Schema(description = "被访人姓名")
+ private String visitedUserName;
+
+ @Schema(description = "被访人部门名称")
+ private String visitedDeptName;
+
+ @Schema(description = "被访人展示文案")
+ private String visitedUserDisplay;
+
+ @Schema(description = "访问区域文案")
+ private String accessAreaText;
+
+ @Schema(description = "来访类型文案")
+ private String visitTypeText;
+
+ @Schema(description = "来访事由文案")
+ private String visitReasonText;
+
+ @Schema(description = "来访时间文案")
+ private String visitTimeText;
+
+ @Schema(description = "创建时间")
+ private LocalDateTime createTime;
+
+ @Schema(description = "来访人列表")
+ private List<MiniApprovalDetailVisitorVo> visitors = new ArrayList<>();
+
+ @Schema(description = "审批流程树")
+ private List<ApprovalFlowNodeVo> flowTree = new ArrayList<>();
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniMyVisitorCodeOrderVo.java b/backend/src/main/java/com/sinata/appointment/dto/MiniMyVisitorCodeOrderVo.java
new file mode 100644
index 0000000..9de650f
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniMyVisitorCodeOrderVo.java
@@ -0,0 +1,36 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序我的访客码-预约卡片")
+public class MiniMyVisitorCodeOrderVo {
+
+ @Schema(description = "预约单ID")
+ private Long orderId;
+
+ @Schema(description = "预约单UUID")
+ private String orderUuid;
+
+ @Schema(description = "被访人展示文案")
+ private String visitedUserDisplay;
+
+ @Schema(description = "访问区域文案")
+ private String accessAreaText;
+
+ @Schema(description = "来访时间文案")
+ private String visitTimeText;
+
+ @Schema(description = "二维码开始时间")
+ private LocalDateTime validStartTime;
+
+ @Schema(description = "二维码结束时间")
+ private LocalDateTime validEndTime;
+
+ @Schema(description = "二维码列表")
+ private List<MiniMyVisitorCodeTicketVo> qrCodes = new ArrayList<>();
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniMyVisitorCodeTicketVo.java b/backend/src/main/java/com/sinata/appointment/dto/MiniMyVisitorCodeTicketVo.java
new file mode 100644
index 0000000..8186dac
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniMyVisitorCodeTicketVo.java
@@ -0,0 +1,31 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序我的访客码-二维码项")
+public class MiniMyVisitorCodeTicketVo {
+
+ @Schema(description = "二维码票据ID")
+ private Long ticketId;
+
+ @Schema(description = "二维码票据UUID")
+ private String ticketUuid;
+
+ @Schema(description = "访客ID(mp_visitor.id)")
+ private Long mpVisitorId;
+
+ @Schema(description = "访客姓名")
+ private String visitorName;
+
+ @Schema(description = "二维码原始内容")
+ private String payload;
+
+ @Schema(description = "二维码生成基准时间")
+ private LocalDateTime generateTime;
+
+ @Schema(description = "二维码失效时间")
+ private LocalDateTime expireTime;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniPassAccessAreaTreeVo.java b/backend/src/main/java/com/sinata/appointment/dto/MiniPassAccessAreaTreeVo.java
new file mode 100644
index 0000000..d845eb3
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniPassAccessAreaTreeVo.java
@@ -0,0 +1,25 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.io.Serial;
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序通行区域树节点")
+public class MiniPassAccessAreaTreeVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "区域ID")
+ private Long id;
+
+ @Schema(description = "区域名称")
+ private String areaName;
+
+ @Schema(description = "子节点")
+ private List<MiniPassAccessAreaTreeVo> children = new ArrayList<>();
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniPhoneLoginRequest.java b/backend/src/main/java/com/sinata/appointment/dto/MiniPhoneLoginRequest.java
new file mode 100644
index 0000000..1c7a506
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniPhoneLoginRequest.java
@@ -0,0 +1,14 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序手机号临时登录请求")
+public class MiniPhoneLoginRequest {
+
+ @NotBlank
+ @Schema(description = "手机号(11位)")
+ private String mobile;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniVisitReasonVo.java b/backend/src/main/java/com/sinata/appointment/dto/MiniVisitReasonVo.java
new file mode 100644
index 0000000..fbcc673
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniVisitReasonVo.java
@@ -0,0 +1,22 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.io.Serial;
+import java.io.Serializable;
+import lombok.Data;
+
+@Data
+public class MiniVisitReasonVo implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "主键 ID")
+ private Long id;
+
+ @Schema(description = "来访事由编码")
+ private String reasonCode;
+
+ @Schema(description = "来访事由")
+ private String reasonName;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniVisitRecordListVo.java b/backend/src/main/java/com/sinata/appointment/dto/MiniVisitRecordListVo.java
new file mode 100644
index 0000000..be3aabe
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniVisitRecordListVo.java
@@ -0,0 +1,36 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序来访记录列表项")
+public class MiniVisitRecordListVo {
+
+ @Schema(description = "预约单ID")
+ private Long orderId;
+
+ @Schema(description = "预约单号")
+ private String orderNo;
+
+ @Schema(description = "来访人姓名列表")
+ private List<String> visitorNames;
+
+ @Schema(description = "拜访开始日期")
+ private LocalDate visitStartDate;
+
+ @Schema(description = "拜访结束日期")
+ private LocalDate visitEndDate;
+
+ @Schema(description = "拜访时间模式")
+ private String visitTimeMode;
+
+ @Schema(description = "单日时段")
+ private String visitTimeSlot;
+
+ @Schema(description = "创建时间")
+ private LocalDateTime createTime;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniVisitedUserVo.java b/backend/src/main/java/com/sinata/appointment/dto/MiniVisitedUserVo.java
new file mode 100644
index 0000000..9cec443
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniVisitedUserVo.java
@@ -0,0 +1,19 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序被访人搜索结果")
+public class MiniVisitedUserVo {
+
+ @Schema(description = "被访人用户ID")
+ private Long id;
+
+ @Schema(description = "姓名")
+ private String realName;
+
+ @Schema(description = "部门名")
+ private String deptName;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniVisitorCreateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/MiniVisitorCreateRequest.java
new file mode 100644
index 0000000..a701a0c
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniVisitorCreateRequest.java
@@ -0,0 +1,24 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序新增访客请求")
+public class MiniVisitorCreateRequest {
+
+ @Schema(description = "来访人姓名")
+ private String visitorName;
+
+ @Schema(description = "来访人电话")
+ private String visitorMobile;
+
+ @Schema(description = "来访人单位")
+ private String visitorCompany;
+
+ @Schema(description = "身份证号码")
+ private String idCard;
+
+ @Schema(description = "性别:男/女")
+ private String gender;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniVisitorDetailVo.java b/backend/src/main/java/com/sinata/appointment/dto/MiniVisitorDetailVo.java
new file mode 100644
index 0000000..ce89f38
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniVisitorDetailVo.java
@@ -0,0 +1,30 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序访客详情")
+public class MiniVisitorDetailVo {
+
+ @Schema(description = "访客ID")
+ private Long id;
+
+ @Schema(description = "访客UUID")
+ private String uuid;
+
+ @Schema(description = "来访人姓名")
+ private String visitorName;
+
+ @Schema(description = "来访人电话")
+ private String visitorMobile;
+
+ @Schema(description = "来访人单位")
+ private String visitorCompany;
+
+ @Schema(description = "身份证号码")
+ private String idCard;
+
+ @Schema(description = "性别")
+ private String gender;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniVisitorListVo.java b/backend/src/main/java/com/sinata/appointment/dto/MiniVisitorListVo.java
new file mode 100644
index 0000000..f157558
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniVisitorListVo.java
@@ -0,0 +1,31 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序访客列表项")
+public class MiniVisitorListVo {
+
+ @Schema(description = "访客ID")
+ private Long id;
+
+ @Schema(description = "访客UUID")
+ private String uuid;
+
+ @Schema(description = "来访人姓名")
+ private String visitorName;
+
+ @Schema(description = "性别")
+ private String gender;
+
+ @Schema(description = "身份证(前4后4,其余*)")
+ private String idCardMasked;
+
+ @Schema(description = "来访人单位")
+ private String visitorCompany;
+
+ @Schema(description = "缺失字段标识列表(按 biz_field_config.field_code 返回)")
+ private List<String> missingFieldCodes;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniVisitorUpdateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/MiniVisitorUpdateRequest.java
new file mode 100644
index 0000000..6e31fdb
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniVisitorUpdateRequest.java
@@ -0,0 +1,24 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序编辑访客请求")
+public class MiniVisitorUpdateRequest {
+
+ @Schema(description = "来访人姓名")
+ private String visitorName;
+
+ @Schema(description = "来访人电话")
+ private String visitorMobile;
+
+ @Schema(description = "来访人单位")
+ private String visitorCompany;
+
+ @Schema(description = "身份证号码")
+ private String idCard;
+
+ @Schema(description = "性别:男/女")
+ private String gender;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/MiniWxLoginRequest.java b/backend/src/main/java/com/sinata/appointment/dto/MiniWxLoginRequest.java
new file mode 100644
index 0000000..83c5511
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/MiniWxLoginRequest.java
@@ -0,0 +1,22 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+@Schema(description = "小程序微信登录(与平台账密无关)")
+public class MiniWxLoginRequest {
+
+ @NotBlank
+ @Schema(description = "wx.login 返回的 js_code")
+ private String wxCode;
+
+ @NotBlank
+ @Schema(description = "手机号按钮 getPhoneNumber 回调中的 code(服务端用 access_token 换手机号)")
+ private String phoneCode;
+
+ @NotBlank
+ @Schema(description = "VISITOR=访客入口;EMPLOYEE=员工入口", example = "VISITOR")
+ private String loginIntent;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/PassAreaCreateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/PassAreaCreateRequest.java
new file mode 100644
index 0000000..1cc25c5
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/PassAreaCreateRequest.java
@@ -0,0 +1,22 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+public class PassAreaCreateRequest {
+
+ @Schema(description = "父级 id,0 或不传为一级区域")
+ private Long pid;
+
+ @Schema(description = "区域名称")
+ @NotBlank(message = "区域名称不能为空")
+ private String areaName;
+
+ @Schema(description = "备注(与海康地点管理一致)")
+ private String remark;
+
+ @Schema(description = "区域说明")
+ private String areaDesc;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/PassAreaDetailVo.java b/backend/src/main/java/com/sinata/appointment/dto/PassAreaDetailVo.java
new file mode 100644
index 0000000..d333b8b
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/PassAreaDetailVo.java
@@ -0,0 +1,24 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+public class PassAreaDetailVo {
+
+ private Long id;
+ private String uuid;
+ private Long pid;
+
+ @Schema(description = "区域名称")
+ private String areaName;
+
+ @Schema(description = "备注(与海康地点管理一致)")
+ private String remark;
+
+ @Schema(description = "区域说明")
+ private String areaDesc;
+
+ @Schema(description = "上级区域名称(子区域时有值)")
+ private String parentAreaName;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/PassAreaListVo.java b/backend/src/main/java/com/sinata/appointment/dto/PassAreaListVo.java
new file mode 100644
index 0000000..d098777
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/PassAreaListVo.java
@@ -0,0 +1,23 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import lombok.Data;
+
+@Data
+public class PassAreaListVo {
+
+ private Long id;
+ private String uuid;
+
+ @Schema(description = "父级 id")
+ private Long pid;
+
+ @Schema(description = "区域名称")
+ private String areaName;
+
+ @Schema(description = "区域说明")
+ private String areaDesc;
+
+ private LocalDateTime createTime;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/PassAreaUpdateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/PassAreaUpdateRequest.java
new file mode 100644
index 0000000..d39745b
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/PassAreaUpdateRequest.java
@@ -0,0 +1,26 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+public class PassAreaUpdateRequest {
+
+ @Schema(description = "uuid")
+ @NotBlank(message = "uuid 不能为空")
+ private String uuid;
+
+ @Schema(description = "区域名称")
+ @NotBlank(message = "区域名称不能为空")
+ private String areaName;
+
+ @Schema(description = "备注;不传则保持原值,传空串可清空")
+ private String remark;
+
+ @Schema(description = "区域说明")
+ private String areaDesc;
+
+ @Schema(description = "父级 id(仅子区域可改,且须为一级区域 id)")
+ private Long pid;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/ResetPasswordRequest.java b/backend/src/main/java/com/sinata/appointment/dto/ResetPasswordRequest.java
new file mode 100644
index 0000000..487291d
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/ResetPasswordRequest.java
@@ -0,0 +1,22 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+@Schema(description = "重置密码")
+public class ResetPasswordRequest {
+
+ @NotBlank
+ private String uuid;
+
+ @NotBlank
+ @Size(min = 6, max = 64)
+ private String newPassword;
+
+ @NotBlank
+ @Size(min = 6, max = 64)
+ private String newPasswordConfirm;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/RoleManageCreateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/RoleManageCreateRequest.java
new file mode 100644
index 0000000..c059cee
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/RoleManageCreateRequest.java
@@ -0,0 +1,23 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "新增角色")
+public class RoleManageCreateRequest {
+
+ @NotBlank
+ @Size(max = 128)
+ private String roleName;
+
+ @Size(max = 512)
+ private String remark;
+
+ @Schema(description = "勾选的菜单 id 列表,可为空")
+ private List<Long> menuIds = new ArrayList<>();
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/RoleManageDetailVo.java b/backend/src/main/java/com/sinata/appointment/dto/RoleManageDetailVo.java
new file mode 100644
index 0000000..e0a7523
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/RoleManageDetailVo.java
@@ -0,0 +1,21 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "角色详情(含已选菜单 id 列表)")
+public class RoleManageDetailVo {
+
+ private Long id;
+ private String uuid;
+ private String roleName;
+ private String remark;
+ private LocalDateTime createTime;
+
+ @Schema(description = "已勾选的菜单 id(树勾选结果的扁平列表)")
+ private List<Long> menuIds = new ArrayList<>();
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/RoleManageListVo.java b/backend/src/main/java/com/sinata/appointment/dto/RoleManageListVo.java
new file mode 100644
index 0000000..4dc1536
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/RoleManageListVo.java
@@ -0,0 +1,17 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import lombok.Data;
+
+@Data
+@Schema(description = "角色管理列表行")
+public class RoleManageListVo {
+
+ private Long id;
+ private String uuid;
+ private String roleName;
+ private String remark;
+ private long userCount;
+ private LocalDateTime createTime;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/RoleManageUpdateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/RoleManageUpdateRequest.java
new file mode 100644
index 0000000..7999250
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/RoleManageUpdateRequest.java
@@ -0,0 +1,27 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "编辑角色(须 uuid)")
+public class RoleManageUpdateRequest {
+
+ @NotBlank
+ @Schema(description = "与路径 id 对应,防越权")
+ private String uuid;
+
+ @NotBlank
+ @Size(max = 128)
+ private String roleName;
+
+ @Size(max = 512)
+ private String remark;
+
+ @Schema(description = "勾选的菜单 id 列表,可为空")
+ private List<Long> menuIds = new ArrayList<>();
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/RoleUserCountRow.java b/backend/src/main/java/com/sinata/appointment/dto/RoleUserCountRow.java
new file mode 100644
index 0000000..c05990d
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/RoleUserCountRow.java
@@ -0,0 +1,10 @@
+package com.sinata.appointment.dto;
+
+import lombok.Data;
+
+@Data
+public class RoleUserCountRow {
+
+ private Long roleId;
+ private Long userCount;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/StaffCreateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/StaffCreateRequest.java
new file mode 100644
index 0000000..ba8a222
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/StaffCreateRequest.java
@@ -0,0 +1,35 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+@Schema(description = "新增员工")
+public class StaffCreateRequest {
+
+ @NotBlank
+ @Size(max = 128)
+ @Schema(description = "员工名称")
+ private String nickName;
+
+ @Size(max = 64)
+ @Schema(description = "员工编号,可空")
+ private String staffNo;
+
+ @Size(max = 512)
+ private String avatarUrl;
+
+ @NotBlank
+ @Size(max = 32)
+ private String mobile;
+
+ @NotNull
+ private Long deptId;
+
+ @NotNull
+ @Schema(description = "true 部门负责人,false 员工")
+ private Boolean deptLeader;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/StaffDetailVo.java b/backend/src/main/java/com/sinata/appointment/dto/StaffDetailVo.java
new file mode 100644
index 0000000..3c666d4
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/StaffDetailVo.java
@@ -0,0 +1,23 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "员工详情")
+public class StaffDetailVo {
+
+ private Long id;
+ private String uuid;
+ private String nickName;
+ private String staffNo;
+ private String avatarUrl;
+ private String mobile;
+ private Long deptId;
+ private Boolean deptLeader;
+ private Integer status;
+
+ @Schema(description = "所属人员分组名称(可多选)")
+ private List<String> groupNames;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/StaffGroupAppendMembersRequest.java b/backend/src/main/java/com/sinata/appointment/dto/StaffGroupAppendMembersRequest.java
new file mode 100644
index 0000000..166c36f
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/StaffGroupAppendMembersRequest.java
@@ -0,0 +1,19 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import java.util.List;
+import lombok.Data;
+
+@Data
+public class StaffGroupAppendMembersRequest {
+
+ @Schema(description = "uuid")
+ @NotBlank(message = "uuid 不能为空")
+ private String uuid;
+
+ @Schema(description = "待追加的成员 mpUserId 列表")
+ @NotEmpty(message = "请至少选择一名成员")
+ private List<Long> memberIds;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/StaffGroupCreateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/StaffGroupCreateRequest.java
new file mode 100644
index 0000000..2d4893c
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/StaffGroupCreateRequest.java
@@ -0,0 +1,25 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import java.util.List;
+import lombok.Data;
+
+@Data
+public class StaffGroupCreateRequest {
+
+ @Schema(description = "分组名称")
+ @NotBlank(message = "分组名称不能为空")
+ private String groupName;
+
+ @Schema(description = "负责人 mpUserId")
+ @NotNull(message = "负责人不能为空")
+ private Long leaderMpUserId;
+
+ @Schema(description = "成员 mpUserId 列表")
+ @NotEmpty(message = "关联人员不能为空")
+ private List<Long> memberIds;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/dto/StaffGroupDetailVo.java b/backend/src/main/java/com/sinata/appointment/dto/StaffGroupDetailVo.java
new file mode 100644
index 0000000..52874f3
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/StaffGroupDetailVo.java
@@ -0,0 +1,32 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import java.util.List;
+import lombok.Data;
+
+@Data
+public class StaffGroupDetailVo {
+
+ private Long id;
+ private String uuid;
+
+ private String groupName;
+
+ private Long leaderMpUserId;
+ private String leaderName;
+
+ @Schema(description = "负责人联系电话(详情/编辑用,不脱敏)")
+ private String leaderMobile;
+
+ @Schema(description = "负责人联系电话(脱敏,列表等场景)")
+ private String leaderMobileMasked;
+
+ @Schema(description = "绑定人员数量")
+ private Integer memberCount;
+
+ private LocalDateTime createTime;
+
+ private List<StaffGroupMemberVo> members;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/dto/StaffGroupListVo.java b/backend/src/main/java/com/sinata/appointment/dto/StaffGroupListVo.java
new file mode 100644
index 0000000..034556b
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/StaffGroupListVo.java
@@ -0,0 +1,27 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import lombok.Data;
+
+@Data
+public class StaffGroupListVo {
+
+ private Long id;
+ private String uuid;
+
+ @Schema(description = "分组名称")
+ private String groupName;
+
+ @Schema(description = "负责人姓名")
+ private String leaderName;
+
+ @Schema(description = "负责人联系电话(脱敏)")
+ private String leaderMobileMasked;
+
+ @Schema(description = "绑定人员数量")
+ private Integer memberCount;
+
+ private LocalDateTime createTime;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/dto/StaffGroupMemberVo.java b/backend/src/main/java/com/sinata/appointment/dto/StaffGroupMemberVo.java
new file mode 100644
index 0000000..330351a
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/StaffGroupMemberVo.java
@@ -0,0 +1,14 @@
+package com.sinata.appointment.dto;
+
+import lombok.Data;
+
+@Data
+public class StaffGroupMemberVo {
+ private Long id;
+ private String uuid;
+ private String realName;
+ private String staffNo;
+ private Long deptId;
+ private String deptName;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/dto/StaffGroupUpdateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/StaffGroupUpdateRequest.java
new file mode 100644
index 0000000..36ea61c
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/StaffGroupUpdateRequest.java
@@ -0,0 +1,29 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.NotNull;
+import java.util.List;
+import lombok.Data;
+
+@Data
+public class StaffGroupUpdateRequest {
+
+ @Schema(description = "uuid")
+ @NotBlank(message = "uuid 不能为空")
+ private String uuid;
+
+ @Schema(description = "分组名称")
+ @NotBlank(message = "分组名称不能为空")
+ private String groupName;
+
+ @Schema(description = "负责人 mpUserId")
+ @NotNull(message = "负责人不能为空")
+ private Long leaderMpUserId;
+
+ @Schema(description = "成员 mpUserId 列表")
+ @NotEmpty(message = "关联人员不能为空")
+ private List<Long> memberIds;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/dto/StaffImportExcelRow.java b/backend/src/main/java/com/sinata/appointment/dto/StaffImportExcelRow.java
new file mode 100644
index 0000000..d635bbc
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/StaffImportExcelRow.java
@@ -0,0 +1,27 @@
+package com.sinata.appointment.dto;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+import lombok.Data;
+
+@Data
+public class StaffImportExcelRow {
+
+ @ExcelProperty("员工姓名")
+ private String realName;
+
+ @ExcelProperty("员工编号")
+ private String staffNo;
+
+ @ExcelProperty("联系方式")
+ private String mobile;
+
+ @ExcelProperty("所属部门")
+ private String dept;
+
+ @ExcelProperty("所属职位")
+ private String position;
+
+ @ExcelProperty("所属分组")
+ private String group;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/dto/StaffImportResultVo.java b/backend/src/main/java/com/sinata/appointment/dto/StaffImportResultVo.java
new file mode 100644
index 0000000..dc3b51f
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/StaffImportResultVo.java
@@ -0,0 +1,13 @@
+package com.sinata.appointment.dto;
+
+import java.util.ArrayList;
+import java.util.List;
+import lombok.Data;
+
+@Data
+public class StaffImportResultVo {
+
+ private int success;
+ private int fail;
+ private List<String> errors = new ArrayList<>();
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/StaffListVo.java b/backend/src/main/java/com/sinata/appointment/dto/StaffListVo.java
new file mode 100644
index 0000000..bdd7996
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/StaffListVo.java
@@ -0,0 +1,23 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import lombok.Data;
+
+@Data
+@Schema(description = "员工列表行")
+public class StaffListVo {
+
+ private Long id;
+ private String uuid;
+ private String realName;
+ private String avatarUrl;
+ private String staffNo;
+ private String mobileMasked;
+ private Long deptId;
+ private String deptName;
+ private String positionLabel;
+ private String groupDisplay;
+ private Integer status;
+ private LocalDateTime createTime;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/StaffStatusPatchRequest.java b/backend/src/main/java/com/sinata/appointment/dto/StaffStatusPatchRequest.java
new file mode 100644
index 0000000..83c584e
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/StaffStatusPatchRequest.java
@@ -0,0 +1,17 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Data
+public class StaffStatusPatchRequest {
+
+ @NotBlank
+ private String uuid;
+
+ @NotNull
+ @Schema(description = "1 启用 0 禁用")
+ private Integer status;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/StaffUpdateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/StaffUpdateRequest.java
new file mode 100644
index 0000000..ac45b2b
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/StaffUpdateRequest.java
@@ -0,0 +1,36 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+@Schema(description = "编辑员工")
+public class StaffUpdateRequest {
+
+ @NotBlank
+ private String uuid;
+
+ @NotBlank
+ @Size(max = 128)
+ private String nickName;
+
+ @Size(max = 64)
+ private String staffNo;
+
+ @Size(max = 512)
+ private String avatarUrl;
+
+ @NotBlank
+ @Size(max = 32)
+ private String mobile;
+
+ @NotNull
+ private Long deptId;
+
+ @NotNull
+ @Schema(description = "true 部门负责人,false 员工")
+ private Boolean deptLeader;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/SysUserCreateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/SysUserCreateRequest.java
new file mode 100644
index 0000000..89fcb3c
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/SysUserCreateRequest.java
@@ -0,0 +1,35 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.Size;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "新增系统用户")
+public class SysUserCreateRequest {
+
+ @NotBlank
+ @Size(max = 64)
+ private String username;
+
+ @NotBlank
+ @Size(min = 6, max = 64)
+ private String password;
+
+ @Size(max = 64)
+ private String realName;
+
+ @Size(max = 32)
+ private String mobile;
+
+ @Size(max = 512)
+ @Schema(description = "头像 URL,可选")
+ private String avatarUrl;
+
+ @NotEmpty
+ @Schema(description = "角色 id,至少选一个")
+ private List<Long> roleIds;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/SysUserDetailVo.java b/backend/src/main/java/com/sinata/appointment/dto/SysUserDetailVo.java
new file mode 100644
index 0000000..5fb2ea0
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/SysUserDetailVo.java
@@ -0,0 +1,24 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "系统用户详情(不含密码)")
+public class SysUserDetailVo {
+
+ private Long id;
+
+ @Schema(description = "写操作、单查须与 id 一并提交")
+ private String uuid;
+
+ private String username;
+ private String realName;
+ private String mobile;
+ private String avatarUrl;
+ private Integer status;
+
+ @Schema(description = "角色 id 列表")
+ private List<Long> roleIds;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/SysUserListVo.java b/backend/src/main/java/com/sinata/appointment/dto/SysUserListVo.java
new file mode 100644
index 0000000..abc58e2
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/SysUserListVo.java
@@ -0,0 +1,28 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import lombok.Data;
+
+@Data
+@Schema(description = "系统用户列表项(手机号为脱敏展示)")
+public class SysUserListVo {
+
+ private Long id;
+
+ @Schema(description = "与 id 同时用于详情与写操作防越权")
+ private String uuid;
+
+ private String username;
+ private String realName;
+
+ @Schema(description = "脱敏手机号,前3后4中间*")
+ private String mobileMasked;
+
+ @Schema(description = "角色名称展示,顿号分隔")
+ private String rolesDisplay;
+
+ private Integer status;
+
+ private LocalDateTime createTime;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/SysUserUpdateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/SysUserUpdateRequest.java
new file mode 100644
index 0000000..38a0ce6
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/SysUserUpdateRequest.java
@@ -0,0 +1,29 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotEmpty;
+import jakarta.validation.constraints.Size;
+import java.util.List;
+import lombok.Data;
+
+@Data
+@Schema(description = "修改系统用户(不含登录名与密码)")
+public class SysUserUpdateRequest {
+
+ @NotBlank
+ @Schema(description = "与路径 id 对应,防越权")
+ private String uuid;
+
+ @Size(max = 64)
+ private String realName;
+
+ @Size(max = 32)
+ private String mobile;
+
+ @Size(max = 512)
+ private String avatarUrl;
+
+ @NotEmpty
+ private List<Long> roleIds;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/UserRoleLabelRow.java b/backend/src/main/java/com/sinata/appointment/dto/UserRoleLabelRow.java
new file mode 100644
index 0000000..a70602b
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/UserRoleLabelRow.java
@@ -0,0 +1,11 @@
+package com.sinata.appointment.dto;
+
+import lombok.Data;
+
+/** 批量查询用户展示用角色名 */
+@Data
+public class UserRoleLabelRow {
+
+ private Long userId;
+ private String roleName;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/UserStatusPatchRequest.java b/backend/src/main/java/com/sinata/appointment/dto/UserStatusPatchRequest.java
new file mode 100644
index 0000000..3ba0458
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/UserStatusPatchRequest.java
@@ -0,0 +1,18 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Data
+@Schema(description = "启用/禁用用户")
+public class UserStatusPatchRequest {
+
+ @NotBlank
+ private String uuid;
+
+ @NotNull
+ @Schema(description = "1 启用,0 禁用", allowableValues = {"0", "1"})
+ private Integer status;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/VisitReasonCreateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/VisitReasonCreateRequest.java
new file mode 100644
index 0000000..edc0397
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/VisitReasonCreateRequest.java
@@ -0,0 +1,16 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+public class VisitReasonCreateRequest {
+
+ @Schema(description = "来访事由")
+ @NotBlank(message = "来访事由不能为空")
+ @Size(max = 128, message = "来访事由长度不能超过 128")
+ private String reasonName;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/dto/VisitReasonListVo.java b/backend/src/main/java/com/sinata/appointment/dto/VisitReasonListVo.java
new file mode 100644
index 0000000..f69380f
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/VisitReasonListVo.java
@@ -0,0 +1,18 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import lombok.Data;
+
+@Data
+public class VisitReasonListVo {
+
+ private Long id;
+ private String uuid;
+
+ @Schema(description = "来访事由")
+ private String reasonName;
+
+ private LocalDateTime createTime;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/dto/VisitReasonUpdateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/VisitReasonUpdateRequest.java
new file mode 100644
index 0000000..fee44e2
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/VisitReasonUpdateRequest.java
@@ -0,0 +1,20 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+public class VisitReasonUpdateRequest {
+
+ @Schema(description = "uuid")
+ @NotBlank(message = "uuid 不能为空")
+ private String uuid;
+
+ @Schema(description = "来访事由")
+ @NotBlank(message = "来访事由不能为空")
+ @Size(max = 128, message = "来访事由长度不能超过 128")
+ private String reasonName;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/dto/VisitorCompetentDeptSaveRequest.java b/backend/src/main/java/com/sinata/appointment/dto/VisitorCompetentDeptSaveRequest.java
new file mode 100644
index 0000000..0937c31
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/VisitorCompetentDeptSaveRequest.java
@@ -0,0 +1,11 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+public class VisitorCompetentDeptSaveRequest {
+
+ @Schema(description = "主管部门 id,传 null 表示清空")
+ private Long deptId;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/VisitorCompetentDeptVo.java b/backend/src/main/java/com/sinata/appointment/dto/VisitorCompetentDeptVo.java
new file mode 100644
index 0000000..b216034
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/VisitorCompetentDeptVo.java
@@ -0,0 +1,11 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+public class VisitorCompetentDeptVo {
+
+ @Schema(description = "主管部门 sys_dept.id,未配置为 null")
+ private Long deptId;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/VisitorItemRequest.java b/backend/src/main/java/com/sinata/appointment/dto/VisitorItemRequest.java
new file mode 100644
index 0000000..2fb6571
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/VisitorItemRequest.java
@@ -0,0 +1,26 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+@Schema(description = "访客明细")
+public class VisitorItemRequest {
+
+ @NotBlank
+ @Schema(description = "姓名")
+ private String name;
+
+ @Schema(description = "身份证号")
+ private String idCard;
+
+ @Schema(description = "手机号")
+ private String mobile;
+
+ @Schema(description = "驾驶员姓名")
+ private String driverName;
+
+ @Schema(description = "车牌号")
+ private String plateNo;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/VisitorUserDetailVo.java b/backend/src/main/java/com/sinata/appointment/dto/VisitorUserDetailVo.java
new file mode 100644
index 0000000..ad98a27
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/VisitorUserDetailVo.java
@@ -0,0 +1,17 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "访客账号详情")
+public class VisitorUserDetailVo {
+
+ private Long id;
+ private String uuid;
+ private String userName;
+ private String mobile;
+ private String gender;
+ private String companyName;
+ private Integer status;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/VisitorUserExportRow.java b/backend/src/main/java/com/sinata/appointment/dto/VisitorUserExportRow.java
new file mode 100644
index 0000000..57dbac2
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/VisitorUserExportRow.java
@@ -0,0 +1,31 @@
+package com.sinata.appointment.dto;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+@Data
+@Schema(description = "访客账号导出行")
+public class VisitorUserExportRow {
+
+ @ExcelProperty("用户名称")
+ private String userName;
+
+ @ExcelProperty("性别")
+ private String gender;
+
+ @ExcelProperty("用户电话")
+ private String mobile;
+
+ @ExcelProperty("所属公司")
+ private String companyName;
+
+ @ExcelProperty("预约次数")
+ private Long bookingCount;
+
+ @ExcelProperty("状态")
+ private String statusText;
+
+ @ExcelProperty("注册时间")
+ private String createTime;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/VisitorUserListVo.java b/backend/src/main/java/com/sinata/appointment/dto/VisitorUserListVo.java
new file mode 100644
index 0000000..a420b8f
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/VisitorUserListVo.java
@@ -0,0 +1,20 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import lombok.Data;
+
+@Data
+@Schema(description = "访客账号列表")
+public class VisitorUserListVo {
+
+ private Long id;
+ private String uuid;
+ private String userName;
+ private String gender;
+ private String mobileMasked;
+ private String companyName;
+ private Long bookingCount;
+ private Integer status;
+ private LocalDateTime createTime;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/VisitorUserStatusPatchRequest.java b/backend/src/main/java/com/sinata/appointment/dto/VisitorUserStatusPatchRequest.java
new file mode 100644
index 0000000..9ea576c
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/VisitorUserStatusPatchRequest.java
@@ -0,0 +1,18 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import lombok.Data;
+
+@Data
+@Schema(description = "访客账号启停")
+public class VisitorUserStatusPatchRequest {
+
+ @NotBlank
+ private String uuid;
+
+ @NotNull
+ @Schema(description = "1 启用 0 禁用")
+ private Integer status;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/dto/VisitorUserUpdateRequest.java b/backend/src/main/java/com/sinata/appointment/dto/VisitorUserUpdateRequest.java
new file mode 100644
index 0000000..7b8bb86
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/dto/VisitorUserUpdateRequest.java
@@ -0,0 +1,28 @@
+package com.sinata.appointment.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.Size;
+import lombok.Data;
+
+@Data
+@Schema(description = "编辑访客账号")
+public class VisitorUserUpdateRequest {
+
+ @NotBlank
+ private String uuid;
+
+ @NotBlank
+ @Size(max = 30)
+ private String userName;
+
+ @NotBlank
+ @Size(max = 32)
+ private String mobile;
+
+ @Size(max = 8)
+ private String gender;
+
+ @Size(max = 128)
+ private String companyName;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/AppointmentOrder.java b/backend/src/main/java/com/sinata/appointment/entity/AppointmentOrder.java
new file mode 100644
index 0000000..863c160
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/AppointmentOrder.java
@@ -0,0 +1,83 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDate;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("appointment_order")
+@Schema(description = "预约单")
+public class AppointmentOrder extends BaseEntity {
+
+ @Schema(description = "预约单号")
+ private String orderNo;
+
+ @Schema(description = "访问类型 VISITOR/VEHICLE")
+ private String visitType;
+
+ @Schema(description = "被访人用户 id")
+ private Long visitedUserId;
+
+ @Schema(description = "被访人姓名(脱敏展示用快照)")
+ private String visitedUserName;
+
+ @Schema(description = "被访人所属部门(二级审批匹配部门负责人)")
+ private Long visitedDeptId;
+
+ @Schema(description = "来访事由编码或文本键")
+ private String visitReasonCode;
+
+ @Schema(description = "来访事由自定义内容")
+ private String visitReasonOther;
+
+ @Schema(description = "来访单位")
+ private String visitorOrg;
+
+ @Schema(description = "拜访时间模式(SINGLE/MULTI)")
+ private String visitTimeMode;
+
+ @Schema(description = "单日时段(ALL_DAY/AM/PM)")
+ private String visitTimeSlot;
+
+ @Schema(description = "拜访开始日期")
+ private LocalDate visitStartDate;
+
+ @Schema(description = "拜访结束日期")
+ private LocalDate visitEndDate;
+
+ @Schema(description = "状态")
+ private String status;
+
+ @Schema(description = "是否节假日触发额外审批")
+ private Boolean holidayFlag;
+
+ @Schema(description = "是否有人员进场")
+ private Boolean personEntryFlag;
+
+ @Schema(description = "是否有车辆进场")
+ private Boolean vehicleEntryFlag;
+
+ @Schema(description = "提交人用户ID(mp_user.id)")
+ private Long submitterId;
+
+ @Schema(description = "拒绝原因")
+ private String rejectReason;
+
+ @Schema(description = "经办人审批通行区域ID列表(JSON)")
+ private String passAccessAreaIds;
+
+ @Schema(description = "经办人审批通行区域名称列表(JSON)")
+ private String passAccessAreaNames;
+
+ @Schema(description = "经办人审批是否涉及其他属地单位")
+ private Boolean involveOtherLocalUnit;
+
+ @Schema(description = "经办人审批其他属地单位ID列表(JSON)")
+ private String passLocalUnitIds;
+
+ @Schema(description = "经办人审批其他属地单位名称列表(JSON)")
+ private String passLocalUnitNames;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/AppointmentVehicle.java b/backend/src/main/java/com/sinata/appointment/entity/AppointmentVehicle.java
new file mode 100644
index 0000000..74447b6
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/AppointmentVehicle.java
@@ -0,0 +1,26 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("appointment_vehicle")
+@Schema(description = "预约车辆明细")
+public class AppointmentVehicle extends BaseEntity {
+
+ @Schema(description = "预约单ID")
+ private Long orderId;
+
+ @Schema(description = "车牌号")
+ private String plateNo;
+
+ @Schema(description = "驾驶员访客ID(mp_visitor.id)")
+ private Long driverVisitorId;
+
+ @Schema(description = "驾驶员姓名快照")
+ private String driverName;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/entity/AppointmentVisitor.java b/backend/src/main/java/com/sinata/appointment/entity/AppointmentVisitor.java
new file mode 100644
index 0000000..2073836
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/AppointmentVisitor.java
@@ -0,0 +1,34 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("appointment_visitor")
+@Schema(description = "预约访客明细")
+public class AppointmentVisitor extends BaseEntity {
+
+ @Schema(description = "预约单 id")
+ private Long orderId;
+
+ @Schema(description = "访客ID(mp_visitor.id)")
+ private Long mpVisitorId;
+
+ @Schema(description = "姓名")
+ private String name;
+
+ @Schema(description = "身份证号(密文存储由业务层处理)")
+ private String idCardCipher;
+
+ @Schema(description = "手机号")
+ private String mobile;
+
+ @Schema(description = "驾驶员姓名(车辆预约)")
+ private String driverName;
+
+ @Schema(description = "车牌号")
+ private String plateNo;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/ApprovalInstance.java b/backend/src/main/java/com/sinata/appointment/entity/ApprovalInstance.java
new file mode 100644
index 0000000..94c417a
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/ApprovalInstance.java
@@ -0,0 +1,26 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("approval_instance")
+@Schema(description = "审批实例")
+public class ApprovalInstance extends BaseEntity {
+
+ @Schema(description = "预约单 id")
+ private Long orderId;
+
+ @Schema(description = "当前节点编码")
+ private String currentNodeCode;
+
+ @Schema(description = "状态 IN_PROGRESS/APPROVED/REJECTED")
+ private String status;
+
+ @Schema(description = "完成时间")
+ private LocalDateTime finishedAt;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/ApprovalTask.java b/backend/src/main/java/com/sinata/appointment/entity/ApprovalTask.java
new file mode 100644
index 0000000..4b25f52
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/ApprovalTask.java
@@ -0,0 +1,31 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("approval_task")
+@Schema(description = "审批任务")
+public class ApprovalTask extends BaseEntity {
+
+ @Schema(description = "审批实例 id")
+ private Long instanceId;
+
+ @Schema(description = "节点编码")
+ private String nodeCode;
+
+ @Schema(description = "并行组 key,同组任一通过即组通过")
+ private String groupKey;
+
+ @Schema(description = "审批人用户 id")
+ private Long assigneeUserId;
+
+ @Schema(description = "状态 PENDING/APPROVED/REJECTED/SKIPPED")
+ private String taskStatus;
+
+ @Schema(description = "审批意见")
+ private String comment;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/BaseEntity.java b/backend/src/main/java/com/sinata/appointment/entity/BaseEntity.java
new file mode 100644
index 0000000..258c798
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/BaseEntity.java
@@ -0,0 +1,65 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.FieldFill;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.Version;
+import com.sinata.appointment.validation.ChangePasswordGroup;
+import com.sinata.appointment.validation.CreateGroup;
+import com.sinata.appointment.validation.DeleteGroup;
+import com.sinata.appointment.validation.UpdateGroup;
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Null;
+import java.time.LocalDateTime;
+import lombok.Getter;
+import lombok.Setter;
+
+@Schema(description = "基础实体:id/uuid/version + 审计字段 + 逻辑删除 delFlag")
+@Getter
+@Setter
+public class BaseEntity {
+
+ @Schema(description = "id")
+ @Null(groups = CreateGroup.class, message = "新增不能携带 id")
+ @NotNull(
+ groups = {UpdateGroup.class, DeleteGroup.class, ChangePasswordGroup.class},
+ message = "id 不能为空")
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ @Schema(description = "UUID")
+ @Null(groups = CreateGroup.class, message = "新增不能携带 uuid")
+ @NotBlank(
+ groups = {UpdateGroup.class, DeleteGroup.class, ChangePasswordGroup.class},
+ message = "uuid 不能为空")
+ @TableField(fill = FieldFill.INSERT)
+ private String uuid;
+
+ @Schema(description = "乐观锁")
+ @Version
+ @Null(groups = CreateGroup.class, message = "新增不能携带 version")
+ @NotNull(
+ groups = {UpdateGroup.class, DeleteGroup.class, ChangePasswordGroup.class},
+ message = "version 不能为空")
+ private Long version;
+
+ @Schema(description = "创建人")
+ private Long createId;
+
+ @Schema(description = "创建时间")
+ @TableField(fill = FieldFill.INSERT)
+ private LocalDateTime createTime;
+
+ @Schema(description = "更新人")
+ private Long updateId;
+
+ @Schema(description = "更新时间")
+ @TableField(fill = FieldFill.UPDATE)
+ private LocalDateTime updateTime;
+
+ @Schema(description = "删除标记(0 未删,1 已删)")
+ private Integer delFlag = 0;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/BizApprovalNodeConfig.java b/backend/src/main/java/com/sinata/appointment/entity/BizApprovalNodeConfig.java
new file mode 100644
index 0000000..af73141
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/BizApprovalNodeConfig.java
@@ -0,0 +1,25 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("biz_approval_node_config")
+@Schema(description = "审批节点启停配置")
+public class BizApprovalNodeConfig extends BaseEntity {
+
+ @Schema(description = "节点编码")
+ private String nodeCode;
+
+ @Schema(description = "节点名称")
+ private String nodeName;
+
+ @Schema(description = "是否启用 1/0")
+ private Integer enabled;
+
+ @Schema(description = "排序")
+ private Integer sortOrder;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/BizFieldConfig.java b/backend/src/main/java/com/sinata/appointment/entity/BizFieldConfig.java
new file mode 100644
index 0000000..387d399
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/BizFieldConfig.java
@@ -0,0 +1,28 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("biz_field_config")
+@Schema(description = "预约字段开关/必填配置")
+public class BizFieldConfig extends BaseEntity {
+
+ @Schema(description = "字段编码")
+ private String fieldCode;
+
+ @Schema(description = "字段名称")
+ private String fieldName;
+
+ @Schema(description = "是否展示 1/0")
+ private Integer visible;
+
+ @Schema(description = "是否必填 1/0")
+ private Integer requiredFlag;
+
+ @Schema(description = "排序")
+ private Integer sortOrder;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/BizHolidayDate.java b/backend/src/main/java/com/sinata/appointment/entity/BizHolidayDate.java
new file mode 100644
index 0000000..5005ee1
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/BizHolidayDate.java
@@ -0,0 +1,18 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDate;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("biz_holiday_date")
+@Schema(description = "节假日/高温假日期")
+public class BizHolidayDate extends BaseEntity {
+
+ @Schema(description = "日期")
+ private LocalDate holidayDate;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/entity/BizOpLog.java b/backend/src/main/java/com/sinata/appointment/entity/BizOpLog.java
new file mode 100644
index 0000000..a54390c
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/BizOpLog.java
@@ -0,0 +1,38 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("biz_op_log")
+@Schema(description = "操作审计日志")
+public class BizOpLog extends BaseEntity {
+
+ @Schema(description = "操作人用户 id")
+ private Long operatorUserId;
+
+ @Schema(description = "模块")
+ private String module;
+
+ @Schema(description = "动作")
+ private String action;
+
+ @Schema(description = "资源类型")
+ private String resourceType;
+
+ @Schema(description = "资源 id")
+ private String resourceId;
+
+ @Schema(description = "详情 JSON")
+ private String detailJson;
+
+ @Schema(description = "操作时间")
+ private LocalDateTime opTime;
+
+ @Schema(description = "TraceId")
+ private String traceId;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/BizVisitReason.java b/backend/src/main/java/com/sinata/appointment/entity/BizVisitReason.java
new file mode 100644
index 0000000..23547c0
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/BizVisitReason.java
@@ -0,0 +1,25 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("biz_visit_reason")
+@Schema(description = "来访事由配置")
+public class BizVisitReason extends BaseEntity {
+
+ @Schema(description = "事由编码")
+ private String reasonCode;
+
+ @Schema(description = "事由名称")
+ private String reasonName;
+
+ @Schema(description = "排序")
+ private Integer sortOrder;
+
+ @Schema(description = "是否启用")
+ private Integer enabled;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/MpPassAccessArea.java b/backend/src/main/java/com/sinata/appointment/entity/MpPassAccessArea.java
new file mode 100644
index 0000000..19b9700
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/MpPassAccessArea.java
@@ -0,0 +1,25 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("pass_access_area")
+@Schema(description = "通行区域(两层:pid=0 为一级)")
+public class MpPassAccessArea extends BaseEntity {
+
+ @Schema(description = "父级 id,0 表示一级区域")
+ private Long pid;
+
+ @Schema(description = "区域名称")
+ private String areaName;
+
+ @Schema(description = "备注(与海康地点管理一致)")
+ private String remark;
+
+ @Schema(description = "区域说明")
+ private String areaDesc;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/MpPassLocalUnit.java b/backend/src/main/java/com/sinata/appointment/entity/MpPassLocalUnit.java
new file mode 100644
index 0000000..0a2185f
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/MpPassLocalUnit.java
@@ -0,0 +1,28 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("pass_local_unit")
+@Schema(description = "属地单位")
+public class MpPassLocalUnit extends BaseEntity {
+
+ @Schema(description = "单位名称")
+ private String unitName;
+
+ @Schema(description = "备注")
+ private String remark;
+
+ @Schema(description = "审批类型:1部门 2人员分组 3人员")
+ private Integer approverType;
+
+ @Schema(description = "人员分组 id")
+ private Long approverGroupId;
+
+ @Schema(description = "部门 id 列表 JSON")
+ private String approverDeptIds;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/MpPassLocalUnitMember.java b/backend/src/main/java/com/sinata/appointment/entity/MpPassLocalUnitMember.java
new file mode 100644
index 0000000..30d0054
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/MpPassLocalUnitMember.java
@@ -0,0 +1,19 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("pass_local_unit_member")
+@Schema(description = "属地单位审批人员(人员类型)")
+public class MpPassLocalUnitMember extends BaseEntity {
+
+ @Schema(description = "属地单位 id")
+ private Long unitId;
+
+ @Schema(description = "员工 mp_user_id")
+ private Long mpUserId;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/MpStaffGroup.java b/backend/src/main/java/com/sinata/appointment/entity/MpStaffGroup.java
new file mode 100644
index 0000000..6abb0ec
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/MpStaffGroup.java
@@ -0,0 +1,20 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("mp_staff_group")
+@Schema(description = "人员分组")
+public class MpStaffGroup extends BaseEntity {
+
+ @Schema(description = "分组名称")
+ private String groupName;
+
+ @Schema(description = "负责人 mp_user_id")
+ private Long leaderMpUserId;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/entity/MpStaffGroupMember.java b/backend/src/main/java/com/sinata/appointment/entity/MpStaffGroupMember.java
new file mode 100644
index 0000000..317dbad
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/MpStaffGroupMember.java
@@ -0,0 +1,20 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("mp_staff_group_member")
+@Schema(description = "人员分组成员关联")
+public class MpStaffGroupMember extends BaseEntity {
+
+ @Schema(description = "分组ID")
+ private Long groupId;
+
+ @Schema(description = "员工 mp_user_id")
+ private Long mpUserId;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/entity/MpUser.java b/backend/src/main/java/com/sinata/appointment/entity/MpUser.java
new file mode 100644
index 0000000..421903b
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/MpUser.java
@@ -0,0 +1,50 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("mp_user")
+@Schema(description = "小程序用户(与平台 sys_user 分离)")
+public class MpUser extends BaseEntity {
+
+ @Schema(description = "微信 openId")
+ private String openId;
+
+ @Schema(description = "微信 unionId")
+ private String unionId;
+
+ @Schema(description = "VISITOR / EMPLOYEE")
+ private String userType;
+
+ @Schema(description = "员工所属部门 id,访客为空")
+ private Long deptId;
+
+ @Schema(description = "是否部门负责人(员工有效)")
+ private Boolean deptLeaderFlag;
+
+ @Schema(description = "昵称")
+ private String nickName;
+
+ @Schema(description = "头像")
+ private String avatarUrl;
+
+ @Schema(description = "手机号")
+ private String mobile;
+
+ @Schema(description = "性别:男/女")
+ private String gender;
+
+ @Schema(description = "所属公司")
+ private String companyName;
+
+ @Schema(description = "员工编号")
+ private String staffNo;
+
+ /** 1 启用 0 禁用(员工/预建档有效) */
+ @Schema(description = "1启用 0禁用")
+ private Integer status;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/MpVisitor.java b/backend/src/main/java/com/sinata/appointment/entity/MpVisitor.java
new file mode 100644
index 0000000..1352522
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/MpVisitor.java
@@ -0,0 +1,36 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("mp_visitor")
+@Schema(description = "小程序访客")
+public class MpVisitor extends BaseEntity {
+
+ @Schema(description = "小程序用户 id")
+ private Long mpUserId;
+
+ @TableField("visitor_name")
+ @Schema(description = "来访人姓名")
+ private String visitorName;
+
+ @TableField("visitor_mobile")
+ @Schema(description = "来访人电话")
+ private String visitorMobile;
+
+ @TableField("visitor_company")
+ @Schema(description = "来访人单位")
+ private String visitorCompany;
+
+ @Schema(description = "身份证号密文")
+ private String idCardCipher;
+
+ @Schema(description = "性别:男/女")
+ private String gender;
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/entity/QrcodeTicket.java b/backend/src/main/java/com/sinata/appointment/entity/QrcodeTicket.java
new file mode 100644
index 0000000..12d9153
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/QrcodeTicket.java
@@ -0,0 +1,35 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import java.time.LocalDateTime;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("qrcode_ticket")
+@Schema(description = "预约二维码票据")
+public class QrcodeTicket extends BaseEntity {
+
+ @Schema(description = "预约单 id")
+ private Long orderId;
+
+ @Schema(description = "访客ID(mp_visitor.id)")
+ private Long mpVisitorId;
+
+ @Schema(description = "海康或业务侧二维码内容/令牌")
+ private String payload;
+
+ @Schema(description = "状态 UNUSED/USED/EXPIRED")
+ private String ticketStatus;
+
+ @Schema(description = "二维码生成基准时间")
+ private LocalDateTime generateTime;
+
+ @Schema(description = "二维码失效时间")
+ private LocalDateTime expireTime;
+
+ @Schema(description = "使用时间")
+ private LocalDateTime usedAt;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/SysBizSetting.java b/backend/src/main/java/com/sinata/appointment/entity/SysBizSetting.java
new file mode 100644
index 0000000..c4483b0
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/SysBizSetting.java
@@ -0,0 +1,18 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+@Data
+@TableName("sys_biz_setting")
+public class SysBizSetting {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+
+ private String settingKey;
+
+ private String settingValue;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/SysDept.java b/backend/src/main/java/com/sinata/appointment/entity/SysDept.java
new file mode 100644
index 0000000..5e48125
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/SysDept.java
@@ -0,0 +1,26 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("sys_dept")
+@Schema(description = "部门")
+public class SysDept extends BaseEntity {
+
+ @Schema(description = "父部门 id,根为 0")
+ private Long parentId;
+
+ @Schema(description = "部门名称")
+ private String deptName;
+
+ @Schema(description = "排序")
+ private Integer sortOrder;
+
+ /** 1 启用 0 禁用 */
+ @Schema(description = "部门状态:1启用 0禁用")
+ private Integer status;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/SysMenu.java b/backend/src/main/java/com/sinata/appointment/entity/SysMenu.java
new file mode 100644
index 0000000..867fd1a
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/SysMenu.java
@@ -0,0 +1,32 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("sys_menu")
+@Schema(description = "菜单(板块/页面/权限,数据由库维护)")
+public class SysMenu extends BaseEntity {
+
+ @Schema(description = "父级 id,板块为 0")
+ private Long pid;
+
+ /** 1 板块 2 页面 3 权限 */
+ @Schema(description = "1板块 2页面 3权限")
+ private Integer menuType;
+
+ @Schema(description = "菜单名称")
+ private String menuName;
+
+ @Schema(description = "权限编码")
+ private String permCode;
+
+ @Schema(description = "排序")
+ private Integer sortOrder;
+
+ @Schema(description = "前端路由")
+ private String routePath;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/SysRole.java b/backend/src/main/java/com/sinata/appointment/entity/SysRole.java
new file mode 100644
index 0000000..4bc0727
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/SysRole.java
@@ -0,0 +1,22 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("sys_role")
+@Schema(description = "角色")
+public class SysRole extends BaseEntity {
+
+ @Schema(description = "角色编码")
+ private String roleCode;
+
+ @Schema(description = "角色名称")
+ private String roleName;
+
+ @Schema(description = "备注")
+ private String remark;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/SysRoleMenu.java b/backend/src/main/java/com/sinata/appointment/entity/SysRoleMenu.java
new file mode 100644
index 0000000..6fc79f7
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/SysRoleMenu.java
@@ -0,0 +1,19 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("sys_role_menu")
+@Schema(description = "角色菜单关联")
+public class SysRoleMenu extends BaseEntity {
+
+ @Schema(description = "角色 id")
+ private Long roleId;
+
+ @Schema(description = "菜单 id")
+ private Long menuId;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/SysUser.java b/backend/src/main/java/com/sinata/appointment/entity/SysUser.java
new file mode 100644
index 0000000..82a654b
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/SysUser.java
@@ -0,0 +1,31 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("sys_user")
+@Schema(description = "系统用户")
+public class SysUser extends BaseEntity {
+
+ @Schema(description = "登录名")
+ private String username;
+
+ @Schema(description = "密码哈希")
+ private String passwordHash;
+
+ @Schema(description = "姓名")
+ private String realName;
+
+ @Schema(description = "手机号")
+ private String mobile;
+
+ @Schema(description = "头像 URL")
+ private String avatarUrl;
+
+ @Schema(description = "状态 1 启用 0 禁用")
+ private Integer status;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/entity/SysUserRole.java b/backend/src/main/java/com/sinata/appointment/entity/SysUserRole.java
new file mode 100644
index 0000000..d249ee8
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/entity/SysUserRole.java
@@ -0,0 +1,19 @@
+package com.sinata.appointment.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("sys_user_role")
+@Schema(description = "用户角色关联")
+public class SysUserRole extends BaseEntity {
+
+ @Schema(description = "用户 id")
+ private Long userId;
+
+ @Schema(description = "角色 id")
+ private Long roleId;
+}
diff --git a/backend/src/main/java/com/sinata/appointment/exception/BizException.java b/backend/src/main/java/com/sinata/appointment/exception/BizException.java
new file mode 100644
index 0000000..5867165
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/exception/BizException.java
@@ -0,0 +1,14 @@
+package com.sinata.appointment.exception;
+
+import lombok.Getter;
+
+@Getter
+public class BizException extends RuntimeException {
+
+ private final int code;
+
+ public BizException(int code, String message) {
+ super(message);
+ this.code = code;
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/integration/WechatMiniGateway.java b/backend/src/main/java/com/sinata/appointment/integration/WechatMiniGateway.java
new file mode 100644
index 0000000..0c31b96
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/integration/WechatMiniGateway.java
@@ -0,0 +1,180 @@
+package com.sinata.appointment.integration;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sinata.appointment.config.WechatMiniAppProperties;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.exception.BizException;
+import java.util.concurrent.locks.ReentrantLock;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.util.UriComponentsBuilder;
+
+/**
+ * 微信小程序服务端:js_code → openId;access_token + phoneCode → 手机号。
+ *
+ * <p>官方文档:code2session、getAccessToken(client_credential)、getuserphonenumber。
+ */
+@Slf4j
+@Service
+public class WechatMiniGateway {
+
+ private static final String JSCODE2SESSION =
+ "https://api.weixin.qq.com/sns/jscode2session";
+ private static final String CGI_BIN_TOKEN =
+ "https://api.weixin.qq.com/cgi-bin/token";
+ private static final String GET_USER_PHONE =
+ "https://api.weixin.qq.com/wxa/business/getuserphonenumber";
+
+ private final WechatMiniAppProperties props;
+ private final RestTemplate restTemplate;
+ private final ObjectMapper objectMapper;
+
+ private final ReentrantLock tokenLock = new ReentrantLock();
+ private volatile String cachedAccessToken;
+ private volatile long accessTokenExpireAtEpochMs;
+
+ public WechatMiniGateway(
+ WechatMiniAppProperties props,
+ @Qualifier("wechatRestTemplate") RestTemplate restTemplate,
+ ObjectMapper objectMapper) {
+ this.props = props;
+ this.restTemplate = restTemplate;
+ this.objectMapper = objectMapper;
+ }
+
+ public WxCode2SessionResult code2Session(String jsCode) {
+ if (jsCode == null || jsCode.isBlank()) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "wxCode 不能为空");
+ }
+ if (props.useStub()) {
+ log.debug("Wechat mini stub: code2Session");
+ return WxCode2SessionResult.stub(jsCode);
+ }
+ String url =
+ UriComponentsBuilder.fromHttpUrl(JSCODE2SESSION)
+ .queryParam("appid", props.getAppId())
+ .queryParam("secret", props.getAppSecret())
+ .queryParam("js_code", jsCode)
+ .queryParam("grant_type", "authorization_code")
+ .build()
+ .toUriString();
+ try {
+ String body = restTemplate.getForObject(url, String.class);
+ JsonNode root = objectMapper.readTree(body);
+ int err = root.path("errcode").asInt(0);
+ if (err != 0) {
+ String msg = root.path("errmsg").asText("wx error");
+ log.warn("code2session errcode={} msg={}", err, msg);
+ throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "微信登录失败: " + msg);
+ }
+ String openId = root.path("openid").asText(null);
+ if (openId == null || openId.isBlank()) {
+ throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "微信未返回 openid");
+ }
+ return WxCode2SessionResult.builder()
+ .openId(openId)
+ .unionId(root.path("unionid").asText(null))
+ .sessionKey(root.path("session_key").asText(null))
+ .build();
+ } catch (BizException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("code2session http error", e);
+ throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "微信接口调用异常");
+ }
+ }
+
+ /**
+ * @param phoneCode 前端 button open-type=getPhoneNumber 回调中的 code(非手机号明文)
+ */
+ public String getPhoneNumber(String phoneCode) {
+ if (phoneCode == null || phoneCode.isBlank()) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "phoneCode 不能为空");
+ }
+ if (props.useStub()) {
+ int h = Math.abs(phoneCode.hashCode() % 100_000_000);
+ String stubPhone = "138" + String.format("%08d", h);
+ log.debug("Wechat mini stub: phoneCode -> {}", stubPhone);
+ return stubPhone;
+ }
+ String token = getAccessToken();
+ String url =
+ UriComponentsBuilder.fromHttpUrl(GET_USER_PHONE)
+ .queryParam("access_token", token)
+ .build()
+ .toUriString();
+ try {
+ String reqJson = objectMapper.createObjectNode().put("code", phoneCode).toString();
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_JSON);
+ String body =
+ restTemplate.postForObject(url, new HttpEntity<>(reqJson, headers), String.class);
+ JsonNode root = objectMapper.readTree(body);
+ int err = root.path("errcode").asInt(-1);
+ if (err != 0) {
+ String msg = root.path("errmsg").asText("wx error");
+ log.warn("getuserphonenumber errcode={} msg={}", err, msg);
+ throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "获取手机号失败: " + msg);
+ }
+ String phone = root.path("phone_info").path("purePhoneNumber").asText(null);
+ if (phone == null || phone.isBlank()) {
+ phone = root.path("phone_info").path("phoneNumber").asText(null);
+ }
+ if (phone == null || phone.isBlank()) {
+ throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "微信未返回手机号");
+ }
+ return phone;
+ } catch (BizException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("getuserphonenumber http error", e);
+ throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "微信手机号接口异常");
+ }
+ }
+
+ private String getAccessToken() {
+ tokenLock.lock();
+ try {
+ long now = System.currentTimeMillis();
+ if (cachedAccessToken != null && now < accessTokenExpireAtEpochMs - 120_000) {
+ return cachedAccessToken;
+ }
+ String url =
+ UriComponentsBuilder.fromHttpUrl(CGI_BIN_TOKEN)
+ .queryParam("grant_type", "client_credential")
+ .queryParam("appid", props.getAppId())
+ .queryParam("secret", props.getAppSecret())
+ .build()
+ .toUriString();
+ String body = restTemplate.getForObject(url, String.class);
+ JsonNode root = objectMapper.readTree(body);
+ int err = root.path("errcode").asInt(0);
+ if (err != 0) {
+ throw new BizException(
+ BizErrorCode.MINI_WX_API_ERROR,
+ "获取 access_token 失败: " + root.path("errmsg").asText());
+ }
+ String token = root.path("access_token").asText(null);
+ int expiresIn = root.path("expires_in").asInt(7200);
+ if (token == null || token.isBlank()) {
+ throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "微信未返回 access_token");
+ }
+ cachedAccessToken = token;
+ accessTokenExpireAtEpochMs = now + expiresIn * 1000L;
+ return token;
+ } catch (BizException e) {
+ throw e;
+ } catch (Exception e) {
+ log.error("getAccessToken error", e);
+ throw new BizException(BizErrorCode.MINI_WX_API_ERROR, "获取 access_token 异常");
+ } finally {
+ tokenLock.unlock();
+ }
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/integration/WxCode2SessionResult.java b/backend/src/main/java/com/sinata/appointment/integration/WxCode2SessionResult.java
new file mode 100644
index 0000000..e43afaa
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/integration/WxCode2SessionResult.java
@@ -0,0 +1,22 @@
+package com.sinata.appointment.integration;
+
+import lombok.Builder;
+import lombok.Value;
+
+@Value
+@Builder
+public class WxCode2SessionResult {
+
+ String openId;
+ String unionId;
+ String sessionKey;
+
+ static WxCode2SessionResult stub(String jsCode) {
+ String openId = "STUB-" + Integer.toHexString(jsCode.hashCode());
+ return WxCode2SessionResult.builder()
+ .openId(openId)
+ .unionId(null)
+ .sessionKey("stub-session")
+ .build();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/AppointmentOrderMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/AppointmentOrderMapper.java
new file mode 100644
index 0000000..2f54b50
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/AppointmentOrderMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.AppointmentOrder;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AppointmentOrderMapper extends BaseMapper<AppointmentOrder> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/AppointmentVehicleMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/AppointmentVehicleMapper.java
new file mode 100644
index 0000000..c8cea9c
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/AppointmentVehicleMapper.java
@@ -0,0 +1,9 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.AppointmentVehicle;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AppointmentVehicleMapper extends BaseMapper<AppointmentVehicle> {}
+
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/AppointmentVisitorMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/AppointmentVisitorMapper.java
new file mode 100644
index 0000000..5935a28
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/AppointmentVisitorMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.AppointmentVisitor;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AppointmentVisitorMapper extends BaseMapper<AppointmentVisitor> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/ApprovalInstanceMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/ApprovalInstanceMapper.java
new file mode 100644
index 0000000..e6fa700
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/ApprovalInstanceMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.ApprovalInstance;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ApprovalInstanceMapper extends BaseMapper<ApprovalInstance> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/ApprovalTaskMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/ApprovalTaskMapper.java
new file mode 100644
index 0000000..88ffc8e
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/ApprovalTaskMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.ApprovalTask;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ApprovalTaskMapper extends BaseMapper<ApprovalTask> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/BizApprovalNodeConfigMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/BizApprovalNodeConfigMapper.java
new file mode 100644
index 0000000..e645efb
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/BizApprovalNodeConfigMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.BizApprovalNodeConfig;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface BizApprovalNodeConfigMapper extends BaseMapper<BizApprovalNodeConfig> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/BizFieldConfigMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/BizFieldConfigMapper.java
new file mode 100644
index 0000000..11d69ae
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/BizFieldConfigMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.BizFieldConfig;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface BizFieldConfigMapper extends BaseMapper<BizFieldConfig> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/BizHolidayDateMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/BizHolidayDateMapper.java
new file mode 100644
index 0000000..91c8f99
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/BizHolidayDateMapper.java
@@ -0,0 +1,15 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.BizHolidayDate;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import java.time.LocalDate;
+import java.util.List;
+
+@Mapper
+public interface BizHolidayDateMapper extends BaseMapper<BizHolidayDate> {
+
+ int deleteByHolidayDates(@Param("dates") List<LocalDate> dates);
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/BizOpLogMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/BizOpLogMapper.java
new file mode 100644
index 0000000..e283ef6
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/BizOpLogMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.BizOpLog;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface BizOpLogMapper extends BaseMapper<BizOpLog> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/BizVisitReasonMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/BizVisitReasonMapper.java
new file mode 100644
index 0000000..bd41ab9
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/BizVisitReasonMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.BizVisitReason;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface BizVisitReasonMapper extends BaseMapper<BizVisitReason> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/MpPassAccessAreaMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/MpPassAccessAreaMapper.java
new file mode 100644
index 0000000..9ef659e
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/MpPassAccessAreaMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.MpPassAccessArea;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface MpPassAccessAreaMapper extends BaseMapper<MpPassAccessArea> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/MpPassLocalUnitMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/MpPassLocalUnitMapper.java
new file mode 100644
index 0000000..078770f
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/MpPassLocalUnitMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.MpPassLocalUnit;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface MpPassLocalUnitMapper extends BaseMapper<MpPassLocalUnit> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/MpPassLocalUnitMemberMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/MpPassLocalUnitMemberMapper.java
new file mode 100644
index 0000000..d5a1dd6
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/MpPassLocalUnitMemberMapper.java
@@ -0,0 +1,12 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.MpPassLocalUnitMember;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+@Mapper
+public interface MpPassLocalUnitMemberMapper extends BaseMapper<MpPassLocalUnitMember> {
+
+ int deletePhysicalByUnitId(@Param("unitId") Long unitId);
+}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/MpStaffGroupMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/MpStaffGroupMapper.java
new file mode 100644
index 0000000..ad5672a
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/MpStaffGroupMapper.java
@@ -0,0 +1,9 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.MpStaffGroup;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface MpStaffGroupMapper extends BaseMapper<MpStaffGroup> {}
+
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/MpStaffGroupMemberMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/MpStaffGroupMemberMapper.java
new file mode 100644
index 0000000..a08d8b1
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/MpStaffGroupMemberMapper.java
@@ -0,0 +1,17 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.MpStaffGroupMember;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+@Mapper
+public interface MpStaffGroupMemberMapper extends BaseMapper<MpStaffGroupMember> {
+
+ /** 物理删除成员行(绕过全局逻辑删除对 BaseMapper.delete 的改写)。 */
+ int deletePhysicalByGroupId(@Param("groupId") Long groupId);
+
+ int deletePhysicalByGroupAndUser(
+ @Param("groupId") Long groupId, @Param("mpUserId") Long mpUserId);
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/MpUserMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/MpUserMapper.java
new file mode 100644
index 0000000..970fbe5
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/MpUserMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.MpUser;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface MpUserMapper extends BaseMapper<MpUser> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/MpVisitorMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/MpVisitorMapper.java
new file mode 100644
index 0000000..ae0dd62
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/MpVisitorMapper.java
@@ -0,0 +1,9 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.MpVisitor;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface MpVisitorMapper extends BaseMapper<MpVisitor> {}
+
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/QrcodeTicketMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/QrcodeTicketMapper.java
new file mode 100644
index 0000000..a34ddf2
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/QrcodeTicketMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.QrcodeTicket;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface QrcodeTicketMapper extends BaseMapper<QrcodeTicket> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/SysBizSettingMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/SysBizSettingMapper.java
new file mode 100644
index 0000000..e30b686
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/SysBizSettingMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.SysBizSetting;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SysBizSettingMapper extends BaseMapper<SysBizSetting> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/SysDeptMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/SysDeptMapper.java
new file mode 100644
index 0000000..ca80976
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/SysDeptMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.SysDept;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SysDeptMapper extends BaseMapper<SysDept> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/SysMenuMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/SysMenuMapper.java
new file mode 100644
index 0000000..da214e6
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/SysMenuMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.SysMenu;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SysMenuMapper extends BaseMapper<SysMenu> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/SysRoleMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/SysRoleMapper.java
new file mode 100644
index 0000000..7088366
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/SysRoleMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.SysRole;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SysRoleMapper extends BaseMapper<SysRole> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/SysRoleMenuMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/SysRoleMenuMapper.java
new file mode 100644
index 0000000..277a915
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/SysRoleMenuMapper.java
@@ -0,0 +1,8 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.entity.SysRoleMenu;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface SysRoleMenuMapper extends BaseMapper<SysRoleMenu> {}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/SysUserMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/SysUserMapper.java
new file mode 100644
index 0000000..bf9cbf7
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/SysUserMapper.java
@@ -0,0 +1,21 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.dto.UserRoleLabelRow;
+import com.sinata.appointment.entity.SysUser;
+import java.util.Collection;
+import java.util.List;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+@Mapper
+public interface SysUserMapper extends BaseMapper<SysUser> {
+
+ List<Long> listUserIdsByRoleCode(@Param("roleCode") String roleCode);
+
+ List<String> listRoleCodesByUserId(@Param("userId") Long userId);
+
+ List<String> listPermissionCodesByUserId(@Param("userId") Long userId);
+
+ List<UserRoleLabelRow> listRoleLabelsByUserIds(@Param("userIds") Collection<Long> userIds);
+}
diff --git a/backend/src/main/java/com/sinata/appointment/mapper/SysUserRoleMapper.java b/backend/src/main/java/com/sinata/appointment/mapper/SysUserRoleMapper.java
new file mode 100644
index 0000000..b879094
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/mapper/SysUserRoleMapper.java
@@ -0,0 +1,14 @@
+package com.sinata.appointment.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.sinata.appointment.dto.RoleUserCountRow;
+import com.sinata.appointment.entity.SysUserRole;
+import java.util.List;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+@Mapper
+public interface SysUserRoleMapper extends BaseMapper<SysUserRole> {
+
+ List<RoleUserCountRow> countUsersGroupedByRoleId(@Param("ids") List<Long> ids);
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/AdminLoginCryptoService.java b/backend/src/main/java/com/sinata/appointment/service/AdminLoginCryptoService.java
new file mode 100644
index 0000000..9dd7975
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/AdminLoginCryptoService.java
@@ -0,0 +1,91 @@
+package com.sinata.appointment.service;
+
+import com.sinata.appointment.config.AdminLoginCryptoProperties;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.exception.BizException;
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.util.Base64;
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+@Service
+@RequiredArgsConstructor
+public class AdminLoginCryptoService {
+
+ private static final String TRANSFORMATION = "AES/CBC/PKCS5Padding";
+ private static final String AES = "AES";
+ private static final String SEPARATOR = "|";
+
+ private final AdminLoginCryptoProperties properties;
+
+ public String decryptLoginField(String encryptedValue, String fieldLabel) {
+ if (!StringUtils.hasText(encryptedValue)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, fieldLabel + "不能为空");
+ }
+ String plain = decryptAes(encryptedValue.trim());
+ int separatorIndex = plain.indexOf(SEPARATOR);
+ if (separatorIndex <= 0 || separatorIndex >= plain.length() - 1) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "登录信息格式错误,请重试");
+ }
+ long timestamp = parseTimestamp(plain.substring(0, separatorIndex));
+ validateTimestamp(timestamp);
+ String value = plain.substring(separatorIndex + 1);
+ if (!StringUtils.hasText(value)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, fieldLabel + "不能为空");
+ }
+ return value.trim();
+ }
+
+ private String decryptAes(String encryptedValue) {
+ validateKeyAndIv();
+ try {
+ Cipher cipher = Cipher.getInstance(TRANSFORMATION);
+ SecretKeySpec keySpec =
+ new SecretKeySpec(properties.getKey().getBytes(StandardCharsets.UTF_8), AES);
+ IvParameterSpec ivSpec =
+ new IvParameterSpec(properties.getIv().getBytes(StandardCharsets.UTF_8));
+ cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
+ byte[] decrypted =
+ cipher.doFinal(Base64.getDecoder().decode(encryptedValue.getBytes(StandardCharsets.UTF_8)));
+ return new String(decrypted, StandardCharsets.UTF_8);
+ } catch (Exception ex) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "登录信息解密失败,请重试");
+ }
+ }
+
+ private long parseTimestamp(String raw) {
+ try {
+ return Long.parseLong(raw);
+ } catch (NumberFormatException ex) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "登录信息时间戳无效,请重试");
+ }
+ }
+
+ private void validateTimestamp(long timestamp) {
+ long now = Instant.now().toEpochMilli();
+ if (Math.abs(now - timestamp) > properties.getMaxSkewMillis()) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "登录信息已过期,请刷新后重试");
+ }
+ }
+
+ private void validateKeyAndIv() {
+ if (!StringUtils.hasText(properties.getKey())
+ || !StringUtils.hasText(properties.getIv())
+ || properties.getKey().length() != 16
+ || properties.getIv().length() != 16) {
+ int keyLength = properties.getKey() == null ? 0 : properties.getKey().length();
+ int ivLength = properties.getIv() == null ? 0 : properties.getIv().length();
+ throw new IllegalStateException(
+ "管理后台登录AES配置无效,key/iv 必须为16位字符,当前 key="
+ + keyLength
+ + " 位, iv="
+ + ivLength
+ + " 位");
+ }
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/AppointmentApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/AppointmentApplicationService.java
new file mode 100644
index 0000000..e4f9120
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/AppointmentApplicationService.java
@@ -0,0 +1,930 @@
+package com.sinata.appointment.service;
+
+import com.alibaba.excel.EasyExcel;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sinata.appointment.constants.AppointmentOrderStatus;
+import com.sinata.appointment.constants.ApprovalNodeCode;
+import com.sinata.appointment.constants.ApprovalTaskStatus;
+import com.sinata.appointment.dto.AppointmentAdminDetailVisitorVo;
+import com.sinata.appointment.dto.AppointmentAdminDetailVo;
+import com.sinata.appointment.dto.AppointmentAdminExportRow;
+import com.sinata.appointment.dto.AppointmentAdminListVo;
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.entity.AppointmentVehicle;
+import com.sinata.appointment.entity.AppointmentVisitor;
+import com.sinata.appointment.entity.ApprovalInstance;
+import com.sinata.appointment.entity.ApprovalTask;
+import com.sinata.appointment.entity.BizVisitReason;
+import com.sinata.appointment.entity.MpUser;
+import com.sinata.appointment.entity.MpVisitor;
+import com.sinata.appointment.entity.SysDept;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.mapper.AppointmentOrderMapper;
+import com.sinata.appointment.mapper.AppointmentVehicleMapper;
+import com.sinata.appointment.mapper.AppointmentVisitorMapper;
+import com.sinata.appointment.mapper.ApprovalInstanceMapper;
+import com.sinata.appointment.mapper.ApprovalTaskMapper;
+import com.sinata.appointment.mapper.BizVisitReasonMapper;
+import com.sinata.appointment.mapper.MpUserMapper;
+import com.sinata.appointment.mapper.MpVisitorMapper;
+import com.sinata.appointment.mapper.SysDeptMapper;
+import com.sinata.appointment.util.MobileMaskUtil;
+import com.sinata.appointment.util.SensitiveDataUtil;
+import com.sinata.appointment.util.UuidAssert;
+import java.io.ByteArrayOutputStream;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+@Service
+@RequiredArgsConstructor
+public class AppointmentApplicationService {
+
+ private static final String DISPLAY_STATUS_PENDING_VISIT = "PENDING_VISIT";
+ private static final String DISPLAY_STATUS_EFFECTIVE = "EFFECTIVE";
+ private static final String DISPLAY_STATUS_INVALID = "INVALID";
+ private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
+ private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private final AppointmentOrderMapper appointmentOrderMapper;
+ private final AppointmentVehicleMapper appointmentVehicleMapper;
+ private final AppointmentVisitorMapper appointmentVisitorMapper;
+ private final ApprovalInstanceMapper approvalInstanceMapper;
+ private final ApprovalTaskMapper approvalTaskMapper;
+ private final BizVisitReasonMapper bizVisitReasonMapper;
+ private final MpUserMapper mpUserMapper;
+ private final MpVisitorMapper mpVisitorMapper;
+ private final SysDeptMapper sysDeptMapper;
+ private final ObjectMapper objectMapper;
+
+ public IPage<AppointmentAdminListVo> pageOrders(
+ long page,
+ long size,
+ String visitedUserName,
+ Long visitedDeptId,
+ String accessArea,
+ String localUnit,
+ LocalDate visitDateStart,
+ LocalDate visitDateEnd,
+ String displayStatus,
+ String approvalStatus,
+ String visitorKeyword,
+ String visitReasonCode) {
+ List<AppointmentAdminListVo> all =
+ listAdminOrders(
+ visitedUserName,
+ visitedDeptId,
+ accessArea,
+ localUnit,
+ visitDateStart,
+ visitDateEnd,
+ displayStatus,
+ approvalStatus,
+ visitorKeyword,
+ visitReasonCode);
+ return slicePage(page, size, all);
+ }
+
+ public byte[] exportExcel(
+ String visitedUserName,
+ Long visitedDeptId,
+ String accessArea,
+ String localUnit,
+ LocalDate visitDateStart,
+ LocalDate visitDateEnd,
+ String displayStatus,
+ String approvalStatus,
+ String visitorKeyword,
+ String visitReasonCode) {
+ Set<Long> visitorOrderIds = findOrderIdsByVisitorKeyword(visitorKeyword);
+ if (visitorOrderIds != null && visitorOrderIds.isEmpty()) {
+ return writeExportRows(List.of());
+ }
+
+ List<AppointmentOrder> orders =
+ appointmentOrderMapper.selectList(
+ buildOrderQuery(
+ visitedUserName,
+ visitedDeptId,
+ accessArea,
+ localUnit,
+ visitDateStart,
+ visitDateEnd,
+ visitReasonCode,
+ visitorOrderIds));
+ if (orders.isEmpty()) {
+ return writeExportRows(List.of());
+ }
+
+ List<Long> orderIds = orders.stream().map(AppointmentOrder::getId).filter(Objects::nonNull).toList();
+ Map<Long, List<AppointmentVisitor>> visitorRowsMap = loadVisitorRows(orderIds);
+ Map<Long, ApprovalInstance> approvalInstanceMap = loadApprovalInstanceMap(orderIds);
+ Map<Long, String> deptNameMap = loadDeptNameMap(orders);
+ Map<String, String> reasonNameMap = loadReasonNameMap(orders);
+
+ List<AppointmentOrder> filteredOrders = new ArrayList<>();
+ for (AppointmentOrder order : orders) {
+ AppointmentAdminListVo vo =
+ toAdminListVo(
+ order,
+ deptNameMap.get(order.getVisitedDeptId()),
+ reasonNameMap,
+ visitorRowsMap.get(order.getId()),
+ approvalInstanceMap.get(order.getId()));
+ if (matchesDisplayStatus(vo, displayStatus) && matchesApprovalStatus(vo, approvalStatus)) {
+ filteredOrders.add(order);
+ }
+ }
+
+ List<AppointmentAdminExportRow> rows = buildExportRows(filteredOrders, visitorRowsMap, approvalInstanceMap, reasonNameMap);
+ return writeExportRows(rows);
+ }
+
+ private byte[] writeExportRows(List<AppointmentAdminExportRow> rows) {
+ try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
+ EasyExcel.write(bos, AppointmentAdminExportRow.class).sheet("预约记录").doWrite(rows);
+ return bos.toByteArray();
+ } catch (Exception e) {
+ return new byte[0];
+ }
+ }
+
+ public AppointmentOrder getById(Long id, String uuid) {
+ AppointmentOrder o = appointmentOrderMapper.selectById(id);
+ if (o == null || o.getDelFlag() != null && o.getDelFlag() == 1) {
+ throw new BizException(BizErrorCode.ORDER_NOT_FOUND, "预约单不存在");
+ }
+ UuidAssert.match(o, uuid);
+ return o;
+ }
+
+ public AppointmentAdminDetailVo detail(Long id, String uuid) {
+ AppointmentOrder order = getById(id, uuid);
+ String deptName = loadDeptNameMap(List.of(order)).get(order.getVisitedDeptId());
+ Map<String, String> reasonNameMap = loadReasonNameMap(List.of(order));
+ List<AppointmentVisitor> visitorRows = listVisitors(id);
+ Map<Long, MpVisitor> visitorMap = loadVisitorMap(visitorRows);
+ Map<Long, String> plateMap = loadVisitorPlateMap(id);
+ ApprovalInstance approvalInstance = loadLatestInstance(id);
+
+ AppointmentAdminDetailVo vo = new AppointmentAdminDetailVo();
+ vo.setId(order.getId());
+ vo.setUuid(order.getUuid());
+ vo.setOrderNo(order.getOrderNo());
+ vo.setVisitTypeText(buildVisitTypeText(order));
+ vo.setVisitedUserName(defaultText(trimToNull(order.getVisitedUserName())));
+ vo.setVisitedDeptName(defaultText(trimToNull(deptName)));
+ vo.setAccessAreaText(defaultText(buildJsonNameText(order.getPassAccessAreaNames())));
+ vo.setLocalUnitText(defaultText(buildJsonNameText(order.getPassLocalUnitNames())));
+ vo.setPlateNoText(defaultText(joinUniqueTexts(visitorRows, AppointmentVisitor::getPlateNo)));
+ vo.setVisitReasonText(defaultText(buildVisitReasonText(order, reasonNameMap)));
+ vo.setVisitStartText(defaultText(resolveVisitStartText(order)));
+ vo.setVisitEndText(defaultText(resolveVisitEndText(order)));
+ String displayStatusCode = resolveDisplayStatus(order);
+ vo.setDisplayStatusCode(displayStatusCode);
+ vo.setDisplayStatusText(resolveDisplayStatusText(displayStatusCode));
+ String approvalStatusCode = resolveApprovalStatusCode(order, approvalInstance);
+ vo.setApprovalStatusCode(approvalStatusCode);
+ vo.setApprovalStatusText(resolveApprovalStatusText(approvalStatusCode));
+ vo.setVisitors(buildDetailVisitors(visitorRows, visitorMap, plateMap));
+ return vo;
+ }
+
+ public List<AppointmentVisitor> listVisitors(Long orderId) {
+ return appointmentVisitorMapper.selectList(
+ new LambdaQueryWrapper<AppointmentVisitor>()
+ .eq(AppointmentVisitor::getOrderId, orderId)
+ .eq(AppointmentVisitor::getDelFlag, 0)
+ .orderByAsc(AppointmentVisitor::getId));
+ }
+
+ private List<AppointmentAdminListVo> listAdminOrders(
+ String visitedUserName,
+ Long visitedDeptId,
+ String accessArea,
+ String localUnit,
+ LocalDate visitDateStart,
+ LocalDate visitDateEnd,
+ String displayStatus,
+ String approvalStatus,
+ String visitorKeyword,
+ String visitReasonCode) {
+ Set<Long> visitorOrderIds = findOrderIdsByVisitorKeyword(visitorKeyword);
+ if (visitorOrderIds != null && visitorOrderIds.isEmpty()) {
+ return List.of();
+ }
+
+ List<AppointmentOrder> orders =
+ appointmentOrderMapper.selectList(
+ buildOrderQuery(
+ visitedUserName,
+ visitedDeptId,
+ accessArea,
+ localUnit,
+ visitDateStart,
+ visitDateEnd,
+ visitReasonCode,
+ visitorOrderIds));
+ if (orders.isEmpty()) {
+ return List.of();
+ }
+
+ List<Long> orderIds = orders.stream().map(AppointmentOrder::getId).filter(Objects::nonNull).toList();
+ Map<Long, List<AppointmentVisitor>> visitorRowsMap = loadVisitorRows(orderIds);
+ Map<Long, ApprovalInstance> approvalInstanceMap = loadApprovalInstanceMap(orderIds);
+ Map<Long, String> deptNameMap = loadDeptNameMap(orders);
+ Map<String, String> reasonNameMap = loadReasonNameMap(orders);
+
+ List<AppointmentAdminListVo> result = new ArrayList<>();
+ for (AppointmentOrder order : orders) {
+ AppointmentAdminListVo vo =
+ toAdminListVo(
+ order,
+ deptNameMap.get(order.getVisitedDeptId()),
+ reasonNameMap,
+ visitorRowsMap.get(order.getId()),
+ approvalInstanceMap.get(order.getId()));
+ if (matchesDisplayStatus(vo, displayStatus) && matchesApprovalStatus(vo, approvalStatus)) {
+ result.add(vo);
+ }
+ }
+ return result;
+ }
+
+ private LambdaQueryWrapper<AppointmentOrder> buildOrderQuery(
+ String visitedUserName,
+ Long visitedDeptId,
+ String accessArea,
+ String localUnit,
+ LocalDate visitDateStart,
+ LocalDate visitDateEnd,
+ String visitReasonCode,
+ Set<Long> visitorOrderIds) {
+ LambdaQueryWrapper<AppointmentOrder> q = new LambdaQueryWrapper<>();
+ q.eq(AppointmentOrder::getDelFlag, 0);
+ q.like(StringUtils.hasText(visitedUserName), AppointmentOrder::getVisitedUserName, trim(visitedUserName));
+ q.eq(visitedDeptId != null, AppointmentOrder::getVisitedDeptId, visitedDeptId);
+ q.like(StringUtils.hasText(accessArea), AppointmentOrder::getPassAccessAreaNames, trim(accessArea));
+ q.like(StringUtils.hasText(localUnit), AppointmentOrder::getPassLocalUnitNames, trim(localUnit));
+ q.eq(StringUtils.hasText(visitReasonCode), AppointmentOrder::getVisitReasonCode, trim(visitReasonCode));
+ q.le(visitDateEnd != null, AppointmentOrder::getVisitStartDate, visitDateEnd);
+ q.ge(visitDateStart != null, AppointmentOrder::getVisitEndDate, visitDateStart);
+ if (visitorOrderIds != null) {
+ q.in(AppointmentOrder::getId, visitorOrderIds);
+ }
+ q.orderByDesc(AppointmentOrder::getCreateTime).orderByDesc(AppointmentOrder::getId);
+ return q;
+ }
+
+ private Set<Long> findOrderIdsByVisitorKeyword(String visitorKeyword) {
+ String keyword = trimToNull(visitorKeyword);
+ if (!StringUtils.hasText(keyword)) {
+ return null;
+ }
+ List<AppointmentVisitor> rows =
+ appointmentVisitorMapper.selectList(
+ new LambdaQueryWrapper<AppointmentVisitor>()
+ .eq(AppointmentVisitor::getDelFlag, 0)
+ .like(AppointmentVisitor::getName, keyword)
+ .select(AppointmentVisitor::getOrderId));
+ return rows.stream()
+ .map(AppointmentVisitor::getOrderId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ }
+
+ private Map<Long, MpVisitor> loadVisitorMap(List<AppointmentVisitor> rows) {
+ if (rows == null || rows.isEmpty()) {
+ return Map.of();
+ }
+ Set<Long> visitorIds =
+ rows.stream()
+ .map(AppointmentVisitor::getMpVisitorId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ if (visitorIds.isEmpty()) {
+ return Map.of();
+ }
+ List<MpVisitor> visitors =
+ mpVisitorMapper.selectList(
+ new LambdaQueryWrapper<MpVisitor>()
+ .eq(MpVisitor::getDelFlag, 0)
+ .in(MpVisitor::getId, visitorIds));
+ return visitors.stream().collect(Collectors.toMap(MpVisitor::getId, v -> v, (a, b) -> a, LinkedHashMap::new));
+ }
+
+ private Map<Long, String> loadVisitorPlateMap(Long orderId) {
+ List<AppointmentVehicle> rows =
+ appointmentVehicleMapper.selectList(
+ new LambdaQueryWrapper<AppointmentVehicle>()
+ .eq(AppointmentVehicle::getDelFlag, 0)
+ .eq(AppointmentVehicle::getOrderId, orderId)
+ .orderByAsc(AppointmentVehicle::getId));
+ if (rows.isEmpty()) {
+ return Map.of();
+ }
+ Map<Long, List<String>> plates = new LinkedHashMap<>();
+ for (AppointmentVehicle row : rows) {
+ if (row.getDriverVisitorId() == null || !StringUtils.hasText(row.getPlateNo())) {
+ continue;
+ }
+ plates.computeIfAbsent(row.getDriverVisitorId(), key -> new ArrayList<>());
+ plates.get(row.getDriverVisitorId()).add(row.getPlateNo().trim());
+ }
+ Map<Long, String> result = new LinkedHashMap<>();
+ for (Map.Entry<Long, List<String>> entry : plates.entrySet()) {
+ String joined =
+ entry.getValue().stream()
+ .filter(StringUtils::hasText)
+ .map(String::trim)
+ .distinct()
+ .collect(Collectors.joining("、"));
+ result.put(entry.getKey(), joined);
+ }
+ return result;
+ }
+
+ private Map<Long, List<AppointmentVisitor>> loadVisitorRows(List<Long> orderIds) {
+ if (orderIds == null || orderIds.isEmpty()) {
+ return Map.of();
+ }
+ List<AppointmentVisitor> rows =
+ appointmentVisitorMapper.selectList(
+ new LambdaQueryWrapper<AppointmentVisitor>()
+ .eq(AppointmentVisitor::getDelFlag, 0)
+ .in(AppointmentVisitor::getOrderId, orderIds)
+ .orderByAsc(AppointmentVisitor::getId));
+ return rows.stream()
+ .collect(Collectors.groupingBy(AppointmentVisitor::getOrderId, LinkedHashMap::new, Collectors.toList()));
+ }
+
+ private Map<Long, ApprovalInstance> loadApprovalInstanceMap(List<Long> orderIds) {
+ if (orderIds == null || orderIds.isEmpty()) {
+ return Map.of();
+ }
+ List<ApprovalInstance> rows =
+ approvalInstanceMapper.selectList(
+ new LambdaQueryWrapper<ApprovalInstance>()
+ .eq(ApprovalInstance::getDelFlag, 0)
+ .in(ApprovalInstance::getOrderId, orderIds)
+ .orderByDesc(ApprovalInstance::getId));
+ Map<Long, ApprovalInstance> result = new LinkedHashMap<>();
+ for (ApprovalInstance row : rows) {
+ result.putIfAbsent(row.getOrderId(), row);
+ }
+ return result;
+ }
+
+ private ApprovalInstance loadLatestInstance(Long orderId) {
+ List<ApprovalInstance> list =
+ approvalInstanceMapper.selectList(
+ new LambdaQueryWrapper<ApprovalInstance>()
+ .eq(ApprovalInstance::getDelFlag, 0)
+ .eq(ApprovalInstance::getOrderId, orderId)
+ .orderByDesc(ApprovalInstance::getId));
+ return list.isEmpty() ? null : list.get(0);
+ }
+
+ private Map<Long, String> loadDeptNameMap(List<AppointmentOrder> orders) {
+ Set<Long> deptIds =
+ orders.stream()
+ .map(AppointmentOrder::getVisitedDeptId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ if (deptIds.isEmpty()) {
+ return Map.of();
+ }
+ List<SysDept> rows =
+ sysDeptMapper.selectList(
+ new LambdaQueryWrapper<SysDept>()
+ .eq(SysDept::getDelFlag, 0)
+ .in(SysDept::getId, deptIds));
+ return rows.stream()
+ .collect(Collectors.toMap(SysDept::getId, SysDept::getDeptName, (a, b) -> a, LinkedHashMap::new));
+ }
+
+ private Map<String, String> loadReasonNameMap(List<AppointmentOrder> orders) {
+ Set<String> codes =
+ orders.stream()
+ .map(AppointmentOrder::getVisitReasonCode)
+ .filter(StringUtils::hasText)
+ .map(String::trim)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ if (codes.isEmpty()) {
+ return Map.of();
+ }
+ List<BizVisitReason> rows =
+ bizVisitReasonMapper.selectList(
+ new LambdaQueryWrapper<BizVisitReason>()
+ .eq(BizVisitReason::getDelFlag, 0)
+ .in(BizVisitReason::getReasonCode, codes));
+ return rows.stream()
+ .collect(
+ Collectors.toMap(
+ BizVisitReason::getReasonCode,
+ BizVisitReason::getReasonName,
+ (a, b) -> a,
+ LinkedHashMap::new));
+ }
+
+ private AppointmentAdminListVo toAdminListVo(
+ AppointmentOrder order,
+ String deptName,
+ Map<String, String> reasonNameMap,
+ List<AppointmentVisitor> visitorRows,
+ ApprovalInstance approvalInstance) {
+ AppointmentAdminListVo vo = new AppointmentAdminListVo();
+ vo.setId(order.getId());
+ vo.setUuid(order.getUuid());
+ vo.setOrderNo(order.getOrderNo());
+ vo.setVisitTypeText(buildVisitTypeText(order));
+ vo.setVisitedUserName(defaultText(trimToNull(order.getVisitedUserName())));
+ vo.setVisitedDeptName(defaultText(trimToNull(deptName)));
+ vo.setAccessAreaText(defaultText(buildJsonNameText(order.getPassAccessAreaNames())));
+ vo.setLocalUnitText(defaultText(buildJsonNameText(order.getPassLocalUnitNames())));
+ vo.setPlateNoText(defaultText(joinUniqueTexts(visitorRows, AppointmentVisitor::getPlateNo)));
+ vo.setVisitReasonText(defaultText(buildVisitReasonText(order, reasonNameMap)));
+ vo.setVisitTimeText(defaultText(buildVisitTimeText(order)));
+ vo.setVisitorNamesText(defaultText(joinUniqueTexts(visitorRows, AppointmentVisitor::getName)));
+ vo.setStatus(order.getStatus());
+ vo.setCreateTime(order.getCreateTime());
+
+ String displayStatusCode = resolveDisplayStatus(order);
+ vo.setDisplayStatusCode(displayStatusCode);
+ vo.setDisplayStatusText(resolveDisplayStatusText(displayStatusCode));
+
+ String approvalStatusCode = resolveApprovalStatusCode(order, approvalInstance);
+ vo.setApprovalStatusCode(approvalStatusCode);
+ vo.setApprovalStatusText(resolveApprovalStatusText(approvalStatusCode));
+ return vo;
+ }
+
+ private List<AppointmentAdminExportRow> buildExportRows(
+ List<AppointmentOrder> orders,
+ Map<Long, List<AppointmentVisitor>> visitorRowsMap,
+ Map<Long, ApprovalInstance> approvalInstanceMap,
+ Map<String, String> reasonNameMap) {
+ if (orders == null || orders.isEmpty()) {
+ return List.of();
+ }
+ List<Long> orderIds = orders.stream().map(AppointmentOrder::getId).filter(Objects::nonNull).toList();
+ List<AppointmentVisitor> allVisitorRows =
+ orderIds.isEmpty()
+ ? List.of()
+ : visitorRowsMap.values().stream().flatMap(Collection::stream).toList();
+ Map<Long, MpVisitor> visitorMap = loadVisitorMap(allVisitorRows);
+ Map<Long, Map<Long, String>> visitorPlateMap = loadVisitorPlateMap(orderIds);
+ Map<Long, Map<String, String>> approvalNameMap = loadApprovalNodeApproverTextMap(approvalInstanceMap.values());
+ Map<Long, MpUser> submitterMap = loadSubmitterMap(orders);
+
+ List<AppointmentAdminExportRow> result = new ArrayList<>();
+ for (AppointmentOrder order : orders) {
+ AppointmentAdminListVo summary =
+ toAdminListVo(
+ order,
+ null,
+ reasonNameMap,
+ visitorRowsMap.get(order.getId()),
+ approvalInstanceMap.get(order.getId()));
+ Map<String, String> nodeApproverNames =
+ approvalNameMap.getOrDefault(order.getId(), Map.of());
+ MpUser submitter = submitterMap.get(order.getSubmitterId());
+ List<AppointmentVisitor> visitors = visitorRowsMap.get(order.getId());
+ if (visitors == null || visitors.isEmpty()) {
+ result.add(buildExportRow(summary, order, null, null, Map.of(), nodeApproverNames, submitter));
+ continue;
+ }
+ Map<Long, String> orderPlateMap = visitorPlateMap.getOrDefault(order.getId(), Map.of());
+ for (AppointmentVisitor visitorRow : visitors) {
+ MpVisitor visitor = visitorMap.get(visitorRow.getMpVisitorId());
+ result.add(buildExportRow(summary, order, visitorRow, visitor, orderPlateMap, nodeApproverNames, submitter));
+ }
+ }
+ return result;
+ }
+
+ private AppointmentAdminExportRow buildExportRow(
+ AppointmentAdminListVo summary,
+ AppointmentOrder order,
+ AppointmentVisitor visitorRow,
+ MpVisitor visitor,
+ Map<Long, String> orderPlateMap,
+ Map<String, String> nodeApproverNames,
+ MpUser submitter) {
+ AppointmentAdminExportRow row = new AppointmentAdminExportRow();
+ row.setVisitTypeText(summary.getVisitTypeText());
+ row.setVisitedUserName(summary.getVisitedUserName());
+ row.setAccessAreaText(summary.getAccessAreaText());
+ row.setLocalUnitText(summary.getLocalUnitText());
+ row.setPlateNoText(
+ defaultText(
+ firstNonBlank(
+ visitorRow == null ? null : visitorRow.getPlateNo(),
+ visitorRow == null ? null : orderPlateMap.get(visitorRow.getMpVisitorId()),
+ summary.getPlateNoText())));
+ row.setVisitReasonText(summary.getVisitReasonText());
+ row.setVisitTimeText(summary.getVisitTimeText());
+ row.setDisplayStatusText(summary.getDisplayStatusText());
+ row.setVisitorLabel(defaultText(visitorRow == null ? null : trimToNull(visitorRow.getName())));
+ row.setVisitorName(
+ defaultText(
+ firstNonBlank(
+ visitorRow == null ? null : visitorRow.getName(),
+ visitor == null ? null : visitor.getVisitorName(),
+ null)));
+ row.setIdCardText(defaultText(resolveIdCardMasked(visitorRow, visitor)));
+ row.setMobileText(
+ defaultText(
+ MobileMaskUtil.mask(
+ firstNonBlank(
+ visitorRow == null ? null : visitorRow.getMobile(),
+ visitor == null ? null : visitor.getVisitorMobile(),
+ null))));
+ row.setCompanyName(defaultText(visitor == null ? null : trimToNull(visitor.getVisitorCompany())));
+ row.setHandlerApproverNames(defaultText(nodeApproverNames.get(ApprovalNodeCode.LEVEL1_HANDLER)));
+ row.setDeptLeaderApproverNames(defaultText(nodeApproverNames.get(ApprovalNodeCode.LEVEL2_DEPT_LEADER)));
+ row.setVisitDeptApproverNames(defaultText(nodeApproverNames.get(ApprovalNodeCode.LEVEL4_VISIT_DEPT)));
+ row.setVisitDeptLeaderApproverNames(defaultText(nodeApproverNames.get(ApprovalNodeCode.LEVEL5_VISIT_DEPT_LEADER)));
+ row.setLocalDeptLeaderApproverNames(defaultText(nodeApproverNames.get(ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER)));
+ row.setSubmitterMobile(defaultText(MobileMaskUtil.mask(submitter == null ? null : trimToNull(submitter.getMobile()))));
+ row.setSubmitTimeText(defaultText(formatDateTime(order.getCreateTime())));
+ return row;
+ }
+
+ private Map<Long, MpUser> loadSubmitterMap(List<AppointmentOrder> orders) {
+ Set<Long> submitterIds =
+ orders.stream()
+ .map(AppointmentOrder::getSubmitterId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ if (submitterIds.isEmpty()) {
+ return Map.of();
+ }
+ List<MpUser> rows =
+ mpUserMapper.selectList(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getDelFlag, 0)
+ .in(MpUser::getId, submitterIds));
+ return rows.stream().collect(Collectors.toMap(MpUser::getId, u -> u, (a, b) -> a, LinkedHashMap::new));
+ }
+
+ private Map<Long, Map<String, String>> loadApprovalNodeApproverTextMap(Collection<ApprovalInstance> instances) {
+ List<ApprovalInstance> instanceList =
+ instances == null ? List.of() : instances.stream().filter(Objects::nonNull).toList();
+ if (instanceList.isEmpty()) {
+ return Map.of();
+ }
+ List<Long> instanceIds = instanceList.stream().map(ApprovalInstance::getId).filter(Objects::nonNull).toList();
+ if (instanceIds.isEmpty()) {
+ return Map.of();
+ }
+ List<ApprovalTask> tasks =
+ approvalTaskMapper.selectList(
+ new LambdaQueryWrapper<ApprovalTask>()
+ .eq(ApprovalTask::getDelFlag, 0)
+ .in(ApprovalTask::getInstanceId, instanceIds)
+ .ne(ApprovalTask::getTaskStatus, ApprovalTaskStatus.SKIPPED)
+ .orderByAsc(ApprovalTask::getId));
+ if (tasks.isEmpty()) {
+ return Map.of();
+ }
+ Set<Long> userIds =
+ tasks.stream()
+ .map(ApprovalTask::getAssigneeUserId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ Map<Long, String> userNameMap = loadMpUserNameMap(userIds);
+ Map<Long, Long> orderIdByInstanceId =
+ instanceList.stream()
+ .collect(Collectors.toMap(ApprovalInstance::getId, ApprovalInstance::getOrderId, (a, b) -> a, LinkedHashMap::new));
+ Map<Long, Map<String, LinkedHashSet<String>>> grouped = new LinkedHashMap<>();
+ for (ApprovalTask task : tasks) {
+ Long orderId = orderIdByInstanceId.get(task.getInstanceId());
+ if (orderId == null || !StringUtils.hasText(task.getNodeCode())) {
+ continue;
+ }
+ String userName = trimToNull(userNameMap.get(task.getAssigneeUserId()));
+ if (!StringUtils.hasText(userName)) {
+ continue;
+ }
+ grouped.computeIfAbsent(orderId, key -> new LinkedHashMap<>());
+ grouped.get(orderId).computeIfAbsent(task.getNodeCode(), key -> new LinkedHashSet<>());
+ grouped.get(orderId).get(task.getNodeCode()).add(userName);
+ }
+ Map<Long, Map<String, String>> result = new LinkedHashMap<>();
+ for (Map.Entry<Long, Map<String, LinkedHashSet<String>>> orderEntry : grouped.entrySet()) {
+ Map<String, String> nodeTextMap = new LinkedHashMap<>();
+ for (Map.Entry<String, LinkedHashSet<String>> nodeEntry : orderEntry.getValue().entrySet()) {
+ nodeTextMap.put(nodeEntry.getKey(), String.join("、", nodeEntry.getValue()));
+ }
+ result.put(orderEntry.getKey(), nodeTextMap);
+ }
+ return result;
+ }
+
+ private Map<Long, String> loadMpUserNameMap(Set<Long> userIds) {
+ if (userIds == null || userIds.isEmpty()) {
+ return Map.of();
+ }
+ List<MpUser> rows =
+ mpUserMapper.selectList(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getDelFlag, 0)
+ .in(MpUser::getId, userIds));
+ return rows.stream()
+ .collect(
+ Collectors.toMap(
+ MpUser::getId,
+ user -> firstNonBlank(user.getNickName(), user.getMobile(), String.valueOf(user.getId())),
+ (a, b) -> a,
+ LinkedHashMap::new));
+ }
+
+ private Map<Long, Map<Long, String>> loadVisitorPlateMap(List<Long> orderIds) {
+ if (orderIds == null || orderIds.isEmpty()) {
+ return Map.of();
+ }
+ List<AppointmentVehicle> rows =
+ appointmentVehicleMapper.selectList(
+ new LambdaQueryWrapper<AppointmentVehicle>()
+ .eq(AppointmentVehicle::getDelFlag, 0)
+ .in(AppointmentVehicle::getOrderId, orderIds)
+ .orderByAsc(AppointmentVehicle::getId));
+ if (rows.isEmpty()) {
+ return Map.of();
+ }
+ Map<Long, Map<Long, List<String>>> temp = new LinkedHashMap<>();
+ for (AppointmentVehicle row : rows) {
+ if (row.getOrderId() == null || row.getDriverVisitorId() == null || !StringUtils.hasText(row.getPlateNo())) {
+ continue;
+ }
+ temp.computeIfAbsent(row.getOrderId(), key -> new LinkedHashMap<>());
+ temp.get(row.getOrderId()).computeIfAbsent(row.getDriverVisitorId(), key -> new ArrayList<>());
+ temp.get(row.getOrderId()).get(row.getDriverVisitorId()).add(row.getPlateNo().trim());
+ }
+ Map<Long, Map<Long, String>> result = new LinkedHashMap<>();
+ for (Map.Entry<Long, Map<Long, List<String>>> orderEntry : temp.entrySet()) {
+ Map<Long, String> visitorPlateTextMap = new LinkedHashMap<>();
+ for (Map.Entry<Long, List<String>> visitorEntry : orderEntry.getValue().entrySet()) {
+ String joined =
+ visitorEntry.getValue().stream()
+ .filter(StringUtils::hasText)
+ .map(String::trim)
+ .distinct()
+ .collect(Collectors.joining("、"));
+ visitorPlateTextMap.put(visitorEntry.getKey(), joined);
+ }
+ result.put(orderEntry.getKey(), visitorPlateTextMap);
+ }
+ return result;
+ }
+
+ private String resolveIdCardMasked(AppointmentVisitor row, MpVisitor visitor) {
+ String idCardPlain =
+ row != null && StringUtils.hasText(row.getIdCardCipher())
+ ? SensitiveDataUtil.decryptIdCard(row.getIdCardCipher())
+ : (visitor != null && StringUtils.hasText(visitor.getIdCardCipher())
+ ? SensitiveDataUtil.decryptIdCard(visitor.getIdCardCipher())
+ : null);
+ return StringUtils.hasText(idCardPlain) ? SensitiveDataUtil.maskIdCard(idCardPlain) : null;
+ }
+
+ private String formatDateTime(LocalDateTime value) {
+ return value == null ? null : value.format(DATE_TIME_FORMATTER);
+ }
+
+ private List<AppointmentAdminDetailVisitorVo> buildDetailVisitors(
+ List<AppointmentVisitor> rows, Map<Long, MpVisitor> visitorMap, Map<Long, String> plateMap) {
+ if (rows == null || rows.isEmpty()) {
+ return List.of();
+ }
+ List<AppointmentAdminDetailVisitorVo> result = new ArrayList<>();
+ for (AppointmentVisitor row : rows) {
+ MpVisitor visitor = visitorMap.get(row.getMpVisitorId());
+ AppointmentAdminDetailVisitorVo vo = new AppointmentAdminDetailVisitorVo();
+ vo.setVisitorId(row.getMpVisitorId());
+ vo.setName(firstNonBlank(row.getName(), visitor == null ? null : visitor.getVisitorName(), "-"));
+ vo.setGender(defaultText(visitor == null ? null : trimToNull(visitor.getGender())));
+ vo.setCompanyName(defaultText(visitor == null ? null : trimToNull(visitor.getVisitorCompany())));
+ vo.setMobileMasked(
+ defaultText(
+ MobileMaskUtil.mask(
+ firstNonBlank(row.getMobile(), visitor == null ? null : visitor.getVisitorMobile(), null))));
+ String idCardPlain =
+ StringUtils.hasText(row.getIdCardCipher())
+ ? SensitiveDataUtil.decryptIdCard(row.getIdCardCipher())
+ : (visitor != null && StringUtils.hasText(visitor.getIdCardCipher())
+ ? SensitiveDataUtil.decryptIdCard(visitor.getIdCardCipher())
+ : null);
+ vo.setIdCardMasked(
+ defaultText(
+ StringUtils.hasText(idCardPlain)
+ ? SensitiveDataUtil.maskIdCard(idCardPlain)
+ : null));
+ vo.setPlateNo(defaultText(firstNonBlank(row.getPlateNo(), plateMap.get(row.getMpVisitorId()), null)));
+ vo.setDriverName(defaultText(trimToNull(row.getDriverName())));
+ result.add(vo);
+ }
+ return result;
+ }
+
+ private boolean matchesDisplayStatus(AppointmentAdminListVo row, String filter) {
+ String value = trimToNull(filter);
+ return !StringUtils.hasText(value) || Objects.equals(value, row.getDisplayStatusCode());
+ }
+
+ private boolean matchesApprovalStatus(AppointmentAdminListVo row, String filter) {
+ String value = trimToNull(filter);
+ return !StringUtils.hasText(value) || Objects.equals(value, row.getApprovalStatusCode());
+ }
+
+ private String buildVisitTypeText(AppointmentOrder order) {
+ boolean personEntry = order.getPersonEntryFlag() == null || Boolean.TRUE.equals(order.getPersonEntryFlag());
+ boolean vehicleEntry = Boolean.TRUE.equals(order.getVehicleEntryFlag());
+ if (personEntry && vehicleEntry) {
+ return "访客+车辆预约";
+ }
+ if (vehicleEntry) {
+ return "车辆预约";
+ }
+ return "访客预约";
+ }
+
+ private String buildVisitReasonText(AppointmentOrder row, Map<String, String> reasonNameMap) {
+ String code = trimToNull(row.getVisitReasonCode());
+ if (!StringUtils.hasText(code)) {
+ return null;
+ }
+ if ("OTHER".equalsIgnoreCase(code)) {
+ String other = trimToNull(row.getVisitReasonOther());
+ return StringUtils.hasText(other) ? "其他-" + other : "其他";
+ }
+ String reasonName = reasonNameMap.get(code);
+ return StringUtils.hasText(reasonName) ? reasonName : code;
+ }
+
+ private String buildVisitTimeText(AppointmentOrder row) {
+ if (row.getVisitStartDate() == null && row.getVisitEndDate() == null) {
+ return null;
+ }
+ if (Objects.equals(row.getVisitStartDate(), row.getVisitEndDate()) || row.getVisitEndDate() == null) {
+ return String.valueOf(row.getVisitStartDate());
+ }
+ return row.getVisitStartDate() + " 至 " + row.getVisitEndDate();
+ }
+
+ private String resolveVisitStartText(AppointmentOrder row) {
+ return row.getVisitStartDate() == null ? null : row.getVisitStartDate().toString();
+ }
+
+ private String resolveVisitEndText(AppointmentOrder row) {
+ LocalDate end = row.getVisitEndDate() != null ? row.getVisitEndDate() : row.getVisitStartDate();
+ return end == null ? null : end.toString();
+ }
+
+ private String resolveDisplayStatus(AppointmentOrder row) {
+ LocalDate today = LocalDate.now();
+ if (AppointmentOrderStatus.PENDING_APPROVAL.equals(row.getStatus())) {
+ return DISPLAY_STATUS_PENDING_VISIT;
+ }
+ if (AppointmentOrderStatus.APPROVED.equals(row.getStatus())) {
+ if (row.getVisitStartDate() != null && today.isBefore(row.getVisitStartDate())) {
+ return DISPLAY_STATUS_PENDING_VISIT;
+ }
+ if (row.getVisitStartDate() != null
+ && row.getVisitEndDate() != null
+ && (today.isEqual(row.getVisitStartDate()) || today.isAfter(row.getVisitStartDate()))
+ && (today.isEqual(row.getVisitEndDate()) || today.isBefore(row.getVisitEndDate()))) {
+ return DISPLAY_STATUS_EFFECTIVE;
+ }
+ }
+ return DISPLAY_STATUS_INVALID;
+ }
+
+ private String resolveDisplayStatusText(String code) {
+ return switch (trimToNull(code)) {
+ case DISPLAY_STATUS_PENDING_VISIT -> "待拜访";
+ case DISPLAY_STATUS_EFFECTIVE -> "生效中";
+ default -> "已失效";
+ };
+ }
+
+ private String resolveApprovalStatusCode(AppointmentOrder order, ApprovalInstance approvalInstance) {
+ String status = trimToNull(order.getStatus());
+ if (AppointmentOrderStatus.PENDING_APPROVAL.equals(status)) {
+ String nodeCode = approvalInstance == null ? null : trimToNull(approvalInstance.getCurrentNodeCode());
+ return StringUtils.hasText(nodeCode) ? nodeCode : AppointmentOrderStatus.PENDING_APPROVAL;
+ }
+ if (AppointmentOrderStatus.APPROVED.equals(status)
+ || AppointmentOrderStatus.VISITED.equals(status)
+ || AppointmentOrderStatus.EXPIRED.equals(status)) {
+ return AppointmentOrderStatus.APPROVED;
+ }
+ if (AppointmentOrderStatus.REJECTED.equals(status)) {
+ return AppointmentOrderStatus.REJECTED;
+ }
+ if (AppointmentOrderStatus.CANCELLED.equals(status)) {
+ return AppointmentOrderStatus.CANCELLED;
+ }
+ return status;
+ }
+
+ private String resolveApprovalStatusText(String code) {
+ return switch (trimToNull(code)) {
+ case ApprovalNodeCode.LEVEL1_HANDLER -> "经办人";
+ case ApprovalNodeCode.LEVEL2_DEPT_LEADER -> "部门";
+ case ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER -> "属地部门";
+ case ApprovalNodeCode.LEVEL4_VISIT_DEPT -> "主管部门";
+ case ApprovalNodeCode.LEVEL5_VISIT_DEPT_LEADER -> "主管部门领导";
+ case AppointmentOrderStatus.APPROVED -> "已通过";
+ case AppointmentOrderStatus.REJECTED -> "已拒绝";
+ case AppointmentOrderStatus.CANCELLED -> "已取消";
+ case AppointmentOrderStatus.PENDING_APPROVAL -> "审批中";
+ default -> "-";
+ };
+ }
+
+ private String buildJsonNameText(String rawJson) {
+ String raw = trimToNull(rawJson);
+ if (!StringUtils.hasText(raw)) {
+ return null;
+ }
+ try {
+ List<String> rows = objectMapper.readValue(raw, STRING_LIST_TYPE);
+ return rows.stream()
+ .filter(StringUtils::hasText)
+ .map(String::trim)
+ .distinct()
+ .collect(Collectors.joining("、"));
+ } catch (Exception ignore) {
+ return raw;
+ }
+ }
+
+ private String joinUniqueTexts(List<AppointmentVisitor> rows, java.util.function.Function<AppointmentVisitor, String> getter) {
+ if (rows == null || rows.isEmpty()) {
+ return null;
+ }
+ return rows.stream()
+ .map(getter)
+ .filter(StringUtils::hasText)
+ .map(String::trim)
+ .distinct()
+ .collect(Collectors.joining("、"));
+ }
+
+ private IPage<AppointmentAdminListVo> slicePage(long page, long size, List<AppointmentAdminListVo> rowsAll) {
+ long current = page <= 0 ? 1 : page;
+ long pageSize = size <= 0 ? 10 : size;
+ int from = (int) ((current - 1) * pageSize);
+ int to = Math.min(rowsAll.size(), from + (int) pageSize);
+ List<AppointmentAdminListVo> records = from >= to ? List.of() : rowsAll.subList(from, to);
+ Page<AppointmentAdminListVo> out = new Page<>(current, pageSize);
+ out.setTotal(rowsAll.size());
+ out.setRecords(records);
+ return out;
+ }
+
+ private static String trim(String value) {
+ return value == null ? null : value.trim();
+ }
+
+ private static String trimToNull(String value) {
+ String trimmed = trim(value);
+ return StringUtils.hasText(trimmed) ? trimmed : null;
+ }
+
+ private static String defaultText(String value) {
+ return StringUtils.hasText(value) ? value : "-";
+ }
+
+ @SafeVarargs
+ private static String firstNonBlank(String... values) {
+ if (values == null) {
+ return null;
+ }
+ for (String value : values) {
+ if (StringUtils.hasText(value)) {
+ return value.trim();
+ }
+ }
+ return null;
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/ApprovalApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/ApprovalApplicationService.java
new file mode 100644
index 0000000..c788437
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/ApprovalApplicationService.java
@@ -0,0 +1,85 @@
+package com.sinata.appointment.service;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.sinata.appointment.constants.ApprovalInstanceStatus;
+import com.sinata.appointment.constants.ApprovalTaskStatus;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.dto.ApprovalActionRequest;
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.entity.ApprovalInstance;
+import com.sinata.appointment.entity.ApprovalTask;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.mapper.AppointmentOrderMapper;
+import com.sinata.appointment.mapper.ApprovalInstanceMapper;
+import com.sinata.appointment.mapper.ApprovalTaskMapper;
+import com.sinata.appointment.util.UuidAssert;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+@RequiredArgsConstructor
+public class ApprovalApplicationService {
+
+ private final ApprovalTaskMapper approvalTaskMapper;
+ private final ApprovalInstanceMapper approvalInstanceMapper;
+ private final AppointmentOrderMapper appointmentOrderMapper;
+ private final ApprovalEngineService approvalEngineService;
+ private final PostApprovalOrchestrationService postApprovalOrchestrationService;
+
+ public IPage<ApprovalTask> pageMyTodo(long page, long size) {
+ Long uid = StpUtil.getLoginIdAsLong();
+ return approvalTaskMapper.selectPage(
+ new Page<>(page, size),
+ new LambdaQueryWrapper<ApprovalTask>()
+ .eq(ApprovalTask::getAssigneeUserId, uid)
+ .eq(ApprovalTask::getTaskStatus, ApprovalTaskStatus.PENDING)
+ .orderByDesc(ApprovalTask::getCreateTime));
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void handleAction(ApprovalActionRequest req) {
+ Long uid = StpUtil.getLoginIdAsLong();
+ ApprovalTask task = approvalTaskMapper.selectById(req.getTaskId());
+ if (task == null || task.getDelFlag() != null && task.getDelFlag() == 1) {
+ throw new BizException(BizErrorCode.APPROVAL_TASK_NOT_FOUND, "审批任务不存在");
+ }
+ UuidAssert.match(task, req.getTaskUuid());
+ if (!ApprovalTaskStatus.PENDING.equals(task.getTaskStatus())) {
+ throw new BizException(BizErrorCode.APPROVAL_CONFLICT, "任务已处理");
+ }
+ if (!uid.equals(task.getAssigneeUserId())) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "无权处理该任务");
+ }
+
+ ApprovalInstance inst = approvalInstanceMapper.selectById(task.getInstanceId());
+ AppointmentOrder order = appointmentOrderMapper.selectById(inst.getOrderId());
+
+ if (Boolean.FALSE.equals(req.getApprove())) {
+ String reason = req.getComment() != null ? req.getComment() : "拒绝";
+ task.setTaskStatus(ApprovalTaskStatus.REJECTED);
+ task.setComment(reason);
+ approvalTaskMapper.updateById(task);
+ approvalEngineService.rejectInstance(inst, reason);
+ postApprovalOrchestrationService.onOrderRejected(order, reason);
+ return;
+ }
+
+ task.setTaskStatus(ApprovalTaskStatus.APPROVED);
+ task.setComment(req.getComment());
+ approvalTaskMapper.updateById(task);
+ approvalEngineService.markGroupSkippedExcept(task);
+
+ if (approvalEngineService.isCurrentNodeComplete(inst)) {
+ approvalEngineService.advanceOrComplete(inst, order);
+ ApprovalInstance latest = approvalInstanceMapper.selectById(inst.getId());
+ if (ApprovalInstanceStatus.APPROVED.equals(latest.getStatus())) {
+ AppointmentOrder fresh = appointmentOrderMapper.selectById(order.getId());
+ postApprovalOrchestrationService.onOrderFullyApproved(fresh);
+ }
+ }
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/ApprovalEngineService.java b/backend/src/main/java/com/sinata/appointment/service/ApprovalEngineService.java
new file mode 100644
index 0000000..1b1bba9
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/ApprovalEngineService.java
@@ -0,0 +1,717 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sinata.appointment.constants.ApprovalInstanceStatus;
+import com.sinata.appointment.constants.ApprovalNodeCode;
+import com.sinata.appointment.constants.ApprovalTaskStatus;
+import com.sinata.appointment.constants.LocalUnitApproverType;
+import com.sinata.appointment.constants.MiniUserType;
+import com.sinata.appointment.constants.SystemSettingKeys;
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.entity.ApprovalInstance;
+import com.sinata.appointment.entity.ApprovalTask;
+import com.sinata.appointment.entity.BizApprovalNodeConfig;
+import com.sinata.appointment.entity.MpPassLocalUnit;
+import com.sinata.appointment.entity.MpPassLocalUnitMember;
+import com.sinata.appointment.entity.MpStaffGroupMember;
+import com.sinata.appointment.entity.MpUser;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.mapper.ApprovalInstanceMapper;
+import com.sinata.appointment.mapper.ApprovalTaskMapper;
+import com.sinata.appointment.mapper.BizApprovalNodeConfigMapper;
+import com.sinata.appointment.mapper.MpPassLocalUnitMapper;
+import com.sinata.appointment.mapper.MpPassLocalUnitMemberMapper;
+import com.sinata.appointment.mapper.MpStaffGroupMemberMapper;
+import com.sinata.appointment.mapper.MpUserMapper;
+import com.sinata.appointment.constants.BizErrorCode;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+/**
+ * 审批引擎:节点链解析、任务创建、组内任一通过、拒绝终止。
+ */
+@Service
+@RequiredArgsConstructor
+public class ApprovalEngineService {
+
+ private static final TypeReference<List<Long>> LONG_LIST_TYPE = new TypeReference<>() {};
+ private static final String AUTO_APPROVE_COMMENT = "前序节点已审批,系统自动同意";
+
+ private final BizApprovalNodeConfigMapper bizApprovalNodeConfigMapper;
+ private final ApprovalInstanceMapper approvalInstanceMapper;
+ private final ApprovalTaskMapper approvalTaskMapper;
+ private final MpUserMapper mpUserMapper;
+ private final MpPassLocalUnitMapper mpPassLocalUnitMapper;
+ private final MpPassLocalUnitMemberMapper mpPassLocalUnitMemberMapper;
+ private final MpStaffGroupMemberMapper mpStaffGroupMemberMapper;
+ private final SysBizSettingCacheService sysBizSettingCacheService;
+ private final ObjectMapper objectMapper;
+
+ public List<String> resolveEnabledNodeChain(AppointmentOrder order) {
+ List<BizApprovalNodeConfig> configs =
+ bizApprovalNodeConfigMapper.selectList(
+ new LambdaQueryWrapper<BizApprovalNodeConfig>()
+ .eq(BizApprovalNodeConfig::getEnabled, 1)
+ .orderByAsc(BizApprovalNodeConfig::getSortOrder));
+ List<String> chain = new ArrayList<>();
+ for (BizApprovalNodeConfig c : configs) {
+ String nc = c.getNodeCode();
+ if (ApprovalNodeCode.LEVEL5_VISIT_DEPT_LEADER.equals(nc)
+ && !Boolean.TRUE.equals(order.getHolidayFlag())) {
+ continue;
+ }
+ // 预约流程已不使用“是否涉及其他属地单位(key_area_flag)”字段,三级节点在预约阶段固定跳过。
+ if (ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER.equals(nc)) {
+ continue;
+ }
+ chain.add(nc);
+ }
+ return chain;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public ApprovalInstance startFlow(AppointmentOrder order) {
+ List<String> chain = resolveEnabledNodeChain(order);
+ if (chain.isEmpty()) {
+ return null;
+ }
+ String firstNode = resolveFirstNodeWithAssignees(chain, order);
+ if (firstNode == null) {
+ return null;
+ }
+ ApprovalInstance inst = new ApprovalInstance();
+ inst.setOrderId(order.getId());
+ inst.setCurrentNodeCode(firstNode);
+ inst.setStatus(ApprovalInstanceStatus.IN_PROGRESS);
+ approvalInstanceMapper.insert(inst);
+ for (String nodeCode : chain) {
+ String status =
+ Objects.equals(nodeCode, firstNode)
+ ? ApprovalTaskStatus.PENDING
+ : ApprovalTaskStatus.WAITING;
+ createTasksForNode(inst, order, nodeCode, status);
+ }
+ return inst;
+ }
+
+ public int createTasksForNode(
+ ApprovalInstance inst, AppointmentOrder order, String nodeCode, String initialStatus) {
+ List<Long> assignees = resolveAssignees(nodeCode, order);
+ return createTasksWithGroupKey(inst.getId(), nodeCode, nodeCode, assignees, initialStatus);
+ }
+
+ public List<Long> resolveAssignees(String nodeCode, AppointmentOrder order) {
+ if (ApprovalNodeCode.LEVEL1_HANDLER.equals(nodeCode)) {
+ return order.getVisitedUserId() == null ? List.of() : List.of(order.getVisitedUserId());
+ }
+ if (ApprovalNodeCode.LEVEL2_DEPT_LEADER.equals(nodeCode)) {
+ Long deptId = resolveVisitedDeptId(order);
+ return listEmployeeIdsByDeptAndLeaderFlag(deptId, true);
+ }
+ if (ApprovalNodeCode.LEVEL4_VISIT_DEPT.equals(nodeCode)) {
+ Long competentDeptId = getVisitCompetentDeptId();
+ return listEmployeeIdsByDeptAndLeaderFlag(competentDeptId, false);
+ }
+ if (ApprovalNodeCode.LEVEL5_VISIT_DEPT_LEADER.equals(nodeCode)) {
+ Long competentDeptId = getVisitCompetentDeptId();
+ return listEmployeeIdsByDeptAndLeaderFlag(competentDeptId, true);
+ }
+ if (ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER.equals(nodeCode)) {
+ return List.of();
+ }
+ return List.of();
+ }
+
+ private Long resolveVisitedDeptId(AppointmentOrder order) {
+ if (order.getVisitedUserId() != null) {
+ MpUser visited = mpUserMapper.selectById(order.getVisitedUserId());
+ if (visited != null
+ && (visited.getDelFlag() == null || visited.getDelFlag() == 0)
+ && MiniUserType.EMPLOYEE.equals(visited.getUserType())
+ && visited.getDeptId() != null
+ && visited.getDeptId() > 0) {
+ return visited.getDeptId();
+ }
+ }
+ return order.getVisitedDeptId();
+ }
+
+ private Long getVisitCompetentDeptId() {
+ String raw = sysBizSettingCacheService.getValue(SystemSettingKeys.VISIT_COMPETENT_DEPT_ID);
+ if (raw == null || raw.trim().isEmpty()) {
+ return null;
+ }
+ try {
+ long id = Long.parseLong(raw.trim());
+ return id > 0 ? id : null;
+ } catch (NumberFormatException ex) {
+ return null;
+ }
+ }
+
+ private List<Long> listEmployeeIdsByDeptAndLeaderFlag(Long deptId, boolean leaderFlag) {
+ if (deptId == null || deptId <= 0) {
+ return List.of();
+ }
+ List<MpUser> users =
+ mpUserMapper.selectList(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getDelFlag, 0)
+ .eq(MpUser::getStatus, 1)
+ .eq(MpUser::getUserType, MiniUserType.EMPLOYEE)
+ .eq(MpUser::getDeptId, deptId)
+ .eq(MpUser::getDeptLeaderFlag, leaderFlag));
+ return users.stream().map(MpUser::getId).filter(Objects::nonNull).toList();
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void markGroupSkippedExcept(ApprovalTask approvedTask) {
+ List<ApprovalTask> siblings =
+ approvalTaskMapper.selectList(
+ new LambdaQueryWrapper<ApprovalTask>()
+ .eq(ApprovalTask::getInstanceId, approvedTask.getInstanceId())
+ .eq(ApprovalTask::getNodeCode, approvedTask.getNodeCode())
+ .eq(ApprovalTask::getGroupKey, approvedTask.getGroupKey())
+ .eq(ApprovalTask::getTaskStatus, ApprovalTaskStatus.PENDING));
+ for (ApprovalTask s : siblings) {
+ if (!Objects.equals(s.getId(), approvedTask.getId())) {
+ s.setTaskStatus(ApprovalTaskStatus.SKIPPED);
+ approvalTaskMapper.updateById(s);
+ }
+ }
+ }
+
+ public boolean isCurrentNodeComplete(ApprovalInstance inst) {
+ long pending =
+ approvalTaskMapper.selectCount(
+ new LambdaQueryWrapper<ApprovalTask>()
+ .eq(ApprovalTask::getInstanceId, inst.getId())
+ .eq(ApprovalTask::getNodeCode, inst.getCurrentNodeCode())
+ .eq(ApprovalTask::getTaskStatus, ApprovalTaskStatus.PENDING));
+ return pending == 0;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void advanceOrComplete(ApprovalInstance inst, AppointmentOrder order) {
+ List<String> chain = resolveAdvanceChain(inst, order);
+ int idx = chain.indexOf(inst.getCurrentNodeCode());
+ if (idx < 0) {
+ inst.setStatus(ApprovalInstanceStatus.APPROVED);
+ inst.setFinishedAt(LocalDateTime.now());
+ inst.setCurrentNodeCode(null);
+ approvalInstanceMapper.updateById(inst);
+ return;
+ }
+ for (int i = idx + 1; i < chain.size(); i++) {
+ String next = chain.get(i);
+ if (activateWaitingTasks(inst.getId(), next) > 0) {
+ inst.setCurrentNodeCode(next);
+ approvalInstanceMapper.updateById(inst);
+ autoApproveCurrentNodeByHistory(inst.getId(), next);
+ if (!isCurrentNodeComplete(inst)) {
+ return;
+ }
+ }
+ }
+ inst.setStatus(ApprovalInstanceStatus.APPROVED);
+ inst.setFinishedAt(LocalDateTime.now());
+ inst.setCurrentNodeCode(null);
+ approvalInstanceMapper.updateById(inst);
+ }
+
+ private List<String> resolveAdvanceChain(ApprovalInstance inst, AppointmentOrder order) {
+ List<ApprovalTask> instanceTasks =
+ approvalTaskMapper.selectList(
+ new LambdaQueryWrapper<ApprovalTask>()
+ .eq(ApprovalTask::getDelFlag, 0)
+ .eq(ApprovalTask::getInstanceId, inst.getId())
+ .select(ApprovalTask::getNodeCode));
+ boolean containsLocalUnitNode =
+ instanceTasks.stream()
+ .map(ApprovalTask::getNodeCode)
+ .anyMatch(ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER::equals);
+ return containsLocalUnitNode
+ ? resolveEnabledNodeChainForPostHandler(order)
+ : resolveEnabledNodeChain(order);
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void rejectInstance(ApprovalInstance inst, String reason) {
+ inst.setStatus(ApprovalInstanceStatus.REJECTED);
+ inst.setFinishedAt(LocalDateTime.now());
+ approvalInstanceMapper.updateById(inst);
+ List<ApprovalTask> pending =
+ approvalTaskMapper.selectList(
+ new LambdaQueryWrapper<ApprovalTask>()
+ .eq(ApprovalTask::getInstanceId, inst.getId())
+ .in(
+ ApprovalTask::getTaskStatus,
+ ApprovalTaskStatus.PENDING,
+ ApprovalTaskStatus.WAITING));
+ for (ApprovalTask t : pending) {
+ t.setTaskStatus(ApprovalTaskStatus.REJECTED);
+ t.setComment(reason);
+ approvalTaskMapper.updateById(t);
+ }
+ }
+
+ private String resolveFirstNodeWithAssignees(List<String> chain, AppointmentOrder order) {
+ for (String nodeCode : chain) {
+ List<Long> assignees = resolveAssignees(nodeCode, order);
+ if (assignees != null && !assignees.isEmpty()) {
+ return nodeCode;
+ }
+ }
+ return null;
+ }
+
+ private int activateWaitingTasks(Long instanceId, String nodeCode) {
+ List<ApprovalTask> waiting =
+ approvalTaskMapper.selectList(
+ new LambdaQueryWrapper<ApprovalTask>()
+ .eq(ApprovalTask::getInstanceId, instanceId)
+ .eq(ApprovalTask::getNodeCode, nodeCode)
+ .eq(ApprovalTask::getTaskStatus, ApprovalTaskStatus.WAITING));
+ for (ApprovalTask t : waiting) {
+ t.setTaskStatus(ApprovalTaskStatus.PENDING);
+ approvalTaskMapper.updateById(t);
+ }
+ return waiting.size();
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void rebuildAfterHandlerApproved(
+ ApprovalTask approvedTask, ApprovalInstance inst, AppointmentOrder order) {
+ approvalTaskMapper.delete(
+ new LambdaQueryWrapper<ApprovalTask>()
+ .eq(ApprovalTask::getInstanceId, inst.getId())
+ .ne(ApprovalTask::getId, approvedTask.getId()));
+
+ List<String> chain = resolveEnabledNodeChainForPostHandler(order);
+ int idx = chain.indexOf(ApprovalNodeCode.LEVEL1_HANDLER);
+ int start = idx >= 0 ? idx + 1 : 0;
+
+ String firstNode = null;
+ for (int i = start; i < chain.size(); i++) {
+ String nodeCode = chain.get(i);
+ String status = firstNode == null ? ApprovalTaskStatus.PENDING : ApprovalTaskStatus.WAITING;
+ int created;
+ if (ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER.equals(nodeCode)) {
+ created = createTasksForLocalUnitNodeStrict(inst.getId(), order, status);
+ } else {
+ created = createTasksForNodeStrict(inst, order, nodeCode, status);
+ }
+ if (created > 0 && firstNode == null) {
+ firstNode = nodeCode;
+ }
+ }
+
+ if (firstNode == null) {
+ inst.setStatus(ApprovalInstanceStatus.APPROVED);
+ inst.setFinishedAt(LocalDateTime.now());
+ inst.setCurrentNodeCode(null);
+ approvalInstanceMapper.updateById(inst);
+ return;
+ }
+ inst.setCurrentNodeCode(firstNode);
+ inst.setStatus(ApprovalInstanceStatus.IN_PROGRESS);
+ inst.setFinishedAt(null);
+ approvalInstanceMapper.updateById(inst);
+ autoApproveCurrentNodeByHistory(inst.getId(), firstNode);
+ if (isCurrentNodeComplete(inst)) {
+ advanceOrComplete(inst, order);
+ }
+ }
+
+ private List<String> resolveEnabledNodeChainForPostHandler(AppointmentOrder order) {
+ List<BizApprovalNodeConfig> configs =
+ bizApprovalNodeConfigMapper.selectList(
+ new LambdaQueryWrapper<BizApprovalNodeConfig>()
+ .eq(BizApprovalNodeConfig::getEnabled, 1)
+ .orderByAsc(BizApprovalNodeConfig::getSortOrder));
+ List<String> chain = new ArrayList<>();
+ List<Long> localUnitIds = parseLongJsonArray(order.getPassLocalUnitIds());
+ for (BizApprovalNodeConfig c : configs) {
+ String nc = c.getNodeCode();
+ if (ApprovalNodeCode.LEVEL5_VISIT_DEPT_LEADER.equals(nc)
+ && !Boolean.TRUE.equals(order.getHolidayFlag())) {
+ continue;
+ }
+ if (ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER.equals(nc)) {
+ if (!Boolean.TRUE.equals(order.getInvolveOtherLocalUnit()) || localUnitIds.isEmpty()) {
+ continue;
+ }
+ }
+ chain.add(nc);
+ }
+ return chain;
+ }
+
+ private int createTasksForLocalUnitNode(Long instanceId, AppointmentOrder order, String initialStatus) {
+ List<Long> localUnitIds = parseLongJsonArray(order.getPassLocalUnitIds());
+ if (localUnitIds.isEmpty()) {
+ return 0;
+ }
+ List<MpPassLocalUnit> units =
+ mpPassLocalUnitMapper.selectList(
+ new LambdaQueryWrapper<MpPassLocalUnit>()
+ .eq(MpPassLocalUnit::getDelFlag, 0)
+ .in(MpPassLocalUnit::getId, localUnitIds));
+ if (units.isEmpty()) {
+ return 0;
+ }
+ Map<Long, MpPassLocalUnit> unitMap =
+ units.stream().collect(java.util.stream.Collectors.toMap(MpPassLocalUnit::getId, x -> x, (a, b) -> a));
+
+ LinkedHashSet<Long> deptIds = new LinkedHashSet<>();
+ LinkedHashSet<Long> groupIds = new LinkedHashSet<>();
+ LinkedHashMap<String, List<Long>> tempGroups = new LinkedHashMap<>();
+
+ for (Long unitId : localUnitIds) {
+ MpPassLocalUnit unit = unitMap.get(unitId);
+ if (unit == null || unit.getApproverType() == null) {
+ continue;
+ }
+ if (unit.getApproverType() == LocalUnitApproverType.DEPT) {
+ for (Long deptId : parseLongJsonArray(unit.getApproverDeptIds())) {
+ if (deptId != null && deptId > 0) {
+ deptIds.add(deptId);
+ }
+ }
+ continue;
+ }
+ if (unit.getApproverType() == LocalUnitApproverType.STAFF_GROUP) {
+ Long gid = unit.getApproverGroupId();
+ if (gid != null && gid > 0) {
+ groupIds.add(gid);
+ }
+ continue;
+ }
+ if (unit.getApproverType() == LocalUnitApproverType.STAFF) {
+ List<MpPassLocalUnitMember> members =
+ mpPassLocalUnitMemberMapper.selectList(
+ new LambdaQueryWrapper<MpPassLocalUnitMember>()
+ .eq(MpPassLocalUnitMember::getDelFlag, 0)
+ .eq(MpPassLocalUnitMember::getUnitId, unitId)
+ .orderByAsc(MpPassLocalUnitMember::getId));
+ LinkedHashSet<Long> mids = new LinkedHashSet<>();
+ for (MpPassLocalUnitMember m : members) {
+ if (m.getMpUserId() != null && m.getMpUserId() > 0) {
+ mids.add(m.getMpUserId());
+ }
+ }
+ if (mids.isEmpty()) {
+ continue;
+ }
+ List<Long> ordered = mids.stream().toList();
+ String sign = buildTempGroupSign(ordered);
+ tempGroups.putIfAbsent(sign, ordered);
+ }
+ }
+
+ int total = 0;
+ for (Long deptId : deptIds) {
+ List<Long> assignees = listEmployeeIdsByDeptAndLeaderFlag(deptId, true);
+ String key = ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER + ":DEPT:" + deptId;
+ total += createTasksWithGroupKey(instanceId, ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER, key, assignees, initialStatus);
+ }
+ for (Long groupId : groupIds) {
+ List<MpStaffGroupMember> members =
+ mpStaffGroupMemberMapper.selectList(
+ new LambdaQueryWrapper<MpStaffGroupMember>()
+ .eq(MpStaffGroupMember::getDelFlag, 0)
+ .eq(MpStaffGroupMember::getGroupId, groupId)
+ .orderByAsc(MpStaffGroupMember::getId));
+ LinkedHashSet<Long> mids = new LinkedHashSet<>();
+ for (MpStaffGroupMember m : members) {
+ if (m.getMpUserId() != null && m.getMpUserId() > 0) {
+ mids.add(m.getMpUserId());
+ }
+ }
+ List<Long> assignees = filterActiveEmployeeIds(mids.stream().toList());
+ String key = ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER + ":GROUP:" + groupId;
+ total += createTasksWithGroupKey(instanceId, ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER, key, assignees, initialStatus);
+ }
+ for (Map.Entry<String, List<Long>> e : tempGroups.entrySet()) {
+ List<Long> assignees = filterActiveEmployeeIds(e.getValue());
+ String key = ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER + ":TMP:" + e.getKey();
+ total += createTasksWithGroupKey(instanceId, ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER, key, assignees, initialStatus);
+ }
+ return total;
+ }
+
+ private int createTasksForNodeStrict(
+ ApprovalInstance inst, AppointmentOrder order, String nodeCode, String initialStatus) {
+ List<Long> assignees = resolveAssignees(nodeCode, order);
+ int created = createTasksWithGroupKey(inst.getId(), nodeCode, nodeCode, assignees, initialStatus);
+ if (created <= 0) {
+ throw new BizException(BizErrorCode.CONFIG_NOT_FOUND, "节点无审批人: " + nodeCode);
+ }
+ return created;
+ }
+
+ private int createTasksForLocalUnitNodeStrict(
+ Long instanceId, AppointmentOrder order, String initialStatus) {
+ List<Long> localUnitIds = parseLongJsonArray(order.getPassLocalUnitIds());
+ if (!Boolean.TRUE.equals(order.getInvolveOtherLocalUnit()) || localUnitIds.isEmpty()) {
+ throw new BizException(
+ BizErrorCode.CONFIG_NOT_FOUND,
+ "节点无审批人: " + ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER);
+ }
+
+ List<MpPassLocalUnit> units =
+ mpPassLocalUnitMapper.selectList(
+ new LambdaQueryWrapper<MpPassLocalUnit>()
+ .eq(MpPassLocalUnit::getDelFlag, 0)
+ .in(MpPassLocalUnit::getId, localUnitIds));
+ if (units.size() != localUnitIds.size()) {
+ throw new BizException(BizErrorCode.CONFIG_NOT_FOUND, "属地单位配置缺失,无法生成审批人");
+ }
+ Map<Long, MpPassLocalUnit> unitMap =
+ units.stream()
+ .collect(
+ java.util.stream.Collectors.toMap(
+ MpPassLocalUnit::getId, x -> x, (a, b) -> a));
+
+ LinkedHashSet<Long> deptIds = new LinkedHashSet<>();
+ LinkedHashSet<Long> groupIds = new LinkedHashSet<>();
+ LinkedHashMap<String, List<Long>> tempGroups = new LinkedHashMap<>();
+
+ for (Long unitId : localUnitIds) {
+ MpPassLocalUnit unit = unitMap.get(unitId);
+ if (unit == null || unit.getApproverType() == null) {
+ throw new BizException(BizErrorCode.CONFIG_NOT_FOUND, "属地单位审批类型缺失: " + unitId);
+ }
+ if (unit.getApproverType() == LocalUnitApproverType.DEPT) {
+ List<Long> ids = parseLongJsonArray(unit.getApproverDeptIds());
+ if (ids.isEmpty()) {
+ throw new BizException(BizErrorCode.CONFIG_NOT_FOUND, "属地单位审批部门未配置: " + unitId);
+ }
+ deptIds.addAll(ids);
+ continue;
+ }
+ if (unit.getApproverType() == LocalUnitApproverType.STAFF_GROUP) {
+ Long gid = unit.getApproverGroupId();
+ if (gid == null || gid <= 0) {
+ throw new BizException(BizErrorCode.CONFIG_NOT_FOUND, "属地单位审批分组未配置: " + unitId);
+ }
+ groupIds.add(gid);
+ continue;
+ }
+ if (unit.getApproverType() == LocalUnitApproverType.STAFF) {
+ List<MpPassLocalUnitMember> members =
+ mpPassLocalUnitMemberMapper.selectList(
+ new LambdaQueryWrapper<MpPassLocalUnitMember>()
+ .eq(MpPassLocalUnitMember::getDelFlag, 0)
+ .eq(MpPassLocalUnitMember::getUnitId, unitId)
+ .orderByAsc(MpPassLocalUnitMember::getId));
+ LinkedHashSet<Long> mids = new LinkedHashSet<>();
+ for (MpPassLocalUnitMember m : members) {
+ if (m.getMpUserId() != null && m.getMpUserId() > 0) {
+ mids.add(m.getMpUserId());
+ }
+ }
+ if (mids.isEmpty()) {
+ throw new BizException(BizErrorCode.CONFIG_NOT_FOUND, "属地单位审批人员未配置: " + unitId);
+ }
+ List<Long> ordered = mids.stream().toList();
+ String sign = buildTempGroupSign(ordered);
+ tempGroups.putIfAbsent(sign, ordered);
+ continue;
+ }
+ throw new BizException(BizErrorCode.CONFIG_NOT_FOUND, "属地单位审批类型无效: " + unitId);
+ }
+
+ int total = 0;
+ for (Long deptId : deptIds) {
+ List<Long> assignees = listEmployeeIdsByDeptAndLeaderFlag(deptId, true);
+ if (assignees.isEmpty()) {
+ throw new BizException(BizErrorCode.CONFIG_NOT_FOUND, "审批部门无负责人: " + deptId);
+ }
+ String key = ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER + ":DEPT:" + deptId;
+ total +=
+ createTasksWithGroupKey(
+ instanceId,
+ ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER,
+ key,
+ assignees,
+ initialStatus);
+ }
+ for (Long groupId : groupIds) {
+ List<MpStaffGroupMember> members =
+ mpStaffGroupMemberMapper.selectList(
+ new LambdaQueryWrapper<MpStaffGroupMember>()
+ .eq(MpStaffGroupMember::getDelFlag, 0)
+ .eq(MpStaffGroupMember::getGroupId, groupId)
+ .orderByAsc(MpStaffGroupMember::getId));
+ LinkedHashSet<Long> mids = new LinkedHashSet<>();
+ for (MpStaffGroupMember m : members) {
+ if (m.getMpUserId() != null && m.getMpUserId() > 0) {
+ mids.add(m.getMpUserId());
+ }
+ }
+ List<Long> assignees = filterActiveEmployeeIds(mids.stream().toList());
+ if (assignees.isEmpty()) {
+ throw new BizException(BizErrorCode.CONFIG_NOT_FOUND, "审批分组无有效成员: " + groupId);
+ }
+ String key = ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER + ":GROUP:" + groupId;
+ total +=
+ createTasksWithGroupKey(
+ instanceId,
+ ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER,
+ key,
+ assignees,
+ initialStatus);
+ }
+ for (Map.Entry<String, List<Long>> e : tempGroups.entrySet()) {
+ List<Long> assignees = filterActiveEmployeeIds(e.getValue());
+ if (assignees.isEmpty()) {
+ throw new BizException(BizErrorCode.CONFIG_NOT_FOUND, "临时审批组无有效成员");
+ }
+ String key = ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER + ":TMP:" + e.getKey();
+ total +=
+ createTasksWithGroupKey(
+ instanceId,
+ ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER,
+ key,
+ assignees,
+ initialStatus);
+ }
+ if (total <= 0) {
+ throw new BizException(
+ BizErrorCode.CONFIG_NOT_FOUND,
+ "节点无审批人: " + ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER);
+ }
+ return total;
+ }
+
+ private int createTasksWithGroupKey(
+ Long instanceId, String nodeCode, String groupKey, List<Long> assignees, String initialStatus) {
+ Set<Long> unique = new LinkedHashSet<>(assignees == null ? List.of() : assignees);
+ unique.removeIf(x -> x == null || x <= 0);
+ if (unique.isEmpty()) {
+ return 0;
+ }
+ for (Long uid : unique) {
+ ApprovalTask t = new ApprovalTask();
+ t.setInstanceId(instanceId);
+ t.setNodeCode(nodeCode);
+ t.setGroupKey(groupKey);
+ t.setAssigneeUserId(uid);
+ t.setTaskStatus(initialStatus);
+ approvalTaskMapper.insert(t);
+ }
+ return unique.size();
+ }
+
+ private void autoApproveCurrentNodeByHistory(Long instanceId, String nodeCode) {
+ List<ApprovalTask> approvedHistory =
+ approvalTaskMapper.selectList(
+ new LambdaQueryWrapper<ApprovalTask>()
+ .eq(ApprovalTask::getInstanceId, instanceId)
+ .eq(ApprovalTask::getTaskStatus, ApprovalTaskStatus.APPROVED)
+ .ne(ApprovalTask::getNodeCode, nodeCode));
+ if (approvedHistory.isEmpty()) {
+ return;
+ }
+ Set<Long> approvedAssigneeIds = new LinkedHashSet<>();
+ for (ApprovalTask t : approvedHistory) {
+ if (t.getAssigneeUserId() != null) {
+ approvedAssigneeIds.add(t.getAssigneeUserId());
+ }
+ }
+ if (approvedAssigneeIds.isEmpty()) {
+ return;
+ }
+ List<ApprovalTask> pending =
+ approvalTaskMapper.selectList(
+ new LambdaQueryWrapper<ApprovalTask>()
+ .eq(ApprovalTask::getInstanceId, instanceId)
+ .eq(ApprovalTask::getNodeCode, nodeCode)
+ .eq(ApprovalTask::getTaskStatus, ApprovalTaskStatus.PENDING)
+ .orderByAsc(ApprovalTask::getId));
+ Set<String> handledGroupKeys = new LinkedHashSet<>();
+ for (ApprovalTask task : pending) {
+ if (!approvedAssigneeIds.contains(task.getAssigneeUserId())) {
+ continue;
+ }
+ String groupKey = StringUtils.hasText(task.getGroupKey()) ? task.getGroupKey() : ("TASK:" + task.getId());
+ if (!handledGroupKeys.add(groupKey)) {
+ continue;
+ }
+ ApprovalTask latest = approvalTaskMapper.selectById(task.getId());
+ if (latest == null || !ApprovalTaskStatus.PENDING.equals(latest.getTaskStatus())) {
+ continue;
+ }
+ latest.setTaskStatus(ApprovalTaskStatus.APPROVED);
+ if (!StringUtils.hasText(latest.getComment())) {
+ latest.setComment(AUTO_APPROVE_COMMENT);
+ }
+ approvalTaskMapper.updateById(latest);
+ markGroupSkippedExcept(latest);
+ }
+ }
+
+ private List<Long> parseLongJsonArray(String raw) {
+ if (!StringUtils.hasText(raw)) {
+ return List.of();
+ }
+ try {
+ List<Long> list = objectMapper.readValue(raw, LONG_LIST_TYPE);
+ if (list == null || list.isEmpty()) {
+ return List.of();
+ }
+ LinkedHashSet<Long> set = new LinkedHashSet<>();
+ for (Long id : list) {
+ if (id != null && id > 0) {
+ set.add(id);
+ }
+ }
+ return set.stream().toList();
+ } catch (Exception ex) {
+ return List.of();
+ }
+ }
+
+ private List<Long> filterActiveEmployeeIds(List<Long> ids) {
+ if (ids == null || ids.isEmpty()) {
+ return List.of();
+ }
+ List<MpUser> users =
+ mpUserMapper.selectList(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getDelFlag, 0)
+ .eq(MpUser::getStatus, 1)
+ .eq(MpUser::getUserType, MiniUserType.EMPLOYEE)
+ .in(MpUser::getId, ids));
+ Map<Long, MpUser> userMap =
+ users.stream().collect(java.util.stream.Collectors.toMap(MpUser::getId, x -> x, (a, b) -> a));
+ List<Long> out = new ArrayList<>();
+ for (Long id : ids) {
+ if (userMap.containsKey(id)) {
+ out.add(id);
+ }
+ }
+ return out;
+ }
+
+ private String buildTempGroupSign(List<Long> memberIds) {
+ String joined = memberIds.stream().map(String::valueOf).collect(java.util.stream.Collectors.joining("-"));
+ if (joined.length() <= 40) {
+ return joined;
+ }
+ return Integer.toHexString(joined.hashCode());
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/ApprovalFlowViewApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/ApprovalFlowViewApplicationService.java
new file mode 100644
index 0000000..48d82c5
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/ApprovalFlowViewApplicationService.java
@@ -0,0 +1,368 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sinata.appointment.constants.ApprovalNodeCode;
+import com.sinata.appointment.constants.ApprovalTaskStatus;
+import com.sinata.appointment.dto.ApprovalFlowGroupVo;
+import com.sinata.appointment.dto.ApprovalFlowNodeVo;
+import com.sinata.appointment.dto.ApprovalFlowTaskUserVo;
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.entity.ApprovalInstance;
+import com.sinata.appointment.entity.ApprovalTask;
+import com.sinata.appointment.entity.BizApprovalNodeConfig;
+import com.sinata.appointment.entity.MpStaffGroup;
+import com.sinata.appointment.entity.MpUser;
+import com.sinata.appointment.entity.SysDept;
+import com.sinata.appointment.mapper.AppointmentOrderMapper;
+import com.sinata.appointment.mapper.ApprovalInstanceMapper;
+import com.sinata.appointment.mapper.ApprovalTaskMapper;
+import com.sinata.appointment.mapper.BizApprovalNodeConfigMapper;
+import com.sinata.appointment.mapper.MpStaffGroupMapper;
+import com.sinata.appointment.mapper.MpUserMapper;
+import com.sinata.appointment.mapper.SysDeptMapper;
+import com.sinata.appointment.util.UuidAssert;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+@Service
+@RequiredArgsConstructor
+public class ApprovalFlowViewApplicationService {
+
+ private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
+
+ private final ApprovalInstanceMapper approvalInstanceMapper;
+ private final ApprovalTaskMapper approvalTaskMapper;
+ private final BizApprovalNodeConfigMapper bizApprovalNodeConfigMapper;
+ private final AppointmentOrderMapper appointmentOrderMapper;
+ private final MpUserMapper mpUserMapper;
+ private final SysDeptMapper sysDeptMapper;
+ private final MpStaffGroupMapper mpStaffGroupMapper;
+ private final ObjectMapper objectMapper;
+
+ public List<ApprovalFlowNodeVo> treeByOrderId(Long orderId) {
+ return treeByOrderId(orderId, null);
+ }
+
+ public List<ApprovalFlowNodeVo> treeByOrderId(Long orderId, String orderUuid) {
+ if (orderId == null || orderId <= 0) {
+ return List.of();
+ }
+ ApprovalInstance inst = loadLatestInstance(orderId);
+ if (inst == null) {
+ return List.of();
+ }
+ AppointmentOrder order = appointmentOrderMapper.selectById(orderId);
+ if (order != null && StringUtils.hasText(orderUuid)) {
+ UuidAssert.match(order, orderUuid);
+ }
+ List<ApprovalTask> tasks =
+ approvalTaskMapper.selectList(
+ new LambdaQueryWrapper<ApprovalTask>()
+ .eq(ApprovalTask::getDelFlag, 0)
+ .eq(ApprovalTask::getInstanceId, inst.getId())
+ .orderByAsc(ApprovalTask::getCreateTime)
+ .orderByAsc(ApprovalTask::getId));
+ if (tasks.isEmpty()) {
+ return List.of();
+ }
+
+ Map<String, String> nodeNameMap = loadNodeNameMap();
+ Map<Long, MpUser> userMap = loadUserMap(tasks);
+ Map<Long, String> deptNameMap = loadDeptNameMap(tasks);
+ Map<Long, String> staffGroupNameMap = loadStaffGroupNameMap(tasks);
+ List<String> localUnitNames = parseStringList(order == null ? null : order.getPassLocalUnitNames());
+
+ LinkedHashMap<String, LinkedHashMap<String, List<ApprovalTask>>> byNode = new LinkedHashMap<>();
+ for (ApprovalTask t : tasks) {
+ byNode.computeIfAbsent(t.getNodeCode(), k -> new LinkedHashMap<>())
+ .computeIfAbsent(t.getGroupKey(), k -> new ArrayList<>())
+ .add(t);
+ }
+
+ List<ApprovalFlowNodeVo> out = new ArrayList<>();
+ int tmpIdx = 1;
+ for (Map.Entry<String, LinkedHashMap<String, List<ApprovalTask>>> nodeEntry : byNode.entrySet()) {
+ String nodeCode = nodeEntry.getKey();
+ ApprovalFlowNodeVo nodeVo = new ApprovalFlowNodeVo();
+ nodeVo.setNodeCode(nodeCode);
+ nodeVo.setNodeName(nodeNameMap.getOrDefault(nodeCode, nodeCode));
+
+ for (Map.Entry<String, List<ApprovalTask>> groupEntry : nodeEntry.getValue().entrySet()) {
+ String groupKey = groupEntry.getKey();
+ List<ApprovalTask> groupTasks = groupEntry.getValue();
+ ApprovalFlowGroupVo g = new ApprovalFlowGroupVo();
+ g.setGroupKey(groupKey);
+ GroupMeta meta = resolveGroupMeta(nodeCode, groupKey, deptNameMap, staffGroupNameMap, localUnitNames, tmpIdx);
+ if ("TEMP_GROUP".equals(meta.groupType())) {
+ tmpIdx++;
+ }
+ g.setGroupType(meta.groupType());
+ g.setGroupName(meta.groupName());
+ g.setGroupStatus(calcStatus(groupTasks));
+
+ for (ApprovalTask t : groupTasks) {
+ ApprovalFlowTaskUserVo u = new ApprovalFlowTaskUserVo();
+ u.setTaskId(t.getId());
+ u.setUserId(t.getAssigneeUserId());
+ MpUser mp = userMap.get(t.getAssigneeUserId());
+ u.setUserName(resolveUserName(mp));
+ u.setAvatarUrl(mp == null ? null : mp.getAvatarUrl());
+ u.setTaskStatus(t.getTaskStatus());
+ u.setComment(t.getComment());
+ u.setActionTime(t.getUpdateTime());
+ g.getUsers().add(u);
+ }
+ nodeVo.getGroups().add(g);
+ }
+ nodeVo.setNodeStatus(calcNodeStatus(nodeVo.getGroups()));
+ out.add(nodeVo);
+ }
+ return out;
+ }
+
+ private ApprovalInstance loadLatestInstance(Long orderId) {
+ List<ApprovalInstance> list =
+ approvalInstanceMapper.selectList(
+ new LambdaQueryWrapper<ApprovalInstance>()
+ .eq(ApprovalInstance::getDelFlag, 0)
+ .eq(ApprovalInstance::getOrderId, orderId)
+ .orderByDesc(ApprovalInstance::getId));
+ return list.isEmpty() ? null : list.get(0);
+ }
+
+ private Map<String, String> loadNodeNameMap() {
+ List<BizApprovalNodeConfig> rows =
+ bizApprovalNodeConfigMapper.selectList(
+ new LambdaQueryWrapper<BizApprovalNodeConfig>()
+ .eq(BizApprovalNodeConfig::getDelFlag, 0)
+ .orderByAsc(BizApprovalNodeConfig::getSortOrder));
+ Map<String, String> map = new LinkedHashMap<>();
+ for (BizApprovalNodeConfig row : rows) {
+ map.put(row.getNodeCode(), row.getNodeName());
+ }
+ return map;
+ }
+
+ private Map<Long, MpUser> loadUserMap(List<ApprovalTask> tasks) {
+ Set<Long> ids = new LinkedHashSet<>();
+ for (ApprovalTask t : tasks) {
+ if (t.getAssigneeUserId() != null && t.getAssigneeUserId() > 0) {
+ ids.add(t.getAssigneeUserId());
+ }
+ }
+ if (ids.isEmpty()) {
+ return Map.of();
+ }
+ List<MpUser> rows =
+ mpUserMapper.selectList(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getDelFlag, 0)
+ .in(MpUser::getId, ids));
+ Map<Long, MpUser> out = new LinkedHashMap<>();
+ for (MpUser row : rows) {
+ out.put(row.getId(), row);
+ }
+ return out;
+ }
+
+ private Map<Long, String> loadDeptNameMap(List<ApprovalTask> tasks) {
+ Set<Long> deptIds = new LinkedHashSet<>();
+ for (ApprovalTask t : tasks) {
+ Long deptId = parseTailId(t.getGroupKey(), ":DEPT:");
+ if (deptId != null && deptId > 0) {
+ deptIds.add(deptId);
+ }
+ }
+ if (deptIds.isEmpty()) {
+ return Map.of();
+ }
+ List<SysDept> rows =
+ sysDeptMapper.selectList(
+ new LambdaQueryWrapper<SysDept>()
+ .eq(SysDept::getDelFlag, 0)
+ .in(SysDept::getId, deptIds));
+ Map<Long, String> out = new LinkedHashMap<>();
+ for (SysDept row : rows) {
+ out.put(row.getId(), row.getDeptName());
+ }
+ return out;
+ }
+
+ private Map<Long, String> loadStaffGroupNameMap(List<ApprovalTask> tasks) {
+ Set<Long> ids = new LinkedHashSet<>();
+ for (ApprovalTask t : tasks) {
+ Long gid = parseTailId(t.getGroupKey(), ":GROUP:");
+ if (gid != null && gid > 0) {
+ ids.add(gid);
+ }
+ }
+ if (ids.isEmpty()) {
+ return Map.of();
+ }
+ List<MpStaffGroup> rows =
+ mpStaffGroupMapper.selectList(
+ new LambdaQueryWrapper<MpStaffGroup>()
+ .eq(MpStaffGroup::getDelFlag, 0)
+ .in(MpStaffGroup::getId, ids));
+ Map<Long, String> out = new LinkedHashMap<>();
+ for (MpStaffGroup row : rows) {
+ out.put(row.getId(), row.getGroupName());
+ }
+ return out;
+ }
+
+ private GroupMeta resolveGroupMeta(
+ String nodeCode,
+ String groupKey,
+ Map<Long, String> deptNameMap,
+ Map<Long, String> staffGroupNameMap,
+ List<String> localUnitNames,
+ int tmpIdx) {
+ if (!ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER.equals(nodeCode)) {
+ return new GroupMeta("DEFAULT", "审批人");
+ }
+ Long deptId = parseTailId(groupKey, ":DEPT:");
+ if (deptId != null) {
+ String name = deptNameMap.getOrDefault(deptId, "部门#" + deptId);
+ return new GroupMeta("DEPT", name + "审批组");
+ }
+ Long gid = parseTailId(groupKey, ":GROUP:");
+ if (gid != null) {
+ String name = staffGroupNameMap.getOrDefault(gid, "分组#" + gid);
+ return new GroupMeta("STAFF_GROUP", name + "审批组");
+ }
+ if (groupKey != null && groupKey.contains(":TMP:")) {
+ String unitName =
+ localUnitNames.isEmpty()
+ ? null
+ : (localUnitNames.size() == 1
+ ? localUnitNames.get(0)
+ : localUnitNames.get(Math.min(tmpIdx - 1, localUnitNames.size() - 1)));
+ String label = StringUtils.hasText(unitName) ? unitName + "临时负责组" : "临时负责组";
+ return new GroupMeta("TEMP_GROUP", label);
+ }
+ return new GroupMeta("DEFAULT", "审批组");
+ }
+
+ private static Long parseTailId(String groupKey, String token) {
+ if (!StringUtils.hasText(groupKey) || !groupKey.contains(token)) {
+ return null;
+ }
+ int idx = groupKey.lastIndexOf(token);
+ if (idx < 0) {
+ return null;
+ }
+ String raw = groupKey.substring(idx + token.length());
+ if (!StringUtils.hasText(raw)) {
+ return null;
+ }
+ try {
+ return Long.parseLong(raw.trim());
+ } catch (Exception ex) {
+ return null;
+ }
+ }
+
+ private List<String> parseStringList(String json) {
+ if (!StringUtils.hasText(json)) {
+ return List.of();
+ }
+ try {
+ List<String> rows = objectMapper.readValue(json, STRING_LIST_TYPE);
+ if (rows == null) {
+ return List.of();
+ }
+ List<String> out = new ArrayList<>();
+ for (String row : rows) {
+ if (StringUtils.hasText(row)) {
+ out.add(row.trim());
+ }
+ }
+ return out;
+ } catch (Exception ex) {
+ return List.of();
+ }
+ }
+
+ private String calcNodeStatus(List<ApprovalFlowGroupVo> groups) {
+ if (groups.isEmpty()) {
+ return ApprovalTaskStatus.WAITING;
+ }
+ boolean hasRejected = false;
+ boolean hasPending = false;
+ boolean hasWaiting = false;
+ for (ApprovalFlowGroupVo g : groups) {
+ String s = g.getGroupStatus();
+ if (ApprovalTaskStatus.REJECTED.equals(s)) {
+ hasRejected = true;
+ } else if (ApprovalTaskStatus.PENDING.equals(s)) {
+ hasPending = true;
+ } else if (ApprovalTaskStatus.WAITING.equals(s)) {
+ hasWaiting = true;
+ }
+ }
+ if (hasRejected) {
+ return ApprovalTaskStatus.REJECTED;
+ }
+ if (hasPending) {
+ return ApprovalTaskStatus.PENDING;
+ }
+ if (hasWaiting) {
+ return ApprovalTaskStatus.WAITING;
+ }
+ return ApprovalTaskStatus.APPROVED;
+ }
+
+ private String calcStatus(List<ApprovalTask> tasks) {
+ if (tasks == null || tasks.isEmpty()) {
+ return ApprovalTaskStatus.WAITING;
+ }
+ boolean hasRejected = false;
+ boolean hasPending = false;
+ boolean hasWaiting = false;
+ for (ApprovalTask t : tasks) {
+ if (ApprovalTaskStatus.REJECTED.equals(t.getTaskStatus())) {
+ hasRejected = true;
+ } else if (ApprovalTaskStatus.PENDING.equals(t.getTaskStatus())) {
+ hasPending = true;
+ } else if (ApprovalTaskStatus.WAITING.equals(t.getTaskStatus())) {
+ hasWaiting = true;
+ }
+ }
+ if (hasRejected) {
+ return ApprovalTaskStatus.REJECTED;
+ }
+ if (hasPending) {
+ return ApprovalTaskStatus.PENDING;
+ }
+ if (hasWaiting) {
+ return ApprovalTaskStatus.WAITING;
+ }
+ return ApprovalTaskStatus.APPROVED;
+ }
+
+ private static String resolveUserName(MpUser user) {
+ if (user == null) {
+ return "";
+ }
+ if (StringUtils.hasText(user.getNickName())) {
+ return user.getNickName();
+ }
+ if (StringUtils.hasText(user.getStaffNo())) {
+ return user.getStaffNo();
+ }
+ return String.valueOf(Objects.requireNonNullElse(user.getId(), 0L));
+ }
+
+ private record GroupMeta(String groupType, String groupName) {}
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/AuthApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/AuthApplicationService.java
new file mode 100644
index 0000000..16534ae
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/AuthApplicationService.java
@@ -0,0 +1,59 @@
+package com.sinata.appointment.service;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.dto.LoginRequest;
+import com.sinata.appointment.dto.LoginResponse;
+import com.sinata.appointment.entity.SysUser;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.mapper.SysUserMapper;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.stereotype.Service;
+
+/**
+ * 平台管理后台登录(sys_user 用户名+密码,BCrypt)。与小程序 {@link MiniAuthApplicationService} 无密码共用关系。
+ */
+@Service
+@RequiredArgsConstructor
+public class AuthApplicationService {
+
+ private final SysUserMapper sysUserMapper;
+ private final BCryptPasswordEncoder bcrypt;
+ private final CaptchaService captchaService;
+ private final AdminLoginCryptoService adminLoginCryptoService;
+
+ public LoginResponse login(LoginRequest req) {
+ captchaService.validateAndConsume(req.getCaptchaId(), req.getCaptchaCode());
+ String username = adminLoginCryptoService.decryptLoginField(req.getUsername(), "用户名");
+ String password = adminLoginCryptoService.decryptLoginField(req.getPassword(), "密码");
+ SysUser user =
+ sysUserMapper.selectOne(
+ new LambdaQueryWrapper<SysUser>()
+ .eq(SysUser::getUsername, username)
+ .eq(SysUser::getDelFlag, 0));
+ if (user == null || user.getStatus() == null || user.getStatus() != 1) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "用户名或密码错误");
+ }
+ if (!bcrypt.matches(password, user.getPasswordHash())) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "用户名或密码错误");
+ }
+ StpUtil.login(user.getId());
+ List<String> roles = sysUserMapper.listRoleCodesByUserId(user.getId());
+ List<String> permissions = sysUserMapper.listPermissionCodesByUserId(user.getId());
+ return LoginResponse.builder()
+ .token(StpUtil.getTokenValue())
+ .userId(user.getId())
+ .displayName(user.getRealName() != null ? user.getRealName() : user.getUsername())
+ .roles(roles)
+ .permissions(permissions)
+ .avatarUrl(user.getAvatarUrl())
+ .build();
+ }
+
+ public void logout() {
+ StpUtil.logout();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/BizHolidayDateCacheService.java b/backend/src/main/java/com/sinata/appointment/service/BizHolidayDateCacheService.java
new file mode 100644
index 0000000..d8b5a03
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/BizHolidayDateCacheService.java
@@ -0,0 +1,38 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.sinata.appointment.constants.CacheNames;
+import com.sinata.appointment.entity.BizHolidayDate;
+import com.sinata.appointment.mapper.BizHolidayDateMapper;
+import java.time.LocalDate;
+import java.util.Set;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class BizHolidayDateCacheService {
+
+ private final BizHolidayDateMapper bizHolidayDateMapper;
+
+ @Cacheable(cacheNames = CacheNames.BIZ_HOLIDAY_DATE, key = "'" + CacheNames.BIZ_HOLIDAY_DATE_ALL_KEY + "'")
+ public Set<LocalDate> getAllEnabledHolidayDates() {
+ return bizHolidayDateMapper.selectList(
+ new LambdaQueryWrapper<BizHolidayDate>()
+ .eq(BizHolidayDate::getDelFlag, 0)
+ .orderByAsc(BizHolidayDate::getHolidayDate))
+ .stream()
+ .map(BizHolidayDate::getHolidayDate)
+ .filter(x -> x != null)
+ .collect(Collectors.toSet());
+ }
+
+ @CacheEvict(cacheNames = CacheNames.BIZ_HOLIDAY_DATE, key = "'" + CacheNames.BIZ_HOLIDAY_DATE_ALL_KEY + "'")
+ public void evictAllHolidayDates() {
+ // use spring cache eviction
+ }
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/service/CaptchaService.java b/backend/src/main/java/com/sinata/appointment/service/CaptchaService.java
new file mode 100644
index 0000000..be42a4f
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/CaptchaService.java
@@ -0,0 +1,97 @@
+package com.sinata.appointment.service;
+
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.dto.CaptchaResponse;
+import com.sinata.appointment.exception.BizException;
+import java.awt.BasicStroke;
+import java.awt.Color;
+import java.awt.Font;
+import java.awt.Graphics2D;
+import java.awt.RenderingHints;
+import java.awt.image.BufferedImage;
+import java.io.ByteArrayOutputStream;
+import java.security.SecureRandom;
+import java.time.Duration;
+import java.util.Base64;
+import java.util.UUID;
+import javax.imageio.ImageIO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.stereotype.Service;
+
+/** 管理端登录图形验证码:Redis 存储,一次性校验。 */
+@Service
+@RequiredArgsConstructor
+public class CaptchaService {
+
+ private static final String KEY_PREFIX = "auth:captcha:";
+ private static final Duration TTL = Duration.ofMinutes(2);
+ /** 易混淆字符已排除:0/O、1/l/I */
+ private static final String CHAR_POOL = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
+ private static final int CODE_LEN = 4;
+ private static final int W = 112;
+ private static final int H = 40;
+
+ private final StringRedisTemplate redisTemplate;
+ private final SecureRandom random = new SecureRandom();
+
+ public CaptchaResponse generate() {
+ String code = randomCode();
+ String captchaId = UUID.randomUUID().toString();
+ redisTemplate.opsForValue().set(KEY_PREFIX + captchaId, code.toLowerCase(), TTL);
+ String imageBase64 = renderPngBase64(code);
+ return CaptchaResponse.builder().captchaId(captchaId).imageBase64(imageBase64).build();
+ }
+
+ public void validateAndConsume(String captchaId, String captchaCode) {
+ if (captchaId == null || captchaId.isBlank()
+ || captchaCode == null || captchaCode.isBlank()) {
+ throw new BizException(BizErrorCode.AUTH_CAPTCHA_INVALID, "请填写验证码");
+ }
+ String key = KEY_PREFIX + captchaId;
+ String expected = redisTemplate.opsForValue().get(key);
+ if (expected == null) {
+ throw new BizException(BizErrorCode.AUTH_CAPTCHA_INVALID, "验证码已失效,请刷新");
+ }
+ redisTemplate.delete(key);
+ if (!expected.equalsIgnoreCase(captchaCode.trim())) {
+ throw new BizException(BizErrorCode.AUTH_CAPTCHA_INVALID, "验证码错误");
+ }
+ }
+
+ private String randomCode() {
+ StringBuilder sb = new StringBuilder(CODE_LEN);
+ for (int i = 0; i < CODE_LEN; i++) {
+ sb.append(CHAR_POOL.charAt(random.nextInt(CHAR_POOL.length())));
+ }
+ return sb.toString();
+ }
+
+ private String renderPngBase64(String code) {
+ BufferedImage image = new BufferedImage(W, H, BufferedImage.TYPE_INT_RGB);
+ Graphics2D g = image.createGraphics();
+ g.setColor(new Color(245, 246, 250));
+ g.fillRect(0, 0, W, H);
+ g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
+ for (int i = 0; i < 6; i++) {
+ g.setColor(new Color(random.nextInt(180), random.nextInt(180), random.nextInt(180), 80));
+ g.setStroke(new BasicStroke(1f));
+ g.drawLine(random.nextInt(W), random.nextInt(H), random.nextInt(W), random.nextInt(H));
+ }
+ g.setFont(new Font("SansSerif", Font.BOLD, 28));
+ for (int i = 0; i < code.length(); i++) {
+ g.setColor(new Color(random.nextInt(80), random.nextInt(80), random.nextInt(80)));
+ int x = 18 + i * 22;
+ int y = 28 + random.nextInt(5) - 2;
+ g.drawString(String.valueOf(code.charAt(i)), x, y);
+ }
+ g.dispose();
+ try {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ImageIO.write(image, "png", out);
+ return Base64.getEncoder().encodeToString(out.toByteArray());
+ } catch (Exception e) {
+ throw new IllegalStateException("生成验证码图片失败", e);
+ }
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/ConfigApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/ConfigApplicationService.java
new file mode 100644
index 0000000..839bcf0
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/ConfigApplicationService.java
@@ -0,0 +1,447 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.constants.CacheNames;
+import com.sinata.appointment.constants.AppointmentBizConstants;
+import com.sinata.appointment.constants.FieldConfigLockedCodes;
+import com.sinata.appointment.constants.SystemSettingKeys;
+import com.sinata.appointment.dto.BookingSettingsSaveRequest;
+import com.sinata.appointment.dto.BookingSettingsVo;
+import com.sinata.appointment.dto.ContentSettingsSaveRequest;
+import com.sinata.appointment.dto.ContentSettingsVo;
+import com.sinata.appointment.dto.FieldConfigPatchRequest;
+import com.sinata.appointment.dto.VisitReasonCreateRequest;
+import com.sinata.appointment.dto.VisitReasonListVo;
+import com.sinata.appointment.dto.VisitReasonUpdateRequest;
+import com.sinata.appointment.entity.BizApprovalNodeConfig;
+import com.sinata.appointment.entity.BizFieldConfig;
+import com.sinata.appointment.entity.BizHolidayDate;
+import com.sinata.appointment.entity.BizVisitReason;
+import com.sinata.appointment.entity.SysBizSetting;
+import com.sinata.appointment.entity.SysDept;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.mapper.BizApprovalNodeConfigMapper;
+import com.sinata.appointment.mapper.BizFieldConfigMapper;
+import com.sinata.appointment.mapper.BizHolidayDateMapper;
+import com.sinata.appointment.mapper.BizVisitReasonMapper;
+import com.sinata.appointment.mapper.SysBizSettingMapper;
+import com.sinata.appointment.mapper.SysDeptMapper;
+import com.sinata.appointment.util.UuidAssert;
+import java.time.LocalDate;
+import java.time.format.DateTimeParseException;
+import java.util.Comparator;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import org.springframework.cache.Cache;
+import org.springframework.cache.CacheManager;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+@Service
+@RequiredArgsConstructor
+public class ConfigApplicationService {
+
+ private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
+
+ private final BizVisitReasonMapper bizVisitReasonMapper;
+ private final BizFieldConfigMapper bizFieldConfigMapper;
+ private final BizApprovalNodeConfigMapper bizApprovalNodeConfigMapper;
+ private final BizHolidayDateMapper bizHolidayDateMapper;
+ private final SysBizSettingMapper sysBizSettingMapper;
+ private final SysDeptMapper sysDeptMapper;
+ private final SysBizSettingCacheService sysBizSettingCacheService;
+ private final BizHolidayDateCacheService bizHolidayDateCacheService;
+ private final CacheManager cacheManager;
+ private final ObjectMapper objectMapper;
+
+ public List<BizVisitReason> listVisitReasons() {
+ return bizVisitReasonMapper.selectList(
+ new LambdaQueryWrapper<BizVisitReason>()
+ .eq(BizVisitReason::getDelFlag, 0)
+ .eq(BizVisitReason::getEnabled, 1)
+ .orderByAsc(BizVisitReason::getSortOrder));
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void saveVisitReason(BizVisitReason entity) {
+ if (entity.getId() == null) {
+ bizVisitReasonMapper.insert(entity);
+ } else {
+ bizVisitReasonMapper.updateById(entity);
+ }
+ evictMiniVisitReasonCache();
+ }
+
+ public IPage<VisitReasonListVo> pageVisitReasons(long page, long size) {
+ Page<BizVisitReason> pg =
+ bizVisitReasonMapper.selectPage(
+ new Page<>(page, size),
+ new LambdaQueryWrapper<BizVisitReason>()
+ .eq(BizVisitReason::getDelFlag, 0)
+ .orderByDesc(BizVisitReason::getCreateTime)
+ .orderByDesc(BizVisitReason::getId));
+ return pg.convert(this::toVisitReasonListVo);
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void createVisitReason(VisitReasonCreateRequest req) {
+ String reasonName = req.getReasonName().trim();
+ assertReasonNameUnique(reasonName, null);
+ BizVisitReason row = new BizVisitReason();
+ row.setReasonName(reasonName);
+ row.setReasonCode("VR_" + UUID.randomUUID().toString().replace("-", ""));
+ row.setEnabled(1);
+ row.setSortOrder(0);
+ bizVisitReasonMapper.insert(row);
+ evictMiniVisitReasonCache();
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void updateVisitReason(Long id, VisitReasonUpdateRequest req) {
+ BizVisitReason row = loadActiveVisitReason(id, req.getUuid());
+ String reasonName = req.getReasonName().trim();
+ assertReasonNameUnique(reasonName, id);
+ row.setReasonName(reasonName);
+ bizVisitReasonMapper.updateById(row);
+ evictMiniVisitReasonCache();
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void deleteVisitReason(Long id, String uuid) {
+ loadActiveVisitReason(id, uuid);
+ bizVisitReasonMapper.deleteById(id);
+ evictMiniVisitReasonCache();
+ }
+
+ public List<BizFieldConfig> listFieldConfigs() {
+ return bizFieldConfigMapper.selectList(
+ new LambdaQueryWrapper<BizFieldConfig>()
+ .eq(BizFieldConfig::getDelFlag, 0)
+ .orderByAsc(BizFieldConfig::getSortOrder));
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void saveFieldConfig(BizFieldConfig entity) {
+ if (entity.getId() == null) {
+ bizFieldConfigMapper.insert(entity);
+ } else {
+ bizFieldConfigMapper.updateById(entity);
+ }
+ evictMiniFieldConfigCache();
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void patchFieldConfig(Long id, FieldConfigPatchRequest req) {
+ if (req.getVisible() == null && req.getRequiredFlag() == null) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "请至少提交一项修改");
+ }
+ BizFieldConfig e = bizFieldConfigMapper.selectById(id);
+ if (e == null || (e.getDelFlag() != null && e.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "字段配置不存在");
+ }
+ UuidAssert.match(e, req.getUuid());
+ if (FieldConfigLockedCodes.isLocked(e.getFieldCode())) {
+ e.setVisible(1);
+ e.setRequiredFlag(1);
+ bizFieldConfigMapper.updateById(e);
+ return;
+ }
+ if (req.getVisible() != null) {
+ e.setVisible(req.getVisible());
+ }
+ if (e.getVisible() != null && e.getVisible() == 0) {
+ e.setRequiredFlag(0);
+ } else if (req.getRequiredFlag() != null) {
+ e.setRequiredFlag(req.getRequiredFlag());
+ }
+ bizFieldConfigMapper.updateById(e);
+ evictMiniFieldConfigCache();
+ }
+
+ public BookingSettingsVo getBookingSettings() {
+ BookingSettingsVo vo = new BookingSettingsVo();
+ String mode =
+ getSettingOrDefault(
+ SystemSettingKeys.BOOKING_RANGE_MODE,
+ AppointmentBizConstants.TIME_MODE_SINGLE)
+ .trim()
+ .toUpperCase();
+ if (!AppointmentBizConstants.TIME_MODE_SINGLE.equals(mode)
+ && !AppointmentBizConstants.TIME_MODE_MULTI.equals(mode)) {
+ mode = AppointmentBizConstants.TIME_MODE_SINGLE;
+ }
+ vo.setRangeMode(mode);
+ String days = getSettingOrDefault(SystemSettingKeys.BOOKING_MAX_CONSECUTIVE_DAYS, "7");
+ int md = 7;
+ try {
+ md = Math.max(1, Integer.parseInt(days.trim()));
+ } catch (NumberFormatException ignored) {
+ // keep default
+ }
+ vo.setMaxConsecutiveDays(md);
+ vo.setHolidayDates(listHolidayDates());
+ String gate = getSettingOrDefault(SystemSettingKeys.VEHICLE_BARRIER_GATE_ENABLED, "0");
+ vo.setVehicleBarrierEnabled("1".equals(gate.trim()));
+ return vo;
+ }
+
+ public ContentSettingsVo getContentSettings() {
+ ContentSettingsVo vo = new ContentSettingsVo();
+ vo.setAboutUsHtml(getSettingOrDefault(SystemSettingKeys.CONTENT_ABOUT_US_HTML, ""));
+ vo.setParkNoticeHtml(getSettingOrDefault(SystemSettingKeys.CONTENT_PARK_NOTICE_HTML, ""));
+ vo.setParkFlowHtml(getSettingOrDefault(SystemSettingKeys.CONTENT_PARK_FLOW_HTML, ""));
+ vo.setAboutUsEnabled("1".equals(getSettingOrDefault(SystemSettingKeys.CONTENT_ABOUT_US_ENABLED, "0").trim()));
+ return vo;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void saveBookingSettings(BookingSettingsSaveRequest req) {
+ String mode = req.getRangeMode().trim().toUpperCase();
+ if (!AppointmentBizConstants.TIME_MODE_SINGLE.equals(mode)
+ && !AppointmentBizConstants.TIME_MODE_MULTI.equals(mode)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "预约时段类型无效");
+ }
+ if (AppointmentBizConstants.TIME_MODE_MULTI.equals(mode)) {
+ if (req.getMaxConsecutiveDays() == null || req.getMaxConsecutiveDays() < 1) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "请输入最长连续预约天数(正整数)");
+ }
+ if (req.getMaxConsecutiveDays() > 999) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "最长连续预约天数不能超过 999");
+ }
+ upsertSetting(SystemSettingKeys.BOOKING_MAX_CONSECUTIVE_DAYS, String.valueOf(req.getMaxConsecutiveDays()));
+ }
+ upsertSetting(SystemSettingKeys.BOOKING_RANGE_MODE, mode);
+ List<String> hol = req.getHolidayDates() == null ? List.of() : req.getHolidayDates();
+ List<String> normalized = normalizeHolidayDates(hol);
+ replaceHolidayDates(normalized);
+ bizHolidayDateCacheService.evictAllHolidayDates();
+ // 兼容旧实现:避免 setting_value 持续变大,保存后清空 JSON
+ upsertSetting(SystemSettingKeys.BOOKING_HOLIDAY_DATES, "[]");
+ upsertSetting(
+ SystemSettingKeys.VEHICLE_BARRIER_GATE_ENABLED,
+ Boolean.TRUE.equals(req.getVehicleBarrierEnabled()) ? "1" : "0");
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void saveContentSettings(ContentSettingsSaveRequest req) {
+ upsertSetting(SystemSettingKeys.CONTENT_ABOUT_US_HTML, normalizeHtml(req.getAboutUsHtml()));
+ upsertSetting(SystemSettingKeys.CONTENT_PARK_NOTICE_HTML, normalizeHtml(req.getParkNoticeHtml()));
+ upsertSetting(SystemSettingKeys.CONTENT_PARK_FLOW_HTML, normalizeHtml(req.getParkFlowHtml()));
+ upsertSetting(SystemSettingKeys.CONTENT_ABOUT_US_ENABLED, Boolean.TRUE.equals(req.getAboutUsEnabled()) ? "1" : "0");
+ }
+
+ private List<String> listHolidayDates() {
+ List<BizHolidayDate> rows =
+ bizHolidayDateMapper.selectList(
+ new LambdaQueryWrapper<BizHolidayDate>()
+ .eq(BizHolidayDate::getDelFlag, 0)
+ .orderByAsc(BizHolidayDate::getHolidayDate));
+ if (rows != null && !rows.isEmpty()) {
+ return rows.stream()
+ .map(BizHolidayDate::getHolidayDate)
+ .filter(x -> x != null)
+ .map(LocalDate::toString)
+ .collect(Collectors.toList());
+ }
+ // 兜底:旧版本存于 setting_value(JSON);若存在则读出来返回
+ String holJson = getSettingOrDefault(SystemSettingKeys.BOOKING_HOLIDAY_DATES, "[]");
+ try {
+ List<String> raw = objectMapper.readValue(holJson, STRING_LIST_TYPE);
+ if (raw != null && !raw.isEmpty()) {
+ return normalizeHolidayDates(raw);
+ }
+ } catch (Exception ignored) {
+ // ignore
+ }
+ return List.of();
+ }
+
+ private void replaceHolidayDates(List<String> normalized) {
+ List<BizHolidayDate> existing =
+ bizHolidayDateMapper.selectList(
+ new LambdaQueryWrapper<BizHolidayDate>()
+ .eq(BizHolidayDate::getDelFlag, 0)
+ .orderByAsc(BizHolidayDate::getHolidayDate));
+
+ Set<LocalDate> existingSet =
+ existing.stream()
+ .map(BizHolidayDate::getHolidayDate)
+ .filter(x -> x != null)
+ .collect(Collectors.toSet());
+
+ Set<LocalDate> targetSet =
+ normalized.stream().map(LocalDate::parse).collect(Collectors.toCollection(java.util.LinkedHashSet::new));
+
+ // 删除:存在但目标不包含的
+ List<LocalDate> toDelete =
+ existingSet.stream().filter(d -> !targetSet.contains(d)).collect(Collectors.toList());
+ if (!toDelete.isEmpty()) {
+ bizHolidayDateMapper.deleteByHolidayDates(toDelete);
+ }
+
+ // 新增:目标包含但不存在的
+ for (LocalDate d : targetSet) {
+ if (existingSet.contains(d)) {
+ continue;
+ }
+ BizHolidayDate row = new BizHolidayDate();
+ row.setHolidayDate(d);
+ bizHolidayDateMapper.insert(row);
+ }
+ }
+
+ public List<BizApprovalNodeConfig> listApprovalNodes() {
+ return bizApprovalNodeConfigMapper.selectList(
+ new LambdaQueryWrapper<BizApprovalNodeConfig>()
+ .eq(BizApprovalNodeConfig::getDelFlag, 0)
+ .orderByAsc(BizApprovalNodeConfig::getSortOrder));
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void saveApprovalNode(BizApprovalNodeConfig entity) {
+ if (entity.getId() == null) {
+ bizApprovalNodeConfigMapper.insert(entity);
+ } else {
+ bizApprovalNodeConfigMapper.updateById(entity);
+ }
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void updateApprovalNodeEnabled(Long id, String uuid, int enabled) {
+ BizApprovalNodeConfig e = bizApprovalNodeConfigMapper.selectById(id);
+ if (e == null || (e.getDelFlag() != null && e.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "节点不存在");
+ }
+ UuidAssert.match(e, uuid);
+ e.setEnabled(enabled);
+ bizApprovalNodeConfigMapper.updateById(e);
+ }
+
+ public Long getVisitCompetentDeptId() {
+ String raw = getSettingOrDefault(SystemSettingKeys.VISIT_COMPETENT_DEPT_ID, null);
+ if (!StringUtils.hasText(raw)) {
+ return null;
+ }
+ try {
+ long v = Long.parseLong(raw.trim());
+ return v > 0 ? v : null;
+ } catch (NumberFormatException ex) {
+ return null;
+ }
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void saveVisitCompetentDept(Long deptId) {
+ if (deptId != null && deptId > 0) {
+ SysDept d = sysDeptMapper.selectById(deptId);
+ if (d == null || (d.getDelFlag() != null && d.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.DEPT_PARENT_INVALID, "部门不存在或已删除");
+ }
+ }
+ String val = (deptId != null && deptId > 0) ? String.valueOf(deptId) : null;
+ upsertSetting(SystemSettingKeys.VISIT_COMPETENT_DEPT_ID, val);
+ }
+
+ private String getSettingOrDefault(String key, String defaultVal) {
+ String value = sysBizSettingCacheService.getValue(key);
+ if (!StringUtils.hasText(value)) {
+ return defaultVal;
+ }
+ return value;
+ }
+
+ private static String normalizeHtml(String html) {
+ return html == null ? "" : html;
+ }
+
+ private void upsertSetting(String key, String value) {
+ SysBizSetting s =
+ sysBizSettingMapper.selectOne(
+ new LambdaQueryWrapper<SysBizSetting>().eq(SysBizSetting::getSettingKey, key));
+ if (s == null) {
+ SysBizSetting row = new SysBizSetting();
+ row.setSettingKey(key);
+ row.setSettingValue(value);
+ sysBizSettingMapper.insert(row);
+ } else {
+ s.setSettingValue(value);
+ sysBizSettingMapper.updateById(s);
+ }
+ sysBizSettingCacheService.evict(key);
+ }
+
+ private static List<String> normalizeHolidayDates(List<String> raw) {
+ LinkedHashSet<String> set = new LinkedHashSet<>();
+ for (String s : raw) {
+ if (!StringUtils.hasText(s)) {
+ continue;
+ }
+ String t = s.trim();
+ try {
+ LocalDate.parse(t);
+ } catch (DateTimeParseException ex) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "非法日期: " + t);
+ }
+ set.add(t);
+ }
+ return set.stream().sorted(Comparator.naturalOrder()).collect(Collectors.toList());
+ }
+
+ private BizVisitReason loadActiveVisitReason(Long id, String uuid) {
+ BizVisitReason row = bizVisitReasonMapper.selectById(id);
+ if (row == null || (row.getDelFlag() != null && row.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "来访事由不存在");
+ }
+ // 兼容历史脏数据:若库中 uuid 为空,则仅按 id 处理;正常数据仍做 id+uuid 校验
+ if (StringUtils.hasText(row.getUuid())) {
+ UuidAssert.match(row, uuid);
+ }
+ return row;
+ }
+
+ private void assertReasonNameUnique(String reasonName, Long excludeId) {
+ LambdaQueryWrapper<BizVisitReason> q =
+ new LambdaQueryWrapper<BizVisitReason>()
+ .eq(BizVisitReason::getDelFlag, 0)
+ .eq(BizVisitReason::getReasonName, reasonName);
+ if (excludeId != null) {
+ q.ne(BizVisitReason::getId, excludeId);
+ }
+ if (bizVisitReasonMapper.selectCount(q) > 0) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "来访事由已存在");
+ }
+ }
+
+ private VisitReasonListVo toVisitReasonListVo(BizVisitReason e) {
+ VisitReasonListVo vo = new VisitReasonListVo();
+ vo.setId(e.getId());
+ vo.setUuid(e.getUuid());
+ vo.setReasonName(e.getReasonName());
+ vo.setCreateTime(e.getCreateTime());
+ return vo;
+ }
+
+ private void evictMiniVisitReasonCache() {
+ Cache cache = cacheManager.getCache(CacheNames.MINI_LOOKUP);
+ if (cache != null) {
+ cache.evict(CacheNames.MINI_LOOKUP_VISIT_REASONS_KEY);
+ }
+ }
+
+ private void evictMiniFieldConfigCache() {
+ Cache cache = cacheManager.getCache(CacheNames.MINI_LOOKUP);
+ if (cache != null) {
+ cache.evict(CacheNames.MINI_LOOKUP_FIELD_CONFIGS_KEY);
+ }
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/DeptManageApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/DeptManageApplicationService.java
new file mode 100644
index 0000000..736cca1
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/DeptManageApplicationService.java
@@ -0,0 +1,279 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.dto.DeptCreateRequest;
+import com.sinata.appointment.dto.DeptTreeNodeVo;
+import com.sinata.appointment.dto.DeptUpdateRequest;
+import com.sinata.appointment.entity.SysDept;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.mapper.SysDeptMapper;
+import com.sinata.appointment.util.UuidAssert;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+@Service
+@RequiredArgsConstructor
+public class DeptManageApplicationService {
+
+ private static final int MAX_LEVEL = 5;
+
+ private final SysDeptMapper sysDeptMapper;
+
+ public List<DeptTreeNodeVo> tree() {
+ List<SysDept> all =
+ sysDeptMapper.selectList(
+ new LambdaQueryWrapper<SysDept>()
+ .eq(SysDept::getDelFlag, 0)
+ .orderByAsc(SysDept::getSortOrder)
+ .orderByAsc(SysDept::getId));
+ Map<Long, DeptTreeNodeVo> vos = new HashMap<>();
+ for (SysDept d : all) {
+ DeptTreeNodeVo v = toVo(d);
+ v.setChildren(new ArrayList<>());
+ vos.put(d.getId(), v);
+ }
+ List<DeptTreeNodeVo> roots = new ArrayList<>();
+ for (SysDept d : all) {
+ DeptTreeNodeVo v = vos.get(d.getId());
+ long pid = d.getParentId() == null ? 0L : d.getParentId();
+ if (pid == 0L) {
+ roots.add(v);
+ } else {
+ DeptTreeNodeVo p = vos.get(pid);
+ if (p != null) {
+ p.getChildren().add(v);
+ } else {
+ roots.add(v);
+ }
+ }
+ }
+ sortChildrenRecursive(roots);
+ for (DeptTreeNodeVo r : roots) {
+ assignLevel(r, 1);
+ }
+ return roots;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public Map<String, Object> create(DeptCreateRequest req) {
+ long pid = req.getParentId() == null ? 0L : req.getParentId();
+ if (pid != 0L) {
+ SysDept parent = loadActive(pid);
+ int parentDepth = depthOf(parent.getId());
+ if (parentDepth >= MAX_LEVEL) {
+ throw new BizException(BizErrorCode.DEPT_MAX_DEPTH, "无法在第五级下再添加下级部门");
+ }
+ if (parentDepth + 1 > MAX_LEVEL) {
+ throw new BizException(BizErrorCode.DEPT_MAX_DEPTH, "部门层级不能超过 " + MAX_LEVEL + " 级");
+ }
+ }
+ SysDept d = new SysDept();
+ d.setParentId(pid);
+ d.setDeptName(trimName(req.getDeptName()));
+ d.setStatus(normalizeStatus(req.getStatus()));
+ d.setSortOrder(0);
+ sysDeptMapper.insert(d);
+ SysDept fresh = sysDeptMapper.selectById(d.getId());
+ if (fresh == null || !StringUtils.hasText(fresh.getUuid())) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "创建后读取部门失败");
+ }
+ Map<String, Object> m = new HashMap<>();
+ m.put("id", fresh.getId());
+ m.put("uuid", fresh.getUuid());
+ return m;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void update(Long id, DeptUpdateRequest req) {
+ SysDept d = loadActiveWithUuid(id, req.getUuid());
+ d.setDeptName(trimName(req.getDeptName()));
+ d.setStatus(normalizeStatus(req.getStatus()));
+
+ Long oldParent = d.getParentId() == null ? 0L : d.getParentId();
+ if (oldParent == 0L) {
+ sysDeptMapper.updateById(d);
+ return;
+ }
+ long newPid = req.getParentId() != null ? req.getParentId() : oldParent;
+ if (newPid == 0L) {
+ throw new BizException(BizErrorCode.DEPT_PARENT_INVALID, "非一级部门不能改为无上级");
+ }
+ if (newPid != oldParent) {
+ validateReparent(id, newPid);
+ d.setParentId(newPid);
+ }
+ sysDeptMapper.updateById(d);
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void delete(Long id, String uuid) {
+ SysDept d = loadActiveWithUuid(id, uuid);
+ long childCnt =
+ sysDeptMapper.selectCount(
+ new LambdaQueryWrapper<SysDept>()
+ .eq(SysDept::getParentId, id)
+ .eq(SysDept::getDelFlag, 0));
+ if (childCnt > 0) {
+ throw new BizException(BizErrorCode.DEPT_HAS_CHILDREN, "存在下级部门,无法删除");
+ }
+ int affected = sysDeptMapper.deleteById(d.getId());
+ if (affected <= 0) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "删除失败,请刷新后重试");
+ }
+ }
+
+ private void validateReparent(long deptId, long newParentId) {
+ if (newParentId == deptId) {
+ throw new BizException(BizErrorCode.DEPT_REORDER_CYCLE, "上级部门不能为自身");
+ }
+ loadActive(newParentId);
+ if (isUnderAncestor(newParentId, deptId)) {
+ throw new BizException(BizErrorCode.DEPT_REORDER_CYCLE, "上级部门不能为当前部门的下级");
+ }
+ Map<Long, List<SysDept>> byParent = buildChildrenMap();
+ int subtreeH = subtreeHeight(deptId, byParent);
+ int newParentDepth = depthOf(newParentId);
+ if (newParentDepth + subtreeH > MAX_LEVEL) {
+ throw new BizException(BizErrorCode.DEPT_MAX_DEPTH, "调整上级后将超过 " + MAX_LEVEL + " 级限制");
+ }
+ }
+
+ private boolean isUnderAncestor(long nodeId, long ancestorId) {
+ Long cur = nodeId;
+ Set<Long> guard = new HashSet<>();
+ while (cur != null && cur != 0L) {
+ if (Objects.equals(cur, ancestorId)) {
+ return true;
+ }
+ if (!guard.add(cur)) {
+ break;
+ }
+ SysDept n = sysDeptMapper.selectById(cur);
+ if (n == null || n.getDelFlag() != null && n.getDelFlag() == 1) {
+ break;
+ }
+ Long p = n.getParentId();
+ if (p == null || p == 0L) {
+ break;
+ }
+ cur = p;
+ }
+ return false;
+ }
+
+ private int subtreeHeight(long id, Map<Long, List<SysDept>> byParent) {
+ List<SysDept> ch = byParent.getOrDefault(id, List.of());
+ int max = 1;
+ for (SysDept c : ch) {
+ if (c.getDelFlag() != null && c.getDelFlag() == 1) {
+ continue;
+ }
+ max = Math.max(max, 1 + subtreeHeight(c.getId(), byParent));
+ }
+ return max;
+ }
+
+ private Map<Long, List<SysDept>> buildChildrenMap() {
+ List<SysDept> all =
+ sysDeptMapper.selectList(
+ new LambdaQueryWrapper<SysDept>().eq(SysDept::getDelFlag, 0));
+ Map<Long, List<SysDept>> m = new HashMap<>();
+ for (SysDept d : all) {
+ m.computeIfAbsent(d.getParentId() == null ? 0L : d.getParentId(), k -> new ArrayList<>())
+ .add(d);
+ }
+ return m;
+ }
+
+ /** 节点到根的距离:根为 1 */
+ private int depthOf(long id) {
+ int d = 1;
+ Long cur = id;
+ Set<Long> guard = new HashSet<>();
+ while (cur != null && cur != 0L) {
+ SysDept n = sysDeptMapper.selectById(cur);
+ if (n == null || n.getDelFlag() != null && n.getDelFlag() == 1) {
+ break;
+ }
+ Long p = n.getParentId();
+ if (p == null || p == 0L) {
+ break;
+ }
+ d++;
+ if (!guard.add(cur)) {
+ break;
+ }
+ cur = p;
+ }
+ return d;
+ }
+
+ private void sortChildrenRecursive(List<DeptTreeNodeVo> nodes) {
+ nodes.sort(
+ Comparator.comparing(DeptTreeNodeVo::getSortOrder, Comparator.nullsLast(Comparator.naturalOrder()))
+ .thenComparing(DeptTreeNodeVo::getId));
+ for (DeptTreeNodeVo n : nodes) {
+ sortChildrenRecursive(n.getChildren());
+ }
+ }
+
+ private void assignLevel(DeptTreeNodeVo n, int level) {
+ n.setLevel(level);
+ for (DeptTreeNodeVo c : n.getChildren()) {
+ assignLevel(c, level + 1);
+ }
+ }
+
+ private DeptTreeNodeVo toVo(SysDept d) {
+ DeptTreeNodeVo v = new DeptTreeNodeVo();
+ v.setId(d.getId());
+ v.setUuid(d.getUuid());
+ v.setParentId(d.getParentId());
+ v.setDeptName(d.getDeptName());
+ v.setSortOrder(d.getSortOrder());
+ v.setStatus(d.getStatus() == null ? 1 : d.getStatus());
+ return v;
+ }
+
+ private SysDept loadActive(long id) {
+ SysDept d = sysDeptMapper.selectById(id);
+ if (d == null || d.getDelFlag() != null && d.getDelFlag() == 1) {
+ throw new BizException(BizErrorCode.DEPT_PARENT_INVALID, "上级部门不存在");
+ }
+ return d;
+ }
+
+ private SysDept loadActiveWithUuid(long id, String uuid) {
+ SysDept d = sysDeptMapper.selectById(id);
+ if (d == null || d.getDelFlag() != null && d.getDelFlag() == 1) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "资源不存在或无权访问");
+ }
+ UuidAssert.match(d, uuid);
+ return d;
+ }
+
+ private static int normalizeStatus(Integer s) {
+ if (s == null || (s != 0 && s != 1)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "部门状态无效");
+ }
+ return s;
+ }
+
+ private static String trimName(String name) {
+ if (!StringUtils.hasText(name)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "部门名称不能为空");
+ }
+ return name.trim();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/ExportApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/ExportApplicationService.java
new file mode 100644
index 0000000..2d0f612
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/ExportApplicationService.java
@@ -0,0 +1,35 @@
+package com.sinata.appointment.service;
+
+import com.sinata.appointment.constants.IntegrationConstant;
+import java.util.Map;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.amqp.rabbit.core.RabbitTemplate;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.stereotype.Service;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ExportApplicationService {
+
+ private final ObjectProvider<RabbitTemplate> rabbitTemplateProvider;
+
+ public String enqueueExport(String exportType, Map<String, Object> criteria) {
+ String taskId = UUID.randomUUID().toString().replace("-", "");
+ RabbitTemplate rabbitTemplate = rabbitTemplateProvider.getIfAvailable();
+ if (rabbitTemplate == null) {
+ log.warn(
+ "RabbitMQ not configured, export task accepted locally only taskId={} type={}",
+ taskId,
+ exportType);
+ return taskId;
+ }
+ log.info("Enqueue export taskId={} type={}", taskId, exportType);
+ rabbitTemplate.convertAndSend(
+ IntegrationConstant.EXPORT_TASK_QUEUE,
+ Map.of("taskId", taskId, "type", exportType, "criteria", criteria));
+ return taskId;
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/IntegrationHikvisionService.java b/backend/src/main/java/com/sinata/appointment/service/IntegrationHikvisionService.java
new file mode 100644
index 0000000..39f7c84
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/IntegrationHikvisionService.java
@@ -0,0 +1,156 @@
+package com.sinata.appointment.service;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sinata.appointment.constants.AppointmentBizConstants;
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.entity.AppointmentVisitor;
+import com.sinata.appointment.entity.MpUser;
+import com.sinata.appointment.entity.MpVisitor;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+/**
+ * 海康出码适配:当前按 PDF 示例组装二维码 payload 字符串。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class IntegrationHikvisionService {
+
+ private static final String VISITOR_ID_CARD_TYPE = "111";
+ private static final String VALID_TIME_PREFIX = "m";
+ private static final String EMPTY = "";
+ private static final DateTimeFormatter QR_TIME_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+ private static final DateTimeFormatter QR_DATE_FORMATTER =
+ DateTimeFormatter.ofPattern("yyMMddHHmmss.SSS");
+
+ private final ObjectMapper objectMapper;
+
+ public String requestQrcodePayload(
+ AppointmentOrder order,
+ AppointmentVisitor visitor,
+ MpVisitor visitorProfile,
+ MpUser visitedUser,
+ String visitPurpose,
+ String areas,
+ LocalDateTime qrGenerateTime,
+ LocalDateTime qrExpireTime,
+ int validMinutes,
+ String codeId,
+ String visitorIdCardPlain) {
+ LocalDateTime startTime = resolveVisitStartTime(order);
+ LocalDateTime endTime = resolveVisitEndTime(order);
+ Map<String, Object> payload = new LinkedHashMap<>();
+ payload.put("visitorName", firstNonBlank(visitor.getName(), valueOf(visitorProfile, MpVisitor::getVisitorName)));
+ payload.put("visitorIdCard", trimToEmpty(visitorIdCardPlain));
+ payload.put("visitorIdCardType", VISITOR_ID_CARD_TYPE);
+ payload.put("visitorPhone", firstNonBlank(visitor.getMobile(), valueOf(visitorProfile, MpVisitor::getVisitorMobile)));
+ payload.put("workUnit", firstNonBlank(valueOf(visitorProfile, MpVisitor::getVisitorCompany), order.getVisitorOrg()));
+ payload.put("carNumber", trimToEmpty(visitor.getPlateNo()));
+ payload.put("visitPurpose", trimToEmpty(visitPurpose));
+ payload.put("jobNo", valueOf(visitedUser, MpUser::getStaffNo));
+ payload.put("beVisitorPhone", valueOf(visitedUser, MpUser::getMobile));
+ payload.put("startTime", formatDateTime(startTime, QR_TIME_FORMATTER));
+ payload.put("endTime", formatDateTime(endTime, QR_TIME_FORMATTER));
+ payload.put("areas", trimToEmpty(areas));
+ payload.put("beVisitorName", firstNonBlank(order.getVisitedUserName(), valueOf(visitedUser, MpUser::getNickName)));
+ payload.put("date", formatDateTime(qrGenerateTime, QR_DATE_FORMATTER));
+ payload.put("validTime", VALID_TIME_PREFIX + Math.max(validMinutes, 1));
+ payload.put("codeId", trimToEmpty(codeId));
+ payload.put("customa", EMPTY);
+ payload.put("customb", EMPTY);
+ payload.put("customc", EMPTY);
+ try {
+ String json = objectMapper.writeValueAsString(payload);
+ log.info(
+ "Hikvision payload prepared orderId={} mpVisitorId={} codeId={} generateTime={} expireTime={} payload={}",
+ order.getId(),
+ visitor.getMpVisitorId(),
+ codeId,
+ qrGenerateTime,
+ qrExpireTime,
+ json);
+ return json;
+ } catch (JsonProcessingException ex) {
+ throw new IllegalStateException("二维码内容生成失败", ex);
+ }
+ }
+
+ private LocalDateTime resolveVisitStartTime(AppointmentOrder order) {
+ LocalDate visitDate = firstNonNull(order.getVisitStartDate(), order.getVisitEndDate(), LocalDate.now());
+ if (!AppointmentBizConstants.TIME_MODE_MULTI.equalsIgnoreCase(trimToEmpty(order.getVisitTimeMode()))) {
+ String slot = trimToEmpty(order.getVisitTimeSlot()).toUpperCase();
+ if (AppointmentBizConstants.TIME_SLOT_PM.equals(slot)) {
+ return LocalDateTime.of(visitDate, LocalTime.of(12, 0));
+ }
+ }
+ return LocalDateTime.of(visitDate, LocalTime.MIN);
+ }
+
+ private LocalDateTime resolveVisitEndTime(AppointmentOrder order) {
+ LocalDate visitDate = firstNonNull(order.getVisitEndDate(), order.getVisitStartDate(), LocalDate.now());
+ if (!AppointmentBizConstants.TIME_MODE_MULTI.equalsIgnoreCase(trimToEmpty(order.getVisitTimeMode()))) {
+ String slot = trimToEmpty(order.getVisitTimeSlot()).toUpperCase();
+ if (AppointmentBizConstants.TIME_SLOT_AM.equals(slot)) {
+ return LocalDateTime.of(visitDate, LocalTime.of(11, 59, 59));
+ }
+ }
+ return LocalDateTime.of(visitDate, LocalTime.of(23, 59, 59));
+ }
+
+ private String formatDateTime(LocalDateTime value, DateTimeFormatter formatter) {
+ return value == null ? EMPTY : value.format(formatter);
+ }
+
+ private String trimToEmpty(String value) {
+ return value == null ? EMPTY : value.trim();
+ }
+
+ private String firstNonBlank(String... values) {
+ if (values == null) {
+ return EMPTY;
+ }
+ for (String value : values) {
+ if (StringUtils.hasText(value)) {
+ return value.trim();
+ }
+ }
+ return EMPTY;
+ }
+
+ @SafeVarargs
+ private final <T> T firstNonNull(T... values) {
+ if (values == null) {
+ return null;
+ }
+ for (T value : values) {
+ if (value != null) {
+ return value;
+ }
+ }
+ return null;
+ }
+
+ private <T> String valueOf(T source, StringGetter<T> getter) {
+ if (source == null) {
+ return EMPTY;
+ }
+ String value = getter.get(source);
+ return StringUtils.hasText(value) ? value.trim() : EMPTY;
+ }
+
+ @FunctionalInterface
+ private interface StringGetter<T> {
+ String get(T source);
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/IntegrationWechatService.java b/backend/src/main/java/com/sinata/appointment/service/IntegrationWechatService.java
new file mode 100644
index 0000000..5b0768c
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/IntegrationWechatService.java
@@ -0,0 +1,22 @@
+package com.sinata.appointment.service;
+
+import com.sinata.appointment.constants.AppointmentOrderStatus;
+import com.sinata.appointment.entity.AppointmentOrder;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+/**
+ * 微信公众号模板消息适配桩。
+ */
+@Slf4j
+@Service
+public class IntegrationWechatService {
+
+ public void notifyApprovalFinalResult(AppointmentOrder order, boolean approved) {
+ log.info(
+ "WeChat stub: notify submitterId={} orderNo={} result={}",
+ order.getSubmitterId(),
+ order.getOrderNo(),
+ approved ? AppointmentOrderStatus.APPROVED : AppointmentOrderStatus.REJECTED);
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/LocalUnitManageApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/LocalUnitManageApplicationService.java
new file mode 100644
index 0000000..390fd75
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/LocalUnitManageApplicationService.java
@@ -0,0 +1,419 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.constants.LocalUnitApproverType;
+import com.sinata.appointment.constants.MiniUserType;
+import com.sinata.appointment.dto.LocalUnitCreateRequest;
+import com.sinata.appointment.dto.LocalUnitDetailVo;
+import com.sinata.appointment.dto.LocalUnitListVo;
+import com.sinata.appointment.dto.LocalUnitMemberItemVo;
+import com.sinata.appointment.dto.LocalUnitUpdateRequest;
+import com.sinata.appointment.entity.MpPassLocalUnit;
+import com.sinata.appointment.entity.MpPassLocalUnitMember;
+import com.sinata.appointment.entity.MpStaffGroup;
+import com.sinata.appointment.entity.MpUser;
+import com.sinata.appointment.entity.SysDept;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.mapper.MpPassLocalUnitMapper;
+import com.sinata.appointment.mapper.MpPassLocalUnitMemberMapper;
+import com.sinata.appointment.mapper.MpStaffGroupMapper;
+import com.sinata.appointment.mapper.MpUserMapper;
+import com.sinata.appointment.mapper.SysDeptMapper;
+import com.sinata.appointment.util.UuidAssert;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+@Service
+public class LocalUnitManageApplicationService {
+
+ private final MpPassLocalUnitMapper mpPassLocalUnitMapper;
+ private final MpPassLocalUnitMemberMapper mpPassLocalUnitMemberMapper;
+ private final ObjectMapper objectMapper;
+ private final SysDeptMapper sysDeptMapper;
+ private final MpStaffGroupMapper mpStaffGroupMapper;
+ private final MpUserMapper mpUserMapper;
+
+ public LocalUnitManageApplicationService(
+ MpPassLocalUnitMapper mpPassLocalUnitMapper,
+ MpPassLocalUnitMemberMapper mpPassLocalUnitMemberMapper,
+ ObjectMapper objectMapper,
+ SysDeptMapper sysDeptMapper,
+ MpStaffGroupMapper mpStaffGroupMapper,
+ MpUserMapper mpUserMapper) {
+ this.mpPassLocalUnitMapper = mpPassLocalUnitMapper;
+ this.mpPassLocalUnitMemberMapper = mpPassLocalUnitMemberMapper;
+ this.objectMapper = objectMapper;
+ this.sysDeptMapper = sysDeptMapper;
+ this.mpStaffGroupMapper = mpStaffGroupMapper;
+ this.mpUserMapper = mpUserMapper;
+ }
+
+ public IPage<LocalUnitListVo> page(long page, long size, String unitName) {
+ LambdaQueryWrapper<MpPassLocalUnit> q =
+ new LambdaQueryWrapper<MpPassLocalUnit>()
+ .eq(MpPassLocalUnit::getDelFlag, 0)
+ .like(StringUtils.hasText(unitName), MpPassLocalUnit::getUnitName, trim(unitName))
+ .orderByDesc(MpPassLocalUnit::getCreateTime);
+ Page<MpPassLocalUnit> pg = mpPassLocalUnitMapper.selectPage(new Page<>(page, size), q);
+ return pg.convert(this::toListVo);
+ }
+
+ public LocalUnitDetailVo detail(Long id, String uuid) {
+ MpPassLocalUnit e = loadActive(id, uuid);
+ LocalUnitDetailVo vo = new LocalUnitDetailVo();
+ vo.setId(e.getId());
+ vo.setUuid(e.getUuid());
+ vo.setUnitName(e.getUnitName());
+ vo.setRemark(e.getRemark());
+ vo.setApproverType(e.getApproverType());
+ vo.setApproverGroupId(e.getApproverGroupId());
+ vo.setApproverDeptIds(readDeptIds(e.getApproverDeptIds()));
+ if (e.getApproverType() != null && e.getApproverType() == LocalUnitApproverType.STAFF) {
+ vo.setMembers(loadMemberItems(e.getId()));
+ }
+ return vo;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public Map<String, Object> create(LocalUnitCreateRequest req) {
+ validateApprover(req.getApproverType(), req.getApproverDeptIds(), req.getApproverGroupId(), req.getMemberIds());
+ MpPassLocalUnit e = new MpPassLocalUnit();
+ e.setUnitName(req.getUnitName().trim());
+ e.setRemark(trimToNull(req.getRemark()));
+ e.setApproverType(req.getApproverType());
+ applyApproverColumns(e, req.getApproverType(), req.getApproverDeptIds(), req.getApproverGroupId());
+ mpPassLocalUnitMapper.insert(e);
+ MpPassLocalUnit fresh = mpPassLocalUnitMapper.selectById(e.getId());
+ if (fresh == null || !StringUtils.hasText(fresh.getUuid())) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "创建后读取失败");
+ }
+ if (req.getApproverType() == LocalUnitApproverType.STAFF) {
+ saveMembers(fresh.getId(), req.getMemberIds());
+ }
+ return Map.of("id", fresh.getId(), "uuid", fresh.getUuid());
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void update(Long id, LocalUnitUpdateRequest req) {
+ MpPassLocalUnit e = loadActive(id, req.getUuid());
+ validateApprover(req.getApproverType(), req.getApproverDeptIds(), req.getApproverGroupId(), req.getMemberIds());
+ mpPassLocalUnitMemberMapper.deletePhysicalByUnitId(e.getId());
+ e.setUnitName(req.getUnitName().trim());
+ e.setRemark(trimToNull(req.getRemark()));
+ e.setApproverType(req.getApproverType());
+ applyApproverColumns(e, req.getApproverType(), req.getApproverDeptIds(), req.getApproverGroupId());
+ mpPassLocalUnitMapper.updateById(e);
+ if (req.getApproverType() == LocalUnitApproverType.STAFF) {
+ saveMembers(e.getId(), req.getMemberIds());
+ }
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void delete(Long id, String uuid) {
+ MpPassLocalUnit e = loadActive(id, uuid);
+ mpPassLocalUnitMemberMapper.deletePhysicalByUnitId(e.getId());
+ int affected = mpPassLocalUnitMapper.deleteById(e.getId());
+ if (affected <= 0) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "删除失败,请刷新后重试");
+ }
+ }
+
+ private void applyApproverColumns(
+ MpPassLocalUnit e, int type, List<Long> deptIds, Long groupId) {
+ e.setApproverDeptIds(null);
+ e.setApproverGroupId(null);
+ if (type == LocalUnitApproverType.DEPT) {
+ e.setApproverDeptIds(writeDeptIds(deptIds));
+ } else if (type == LocalUnitApproverType.STAFF_GROUP) {
+ e.setApproverGroupId(groupId);
+ }
+ }
+
+ private void validateApprover(int type, List<Long> deptIds, Long groupId, List<Long> memberIds) {
+ if (type == LocalUnitApproverType.DEPT) {
+ if (deptIds == null || deptIds.isEmpty()) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "请选择审批部门");
+ }
+ for (Long id : new LinkedHashSet<>(deptIds)) {
+ if (id == null || id <= 0) {
+ continue;
+ }
+ validateDeptExists(id);
+ }
+ } else if (type == LocalUnitApproverType.STAFF_GROUP) {
+ if (groupId == null || groupId <= 0) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "请选择人员分组");
+ }
+ MpStaffGroup g = mpStaffGroupMapper.selectById(groupId);
+ if (g == null || (g.getDelFlag() != null && g.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "人员分组不存在");
+ }
+ } else if (type == LocalUnitApproverType.STAFF) {
+ if (memberIds == null || memberIds.isEmpty()) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "请选择审批人员");
+ }
+ validateEmployees(new ArrayList<>(new LinkedHashSet<>(memberIds)));
+ } else {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "审批人员类型无效");
+ }
+ }
+
+ private void validateDeptExists(Long deptId) {
+ SysDept d = sysDeptMapper.selectById(deptId);
+ if (d == null || (d.getDelFlag() != null && d.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.DEPT_PARENT_INVALID, "部门不存在: " + deptId);
+ }
+ }
+
+ private void validateEmployees(List<Long> mpUserIds) {
+ for (Long id : mpUserIds) {
+ if (id == null || id <= 0) {
+ continue;
+ }
+ MpUser u = mpUserMapper.selectById(id);
+ if (u == null || (u.getDelFlag() != null && u.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "人员不存在: " + id);
+ }
+ if (!MiniUserType.EMPLOYEE.equals(u.getUserType())) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "仅可选择员工: " + id);
+ }
+ if (u.getStatus() != null && u.getStatus() == 0) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "员工已禁用: " + id);
+ }
+ }
+ }
+
+ private void saveMembers(Long unitId, List<Long> memberIds) {
+ if (memberIds == null) {
+ return;
+ }
+ for (Long mid : new LinkedHashSet<>(memberIds)) {
+ if (mid == null || mid <= 0) {
+ continue;
+ }
+ MpPassLocalUnitMember m = new MpPassLocalUnitMember();
+ m.setUnitId(unitId);
+ m.setMpUserId(mid);
+ mpPassLocalUnitMemberMapper.insert(m);
+ }
+ }
+
+ private List<LocalUnitMemberItemVo> loadMemberItems(Long unitId) {
+ List<MpPassLocalUnitMember> rels =
+ mpPassLocalUnitMemberMapper.selectList(
+ new LambdaQueryWrapper<MpPassLocalUnitMember>()
+ .eq(MpPassLocalUnitMember::getUnitId, unitId)
+ .eq(MpPassLocalUnitMember::getDelFlag, 0));
+ if (rels.isEmpty()) {
+ return List.of();
+ }
+ Set<Long> userIds = new HashSet<>();
+ for (MpPassLocalUnitMember r : rels) {
+ userIds.add(r.getMpUserId());
+ }
+ Map<Long, MpUser> userMap = new HashMap<>();
+ if (!userIds.isEmpty()) {
+ List<MpUser> users =
+ mpUserMapper.selectList(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getDelFlag, 0)
+ .in(MpUser::getId, userIds));
+ for (MpUser u : users) {
+ userMap.put(u.getId(), u);
+ }
+ }
+ Set<Long> deptIds = new HashSet<>();
+ for (MpUser u : userMap.values()) {
+ if (u.getDeptId() != null) {
+ deptIds.add(u.getDeptId());
+ }
+ }
+ Map<Long, String> deptNames = new HashMap<>();
+ if (!deptIds.isEmpty()) {
+ List<SysDept> depts =
+ sysDeptMapper.selectList(
+ new LambdaQueryWrapper<SysDept>()
+ .eq(SysDept::getDelFlag, 0)
+ .in(SysDept::getId, deptIds));
+ for (SysDept d : depts) {
+ deptNames.put(d.getId(), d.getDeptName());
+ }
+ }
+ List<LocalUnitMemberItemVo> out = new ArrayList<>();
+ for (MpPassLocalUnitMember r : rels) {
+ MpUser u = userMap.get(r.getMpUserId());
+ if (u == null) {
+ continue;
+ }
+ LocalUnitMemberItemVo it = new LocalUnitMemberItemVo();
+ it.setMpUserId(u.getId());
+ it.setUuid(u.getUuid());
+ it.setRealName(u.getNickName());
+ it.setDeptName(
+ u.getDeptId() == null ? "—" : deptNames.getOrDefault(u.getDeptId(), "—"));
+ out.add(it);
+ }
+ return out;
+ }
+
+ private LocalUnitListVo toListVo(MpPassLocalUnit e) {
+ LocalUnitListVo vo = new LocalUnitListVo();
+ vo.setId(e.getId());
+ vo.setUuid(e.getUuid());
+ vo.setUnitName(e.getUnitName());
+ vo.setRemark(e.getRemark());
+ vo.setApproverSummary(buildApproverSummary(e));
+ vo.setCreateTime(e.getCreateTime());
+ return vo;
+ }
+
+ private String buildApproverSummary(MpPassLocalUnit e) {
+ Integer t = e.getApproverType();
+ if (t == null) {
+ return "—";
+ }
+ if (t == LocalUnitApproverType.DEPT) {
+ return buildDeptApproverDisplay(readDeptIds(e.getApproverDeptIds()));
+ }
+ if (t == LocalUnitApproverType.STAFF_GROUP && e.getApproverGroupId() != null) {
+ MpStaffGroup g = mpStaffGroupMapper.selectById(e.getApproverGroupId());
+ String n = (g != null && StringUtils.hasText(g.getGroupName())) ? g.getGroupName() : "—";
+ return "人员分组:" + n;
+ }
+ if (t == LocalUnitApproverType.STAFF) {
+ return buildStaffApproverDisplay(e.getId());
+ }
+ return "—";
+ }
+
+ /** 列表审批摘要:部门类型展示部门名称(按配置顺序,顿号分隔) */
+ private String buildDeptApproverDisplay(List<Long> rawIds) {
+ List<Long> ids = new ArrayList<>(new LinkedHashSet<>(rawIds));
+ ids.removeIf(x -> x == null || x <= 0);
+ if (ids.isEmpty()) {
+ return "—";
+ }
+ List<SysDept> depts =
+ sysDeptMapper.selectList(
+ new LambdaQueryWrapper<SysDept>()
+ .eq(SysDept::getDelFlag, 0)
+ .in(SysDept::getId, ids));
+ Map<Long, String> nameById = new HashMap<>();
+ for (SysDept d : depts) {
+ if (d.getId() != null && StringUtils.hasText(d.getDeptName())) {
+ nameById.put(d.getId(), d.getDeptName());
+ }
+ }
+ List<String> parts = new ArrayList<>();
+ for (Long id : ids) {
+ parts.add(nameById.getOrDefault(id, "未知部门"));
+ }
+ return String.join("、", parts);
+ }
+
+ /** 列表审批摘要:人员类型展示员工姓名(关联表顺序,顿号分隔) */
+ private String buildStaffApproverDisplay(Long unitId) {
+ List<MpPassLocalUnitMember> rels =
+ mpPassLocalUnitMemberMapper.selectList(
+ new LambdaQueryWrapper<MpPassLocalUnitMember>()
+ .eq(MpPassLocalUnitMember::getUnitId, unitId)
+ .eq(MpPassLocalUnitMember::getDelFlag, 0));
+ if (rels.isEmpty()) {
+ return "—";
+ }
+ LinkedHashSet<Long> userIds = new LinkedHashSet<>();
+ for (MpPassLocalUnitMember r : rels) {
+ if (r.getMpUserId() != null && r.getMpUserId() > 0) {
+ userIds.add(r.getMpUserId());
+ }
+ }
+ if (userIds.isEmpty()) {
+ return "—";
+ }
+ List<MpUser> users =
+ mpUserMapper.selectList(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getDelFlag, 0)
+ .in(MpUser::getId, userIds));
+ Map<Long, String> nameById = new HashMap<>();
+ for (MpUser u : users) {
+ String label;
+ if (StringUtils.hasText(u.getNickName())) {
+ label = u.getNickName().trim();
+ } else if (StringUtils.hasText(u.getStaffNo())) {
+ label = u.getStaffNo().trim();
+ } else {
+ label = "—";
+ }
+ nameById.put(u.getId(), label);
+ }
+ List<String> parts = new ArrayList<>();
+ for (Long uid : userIds) {
+ parts.add(nameById.getOrDefault(uid, "未知人员"));
+ }
+ return String.join("、", parts);
+ }
+
+ private List<Long> readDeptIds(String json) {
+ if (!StringUtils.hasText(json)) {
+ return List.of();
+ }
+ try {
+ List<Long> list = objectMapper.readValue(json, new TypeReference<List<Long>>() {});
+ return list == null ? List.of() : list;
+ } catch (Exception ignored) {
+ return List.of();
+ }
+ }
+
+ private String writeDeptIds(List<Long> ids) {
+ if (ids == null || ids.isEmpty()) {
+ return null;
+ }
+ List<Long> distinct = new ArrayList<>(new LinkedHashSet<>(ids));
+ distinct.removeIf(x -> x == null || x <= 0);
+ if (distinct.isEmpty()) {
+ return null;
+ }
+ try {
+ return objectMapper.writeValueAsString(distinct);
+ } catch (JsonProcessingException ex) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "部门数据序列化失败");
+ }
+ }
+
+ private MpPassLocalUnit loadActive(Long id, String uuid) {
+ MpPassLocalUnit e = mpPassLocalUnitMapper.selectById(id);
+ if (e == null || (e.getDelFlag() != null && e.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "资源不存在或无权访问");
+ }
+ UuidAssert.match(e, uuid);
+ return e;
+ }
+
+ private static String trim(String s) {
+ return s == null ? null : s.trim();
+ }
+
+ private static String trimToNull(String s) {
+ if (!StringUtils.hasText(s)) {
+ return null;
+ }
+ String t = s.trim();
+ return t.isEmpty() ? null : t;
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/MiniAppointmentApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/MiniAppointmentApplicationService.java
new file mode 100644
index 0000000..4cf442b
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/MiniAppointmentApplicationService.java
@@ -0,0 +1,1260 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sinata.appointment.constants.AppointmentOrderStatus;
+import com.sinata.appointment.constants.AppointmentBizConstants;
+import com.sinata.appointment.constants.AppointmentFieldCodes;
+import com.sinata.appointment.constants.ApprovalInstanceStatus;
+import com.sinata.appointment.constants.ApprovalTaskStatus;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.constants.QrcodeTicketStatus;
+import com.sinata.appointment.constants.SystemSettingKeys;
+import com.sinata.appointment.dto.MiniAppointmentCreateRequest;
+import com.sinata.appointment.dto.MiniAppointmentListVo;
+import com.sinata.appointment.dto.MiniAppointmentVehicleItemRequest;
+import com.sinata.appointment.dto.MiniApprovalDetailVisitorVo;
+import com.sinata.appointment.dto.MiniMyAppointmentDetailVo;
+import com.sinata.appointment.dto.MiniMyVisitorCodeOrderVo;
+import com.sinata.appointment.dto.MiniMyVisitorCodeTicketVo;
+import com.sinata.appointment.dto.MiniVisitRecordListVo;
+import com.sinata.appointment.dto.ApprovalFlowNodeVo;
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.entity.ApprovalInstance;
+import com.sinata.appointment.entity.ApprovalTask;
+import com.sinata.appointment.entity.AppointmentVehicle;
+import com.sinata.appointment.entity.AppointmentVisitor;
+import com.sinata.appointment.entity.BizFieldConfig;
+import com.sinata.appointment.entity.BizVisitReason;
+import com.sinata.appointment.entity.MpUser;
+import com.sinata.appointment.entity.MpVisitor;
+import com.sinata.appointment.entity.QrcodeTicket;
+import com.sinata.appointment.entity.SysDept;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.mapper.AppointmentOrderMapper;
+import com.sinata.appointment.mapper.ApprovalInstanceMapper;
+import com.sinata.appointment.mapper.ApprovalTaskMapper;
+import com.sinata.appointment.mapper.AppointmentVehicleMapper;
+import com.sinata.appointment.mapper.AppointmentVisitorMapper;
+import com.sinata.appointment.mapper.BizFieldConfigMapper;
+import com.sinata.appointment.mapper.BizVisitReasonMapper;
+import com.sinata.appointment.mapper.MpUserMapper;
+import com.sinata.appointment.mapper.MpVisitorMapper;
+import com.sinata.appointment.mapper.QrcodeTicketMapper;
+import com.sinata.appointment.mapper.SysDeptMapper;
+import com.sinata.appointment.util.OrderNoGenerator;
+import com.sinata.appointment.util.MobileMaskUtil;
+import com.sinata.appointment.util.SensitiveDataUtil;
+import com.sinata.appointment.util.UuidAssert;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+@Service
+@RequiredArgsConstructor
+public class MiniAppointmentApplicationService {
+
+ private static final String CANCEL_REASON_BY_SUBMITTER = "预约人取消";
+ private static final String DISPLAY_STATUS_PENDING_VISIT = "PENDING_VISIT";
+ private static final String DISPLAY_STATUS_EFFECTIVE = "EFFECTIVE";
+ private static final String DISPLAY_STATUS_INVALID = "INVALID";
+ private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
+
+ private final MiniAuthApplicationService miniAuthApplicationService;
+ private final MpUserMapper mpUserMapper;
+ private final MpVisitorMapper mpVisitorMapper;
+ private final BizVisitReasonMapper bizVisitReasonMapper;
+ private final AppointmentOrderMapper appointmentOrderMapper;
+ private final ApprovalTaskMapper approvalTaskMapper;
+ private final ApprovalInstanceMapper approvalInstanceMapper;
+ private final AppointmentVisitorMapper appointmentVisitorMapper;
+ private final AppointmentVehicleMapper appointmentVehicleMapper;
+ private final QrcodeTicketMapper qrcodeTicketMapper;
+ private final BizFieldConfigMapper bizFieldConfigMapper;
+ private final SysDeptMapper sysDeptMapper;
+ private final BizHolidayDateCacheService bizHolidayDateCacheService;
+ private final SysBizSettingCacheService sysBizSettingCacheService;
+ private final ApprovalEngineService approvalEngineService;
+ private final PostApprovalOrchestrationService postApprovalOrchestrationService;
+ private final ApprovalFlowViewApplicationService approvalFlowViewApplicationService;
+ private final ObjectMapper objectMapper;
+
+ @Transactional(rollbackFor = Exception.class)
+ public Long create(MiniAppointmentCreateRequest req) {
+ MpUser current = miniAuthApplicationService.currentMpUser();
+ FieldRequiredContext required = loadFieldRequiredContext();
+ MpUser visited = loadVisitedUser(req.getVisitedUserId());
+ validateByFieldConfig(req, required);
+ validateVisitReason(req.getVisitReasonCode(), req.getVisitReasonOther());
+
+ String mode = normalizeMode(req.getVisitTimeMode());
+ validateAgainstSystemRangeMode(mode);
+
+ DateRange range = resolveDateRange(req, mode);
+ boolean holidayFlag = calcHolidayFlag(range.start(), range.end());
+ boolean personEntry = req.getPersonEntry() == null || Boolean.TRUE.equals(req.getPersonEntry());
+ boolean vehicleEntry = Boolean.TRUE.equals(req.getVehicleEntry());
+
+ Set<Long> personIds = new LinkedHashSet<>();
+ if (personEntry && req.getVisitorIds() != null) {
+ personIds.addAll(req.getVisitorIds().stream().filter(Objects::nonNull).toList());
+ }
+
+ List<MiniAppointmentVehicleItemRequest> vehicles = vehicleEntry ? normalizeVehicles(req.getVehicles()) : List.of();
+ for (MiniAppointmentVehicleItemRequest v : vehicles) {
+ personIds.add(v.getDriverVisitorId());
+ }
+
+ if (personIds.isEmpty()) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "至少选择一名进场人员");
+ }
+
+ Map<Long, MpVisitor> visitorMap = loadCurrentUserVisitors(current.getId(), personIds);
+ validateVisitorDataByFieldConfig(visitorMap.values(), required);
+
+ AppointmentOrder order = new AppointmentOrder();
+ order.setOrderNo(OrderNoGenerator.next());
+ order.setVisitType(
+ vehicleEntry
+ ? AppointmentBizConstants.VISIT_TYPE_VEHICLE
+ : AppointmentBizConstants.VISIT_TYPE_VISITOR);
+ order.setVisitedUserId(visited.getId());
+ order.setVisitedUserName(StringUtils.hasText(visited.getNickName()) ? visited.getNickName() : "员工");
+ order.setVisitedDeptId(visited.getDeptId());
+ order.setVisitReasonCode(req.getVisitReasonCode().trim());
+ order.setVisitReasonOther(trimToNull(req.getVisitReasonOther()));
+ order.setVisitorOrg(resolveVisitorOrg(visitorMap.values()));
+ order.setVisitTimeMode(mode);
+ order.setVisitTimeSlot(
+ AppointmentBizConstants.TIME_MODE_SINGLE.equals(mode)
+ ? normalizeSlot(req.getVisitTimeSlot())
+ : null);
+ order.setVisitStartDate(range.start());
+ order.setVisitEndDate(range.end());
+ order.setPersonEntryFlag(personEntry);
+ order.setVehicleEntryFlag(vehicleEntry);
+ order.setSubmitterId(current.getId());
+ order.setHolidayFlag(holidayFlag);
+ order.setStatus(AppointmentOrderStatus.PENDING_APPROVAL);
+ appointmentOrderMapper.insert(order);
+
+ for (Long visitorId : personIds) {
+ MpVisitor v = visitorMap.get(visitorId);
+ AppointmentVisitor av = new AppointmentVisitor();
+ av.setOrderId(order.getId());
+ av.setMpVisitorId(v.getId());
+ av.setName(v.getVisitorName());
+ av.setIdCardCipher(v.getIdCardCipher());
+ av.setMobile(v.getVisitorMobile());
+ appointmentVisitorMapper.insert(av);
+ }
+
+ for (MiniAppointmentVehicleItemRequest v : vehicles) {
+ MpVisitor driver = visitorMap.get(v.getDriverVisitorId());
+ AppointmentVehicle row = new AppointmentVehicle();
+ row.setOrderId(order.getId());
+ row.setPlateNo(v.getPlateNo().trim());
+ row.setDriverVisitorId(driver.getId());
+ row.setDriverName(driver.getVisitorName());
+ appointmentVehicleMapper.insert(row);
+ }
+
+ List<String> chain = approvalEngineService.resolveEnabledNodeChain(order);
+ if (chain.isEmpty()) {
+ postApprovalOrchestrationService.onOrderFullyApproved(appointmentOrderMapper.selectById(order.getId()));
+ } else {
+ ApprovalInstance inst = approvalEngineService.startFlow(order);
+ if (inst == null) {
+ postApprovalOrchestrationService.onOrderFullyApproved(appointmentOrderMapper.selectById(order.getId()));
+ }
+ }
+ return order.getId();
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void cancelBySubmitter(Long orderId, String uuid) {
+ if (orderId == null || orderId <= 0) {
+ throw new BizException(BizErrorCode.ORDER_NOT_FOUND, "预约单不存在");
+ }
+ MpUser current = miniAuthApplicationService.currentMpUser();
+ AppointmentOrder order = appointmentOrderMapper.selectById(orderId);
+ if (order == null || (order.getDelFlag() != null && order.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.ORDER_NOT_FOUND, "预约单不存在");
+ }
+ UuidAssert.match(order, uuid);
+ if (!Objects.equals(order.getSubmitterId(), current.getId())) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "仅预约发起人可取消");
+ }
+ if (!isCancelableStatus(order.getStatus())) {
+ throw new BizException(BizErrorCode.ORDER_STATE_INVALID, "当前状态不可取消");
+ }
+
+ ApprovalInstance latestInst = loadLatestInstanceByOrderId(order.getId());
+ if (latestInst != null
+ && ApprovalInstanceStatus.IN_PROGRESS.equals(latestInst.getStatus())) {
+ approvalEngineService.rejectInstance(latestInst, CANCEL_REASON_BY_SUBMITTER);
+ }
+
+ order.setStatus(AppointmentOrderStatus.CANCELLED);
+ order.setRejectReason(CANCEL_REASON_BY_SUBMITTER);
+ appointmentOrderMapper.updateById(order);
+ expireUnusedTicketsByOrderId(order.getId());
+ }
+
+ public IPage<MiniAppointmentListVo> pageMine(
+ long page, long size, String status, String visitedUserNameKeyword, LocalDate visitDate) {
+ MpUser current = miniAuthApplicationService.currentMpUser();
+ LambdaQueryWrapper<ApprovalTask> tq =
+ new LambdaQueryWrapper<ApprovalTask>()
+ .eq(ApprovalTask::getDelFlag, 0)
+ .eq(ApprovalTask::getAssigneeUserId, current.getId())
+ .in(
+ ApprovalTask::getTaskStatus,
+ ApprovalTaskStatus.APPROVED,
+ ApprovalTaskStatus.REJECTED)
+ .orderByDesc(ApprovalTask::getUpdateTime)
+ .orderByDesc(ApprovalTask::getId);
+ if (StringUtils.hasText(status)) {
+ tq.eq(ApprovalTask::getTaskStatus, status.trim());
+ }
+ List<ApprovalTask> tasks = approvalTaskMapper.selectList(tq);
+ if (tasks.isEmpty()) {
+ return new Page<>(page, size);
+ }
+
+ java.util.LinkedHashMap<Long, ApprovalTask> latestTaskByOrder = new java.util.LinkedHashMap<>();
+ for (ApprovalTask task : tasks) {
+ ApprovalInstance inst = approvalInstanceMapper.selectById(task.getInstanceId());
+ if (inst == null || (inst.getDelFlag() != null && inst.getDelFlag() == 1) || inst.getOrderId() == null) {
+ continue;
+ }
+ latestTaskByOrder.putIfAbsent(inst.getOrderId(), task);
+ }
+ if (latestTaskByOrder.isEmpty()) {
+ return new Page<>(page, size);
+ }
+
+ List<AppointmentOrder> orders =
+ appointmentOrderMapper.selectList(
+ new LambdaQueryWrapper<AppointmentOrder>()
+ .eq(AppointmentOrder::getDelFlag, 0)
+ .in(AppointmentOrder::getId, latestTaskByOrder.keySet()));
+ java.util.Map<Long, AppointmentOrder> orderMap =
+ orders.stream().collect(java.util.stream.Collectors.toMap(AppointmentOrder::getId, x -> x, (a, b) -> a));
+
+ List<MiniAppointmentListVo> all = new java.util.ArrayList<>();
+ for (var e : latestTaskByOrder.entrySet()) {
+ AppointmentOrder order = orderMap.get(e.getKey());
+ if (order == null) {
+ continue;
+ }
+ if (StringUtils.hasText(visitedUserNameKeyword)) {
+ if (!StringUtils.hasText(order.getVisitedUserName())
+ || !order.getVisitedUserName().contains(visitedUserNameKeyword.trim())) {
+ continue;
+ }
+ }
+ if (visitDate != null) {
+ if (order.getVisitStartDate() == null
+ || order.getVisitEndDate() == null
+ || visitDate.isBefore(order.getVisitStartDate())
+ || visitDate.isAfter(order.getVisitEndDate())) {
+ continue;
+ }
+ }
+ MiniAppointmentListVo vo = toListVo(order);
+ vo.setStatus(e.getValue().getTaskStatus());
+ all.add(vo);
+ }
+ return slicePage(page, size, all);
+ }
+
+ public IPage<MiniVisitRecordListVo> pageVisitRecords(
+ long page, long size, String visitorNameKeyword, LocalDate visitDate) {
+ MpUser current = miniAuthApplicationService.currentMpUser();
+ List<AppointmentOrder> orders =
+ appointmentOrderMapper.selectList(
+ new LambdaQueryWrapper<AppointmentOrder>()
+ .eq(AppointmentOrder::getDelFlag, 0)
+ .eq(AppointmentOrder::getStatus, AppointmentOrderStatus.APPROVED)
+ .eq(AppointmentOrder::getVisitedUserId, current.getId()));
+ if (orders.isEmpty()) {
+ return new Page<>(page, size);
+ }
+ List<Long> orderIds = orders.stream().map(AppointmentOrder::getId).filter(Objects::nonNull).toList();
+ List<AppointmentVisitor> visitors =
+ appointmentVisitorMapper.selectList(
+ new LambdaQueryWrapper<AppointmentVisitor>()
+ .eq(AppointmentVisitor::getDelFlag, 0)
+ .in(AppointmentVisitor::getOrderId, orderIds)
+ .orderByAsc(AppointmentVisitor::getId));
+ Map<Long, List<String>> visitorNamesMap = new java.util.LinkedHashMap<>();
+ for (AppointmentVisitor row : visitors) {
+ visitorNamesMap.computeIfAbsent(row.getOrderId(), k -> new java.util.ArrayList<>());
+ if (StringUtils.hasText(row.getName())) {
+ visitorNamesMap.get(row.getOrderId()).add(row.getName().trim());
+ }
+ }
+
+ List<MiniVisitRecordListVo> rows = new java.util.ArrayList<>();
+ for (AppointmentOrder order : orders) {
+ List<String> names = visitorNamesMap.getOrDefault(order.getId(), List.of());
+ if (StringUtils.hasText(visitorNameKeyword)) {
+ String kw = visitorNameKeyword.trim();
+ boolean hit = false;
+ for (String n : names) {
+ if (n.contains(kw)) {
+ hit = true;
+ break;
+ }
+ }
+ if (!hit) {
+ continue;
+ }
+ }
+ if (visitDate != null) {
+ if (order.getVisitStartDate() == null
+ || order.getVisitEndDate() == null
+ || visitDate.isBefore(order.getVisitStartDate())
+ || visitDate.isAfter(order.getVisitEndDate())) {
+ continue;
+ }
+ }
+ MiniVisitRecordListVo vo = new MiniVisitRecordListVo();
+ vo.setOrderId(order.getId());
+ vo.setOrderNo(order.getOrderNo());
+ vo.setVisitorNames(names);
+ vo.setVisitStartDate(order.getVisitStartDate());
+ vo.setVisitEndDate(order.getVisitEndDate());
+ vo.setVisitTimeMode(order.getVisitTimeMode());
+ vo.setVisitTimeSlot(order.getVisitTimeSlot());
+ vo.setCreateTime(order.getCreateTime());
+ rows.add(vo);
+ }
+
+ rows.sort(
+ java.util.Comparator
+ .comparing(
+ MiniVisitRecordListVo::getVisitEndDate,
+ java.util.Comparator.nullsLast(java.util.Comparator.naturalOrder()))
+ .reversed()
+ .thenComparing(
+ this::singleDaySlotSortWeight,
+ java.util.Comparator.reverseOrder())
+ .thenComparing(
+ MiniVisitRecordListVo::getCreateTime,
+ java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder())));
+ return slicePageVisitRecord(page, size, rows);
+ }
+
+ public IPage<MiniAppointmentListVo> pageMyOrders(
+ long page, long size, String status, String keyword) {
+ MpUser current = miniAuthApplicationService.currentMpUser();
+ List<AppointmentOrder> orders =
+ appointmentOrderMapper.selectList(
+ new LambdaQueryWrapper<AppointmentOrder>()
+ .eq(AppointmentOrder::getDelFlag, 0)
+ .eq(AppointmentOrder::getSubmitterId, current.getId())
+ .orderByDesc(AppointmentOrder::getCreateTime)
+ .orderByDesc(AppointmentOrder::getId));
+ if (orders.isEmpty()) {
+ return new Page<>(page, size);
+ }
+
+ Map<Long, String> deptNameMap = loadDeptNameMap(orders);
+ Map<String, String> reasonNameMap = loadReasonNameMap(orders);
+ String normalizedKeyword = trimToNull(keyword);
+ String normalizedStatus = trimToNull(status);
+ List<MiniAppointmentListVo> rows = new ArrayList<>();
+ for (AppointmentOrder order : orders) {
+ MiniAppointmentListVo vo = toMyOrderListVo(order, deptNameMap, reasonNameMap);
+ if (StringUtils.hasText(normalizedStatus)
+ && !Objects.equals(normalizedStatus, vo.getDisplayStatus())) {
+ continue;
+ }
+ if (StringUtils.hasText(normalizedKeyword)
+ && !containsIgnoreCase(vo.getVisitedUserDisplay(), normalizedKeyword)
+ && !containsIgnoreCase(vo.getVisitReasonText(), normalizedKeyword)) {
+ continue;
+ }
+ rows.add(vo);
+ }
+
+ rows.sort(
+ Comparator.comparing(
+ MiniAppointmentListVo::getCreateTime,
+ Comparator.nullsLast(Comparator.reverseOrder()))
+ .thenComparing(
+ MiniAppointmentListVo::getId,
+ Comparator.nullsLast(Comparator.reverseOrder())));
+ return slicePage(page, size, rows);
+ }
+
+ public List<MiniMyVisitorCodeOrderVo> listMyVisitorCodes() {
+ MpUser current = miniAuthApplicationService.currentMpUser();
+ List<AppointmentOrder> orders =
+ appointmentOrderMapper.selectList(
+ new LambdaQueryWrapper<AppointmentOrder>()
+ .eq(AppointmentOrder::getDelFlag, 0)
+ .eq(AppointmentOrder::getSubmitterId, current.getId())
+ .eq(AppointmentOrder::getStatus, AppointmentOrderStatus.APPROVED)
+ .orderByDesc(AppointmentOrder::getVisitStartDate)
+ .orderByDesc(AppointmentOrder::getId));
+ if (orders.isEmpty()) {
+ return List.of();
+ }
+
+ LocalDateTime now = LocalDateTime.now();
+ List<AppointmentOrder> eligibleOrders =
+ orders.stream()
+ .filter(order -> resolveVisitStartAt(order).isAfter(now))
+ .toList();
+ if (eligibleOrders.isEmpty()) {
+ return List.of();
+ }
+
+ List<Long> orderIds = eligibleOrders.stream().map(AppointmentOrder::getId).toList();
+ List<QrcodeTicket> tickets =
+ qrcodeTicketMapper.selectList(
+ new LambdaQueryWrapper<QrcodeTicket>()
+ .eq(QrcodeTicket::getDelFlag, 0)
+ .in(QrcodeTicket::getOrderId, orderIds)
+ .eq(QrcodeTicket::getTicketStatus, QrcodeTicketStatus.UNUSED)
+ .orderByAsc(QrcodeTicket::getOrderId)
+ .orderByAsc(QrcodeTicket::getId));
+ if (tickets.isEmpty()) {
+ return List.of();
+ }
+
+ Map<Long, List<QrcodeTicket>> ticketMap =
+ tickets.stream().collect(Collectors.groupingBy(QrcodeTicket::getOrderId, LinkedHashMap::new, Collectors.toList()));
+ Set<Long> visitorIds =
+ tickets.stream()
+ .map(QrcodeTicket::getMpVisitorId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ Map<Long, MpVisitor> visitorMap = loadMpVisitorMap(visitorIds);
+ Map<Long, List<AppointmentVisitor>> orderVisitorRows = loadOrderVisitorRows(orderIds);
+ Map<Long, String> deptNameMap = loadDeptNameMap(eligibleOrders);
+
+ List<MiniMyVisitorCodeOrderVo> result = new ArrayList<>();
+ for (AppointmentOrder order : eligibleOrders) {
+ List<QrcodeTicket> orderTickets = ticketMap.get(order.getId());
+ if (orderTickets == null || orderTickets.isEmpty()) {
+ continue;
+ }
+ MiniMyVisitorCodeOrderVo vo = new MiniMyVisitorCodeOrderVo();
+ vo.setOrderId(order.getId());
+ vo.setOrderUuid(order.getUuid());
+ String deptName = deptNameMap.get(order.getVisitedDeptId());
+ vo.setVisitedUserDisplay(buildVisitedUserDisplay(order.getVisitedUserName(), deptName));
+ vo.setAccessAreaText(buildAccessAreaText(order.getPassAccessAreaNames()));
+ vo.setVisitTimeText(buildVisitTimeText(order));
+ vo.setValidStartTime(orderTickets.get(0).getGenerateTime());
+ vo.setValidEndTime(orderTickets.get(0).getExpireTime());
+ vo.setQrCodes(
+ orderTickets.stream()
+ .map(ticket -> toMyVisitorCodeTicketVo(ticket, orderVisitorRows.get(order.getId()), visitorMap))
+ .toList());
+ result.add(vo);
+ }
+ return result;
+ }
+
+ public MiniMyAppointmentDetailVo detailMyOrder(Long orderId, String uuid) {
+ if (orderId == null || orderId <= 0) {
+ throw new BizException(BizErrorCode.ORDER_NOT_FOUND, "预约单不存在");
+ }
+ MpUser current = miniAuthApplicationService.currentMpUser();
+ AppointmentOrder order = appointmentOrderMapper.selectById(orderId);
+ if (order == null || (order.getDelFlag() != null && order.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.ORDER_NOT_FOUND, "预约单不存在");
+ }
+ UuidAssert.match(order, uuid);
+ if (!Objects.equals(order.getSubmitterId(), current.getId())) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "无权查看该预约详情");
+ }
+
+ String deptName = loadDeptName(order.getVisitedDeptId());
+ String reasonName = loadReasonName(order.getVisitReasonCode());
+
+ MiniMyAppointmentDetailVo vo = new MiniMyAppointmentDetailVo();
+ vo.setOrderId(order.getId());
+ vo.setOrderUuid(order.getUuid());
+ vo.setOrderNo(order.getOrderNo());
+ vo.setStatus(order.getStatus());
+ vo.setDisplayStatus(resolveDisplayStatus(order));
+ vo.setStatusText(resolveOrderStatusText(order));
+ vo.setCancelable(isCancelableStatus(order.getStatus()));
+ vo.setVisitedUserId(order.getVisitedUserId());
+ vo.setVisitedUserName(order.getVisitedUserName());
+ vo.setVisitedDeptName(deptName);
+ vo.setVisitedUserDisplay(buildVisitedUserDisplay(order.getVisitedUserName(), deptName));
+ vo.setAccessAreaText(buildAccessAreaText(order.getPassAccessAreaNames()));
+ vo.setVisitTypeText(buildVisitTypeText(order));
+ vo.setVisitReasonText(buildVisitReasonText(order, reasonName == null ? Map.of() : Map.of(trimToNull(order.getVisitReasonCode()), reasonName)));
+ vo.setVisitTimeText(buildVisitTimeText(order));
+ vo.setCreateTime(order.getCreateTime());
+ vo.setVisitors(loadOrderVisitors(order.getId()));
+ List<ApprovalFlowNodeVo> flowTree = approvalFlowViewApplicationService.treeByOrderId(order.getId());
+ vo.setFlowTree(flowTree == null ? List.of() : flowTree);
+ return vo;
+ }
+
+ private MpUser loadVisitedUser(Long visitedUserId) {
+ MpUser visited = mpUserMapper.selectById(visitedUserId);
+ if (visited == null || (visited.getDelFlag() != null && visited.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "被访人不存在");
+ }
+ if (visited.getStatus() != null && visited.getStatus() == 0) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "被访人已禁用");
+ }
+ if (visited.getDeptId() == null || visited.getDeptId() <= 0) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "被访人未绑定部门");
+ }
+ return visited;
+ }
+
+ private void validateVisitReason(String code, String other) {
+ String c = trimToNull(code);
+ if (!StringUtils.hasText(c)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "拜访事由不能为空");
+ }
+ if (AppointmentBizConstants.VISIT_REASON_OTHER.equalsIgnoreCase(c)) {
+ String txt = trimToNull(other);
+ if (!StringUtils.hasText(txt)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "选择其他时须填写自定义内容");
+ }
+ if (txt.length() > 255) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "自定义内容不能超过255字");
+ }
+ return;
+ }
+ Long cnt =
+ bizVisitReasonMapper.selectCount(
+ new LambdaQueryWrapper<BizVisitReason>()
+ .eq(BizVisitReason::getDelFlag, 0)
+ .eq(BizVisitReason::getEnabled, 1)
+ .eq(BizVisitReason::getReasonCode, c));
+ if (cnt == null || cnt <= 0) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "拜访事由无效");
+ }
+ }
+
+ private String normalizeMode(String mode) {
+ String m = trimToNull(mode);
+ if (!StringUtils.hasText(m)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "拜访时间类型不能为空");
+ }
+ m = m.toUpperCase();
+ if (!AppointmentBizConstants.TIME_MODE_SINGLE.equals(m)
+ && !AppointmentBizConstants.TIME_MODE_MULTI.equals(m)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "拜访时间类型仅支持 SINGLE/MULTI");
+ }
+ return m;
+ }
+
+ private void validateAgainstSystemRangeMode(String reqMode) {
+ String cfg = trimToNull(sysBizSettingCacheService.getValue(SystemSettingKeys.BOOKING_RANGE_MODE));
+ String mode =
+ StringUtils.hasText(cfg)
+ ? cfg.toUpperCase()
+ : AppointmentBizConstants.TIME_MODE_SINGLE;
+ if (!AppointmentBizConstants.TIME_MODE_SINGLE.equals(mode)
+ && !AppointmentBizConstants.TIME_MODE_MULTI.equals(mode)) {
+ mode = AppointmentBizConstants.TIME_MODE_SINGLE;
+ }
+ if (AppointmentBizConstants.TIME_MODE_SINGLE.equals(mode)
+ && AppointmentBizConstants.TIME_MODE_MULTI.equals(reqMode)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "当前预约配置不支持该拜访时间类型");
+ }
+ }
+
+ private DateRange resolveDateRange(MiniAppointmentCreateRequest req, String mode) {
+ if (AppointmentBizConstants.TIME_MODE_SINGLE.equals(mode)) {
+ if (req.getVisitDate() == null) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "单日模式下拜访日期不能为空");
+ }
+ normalizeSlot(req.getVisitTimeSlot());
+ return new DateRange(req.getVisitDate(), req.getVisitDate());
+ }
+ if (req.getVisitStartDate() == null || req.getVisitEndDate() == null) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "多日模式下须填写起止日期");
+ }
+ if (req.getVisitEndDate().isBefore(req.getVisitStartDate())) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "结束日期不能早于开始日期");
+ }
+ long days = ChronoUnit.DAYS.between(req.getVisitStartDate(), req.getVisitEndDate()) + 1;
+ int maxDays = getMaxConsecutiveDays();
+ if (days > maxDays) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "多日预约不能超过最大天数: " + maxDays);
+ }
+ return new DateRange(req.getVisitStartDate(), req.getVisitEndDate());
+ }
+
+ private int getMaxConsecutiveDays() {
+ String raw = trimToNull(sysBizSettingCacheService.getValue(SystemSettingKeys.BOOKING_MAX_CONSECUTIVE_DAYS));
+ if (!StringUtils.hasText(raw)) {
+ return 7;
+ }
+ try {
+ return Math.max(1, Integer.parseInt(raw));
+ } catch (NumberFormatException ex) {
+ return 7;
+ }
+ }
+
+ private String normalizeSlot(String slot) {
+ String s = trimToNull(slot);
+ if (!StringUtils.hasText(s)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "单日模式下须选择全天/上午/下午");
+ }
+ s = s.toUpperCase();
+ if (!AppointmentBizConstants.TIME_SLOT_ALL_DAY.equals(s)
+ && !AppointmentBizConstants.TIME_SLOT_AM.equals(s)
+ && !AppointmentBizConstants.TIME_SLOT_PM.equals(s)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "单日时段仅支持 ALL_DAY/AM/PM");
+ }
+ return s;
+ }
+
+ private List<MiniAppointmentVehicleItemRequest> normalizeVehicles(List<MiniAppointmentVehicleItemRequest> raw) {
+ if (raw == null || raw.isEmpty()) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "勾选车辆进场后,进场车辆列表不能为空");
+ }
+ List<MiniAppointmentVehicleItemRequest> out = new ArrayList<>();
+ for (MiniAppointmentVehicleItemRequest v : raw) {
+ if (v == null || !StringUtils.hasText(v.getPlateNo()) || v.getDriverVisitorId() == null) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "车辆信息不完整");
+ }
+ if (v.getPlateNo().trim().length() > 32) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "车牌号长度不能超过32");
+ }
+ out.add(v);
+ }
+ return out;
+ }
+
+ private Map<Long, MpVisitor> loadCurrentUserVisitors(Long currentMpUserId, Set<Long> ids) {
+ List<MpVisitor> rows =
+ mpVisitorMapper.selectList(
+ new LambdaQueryWrapper<MpVisitor>()
+ .eq(MpVisitor::getMpUserId, currentMpUserId)
+ .eq(MpVisitor::getDelFlag, 0)
+ .in(MpVisitor::getId, ids));
+ Map<Long, MpVisitor> map = rows.stream().collect(Collectors.toMap(MpVisitor::getId, Function.identity(), (a, b) -> a));
+ for (Long id : ids) {
+ if (!map.containsKey(id)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "来访人员不存在或不属于当前账号: " + id);
+ }
+ }
+ return map;
+ }
+
+ private void validateByFieldConfig(MiniAppointmentCreateRequest req, FieldRequiredContext required) {
+ boolean personEntry = req.getPersonEntry() == null || Boolean.TRUE.equals(req.getPersonEntry());
+ boolean vehicleEntry = Boolean.TRUE.equals(req.getVehicleEntry());
+ if (required.isRequired(AppointmentFieldCodes.RESPONDENT) && req.getVisitedUserId() == null) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "被访人不能为空");
+ }
+ if (required.isRequired(AppointmentFieldCodes.VISIT_REASON)
+ && !StringUtils.hasText(trimToNull(req.getVisitReasonCode()))) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "拜访事由不能为空");
+ }
+ if (required.isRequired(AppointmentFieldCodes.VISIT_START_TIME)) {
+ if (AppointmentBizConstants.TIME_MODE_SINGLE.equalsIgnoreCase(trimToNull(req.getVisitTimeMode()))) {
+ if (req.getVisitDate() == null) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "拜访时间不能为空");
+ }
+ } else if (req.getVisitStartDate() == null) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "开始拜访时间不能为空");
+ }
+ }
+ if (required.isRequired(AppointmentFieldCodes.VISIT_END_TIME)
+ && AppointmentBizConstants.TIME_MODE_MULTI.equalsIgnoreCase(trimToNull(req.getVisitTimeMode()))
+ && req.getVisitEndDate() == null) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "结束拜访时间不能为空");
+ }
+ if (required.isRequired(AppointmentFieldCodes.VISIT_TYPE)
+ && !personEntry
+ && !vehicleEntry) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "来访类型不能为空");
+ }
+ if (required.isRequired(AppointmentFieldCodes.LICENSE_PLATE, AppointmentFieldCodes.DRIVER)
+ && vehicleEntry
+ && (req.getVehicles() == null || req.getVehicles().isEmpty())) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "车辆信息不能为空");
+ }
+ }
+
+ private void validateVisitorDataByFieldConfig(
+ java.util.Collection<MpVisitor> visitors, FieldRequiredContext required) {
+ for (MpVisitor v : visitors) {
+ if (required.isRequired(AppointmentFieldCodes.VISITOR_NAME)
+ && !StringUtils.hasText(trimToNull(v.getVisitorName()))) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "访客姓名不能为空");
+ }
+ if (required.isRequired(AppointmentFieldCodes.ID_CARD)
+ && !StringUtils.hasText(trimToNull(v.getIdCardCipher()))) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "身份证号码不能为空");
+ }
+ if (required.isRequired(AppointmentFieldCodes.GENDER)
+ && !StringUtils.hasText(trimToNull(v.getGender()))) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "性别不能为空");
+ }
+ if (required.isRequired(AppointmentFieldCodes.COMPANY)
+ && !StringUtils.hasText(trimToNull(v.getVisitorCompany()))) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "来访单位不能为空");
+ }
+ if (required.isRequired(AppointmentFieldCodes.MOBILE)
+ && !StringUtils.hasText(trimToNull(v.getVisitorMobile()))) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "联系电话不能为空");
+ }
+ }
+ }
+
+ private FieldRequiredContext loadFieldRequiredContext() {
+ List<BizFieldConfig> rows =
+ bizFieldConfigMapper.selectList(
+ new LambdaQueryWrapper<BizFieldConfig>()
+ .eq(BizFieldConfig::getDelFlag, 0)
+ .eq(BizFieldConfig::getVisible, 1)
+ .eq(BizFieldConfig::getRequiredFlag, 1));
+ Set<String> required =
+ rows.stream()
+ .map(BizFieldConfig::getFieldCode)
+ .filter(StringUtils::hasText)
+ .map(String::trim)
+ .collect(Collectors.toCollection(java.util.LinkedHashSet::new));
+ return new FieldRequiredContext(required);
+ }
+
+ private String resolveVisitorOrg(java.util.Collection<MpVisitor> visitors) {
+ for (MpVisitor v : visitors) {
+ String company = trimToNull(v.getVisitorCompany());
+ if (StringUtils.hasText(company)) {
+ return company;
+ }
+ }
+ return null;
+ }
+
+ private boolean calcHolidayFlag(LocalDate start, LocalDate end) {
+ Set<LocalDate> holidays = bizHolidayDateCacheService.getAllEnabledHolidayDates();
+ if (holidays.isEmpty()) {
+ return false;
+ }
+ LocalDate d = start;
+ while (!d.isAfter(end)) {
+ if (holidays.contains(d)) {
+ return true;
+ }
+ d = d.plusDays(1);
+ }
+ return false;
+ }
+
+ private static String trimToNull(String s) {
+ if (s == null) {
+ return null;
+ }
+ String t = s.trim();
+ return t.isEmpty() ? null : t;
+ }
+
+ private MiniAppointmentListVo toListVo(AppointmentOrder row) {
+ MiniAppointmentListVo vo = new MiniAppointmentListVo();
+ vo.setId(row.getId());
+ vo.setUuid(row.getUuid());
+ vo.setOrderNo(row.getOrderNo());
+ vo.setStatus(row.getStatus());
+ vo.setVisitedUserName(row.getVisitedUserName());
+ vo.setVisitStartDate(row.getVisitStartDate());
+ vo.setVisitEndDate(row.getVisitEndDate());
+ vo.setVisitReasonCode(row.getVisitReasonCode());
+ vo.setCreateTime(row.getCreateTime());
+ return vo;
+ }
+
+ private MiniAppointmentListVo toMyOrderListVo(
+ AppointmentOrder row,
+ Map<Long, String> deptNameMap,
+ Map<String, String> reasonNameMap) {
+ MiniAppointmentListVo vo = toListVo(row);
+ String deptName = deptNameMap.get(row.getVisitedDeptId());
+ vo.setVisitedDeptName(deptName);
+ vo.setVisitedUserDisplay(buildVisitedUserDisplay(row.getVisitedUserName(), deptName));
+ vo.setDisplayStatus(resolveDisplayStatus(row));
+ vo.setVisitTimeText(buildVisitTimeText(row));
+ vo.setVisitReasonText(buildVisitReasonText(row, reasonNameMap));
+ vo.setAccessAreaText(buildAccessAreaText(row.getPassAccessAreaNames()));
+ return vo;
+ }
+
+ private Map<Long, String> loadDeptNameMap(List<AppointmentOrder> orders) {
+ Set<Long> deptIds =
+ orders.stream()
+ .map(AppointmentOrder::getVisitedDeptId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ if (deptIds.isEmpty()) {
+ return Map.of();
+ }
+ List<SysDept> rows =
+ sysDeptMapper.selectList(
+ new LambdaQueryWrapper<SysDept>()
+ .eq(SysDept::getDelFlag, 0)
+ .in(SysDept::getId, deptIds));
+ return rows.stream()
+ .collect(Collectors.toMap(SysDept::getId, SysDept::getDeptName, (a, b) -> a, LinkedHashMap::new));
+ }
+
+ private Map<String, String> loadReasonNameMap(List<AppointmentOrder> orders) {
+ Set<String> codes =
+ orders.stream()
+ .map(AppointmentOrder::getVisitReasonCode)
+ .filter(StringUtils::hasText)
+ .map(String::trim)
+ .filter(code -> !AppointmentBizConstants.VISIT_REASON_OTHER.equalsIgnoreCase(code))
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ if (codes.isEmpty()) {
+ return Map.of();
+ }
+ List<BizVisitReason> rows =
+ bizVisitReasonMapper.selectList(
+ new LambdaQueryWrapper<BizVisitReason>()
+ .eq(BizVisitReason::getDelFlag, 0)
+ .in(BizVisitReason::getReasonCode, codes));
+ return rows.stream()
+ .collect(
+ Collectors.toMap(
+ BizVisitReason::getReasonCode,
+ BizVisitReason::getReasonName,
+ (a, b) -> a,
+ LinkedHashMap::new));
+ }
+
+ private String buildVisitedUserDisplay(String userName, String deptName) {
+ String name = trimToNull(userName);
+ String dept = trimToNull(deptName);
+ if (StringUtils.hasText(name) && StringUtils.hasText(dept)) {
+ return name + " " + dept;
+ }
+ if (StringUtils.hasText(name)) {
+ return name;
+ }
+ return StringUtils.hasText(dept) ? dept : "-";
+ }
+
+ private String resolveOrderStatusText(AppointmentOrder row) {
+ return switch (trimToNull(row.getStatus())) {
+ case AppointmentOrderStatus.PENDING_APPROVAL -> "审批中";
+ case AppointmentOrderStatus.APPROVED,
+ AppointmentOrderStatus.VISITED,
+ AppointmentOrderStatus.EXPIRED -> "审批通过";
+ case AppointmentOrderStatus.REJECTED -> "审批驳回";
+ case AppointmentOrderStatus.CANCELLED -> "已取消";
+ default -> "已失效";
+ };
+ }
+
+ private String resolveDisplayStatus(AppointmentOrder row) {
+ LocalDate today = LocalDate.now();
+ if (AppointmentOrderStatus.PENDING_APPROVAL.equals(row.getStatus())) {
+ return DISPLAY_STATUS_PENDING_VISIT;
+ }
+ if (AppointmentOrderStatus.APPROVED.equals(row.getStatus())) {
+ if (row.getVisitStartDate() != null && today.isBefore(row.getVisitStartDate())) {
+ return DISPLAY_STATUS_PENDING_VISIT;
+ }
+ if (row.getVisitStartDate() != null
+ && row.getVisitEndDate() != null
+ && (today.isEqual(row.getVisitStartDate()) || today.isAfter(row.getVisitStartDate()))
+ && (today.isEqual(row.getVisitEndDate()) || today.isBefore(row.getVisitEndDate()))) {
+ return DISPLAY_STATUS_EFFECTIVE;
+ }
+ }
+ return DISPLAY_STATUS_INVALID;
+ }
+
+ private String buildVisitTimeText(AppointmentOrder row) {
+ if (AppointmentBizConstants.TIME_MODE_SINGLE.equals(row.getVisitTimeMode())) {
+ String slotLabel = switch (trimToNull(row.getVisitTimeSlot())) {
+ case AppointmentBizConstants.TIME_SLOT_ALL_DAY -> "全天";
+ case AppointmentBizConstants.TIME_SLOT_AM -> "上午";
+ case AppointmentBizConstants.TIME_SLOT_PM -> "下午";
+ default -> "";
+ };
+ if (row.getVisitStartDate() == null) {
+ return slotLabel;
+ }
+ return StringUtils.hasText(slotLabel)
+ ? row.getVisitStartDate() + " " + slotLabel
+ : row.getVisitStartDate().toString();
+ }
+ if (row.getVisitStartDate() == null && row.getVisitEndDate() == null) {
+ return "-";
+ }
+ if (Objects.equals(row.getVisitStartDate(), row.getVisitEndDate()) || row.getVisitEndDate() == null) {
+ return String.valueOf(row.getVisitStartDate());
+ }
+ return row.getVisitStartDate() + " 至 " + row.getVisitEndDate();
+ }
+
+ private String buildVisitReasonText(AppointmentOrder row, Map<String, String> reasonNameMap) {
+ if (AppointmentBizConstants.VISIT_REASON_OTHER.equalsIgnoreCase(trimToNull(row.getVisitReasonCode()))) {
+ String other = trimToNull(row.getVisitReasonOther());
+ return StringUtils.hasText(other) ? "其他-" + other : "其他";
+ }
+ String reasonName = reasonNameMap.get(trimToNull(row.getVisitReasonCode()));
+ return StringUtils.hasText(reasonName) ? reasonName : trimToNull(row.getVisitReasonCode());
+ }
+
+ private String buildAccessAreaText(String rawJson) {
+ String raw = trimToNull(rawJson);
+ if (!StringUtils.hasText(raw)) {
+ return null;
+ }
+ try {
+ List<String> rows = objectMapper.readValue(raw, STRING_LIST_TYPE);
+ String joined =
+ rows.stream()
+ .filter(StringUtils::hasText)
+ .map(String::trim)
+ .collect(Collectors.joining("、"));
+ return StringUtils.hasText(joined) ? joined : null;
+ } catch (Exception ignore) {
+ return raw;
+ }
+ }
+
+ private LocalDateTime resolveVisitStartAt(AppointmentOrder row) {
+ LocalDate date = row.getVisitStartDate() != null ? row.getVisitStartDate() : row.getVisitEndDate();
+ if (date == null) {
+ return LocalDateTime.MIN;
+ }
+ if (!AppointmentBizConstants.TIME_MODE_SINGLE.equals(row.getVisitTimeMode())) {
+ return LocalDateTime.of(date, LocalTime.MIN);
+ }
+ return switch (trimToNull(row.getVisitTimeSlot())) {
+ case AppointmentBizConstants.TIME_SLOT_PM -> LocalDateTime.of(date, LocalTime.of(12, 0));
+ default -> LocalDateTime.of(date, LocalTime.MIN);
+ };
+ }
+
+ private Map<Long, MpVisitor> loadMpVisitorMap(Set<Long> visitorIds) {
+ if (visitorIds == null || visitorIds.isEmpty()) {
+ return Map.of();
+ }
+ List<MpVisitor> rows =
+ mpVisitorMapper.selectList(
+ new LambdaQueryWrapper<MpVisitor>()
+ .eq(MpVisitor::getDelFlag, 0)
+ .in(MpVisitor::getId, visitorIds));
+ return rows.stream().collect(Collectors.toMap(MpVisitor::getId, Function.identity(), (a, b) -> a, LinkedHashMap::new));
+ }
+
+ private Map<Long, List<AppointmentVisitor>> loadOrderVisitorRows(List<Long> orderIds) {
+ if (orderIds == null || orderIds.isEmpty()) {
+ return Map.of();
+ }
+ List<AppointmentVisitor> rows =
+ appointmentVisitorMapper.selectList(
+ new LambdaQueryWrapper<AppointmentVisitor>()
+ .eq(AppointmentVisitor::getDelFlag, 0)
+ .in(AppointmentVisitor::getOrderId, orderIds)
+ .orderByAsc(AppointmentVisitor::getId));
+ return rows.stream()
+ .collect(Collectors.groupingBy(AppointmentVisitor::getOrderId, LinkedHashMap::new, Collectors.toList()));
+ }
+
+ private MiniMyVisitorCodeTicketVo toMyVisitorCodeTicketVo(
+ QrcodeTicket ticket,
+ List<AppointmentVisitor> orderVisitors,
+ Map<Long, MpVisitor> visitorMap) {
+ MiniMyVisitorCodeTicketVo vo = new MiniMyVisitorCodeTicketVo();
+ vo.setTicketId(ticket.getId());
+ vo.setTicketUuid(ticket.getUuid());
+ vo.setMpVisitorId(ticket.getMpVisitorId());
+ vo.setPayload(ticket.getPayload());
+ vo.setGenerateTime(ticket.getGenerateTime());
+ vo.setExpireTime(ticket.getExpireTime());
+ vo.setVisitorName(resolveVisitorName(ticket.getMpVisitorId(), orderVisitors, visitorMap));
+ return vo;
+ }
+
+ private String resolveVisitorName(
+ Long mpVisitorId, List<AppointmentVisitor> orderVisitors, Map<Long, MpVisitor> visitorMap) {
+ if (orderVisitors != null && !orderVisitors.isEmpty()) {
+ for (AppointmentVisitor row : orderVisitors) {
+ if (Objects.equals(mpVisitorId, row.getMpVisitorId()) && StringUtils.hasText(row.getName())) {
+ return row.getName().trim();
+ }
+ }
+ }
+ MpVisitor visitor = mpVisitorId == null ? null : visitorMap.get(mpVisitorId);
+ if (visitor != null && StringUtils.hasText(visitor.getVisitorName())) {
+ return visitor.getVisitorName().trim();
+ }
+ return "访客";
+ }
+
+ private String buildVisitTypeText(AppointmentOrder order) {
+ boolean personEntry = order.getPersonEntryFlag() == null || Boolean.TRUE.equals(order.getPersonEntryFlag());
+ boolean vehicleEntry = Boolean.TRUE.equals(order.getVehicleEntryFlag());
+ if (personEntry && vehicleEntry) {
+ return "访客+车辆预约";
+ }
+ if (vehicleEntry) {
+ return "车辆预约";
+ }
+ return "访客预约";
+ }
+
+ private List<MiniApprovalDetailVisitorVo> loadOrderVisitors(Long orderId) {
+ List<AppointmentVisitor> rows =
+ appointmentVisitorMapper.selectList(
+ new LambdaQueryWrapper<AppointmentVisitor>()
+ .eq(AppointmentVisitor::getDelFlag, 0)
+ .eq(AppointmentVisitor::getOrderId, orderId)
+ .orderByAsc(AppointmentVisitor::getId));
+ if (rows.isEmpty()) {
+ return List.of();
+ }
+ List<Long> visitorIds =
+ rows.stream()
+ .map(AppointmentVisitor::getMpVisitorId)
+ .filter(Objects::nonNull)
+ .toList();
+ Map<Long, MpVisitor> visitorMap = loadVisitorMap(visitorIds);
+ Map<Long, String> plateMap = loadVisitorPlateMap(orderId);
+ List<MiniApprovalDetailVisitorVo> result = new ArrayList<>();
+ for (AppointmentVisitor row : rows) {
+ MpVisitor visitor = visitorMap.get(row.getMpVisitorId());
+ MiniApprovalDetailVisitorVo vo = new MiniApprovalDetailVisitorVo();
+ vo.setVisitorId(row.getMpVisitorId());
+ vo.setName(firstNonBlank(row.getName(), visitor == null ? null : visitor.getVisitorName(), "-"));
+ vo.setGender(visitor == null ? null : visitor.getGender());
+ vo.setCompanyName(visitor == null ? null : visitor.getVisitorCompany());
+ vo.setMobileMasked(
+ MobileMaskUtil.mask(
+ firstNonBlank(
+ row.getMobile(),
+ visitor == null ? null : visitor.getVisitorMobile(),
+ null)));
+ String idCardPlain =
+ StringUtils.hasText(row.getIdCardCipher())
+ ? SensitiveDataUtil.decryptIdCard(row.getIdCardCipher())
+ : (visitor != null && StringUtils.hasText(visitor.getIdCardCipher())
+ ? SensitiveDataUtil.decryptIdCard(visitor.getIdCardCipher())
+ : null);
+ vo.setIdCardMasked(
+ StringUtils.hasText(idCardPlain)
+ ? SensitiveDataUtil.maskIdCard(idCardPlain)
+ : null);
+ vo.setPlateNo(plateMap.get(row.getMpVisitorId()));
+ result.add(vo);
+ }
+ return result;
+ }
+
+ private Map<Long, MpVisitor> loadVisitorMap(List<Long> visitorIds) {
+ if (visitorIds.isEmpty()) {
+ return Map.of();
+ }
+ List<MpVisitor> rows =
+ mpVisitorMapper.selectList(
+ new LambdaQueryWrapper<MpVisitor>()
+ .eq(MpVisitor::getDelFlag, 0)
+ .in(MpVisitor::getId, visitorIds));
+ return rows.stream().collect(Collectors.toMap(MpVisitor::getId, Function.identity(), (a, b) -> a));
+ }
+
+ private Map<Long, String> loadVisitorPlateMap(Long orderId) {
+ List<AppointmentVehicle> rows =
+ appointmentVehicleMapper.selectList(
+ new LambdaQueryWrapper<AppointmentVehicle>()
+ .eq(AppointmentVehicle::getDelFlag, 0)
+ .eq(AppointmentVehicle::getOrderId, orderId)
+ .orderByAsc(AppointmentVehicle::getId));
+ if (rows.isEmpty()) {
+ return Map.of();
+ }
+ Map<Long, List<String>> plateMap = new LinkedHashMap<>();
+ for (AppointmentVehicle row : rows) {
+ if (row.getDriverVisitorId() == null) {
+ continue;
+ }
+ plateMap.computeIfAbsent(row.getDriverVisitorId(), key -> new ArrayList<>());
+ if (StringUtils.hasText(row.getPlateNo())) {
+ plateMap.get(row.getDriverVisitorId()).add(row.getPlateNo().trim());
+ }
+ }
+ Map<Long, String> result = new LinkedHashMap<>();
+ for (Map.Entry<Long, List<String>> entry : plateMap.entrySet()) {
+ result.put(entry.getKey(), String.join("、", entry.getValue()));
+ }
+ return result;
+ }
+
+ private String loadDeptName(Long deptId) {
+ if (deptId == null || deptId <= 0) {
+ return null;
+ }
+ SysDept dept = sysDeptMapper.selectById(deptId);
+ return dept == null ? null : dept.getDeptName();
+ }
+
+ private String loadReasonName(String reasonCode) {
+ String code = trimToNull(reasonCode);
+ if (!StringUtils.hasText(code)
+ || AppointmentBizConstants.VISIT_REASON_OTHER.equalsIgnoreCase(code)) {
+ return null;
+ }
+ List<BizVisitReason> rows =
+ bizVisitReasonMapper.selectList(
+ new LambdaQueryWrapper<BizVisitReason>()
+ .eq(BizVisitReason::getDelFlag, 0)
+ .eq(BizVisitReason::getReasonCode, code)
+ .last("limit 1"));
+ if (rows.isEmpty()) {
+ return null;
+ }
+ return trimToNull(rows.get(0).getReasonName());
+ }
+
+ private String firstNonBlank(String... values) {
+ if (values == null) {
+ return null;
+ }
+ for (String value : values) {
+ if (StringUtils.hasText(value)) {
+ return value.trim();
+ }
+ }
+ return null;
+ }
+
+ private boolean containsIgnoreCase(String src, String keyword) {
+ if (!StringUtils.hasText(src) || !StringUtils.hasText(keyword)) {
+ return false;
+ }
+ return src.toLowerCase().contains(keyword.toLowerCase());
+ }
+
+ private IPage<MiniAppointmentListVo> slicePage(long page, long size, List<MiniAppointmentListVo> rowsAll) {
+ long total = rowsAll == null ? 0 : rowsAll.size();
+ long current = Math.max(1L, page);
+ long pageSize = Math.max(1L, size);
+ int from = (int) Math.min(total, (current - 1) * pageSize);
+ int to = (int) Math.min(total, from + pageSize);
+ List<MiniAppointmentListVo> records = from >= to ? List.of() : rowsAll.subList(from, to);
+ Page<MiniAppointmentListVo> out = new Page<>(current, pageSize);
+ out.setTotal(total);
+ out.setRecords(records);
+ return out;
+ }
+
+ private IPage<MiniVisitRecordListVo> slicePageVisitRecord(
+ long page, long size, List<MiniVisitRecordListVo> rowsAll) {
+ long total = rowsAll == null ? 0 : rowsAll.size();
+ long current = Math.max(1L, page);
+ long pageSize = Math.max(1L, size);
+ int from = (int) Math.min(total, (current - 1) * pageSize);
+ int to = (int) Math.min(total, from + pageSize);
+ List<MiniVisitRecordListVo> records = from >= to ? List.of() : rowsAll.subList(from, to);
+ Page<MiniVisitRecordListVo> out = new Page<>(current, pageSize);
+ out.setTotal(total);
+ out.setRecords(records);
+ return out;
+ }
+
+ private int slotSortWeight(String slot) {
+ if (AppointmentBizConstants.TIME_SLOT_ALL_DAY.equals(slot)) {
+ return 3;
+ }
+ if (AppointmentBizConstants.TIME_SLOT_PM.equals(slot)) {
+ return 2;
+ }
+ if (AppointmentBizConstants.TIME_SLOT_AM.equals(slot)) {
+ return 1;
+ }
+ return 0;
+ }
+
+ private boolean isCancelableStatus(String status) {
+ if (!StringUtils.hasText(status)) {
+ return false;
+ }
+ if (AppointmentOrderStatus.REJECTED.equals(status)
+ || AppointmentOrderStatus.APPROVED.equals(status)
+ || AppointmentOrderStatus.VISITED.equals(status)
+ || AppointmentOrderStatus.EXPIRED.equals(status)
+ || AppointmentOrderStatus.CANCELLED.equals(status)) {
+ return false;
+ }
+ return true;
+ }
+
+ private ApprovalInstance loadLatestInstanceByOrderId(Long orderId) {
+ List<ApprovalInstance> rows =
+ approvalInstanceMapper.selectList(
+ new LambdaQueryWrapper<ApprovalInstance>()
+ .eq(ApprovalInstance::getDelFlag, 0)
+ .eq(ApprovalInstance::getOrderId, orderId)
+ .orderByDesc(ApprovalInstance::getId)
+ .last("limit 1"));
+ return rows.isEmpty() ? null : rows.get(0);
+ }
+
+ private void expireUnusedTicketsByOrderId(Long orderId) {
+ List<QrcodeTicket> tickets =
+ qrcodeTicketMapper.selectList(
+ new LambdaQueryWrapper<QrcodeTicket>()
+ .eq(QrcodeTicket::getDelFlag, 0)
+ .eq(QrcodeTicket::getOrderId, orderId)
+ .eq(QrcodeTicket::getTicketStatus, QrcodeTicketStatus.UNUSED));
+ for (QrcodeTicket ticket : tickets) {
+ ticket.setTicketStatus(QrcodeTicketStatus.EXPIRED);
+ qrcodeTicketMapper.updateById(ticket);
+ }
+ }
+
+ private int singleDaySlotSortWeight(MiniVisitRecordListVo row) {
+ if (!AppointmentBizConstants.TIME_MODE_SINGLE.equals(row.getVisitTimeMode())) {
+ return 0;
+ }
+ return slotSortWeight(row.getVisitTimeSlot());
+ }
+
+ private record FieldRequiredContext(Set<String> requiredCodes) {
+ private boolean isRequired(String... codes) {
+ for (String c : codes) {
+ if (requiredCodes.contains(c)) {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+ private record DateRange(LocalDate start, LocalDate end) {}
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/MiniApprovalDetailApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/MiniApprovalDetailApplicationService.java
new file mode 100644
index 0000000..2c11c3b
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/MiniApprovalDetailApplicationService.java
@@ -0,0 +1,461 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sinata.appointment.config.MiniStpUtil;
+import com.sinata.appointment.constants.AppointmentBizConstants;
+import com.sinata.appointment.constants.ApprovalNodeCode;
+import com.sinata.appointment.constants.ApprovalTaskStatus;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.dto.ApprovalFlowNodeVo;
+import com.sinata.appointment.dto.MiniApprovalDetailVisitorVo;
+import com.sinata.appointment.dto.MiniApprovalDetailVo;
+import com.sinata.appointment.dto.MiniLocalUnitVo;
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.entity.AppointmentVehicle;
+import com.sinata.appointment.entity.AppointmentVisitor;
+import com.sinata.appointment.entity.ApprovalInstance;
+import com.sinata.appointment.entity.ApprovalTask;
+import com.sinata.appointment.entity.BizVisitReason;
+import com.sinata.appointment.entity.MpPassLocalUnit;
+import com.sinata.appointment.entity.MpVisitor;
+import com.sinata.appointment.entity.SysDept;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.mapper.AppointmentOrderMapper;
+import com.sinata.appointment.mapper.AppointmentVehicleMapper;
+import com.sinata.appointment.mapper.AppointmentVisitorMapper;
+import com.sinata.appointment.mapper.ApprovalInstanceMapper;
+import com.sinata.appointment.mapper.ApprovalTaskMapper;
+import com.sinata.appointment.mapper.BizVisitReasonMapper;
+import com.sinata.appointment.mapper.MpPassLocalUnitMapper;
+import com.sinata.appointment.mapper.MpVisitorMapper;
+import com.sinata.appointment.mapper.SysDeptMapper;
+import com.sinata.appointment.util.MobileMaskUtil;
+import com.sinata.appointment.util.SensitiveDataUtil;
+import com.sinata.appointment.util.UuidAssert;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+@Service
+@RequiredArgsConstructor
+public class MiniApprovalDetailApplicationService {
+
+ private static final String PAGE_MODE_HANDLER_PENDING = "HANDLER_PENDING";
+ private static final String PAGE_MODE_GENERAL_PENDING = "GENERAL_PENDING";
+ private static final String PAGE_MODE_GENERAL_READONLY = "GENERAL_READONLY";
+ private static final TypeReference<List<Long>> LONG_LIST_TYPE = new TypeReference<>() {};
+ private static final TypeReference<List<String>> STRING_LIST_TYPE = new TypeReference<>() {};
+
+ private final ApprovalTaskMapper approvalTaskMapper;
+ private final ApprovalInstanceMapper approvalInstanceMapper;
+ private final AppointmentOrderMapper appointmentOrderMapper;
+ private final AppointmentVisitorMapper appointmentVisitorMapper;
+ private final AppointmentVehicleMapper appointmentVehicleMapper;
+ private final MpVisitorMapper mpVisitorMapper;
+ private final SysDeptMapper sysDeptMapper;
+ private final BizVisitReasonMapper bizVisitReasonMapper;
+ private final MpPassLocalUnitMapper mpPassLocalUnitMapper;
+ private final ApprovalFlowViewApplicationService approvalFlowViewApplicationService;
+ private final ObjectMapper objectMapper;
+
+ public MiniApprovalDetailVo detail(Long taskId, String taskUuid, Long orderId, String orderUuid) {
+ long uid = MiniStpUtil.getLoginIdAsLong();
+ ApprovalTask task = null;
+ if (taskId != null && taskId > 0) {
+ task = approvalTaskMapper.selectById(taskId);
+ if (task == null || (task.getDelFlag() != null && task.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.APPROVAL_TASK_NOT_FOUND, "审批任务不存在");
+ }
+ UuidAssert.match(task, taskUuid);
+ }
+
+ ApprovalInstance instance = loadInstance(task, orderId);
+ AppointmentOrder order = loadOrder(instance, orderId, orderUuid);
+ ApprovalTask currentUserTask = resolveCurrentUserTask(task, uid, instance, order);
+
+ if (currentUserTask == null && !Objects.equals(order.getVisitedUserId(), uid)) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "无权查看该审批详情");
+ }
+
+ MiniApprovalDetailVo vo = new MiniApprovalDetailVo();
+ vo.setTaskId(currentUserTask == null ? null : currentUserTask.getId());
+ vo.setTaskUuid(currentUserTask == null ? null : currentUserTask.getUuid());
+ vo.setOrderId(order.getId());
+ vo.setOrderUuid(order.getUuid());
+ vo.setOrderNo(order.getOrderNo());
+ vo.setTaskStatus(currentUserTask == null ? null : currentUserTask.getTaskStatus());
+ vo.setNodeCode(currentUserTask == null ? null : currentUserTask.getNodeCode());
+ vo.setNodeName(resolveNodeName(currentUserTask == null ? null : currentUserTask.getNodeCode()));
+ fillMode(vo, uid, order, currentUserTask);
+
+ vo.setVisitedUserId(order.getVisitedUserId());
+ vo.setVisitedUserName(order.getVisitedUserName());
+ vo.setVisitedDeptName(loadDeptName(order.getVisitedDeptId()));
+ vo.setAccessAreaText(joinNames(parseStringList(order.getPassAccessAreaNames())));
+ vo.setVisitTypeText(buildVisitTypeText(order));
+ vo.setVisitReasonText(buildVisitReasonText(order));
+ vo.setVisitTimeText(buildVisitTimeText(order));
+ vo.setPassAccessAreaIds(parseLongList(order.getPassAccessAreaIds()));
+ vo.setPassAccessAreaNames(parseStringList(order.getPassAccessAreaNames()));
+ vo.setInvolveOtherLocalUnit(Boolean.TRUE.equals(order.getInvolveOtherLocalUnit()));
+ vo.setLocalUnits(loadLocalUnits(parseLongList(order.getPassLocalUnitIds())));
+ vo.setVisitors(loadVisitors(order.getId()));
+ List<ApprovalFlowNodeVo> flowTree = approvalFlowViewApplicationService.treeByOrderId(order.getId());
+ vo.setFlowTree(flowTree == null ? List.of() : flowTree);
+ return vo;
+ }
+
+ private void fillMode(
+ MiniApprovalDetailVo vo, long uid, AppointmentOrder order, ApprovalTask currentUserTask) {
+ boolean canApprove =
+ currentUserTask != null
+ && ApprovalTaskStatus.PENDING.equals(currentUserTask.getTaskStatus())
+ && Objects.equals(currentUserTask.getAssigneeUserId(), uid);
+ vo.setCanApprove(canApprove);
+ if (canApprove
+ && Objects.equals(order.getVisitedUserId(), uid)
+ && ApprovalNodeCode.LEVEL1_HANDLER.equals(currentUserTask.getNodeCode())) {
+ vo.setPageMode(PAGE_MODE_HANDLER_PENDING);
+ vo.setApprovalStatusText("待审批");
+ return;
+ }
+ if (canApprove) {
+ vo.setPageMode(PAGE_MODE_GENERAL_PENDING);
+ vo.setApprovalStatusText("待审批");
+ return;
+ }
+ vo.setPageMode(PAGE_MODE_GENERAL_READONLY);
+ if (currentUserTask == null) {
+ vo.setApprovalStatusText("已查看");
+ return;
+ }
+ if (ApprovalTaskStatus.APPROVED.equals(currentUserTask.getTaskStatus())) {
+ vo.setApprovalStatusText("已同意");
+ return;
+ }
+ if (ApprovalTaskStatus.REJECTED.equals(currentUserTask.getTaskStatus())) {
+ vo.setApprovalStatusText("已拒绝");
+ return;
+ }
+ vo.setApprovalStatusText("待审批");
+ }
+
+ private ApprovalInstance loadInstance(ApprovalTask task, Long orderId) {
+ if (task != null) {
+ ApprovalInstance instance = approvalInstanceMapper.selectById(task.getInstanceId());
+ if (instance == null || (instance.getDelFlag() != null && instance.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.ORDER_NOT_FOUND, "审批实例不存在");
+ }
+ return instance;
+ }
+ if (orderId == null || orderId <= 0) {
+ throw new BizException(BizErrorCode.ORDER_NOT_FOUND, "预约单不存在");
+ }
+ List<ApprovalInstance> rows =
+ approvalInstanceMapper.selectList(
+ new LambdaQueryWrapper<ApprovalInstance>()
+ .eq(ApprovalInstance::getDelFlag, 0)
+ .eq(ApprovalInstance::getOrderId, orderId)
+ .orderByDesc(ApprovalInstance::getId)
+ .last("limit 1"));
+ if (rows.isEmpty()) {
+ throw new BizException(BizErrorCode.ORDER_NOT_FOUND, "审批实例不存在");
+ }
+ return rows.get(0);
+ }
+
+ private AppointmentOrder loadOrder(ApprovalInstance instance, Long orderId, String orderUuid) {
+ Long targetOrderId = instance != null ? instance.getOrderId() : orderId;
+ AppointmentOrder order = appointmentOrderMapper.selectById(targetOrderId);
+ if (order == null || (order.getDelFlag() != null && order.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.ORDER_NOT_FOUND, "预约单不存在");
+ }
+ if (orderId != null && orderId > 0) {
+ UuidAssert.match(order, orderUuid);
+ }
+ return order;
+ }
+
+ private ApprovalTask resolveCurrentUserTask(
+ ApprovalTask task, long uid, ApprovalInstance instance, AppointmentOrder order) {
+ if (task != null) {
+ if (!Objects.equals(task.getAssigneeUserId(), uid)) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "无权查看该审批任务");
+ }
+ return task;
+ }
+ List<ApprovalTask> rows =
+ approvalTaskMapper.selectList(
+ new LambdaQueryWrapper<ApprovalTask>()
+ .eq(ApprovalTask::getDelFlag, 0)
+ .eq(ApprovalTask::getInstanceId, instance.getId())
+ .eq(ApprovalTask::getAssigneeUserId, uid)
+ .orderByDesc(ApprovalTask::getUpdateTime)
+ .orderByDesc(ApprovalTask::getId));
+ return rows.isEmpty() ? null : rows.get(0);
+ }
+
+ private List<MiniApprovalDetailVisitorVo> loadVisitors(Long orderId) {
+ List<AppointmentVisitor> rows =
+ appointmentVisitorMapper.selectList(
+ new LambdaQueryWrapper<AppointmentVisitor>()
+ .eq(AppointmentVisitor::getDelFlag, 0)
+ .eq(AppointmentVisitor::getOrderId, orderId)
+ .orderByAsc(AppointmentVisitor::getId));
+ if (rows.isEmpty()) {
+ return List.of();
+ }
+ List<Long> visitorIds =
+ rows.stream()
+ .map(AppointmentVisitor::getMpVisitorId)
+ .filter(Objects::nonNull)
+ .toList();
+ Map<Long, MpVisitor> visitorMap = loadVisitorMap(visitorIds);
+ Map<Long, String> plateMap = loadVisitorPlateMap(orderId);
+ List<MiniApprovalDetailVisitorVo> result = new ArrayList<>();
+ for (AppointmentVisitor row : rows) {
+ MpVisitor visitor = visitorMap.get(row.getMpVisitorId());
+ MiniApprovalDetailVisitorVo vo = new MiniApprovalDetailVisitorVo();
+ vo.setVisitorId(row.getMpVisitorId());
+ vo.setName(firstNonBlank(row.getName(), visitor == null ? null : visitor.getVisitorName(), "-"));
+ vo.setGender(visitor == null ? null : visitor.getGender());
+ vo.setCompanyName(visitor == null ? null : visitor.getVisitorCompany());
+ vo.setMobileMasked(
+ MobileMaskUtil.mask(
+ firstNonBlank(
+ row.getMobile(),
+ visitor == null ? null : visitor.getVisitorMobile(),
+ null)));
+ String idCardPlain =
+ StringUtils.hasText(row.getIdCardCipher())
+ ? SensitiveDataUtil.decryptIdCard(row.getIdCardCipher())
+ : (visitor != null && StringUtils.hasText(visitor.getIdCardCipher())
+ ? SensitiveDataUtil.decryptIdCard(visitor.getIdCardCipher())
+ : null);
+ vo.setIdCardMasked(
+ StringUtils.hasText(idCardPlain)
+ ? SensitiveDataUtil.maskIdCard(idCardPlain)
+ : null);
+ vo.setPlateNo(plateMap.get(row.getMpVisitorId()));
+ result.add(vo);
+ }
+ return result;
+ }
+
+ private Map<Long, MpVisitor> loadVisitorMap(List<Long> visitorIds) {
+ if (visitorIds.isEmpty()) {
+ return Map.of();
+ }
+ List<MpVisitor> rows =
+ mpVisitorMapper.selectList(
+ new LambdaQueryWrapper<MpVisitor>()
+ .eq(MpVisitor::getDelFlag, 0)
+ .in(MpVisitor::getId, visitorIds));
+ return rows.stream().collect(Collectors.toMap(MpVisitor::getId, x -> x, (a, b) -> a));
+ }
+
+ private Map<Long, String> loadVisitorPlateMap(Long orderId) {
+ List<AppointmentVehicle> rows =
+ appointmentVehicleMapper.selectList(
+ new LambdaQueryWrapper<AppointmentVehicle>()
+ .eq(AppointmentVehicle::getDelFlag, 0)
+ .eq(AppointmentVehicle::getOrderId, orderId)
+ .orderByAsc(AppointmentVehicle::getId));
+ if (rows.isEmpty()) {
+ return Map.of();
+ }
+ Map<Long, List<String>> plates = new LinkedHashMap<>();
+ for (AppointmentVehicle row : rows) {
+ if (row.getDriverVisitorId() == null) {
+ continue;
+ }
+ plates.computeIfAbsent(row.getDriverVisitorId(), k -> new ArrayList<>());
+ if (StringUtils.hasText(row.getPlateNo())) {
+ plates.get(row.getDriverVisitorId()).add(row.getPlateNo().trim());
+ }
+ }
+ Map<Long, String> out = new LinkedHashMap<>();
+ for (Map.Entry<Long, List<String>> entry : plates.entrySet()) {
+ out.put(entry.getKey(), String.join("、", entry.getValue()));
+ }
+ return out;
+ }
+
+ private List<MiniLocalUnitVo> loadLocalUnits(List<Long> unitIds) {
+ if (unitIds.isEmpty()) {
+ return List.of();
+ }
+ List<MpPassLocalUnit> rows =
+ mpPassLocalUnitMapper.selectList(
+ new LambdaQueryWrapper<MpPassLocalUnit>()
+ .eq(MpPassLocalUnit::getDelFlag, 0)
+ .in(MpPassLocalUnit::getId, unitIds));
+ Map<Long, String> nameMap =
+ rows.stream()
+ .collect(Collectors.toMap(MpPassLocalUnit::getId, MpPassLocalUnit::getUnitName, (a, b) -> a));
+ List<MiniLocalUnitVo> result = new ArrayList<>();
+ for (Long unitId : unitIds) {
+ String name = nameMap.get(unitId);
+ if (!StringUtils.hasText(name)) {
+ continue;
+ }
+ MiniLocalUnitVo vo = new MiniLocalUnitVo();
+ vo.setId(unitId);
+ vo.setUnitName(name);
+ result.add(vo);
+ }
+ return result;
+ }
+
+ private String buildVisitTypeText(AppointmentOrder order) {
+ boolean personEntry = order.getPersonEntryFlag() == null || Boolean.TRUE.equals(order.getPersonEntryFlag());
+ boolean vehicleEntry = Boolean.TRUE.equals(order.getVehicleEntryFlag());
+ if (personEntry && vehicleEntry) {
+ return "访客+车辆预约";
+ }
+ if (vehicleEntry) {
+ return "车辆预约";
+ }
+ return "访客预约";
+ }
+
+ private String buildVisitReasonText(AppointmentOrder order) {
+ String code = trimToNull(order.getVisitReasonCode());
+ if (AppointmentBizConstants.VISIT_REASON_OTHER.equalsIgnoreCase(code)) {
+ String other = trimToNull(order.getVisitReasonOther());
+ return StringUtils.hasText(other) ? "其他-" + other : "其他";
+ }
+ if (!StringUtils.hasText(code)) {
+ return "-";
+ }
+ List<BizVisitReason> rows =
+ bizVisitReasonMapper.selectList(
+ new LambdaQueryWrapper<BizVisitReason>()
+ .eq(BizVisitReason::getDelFlag, 0)
+ .eq(BizVisitReason::getReasonCode, code)
+ .last("limit 1"));
+ return rows.isEmpty() || !StringUtils.hasText(rows.get(0).getReasonName())
+ ? code
+ : rows.get(0).getReasonName();
+ }
+
+ private String buildVisitTimeText(AppointmentOrder order) {
+ if (AppointmentBizConstants.TIME_MODE_SINGLE.equals(order.getVisitTimeMode())) {
+ String slotLabel =
+ switch (trimToNull(order.getVisitTimeSlot())) {
+ case AppointmentBizConstants.TIME_SLOT_ALL_DAY -> "全天";
+ case AppointmentBizConstants.TIME_SLOT_AM -> "上午";
+ case AppointmentBizConstants.TIME_SLOT_PM -> "下午";
+ default -> "";
+ };
+ if (order.getVisitStartDate() == null) {
+ return slotLabel;
+ }
+ return StringUtils.hasText(slotLabel)
+ ? order.getVisitStartDate() + " " + slotLabel
+ : order.getVisitStartDate().toString();
+ }
+ if (order.getVisitStartDate() == null && order.getVisitEndDate() == null) {
+ return "-";
+ }
+ if (Objects.equals(order.getVisitStartDate(), order.getVisitEndDate()) || order.getVisitEndDate() == null) {
+ return String.valueOf(order.getVisitStartDate());
+ }
+ return order.getVisitStartDate() + " 至 " + order.getVisitEndDate();
+ }
+
+ private String loadDeptName(Long deptId) {
+ if (deptId == null || deptId <= 0) {
+ return null;
+ }
+ SysDept dept = sysDeptMapper.selectById(deptId);
+ return dept == null ? null : dept.getDeptName();
+ }
+
+ private String resolveNodeName(String nodeCode) {
+ if (!StringUtils.hasText(nodeCode)) {
+ return null;
+ }
+ return switch (nodeCode) {
+ case ApprovalNodeCode.LEVEL1_HANDLER -> "经办人审批";
+ case ApprovalNodeCode.LEVEL2_DEPT_LEADER -> "部门负责人审批";
+ case ApprovalNodeCode.LEVEL3_LOCAL_DEPT_LEADER -> "属地管理部门领导(三级)";
+ case ApprovalNodeCode.LEVEL4_VISIT_DEPT -> "访客主管部门审批";
+ case ApprovalNodeCode.LEVEL5_VISIT_DEPT_LEADER -> "访客主管部门领导审批";
+ default -> nodeCode;
+ };
+ }
+
+ private List<Long> parseLongList(String json) {
+ if (!StringUtils.hasText(json)) {
+ return List.of();
+ }
+ try {
+ List<Long> rows = objectMapper.readValue(json, LONG_LIST_TYPE);
+ if (rows == null) {
+ return List.of();
+ }
+ return rows.stream()
+ .filter(Objects::nonNull)
+ .filter(id -> id > 0)
+ .collect(Collectors.toCollection(ArrayList::new));
+ } catch (Exception ex) {
+ return List.of();
+ }
+ }
+
+ private List<String> parseStringList(String json) {
+ if (!StringUtils.hasText(json)) {
+ return List.of();
+ }
+ try {
+ List<String> rows = objectMapper.readValue(json, STRING_LIST_TYPE);
+ if (rows == null) {
+ return List.of();
+ }
+ return rows.stream().filter(StringUtils::hasText).map(String::trim).toList();
+ } catch (Exception ex) {
+ return List.of();
+ }
+ }
+
+ private String joinNames(List<String> names) {
+ if (names == null || names.isEmpty()) {
+ return null;
+ }
+ return names.stream()
+ .filter(StringUtils::hasText)
+ .map(String::trim)
+ .collect(Collectors.joining("、"));
+ }
+
+ private String firstNonBlank(String... values) {
+ if (values == null) {
+ return null;
+ }
+ for (String value : values) {
+ if (StringUtils.hasText(value)) {
+ return value.trim();
+ }
+ }
+ return null;
+ }
+
+ private String trimToNull(String value) {
+ if (!StringUtils.hasText(value)) {
+ return null;
+ }
+ return value.trim();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java
new file mode 100644
index 0000000..256ea59
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/MiniAuthApplicationService.java
@@ -0,0 +1,282 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.constants.MiniLoginIntent;
+import com.sinata.appointment.constants.MiniUserType;
+import com.sinata.appointment.constants.MpStaffConstants;
+import com.sinata.appointment.config.MiniStpUtil;
+import com.sinata.appointment.dto.MiniLoginResponse;
+import com.sinata.appointment.dto.MiniWxLoginRequest;
+import com.sinata.appointment.entity.MpUser;
+import com.sinata.appointment.entity.SysDept;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.integration.WechatMiniGateway;
+import com.sinata.appointment.integration.WxCode2SessionResult;
+import com.sinata.appointment.mapper.SysDeptMapper;
+import com.sinata.appointment.mapper.MpUserMapper;
+import java.util.List;
+import java.util.Objects;
+import com.sinata.appointment.util.PhoneNormalize;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+/**
+ * 小程序登录:js_code → openId;phoneCode + access_token → 手机号;访客/员工与平台 sys_user 无关联。
+ */
+@Service
+public class MiniAuthApplicationService {
+
+ private static final String H5_PHONE_OPEN_ID_PREFIX = "H5_PHONE_";
+
+ private final MpUserMapper mpUserMapper;
+ private final SysDeptMapper sysDeptMapper;
+ private final WechatMiniGateway wechatMiniGateway;
+
+ public MiniAuthApplicationService(
+ MpUserMapper mpUserMapper,
+ SysDeptMapper sysDeptMapper,
+ WechatMiniGateway wechatMiniGateway) {
+ this.mpUserMapper = mpUserMapper;
+ this.sysDeptMapper = sysDeptMapper;
+ this.wechatMiniGateway = wechatMiniGateway;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public MiniLoginResponse loginByWx(MiniWxLoginRequest req) {
+ WxCode2SessionResult session = wechatMiniGateway.code2Session(req.getWxCode());
+ String openId = session.getOpenId();
+ String phoneRaw = wechatMiniGateway.getPhoneNumber(req.getPhoneCode());
+ String phone = PhoneNormalize.mainland(phoneRaw);
+
+ MpUser user =
+ mpUserMapper.selectOne(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getOpenId, openId)
+ .eq(MpUser::getDelFlag, 0));
+
+ if (MiniLoginIntent.EMPLOYEE.equals(req.getLoginIntent())) {
+ if (user == null) {
+ user = tryBindPreRegisteredEmployee(openId, session.getUnionId(), phone, phoneRaw);
+ }
+ if (user == null) {
+ throw new BizException(BizErrorCode.MINI_STAFF_NOT_BOUND, "员工未登记,请联系管理员开通后再登录");
+ }
+ assertAccountEnabled(user);
+ if (!MiniUserType.EMPLOYEE.equals(user.getUserType())) {
+ throw new BizException(
+ BizErrorCode.MINI_LOGIN_INTENT_MISMATCH, "当前微信为访客身份,请使用访客入口登录");
+ }
+ if (user.getDeptId() == null) {
+ throw new BizException(BizErrorCode.MINI_STAFF_NOT_BOUND, "员工未分配部门");
+ }
+ assertPhoneMatchesStored(user, phone);
+ syncMpUserMobile(user, phoneRaw);
+ if (user.getUnionId() == null && session.getUnionId() != null) {
+ user.setUnionId(session.getUnionId());
+ mpUserMapper.updateById(user);
+ }
+ MiniStpUtil.login(user.getId());
+ return buildResponse(user);
+ }
+
+ if (MiniLoginIntent.VISITOR.equals(req.getLoginIntent())) {
+ if (user == null) {
+ MpUser created = new MpUser();
+ created.setOpenId(openId);
+ created.setUnionId(session.getUnionId());
+ created.setUserType(MiniUserType.VISITOR);
+ created.setMobile(phoneRaw);
+ created.setStatus(1);
+ mpUserMapper.insert(created);
+ user = mpUserMapper.selectById(created.getId());
+ } else {
+ assertAccountEnabled(user);
+ if (!MiniUserType.VISITOR.equals(user.getUserType())) {
+ throw new BizException(
+ BizErrorCode.MINI_LOGIN_INTENT_MISMATCH, "当前微信已登记为员工,请使用员工入口登录");
+ }
+ assertPhoneMatchesStored(user, phone);
+ syncMpUserMobile(user, phoneRaw);
+ if (user.getUnionId() == null && session.getUnionId() != null) {
+ user.setUnionId(session.getUnionId());
+ mpUserMapper.updateById(user);
+ }
+ }
+ MiniStpUtil.login(user.getId());
+ return buildResponse(user);
+ }
+
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "loginIntent 无效");
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public MiniLoginResponse loginByPhone(String mobileRaw) {
+ String phone = PhoneNormalize.mainland(mobileRaw);
+ if (!phone.matches("^\\d{11}$")) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "手机号格式不正确");
+ }
+ List<MpUser> users =
+ mpUserMapper.selectList(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getDelFlag, 0));
+ MpUser user =
+ users.stream()
+ .filter(Objects::nonNull)
+ .filter(u -> phone.equals(PhoneNormalize.mainland(u.getMobile())))
+ .sorted(this::compareByLoginPriority)
+ .findFirst()
+ .orElse(null);
+ if (user == null) {
+ user = registerVisitorForPhoneLogin(mobileRaw);
+ } else {
+ assertAccountEnabled(user);
+ }
+ if (MiniUserType.EMPLOYEE.equals(user.getUserType()) && user.getDeptId() == null) {
+ throw new BizException(BizErrorCode.MINI_STAFF_NOT_BOUND, "员工未分配部门");
+ }
+ ensureOpenIdForPhoneLogin(user);
+ MiniStpUtil.login(user.getId());
+ return buildResponse(user);
+ }
+
+ /**
+ * 管理端预建档员工(openId 为 PLHD_ 占位):按手机号绑定当前微信 openId。
+ */
+ private MpUser tryBindPreRegisteredEmployee(
+ String openId, String unionId, String normalizedPhone, String phoneRaw) {
+ List<MpUser> candidates =
+ mpUserMapper.selectList(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getUserType, MiniUserType.EMPLOYEE)
+ .eq(MpUser::getDelFlag, 0)
+ .likeRight(MpUser::getOpenId, MpStaffConstants.PLACEHOLDER_OPEN_ID_PREFIX));
+ for (MpUser u : candidates) {
+ if (u.getOpenId() == null || !MpStaffConstants.isPlaceholderOpenId(u.getOpenId())) {
+ continue;
+ }
+ String stored = PhoneNormalize.mainland(u.getMobile());
+ if (stored.isBlank() || !stored.equals(normalizedPhone)) {
+ continue;
+ }
+ u.setOpenId(openId);
+ if (unionId != null) {
+ u.setUnionId(unionId);
+ }
+ syncMpUserMobile(u, phoneRaw);
+ mpUserMapper.updateById(u);
+ return mpUserMapper.selectById(u.getId());
+ }
+ return null;
+ }
+
+ private void assertPhoneMatchesStored(MpUser user, String normalizedPhone) {
+ if (user.getMobile() == null || user.getMobile().isBlank()) {
+ return;
+ }
+ String stored = PhoneNormalize.mainland(user.getMobile());
+ if (!stored.isBlank() && !stored.equals(normalizedPhone)) {
+ throw new BizException(BizErrorCode.MINI_PHONE_MISMATCH, "手机号与登记信息不一致");
+ }
+ }
+
+ private void syncMpUserMobile(MpUser user, String phoneRaw) {
+ boolean needUpdate = false;
+ if (user.getMobile() == null || user.getMobile().isBlank()) {
+ user.setMobile(phoneRaw);
+ needUpdate = true;
+ }
+ if (needUpdate) {
+ mpUserMapper.updateById(user);
+ }
+ }
+
+ private void assertAccountEnabled(MpUser user) {
+ if (user == null || user.getStatus() == null || user.getStatus() != 0) {
+ return;
+ }
+ if (MiniUserType.EMPLOYEE.equals(user.getUserType())) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "员工账号已禁用");
+ }
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "账号已被禁用");
+ }
+
+ private int compareByLoginPriority(MpUser a, MpUser b) {
+ int pa = loginPriority(a);
+ int pb = loginPriority(b);
+ if (pa != pb) {
+ return Integer.compare(pa, pb);
+ }
+ Long aid = a.getId() == null ? Long.MAX_VALUE : a.getId();
+ Long bid = b.getId() == null ? Long.MAX_VALUE : b.getId();
+ return Long.compare(aid, bid);
+ }
+
+ private int loginPriority(MpUser u) {
+ if (MiniUserType.EMPLOYEE.equals(u.getUserType())) {
+ return 0;
+ }
+ if (MiniUserType.VISITOR.equals(u.getUserType())) {
+ return 1;
+ }
+ return 2;
+ }
+
+ private void ensureOpenIdForPhoneLogin(MpUser user) {
+ if (StringUtils.hasText(user.getOpenId())) {
+ return;
+ }
+ user.setOpenId(H5_PHONE_OPEN_ID_PREFIX + user.getId());
+ mpUserMapper.updateById(user);
+ }
+
+ private MpUser registerVisitorForPhoneLogin(String mobileRaw) {
+ MpUser created = new MpUser();
+ created.setUserType(MiniUserType.VISITOR);
+ created.setMobile(mobileRaw);
+ created.setStatus(1);
+ mpUserMapper.insert(created);
+ return mpUserMapper.selectById(created.getId());
+ }
+
+ public MpUser currentMpUser() {
+ long id = MiniStpUtil.getLoginIdAsLong();
+ MpUser u = mpUserMapper.selectById(id);
+ if (u == null || u.getDelFlag() != null && u.getDelFlag() == 1) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "用户不存在或已禁用");
+ }
+ assertAccountEnabled(u);
+ return u;
+ }
+
+ public void logout() {
+ MiniStpUtil.logout();
+ }
+
+ private MiniLoginResponse buildResponse(MpUser user) {
+ String name =
+ user.getNickName() != null && !user.getNickName().isBlank()
+ ? user.getNickName()
+ : (MiniUserType.EMPLOYEE.equals(user.getUserType()) ? "员工" : "访客");
+ String deptName = null;
+ if (MiniUserType.EMPLOYEE.equals(user.getUserType()) && user.getDeptId() != null) {
+ SysDept dept = sysDeptMapper.selectById(user.getDeptId());
+ deptName = dept == null ? null : dept.getDeptName();
+ }
+ Boolean leader =
+ MiniUserType.EMPLOYEE.equals(user.getUserType())
+ ? Boolean.TRUE.equals(user.getDeptLeaderFlag())
+ : null;
+ return MiniLoginResponse.builder()
+ .token(MiniStpUtil.getTokenValue())
+ .mpUserId(user.getId())
+ .userType(user.getUserType())
+ .deptId(user.getDeptId())
+ .deptName(deptName)
+ .deptLeader(leader)
+ .avatarUrl(user.getAvatarUrl())
+ .displayName(name)
+ .build();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/MiniHandlerApprovalApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/MiniHandlerApprovalApplicationService.java
new file mode 100644
index 0000000..64f730d
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/MiniHandlerApprovalApplicationService.java
@@ -0,0 +1,453 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sinata.appointment.config.MiniStpUtil;
+import com.sinata.appointment.constants.ApprovalInstanceStatus;
+import com.sinata.appointment.constants.ApprovalNodeCode;
+import com.sinata.appointment.constants.ApprovalTaskStatus;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.dto.MiniApprovalTodoVo;
+import com.sinata.appointment.dto.MiniApprovalActionRequest;
+import com.sinata.appointment.dto.MiniHandlerApprovalActionRequest;
+import com.sinata.appointment.dto.MiniHandlerApprovalTodoVo;
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.entity.ApprovalInstance;
+import com.sinata.appointment.entity.ApprovalTask;
+import com.sinata.appointment.entity.BizApprovalNodeConfig;
+import com.sinata.appointment.entity.MpPassAccessArea;
+import com.sinata.appointment.entity.MpPassLocalUnit;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.mapper.AppointmentOrderMapper;
+import com.sinata.appointment.mapper.ApprovalInstanceMapper;
+import com.sinata.appointment.mapper.ApprovalTaskMapper;
+import com.sinata.appointment.mapper.BizApprovalNodeConfigMapper;
+import com.sinata.appointment.mapper.MpPassAccessAreaMapper;
+import com.sinata.appointment.mapper.MpPassLocalUnitMapper;
+import java.time.LocalDate;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+import com.sinata.appointment.util.UuidAssert;
+
+@Service
+@RequiredArgsConstructor
+public class MiniHandlerApprovalApplicationService {
+
+ private final ApprovalTaskMapper approvalTaskMapper;
+ private final ApprovalInstanceMapper approvalInstanceMapper;
+ private final AppointmentOrderMapper appointmentOrderMapper;
+ private final ApprovalEngineService approvalEngineService;
+ private final PostApprovalOrchestrationService postApprovalOrchestrationService;
+ private final BizApprovalNodeConfigMapper bizApprovalNodeConfigMapper;
+ private final MpPassAccessAreaMapper mpPassAccessAreaMapper;
+ private final MpPassLocalUnitMapper mpPassLocalUnitMapper;
+ private final ObjectMapper objectMapper;
+
+ public IPage<MiniApprovalTodoVo> pageMyTodoAll(long page, long size) {
+ return pageMyTodoAll(page, size, null, null);
+ }
+
+ public IPage<MiniApprovalTodoVo> pageMyTodoAll(
+ long page, long size, String visitedUserNameKeyword, LocalDate visitDate) {
+ long uid = MiniStpUtil.getLoginIdAsLong();
+ List<ApprovalTask> tasks =
+ approvalTaskMapper.selectList(
+ new LambdaQueryWrapper<ApprovalTask>()
+ .eq(ApprovalTask::getAssigneeUserId, uid)
+ .eq(ApprovalTask::getTaskStatus, ApprovalTaskStatus.PENDING)
+ .orderByDesc(ApprovalTask::getCreateTime)
+ .orderByDesc(ApprovalTask::getId));
+ if (tasks.isEmpty()) {
+ return new Page<>(page, size);
+ }
+ List<Long> instanceIds =
+ tasks.stream()
+ .map(ApprovalTask::getInstanceId)
+ .filter(Objects::nonNull)
+ .toList();
+ Map<Long, ApprovalInstance> instanceMap = loadInstanceMap(instanceIds);
+ List<Long> orderIds =
+ instanceMap.values().stream()
+ .map(ApprovalInstance::getOrderId)
+ .filter(Objects::nonNull)
+ .toList();
+ Map<Long, AppointmentOrder> orderMap = loadOrderMap(orderIds);
+ Map<String, String> nodeNameMap = loadNodeNameMap();
+ List<MiniApprovalTodoVo> rowsAll =
+ tasks.stream()
+ .filter(t -> matchTodoFilters(t, instanceMap, orderMap, visitedUserNameKeyword, visitDate))
+ .map(t -> toMiniTodoVo(t, instanceMap, orderMap, nodeNameMap))
+ .toList();
+ return slicePage(page, size, rowsAll);
+ }
+
+ public IPage<MiniHandlerApprovalTodoVo> pageMyTodo(long page, long size) {
+ return pageMyTodo(page, size, null, null);
+ }
+
+ public IPage<MiniHandlerApprovalTodoVo> pageMyTodo(
+ long page, long size, String visitedUserNameKeyword, LocalDate visitDate) {
+ long uid = MiniStpUtil.getLoginIdAsLong();
+ List<ApprovalTask> tasks =
+ approvalTaskMapper.selectList(
+ new LambdaQueryWrapper<ApprovalTask>()
+ .eq(ApprovalTask::getAssigneeUserId, uid)
+ .eq(ApprovalTask::getNodeCode, ApprovalNodeCode.LEVEL1_HANDLER)
+ .eq(ApprovalTask::getTaskStatus, ApprovalTaskStatus.PENDING)
+ .orderByDesc(ApprovalTask::getCreateTime)
+ .orderByDesc(ApprovalTask::getId));
+ if (tasks.isEmpty()) {
+ return new Page<>(page, size);
+ }
+ List<Long> instanceIds =
+ tasks.stream()
+ .map(ApprovalTask::getInstanceId)
+ .filter(Objects::nonNull)
+ .toList();
+ Map<Long, ApprovalInstance> instanceMap = loadInstanceMap(instanceIds);
+ List<Long> orderIds =
+ instanceMap.values().stream()
+ .map(ApprovalInstance::getOrderId)
+ .filter(Objects::nonNull)
+ .toList();
+ Map<Long, AppointmentOrder> orderMap = loadOrderMap(orderIds);
+ List<MiniHandlerApprovalTodoVo> rowsAll =
+ tasks.stream()
+ .filter(t -> matchTodoFilters(t, instanceMap, orderMap, visitedUserNameKeyword, visitDate))
+ .map(t -> toTodoVo(t, instanceMap, orderMap))
+ .toList();
+ return slicePage(page, size, rowsAll);
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void handleAction(MiniHandlerApprovalActionRequest req) {
+ long uid = MiniStpUtil.getLoginIdAsLong();
+ ApprovalTask task = approvalTaskMapper.selectById(req.getTaskId());
+ if (task == null || (task.getDelFlag() != null && task.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.APPROVAL_TASK_NOT_FOUND, "审批任务不存在");
+ }
+ UuidAssert.match(task, req.getTaskUuid());
+ if (!ApprovalNodeCode.LEVEL1_HANDLER.equals(task.getNodeCode())) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "仅支持经办人一级审批");
+ }
+ if (!ApprovalTaskStatus.PENDING.equals(task.getTaskStatus())) {
+ throw new BizException(BizErrorCode.APPROVAL_CONFLICT, "任务已处理");
+ }
+ if (!Objects.equals(task.getAssigneeUserId(), uid)) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "无权处理该任务");
+ }
+ String comment = req.getComment().trim();
+ if (!StringUtils.hasText(comment)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "审批意见不能为空");
+ }
+
+ ApprovalInstance inst = approvalInstanceMapper.selectById(task.getInstanceId());
+ if (inst == null || (inst.getDelFlag() != null && inst.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.ORDER_NOT_FOUND, "审批实例不存在");
+ }
+ AppointmentOrder order = appointmentOrderMapper.selectById(inst.getOrderId());
+ if (order == null || (order.getDelFlag() != null && order.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.ORDER_NOT_FOUND, "预约单不存在");
+ }
+
+ if (Boolean.FALSE.equals(req.getApprove())) {
+ task.setTaskStatus(ApprovalTaskStatus.REJECTED);
+ task.setComment(comment);
+ approvalTaskMapper.updateById(task);
+ approvalEngineService.rejectInstance(inst, comment);
+ postApprovalOrchestrationService.onOrderRejected(order, comment);
+ return;
+ }
+
+ fillOrderApprovalExtraFields(order, req);
+ appointmentOrderMapper.updateById(order);
+
+ task.setTaskStatus(ApprovalTaskStatus.APPROVED);
+ task.setComment(comment);
+ approvalTaskMapper.updateById(task);
+ approvalEngineService.markGroupSkippedExcept(task);
+ approvalEngineService.rebuildAfterHandlerApproved(task, inst, order);
+ ApprovalInstance latest = approvalInstanceMapper.selectById(inst.getId());
+ if (ApprovalInstanceStatus.APPROVED.equals(latest.getStatus())) {
+ AppointmentOrder fresh = appointmentOrderMapper.selectById(order.getId());
+ postApprovalOrchestrationService.onOrderFullyApproved(fresh);
+ }
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void handleOtherAction(MiniApprovalActionRequest req) {
+ long uid = MiniStpUtil.getLoginIdAsLong();
+ ApprovalTask task = approvalTaskMapper.selectById(req.getTaskId());
+ if (task == null || (task.getDelFlag() != null && task.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.APPROVAL_TASK_NOT_FOUND, "审批任务不存在");
+ }
+ UuidAssert.match(task, req.getTaskUuid());
+ if (ApprovalNodeCode.LEVEL1_HANDLER.equals(task.getNodeCode())) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "经办人一级请走专用审批接口");
+ }
+ if (!ApprovalTaskStatus.PENDING.equals(task.getTaskStatus())) {
+ throw new BizException(BizErrorCode.APPROVAL_CONFLICT, "任务已处理");
+ }
+ if (!Objects.equals(task.getAssigneeUserId(), uid)) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "无权处理该任务");
+ }
+
+ ApprovalInstance inst = approvalInstanceMapper.selectById(task.getInstanceId());
+ if (inst == null || (inst.getDelFlag() != null && inst.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.ORDER_NOT_FOUND, "审批实例不存在");
+ }
+ AppointmentOrder order = appointmentOrderMapper.selectById(inst.getOrderId());
+ if (order == null || (order.getDelFlag() != null && order.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.ORDER_NOT_FOUND, "预约单不存在");
+ }
+
+ String comment = req.getComment() == null ? null : req.getComment().trim();
+ if (Boolean.FALSE.equals(req.getApprove())) {
+ String reason = StringUtils.hasText(comment) ? comment : "拒绝";
+ task.setTaskStatus(ApprovalTaskStatus.REJECTED);
+ task.setComment(reason);
+ approvalTaskMapper.updateById(task);
+ approvalEngineService.rejectInstance(inst, reason);
+ postApprovalOrchestrationService.onOrderRejected(order, reason);
+ return;
+ }
+
+ task.setTaskStatus(ApprovalTaskStatus.APPROVED);
+ task.setComment(comment);
+ approvalTaskMapper.updateById(task);
+ approvalEngineService.markGroupSkippedExcept(task);
+ if (approvalEngineService.isCurrentNodeComplete(inst)) {
+ approvalEngineService.advanceOrComplete(inst, order);
+ ApprovalInstance latest = approvalInstanceMapper.selectById(inst.getId());
+ if (ApprovalInstanceStatus.APPROVED.equals(latest.getStatus())) {
+ AppointmentOrder fresh = appointmentOrderMapper.selectById(order.getId());
+ postApprovalOrchestrationService.onOrderFullyApproved(fresh);
+ }
+ }
+ }
+
+ private Map<Long, AppointmentOrder> loadOrderMap(List<Long> orderIds) {
+ if (orderIds.isEmpty()) {
+ return Map.of();
+ }
+ List<AppointmentOrder> rows =
+ appointmentOrderMapper.selectList(
+ new LambdaQueryWrapper<AppointmentOrder>()
+ .eq(AppointmentOrder::getDelFlag, 0)
+ .in(AppointmentOrder::getId, orderIds));
+ return rows.stream().collect(Collectors.toMap(AppointmentOrder::getId, x -> x, (a, b) -> a));
+ }
+
+ private Map<Long, ApprovalInstance> loadInstanceMap(List<Long> instanceIds) {
+ if (instanceIds.isEmpty()) {
+ return Map.of();
+ }
+ List<ApprovalInstance> rows =
+ approvalInstanceMapper.selectList(
+ new LambdaQueryWrapper<ApprovalInstance>()
+ .eq(ApprovalInstance::getDelFlag, 0)
+ .in(ApprovalInstance::getId, instanceIds));
+ return rows.stream().collect(Collectors.toMap(ApprovalInstance::getId, x -> x, (a, b) -> a));
+ }
+
+ private MiniHandlerApprovalTodoVo toTodoVo(
+ ApprovalTask task,
+ Map<Long, ApprovalInstance> instanceMap,
+ Map<Long, AppointmentOrder> orderMap) {
+ MiniHandlerApprovalTodoVo vo = new MiniHandlerApprovalTodoVo();
+ vo.setTaskId(task.getId());
+ vo.setTaskUuid(task.getUuid());
+ vo.setCreateTime(task.getCreateTime());
+ ApprovalInstance inst = instanceMap.get(task.getInstanceId());
+ if (inst == null) {
+ return vo;
+ }
+ AppointmentOrder order = orderMap.get(inst.getOrderId());
+ if (order == null) {
+ return vo;
+ }
+ vo.setOrderId(order.getId());
+ vo.setOrderUuid(order.getUuid());
+ vo.setOrderNo(order.getOrderNo());
+ vo.setVisitedUserName(order.getVisitedUserName());
+ vo.setVisitStartDate(order.getVisitStartDate());
+ vo.setVisitEndDate(order.getVisitEndDate());
+ vo.setVisitReasonCode(order.getVisitReasonCode());
+ return vo;
+ }
+
+ private Map<String, String> loadNodeNameMap() {
+ List<BizApprovalNodeConfig> rows =
+ bizApprovalNodeConfigMapper.selectList(
+ new LambdaQueryWrapper<BizApprovalNodeConfig>()
+ .eq(BizApprovalNodeConfig::getDelFlag, 0)
+ .orderByAsc(BizApprovalNodeConfig::getSortOrder));
+ Map<String, String> out = new java.util.LinkedHashMap<>();
+ for (BizApprovalNodeConfig row : rows) {
+ out.put(row.getNodeCode(), row.getNodeName());
+ }
+ return out;
+ }
+
+ private MiniApprovalTodoVo toMiniTodoVo(
+ ApprovalTask task,
+ Map<Long, ApprovalInstance> instanceMap,
+ Map<Long, AppointmentOrder> orderMap,
+ Map<String, String> nodeNameMap) {
+ MiniApprovalTodoVo vo = new MiniApprovalTodoVo();
+ vo.setTaskId(task.getId());
+ vo.setTaskUuid(task.getUuid());
+ vo.setCreateTime(task.getCreateTime());
+ vo.setNodeCode(task.getNodeCode());
+ vo.setNodeName(nodeNameMap.getOrDefault(task.getNodeCode(), task.getNodeCode()));
+ ApprovalInstance inst = instanceMap.get(task.getInstanceId());
+ if (inst == null) {
+ return vo;
+ }
+ AppointmentOrder order = orderMap.get(inst.getOrderId());
+ if (order == null) {
+ return vo;
+ }
+ vo.setOrderId(order.getId());
+ vo.setOrderUuid(order.getUuid());
+ vo.setOrderNo(order.getOrderNo());
+ vo.setVisitedUserName(order.getVisitedUserName());
+ vo.setVisitStartDate(order.getVisitStartDate());
+ vo.setVisitEndDate(order.getVisitEndDate());
+ vo.setVisitReasonCode(order.getVisitReasonCode());
+ return vo;
+ }
+
+ private void fillOrderApprovalExtraFields(AppointmentOrder order, MiniHandlerApprovalActionRequest req) {
+ List<Long> areaIds = normalizeIds(req.getPassAccessAreaIds());
+ if (areaIds.isEmpty()) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "通行区域不能为空");
+ }
+ List<MpPassAccessArea> areas =
+ mpPassAccessAreaMapper.selectList(
+ new LambdaQueryWrapper<MpPassAccessArea>()
+ .eq(MpPassAccessArea::getDelFlag, 0)
+ .in(MpPassAccessArea::getId, areaIds));
+ Map<Long, String> areaNameMap =
+ areas.stream()
+ .collect(Collectors.toMap(MpPassAccessArea::getId, MpPassAccessArea::getAreaName, (a, b) -> a));
+ List<String> areaNames = new ArrayList<>();
+ for (Long id : areaIds) {
+ String name = areaNameMap.get(id);
+ if (!StringUtils.hasText(name)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "通行区域不存在: " + id);
+ }
+ areaNames.add(name);
+ }
+
+ boolean involve = Boolean.TRUE.equals(req.getInvolveOtherLocalUnit());
+ List<Long> unitIds = normalizeIds(req.getPassLocalUnitIds());
+ List<String> unitNames = List.of();
+ if (involve) {
+ if (unitIds.isEmpty()) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "涉及其他属地单位时,单位列表不能为空");
+ }
+ List<MpPassLocalUnit> units =
+ mpPassLocalUnitMapper.selectList(
+ new LambdaQueryWrapper<MpPassLocalUnit>()
+ .eq(MpPassLocalUnit::getDelFlag, 0)
+ .in(MpPassLocalUnit::getId, unitIds));
+ Map<Long, String> unitNameMap =
+ units.stream()
+ .collect(Collectors.toMap(MpPassLocalUnit::getId, MpPassLocalUnit::getUnitName, (a, b) -> a));
+ List<String> names = new ArrayList<>();
+ for (Long id : unitIds) {
+ String name = unitNameMap.get(id);
+ if (!StringUtils.hasText(name)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "属地单位不存在: " + id);
+ }
+ names.add(name);
+ }
+ unitNames = names;
+ } else {
+ unitIds = List.of();
+ }
+
+ order.setPassAccessAreaIds(writeJson(areaIds));
+ order.setPassAccessAreaNames(writeJson(areaNames));
+ order.setInvolveOtherLocalUnit(involve);
+ order.setPassLocalUnitIds(writeJson(unitIds));
+ order.setPassLocalUnitNames(writeJson(unitNames));
+ }
+
+ private static List<Long> normalizeIds(List<Long> ids) {
+ if (ids == null || ids.isEmpty()) {
+ return List.of();
+ }
+ LinkedHashSet<Long> out = new LinkedHashSet<>();
+ for (Long id : ids) {
+ if (id != null && id > 0) {
+ out.add(id);
+ }
+ }
+ return out.stream().toList();
+ }
+
+ private String writeJson(Object value) {
+ try {
+ return objectMapper.writeValueAsString(value);
+ } catch (JsonProcessingException ex) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "数据序列化失败");
+ }
+ }
+
+ private boolean matchTodoFilters(
+ ApprovalTask task,
+ Map<Long, ApprovalInstance> instanceMap,
+ Map<Long, AppointmentOrder> orderMap,
+ String visitedUserNameKeyword,
+ LocalDate visitDate) {
+ ApprovalInstance inst = instanceMap.get(task.getInstanceId());
+ if (inst == null) {
+ return false;
+ }
+ AppointmentOrder order = orderMap.get(inst.getOrderId());
+ if (order == null) {
+ return false;
+ }
+ if (StringUtils.hasText(visitedUserNameKeyword)) {
+ String name = order.getVisitedUserName();
+ if (!StringUtils.hasText(name) || !name.contains(visitedUserNameKeyword.trim())) {
+ return false;
+ }
+ }
+ if (visitDate != null) {
+ if (order.getVisitStartDate() == null || order.getVisitEndDate() == null) {
+ return false;
+ }
+ if (visitDate.isBefore(order.getVisitStartDate()) || visitDate.isAfter(order.getVisitEndDate())) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private <T> IPage<T> slicePage(long page, long size, List<T> rowsAll) {
+ long total = rowsAll == null ? 0 : rowsAll.size();
+ long current = Math.max(1L, page);
+ long pageSize = Math.max(1L, size);
+ int from = (int) Math.min(total, (current - 1) * pageSize);
+ int to = (int) Math.min(total, from + pageSize);
+ List<T> records = from >= to ? List.of() : rowsAll.subList(from, to);
+ Page<T> out = new Page<>(current, pageSize);
+ out.setTotal(total);
+ out.setRecords(records);
+ return out;
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/MiniLookupApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/MiniLookupApplicationService.java
new file mode 100644
index 0000000..c567f8c
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/MiniLookupApplicationService.java
@@ -0,0 +1,183 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.sinata.appointment.constants.CacheNames;
+import com.sinata.appointment.dto.MiniFieldConfigVo;
+import com.sinata.appointment.dto.MiniLocalUnitVo;
+import com.sinata.appointment.constants.MiniUserType;
+import com.sinata.appointment.dto.MiniPassAccessAreaTreeVo;
+import com.sinata.appointment.dto.MiniVisitReasonVo;
+import com.sinata.appointment.dto.MiniVisitedUserVo;
+import com.sinata.appointment.entity.BizFieldConfig;
+import com.sinata.appointment.entity.MpPassAccessArea;
+import com.sinata.appointment.entity.BizVisitReason;
+import com.sinata.appointment.entity.MpPassLocalUnit;
+import com.sinata.appointment.entity.MpUser;
+import com.sinata.appointment.entity.SysDept;
+import com.sinata.appointment.mapper.BizFieldConfigMapper;
+import com.sinata.appointment.mapper.BizVisitReasonMapper;
+import com.sinata.appointment.mapper.MpPassAccessAreaMapper;
+import com.sinata.appointment.mapper.MpPassLocalUnitMapper;
+import com.sinata.appointment.mapper.MpUserMapper;
+import com.sinata.appointment.mapper.SysDeptMapper;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class MiniLookupApplicationService {
+
+ private final BizFieldConfigMapper bizFieldConfigMapper;
+ private final BizVisitReasonMapper bizVisitReasonMapper;
+ private final MpPassAccessAreaMapper mpPassAccessAreaMapper;
+ private final MpPassLocalUnitMapper mpPassLocalUnitMapper;
+ private final MpUserMapper mpUserMapper;
+ private final SysDeptMapper sysDeptMapper;
+
+ @Cacheable(cacheNames = CacheNames.MINI_LOOKUP, key = "'" + CacheNames.MINI_LOOKUP_FIELD_CONFIGS_KEY + "'")
+ public List<MiniFieldConfigVo> listFieldConfigs() {
+ return bizFieldConfigMapper.selectList(
+ new LambdaQueryWrapper<BizFieldConfig>()
+ .eq(BizFieldConfig::getDelFlag, 0)
+ .orderByAsc(BizFieldConfig::getSortOrder)
+ .orderByAsc(BizFieldConfig::getId))
+ .stream()
+ .map(this::toFieldConfigVo)
+ .toList();
+ }
+
+ @Cacheable(cacheNames = CacheNames.MINI_LOOKUP, key = "'" + CacheNames.MINI_LOOKUP_VISIT_REASONS_KEY + "'")
+ public List<MiniVisitReasonVo> listVisitReasons() {
+ return bizVisitReasonMapper.selectList(
+ new LambdaQueryWrapper<BizVisitReason>()
+ .eq(BizVisitReason::getDelFlag, 0)
+ .eq(BizVisitReason::getEnabled, 1)
+ .orderByAsc(BizVisitReason::getSortOrder)
+ .orderByAsc(BizVisitReason::getId))
+ .stream()
+ .map(this::toVisitReasonVo)
+ .toList();
+ }
+
+ @Cacheable(cacheNames = CacheNames.MINI_LOOKUP, key = "'" + CacheNames.MINI_LOOKUP_PASS_ACCESS_AREA_TREE_KEY + "'")
+ public List<MiniPassAccessAreaTreeVo> listPassAccessAreaTree() {
+ List<MpPassAccessArea> rows =
+ mpPassAccessAreaMapper.selectList(
+ new LambdaQueryWrapper<MpPassAccessArea>()
+ .eq(MpPassAccessArea::getDelFlag, 0)
+ .orderByAsc(MpPassAccessArea::getPid)
+ .orderByAsc(MpPassAccessArea::getId));
+ if (rows.isEmpty()) {
+ return List.of();
+ }
+ Map<Long, MiniPassAccessAreaTreeVo> nodeMap = new HashMap<>();
+ List<MiniPassAccessAreaTreeVo> roots = new ArrayList<>();
+ for (MpPassAccessArea row : rows) {
+ MiniPassAccessAreaTreeVo node = new MiniPassAccessAreaTreeVo();
+ node.setId(row.getId());
+ node.setAreaName(row.getAreaName());
+ nodeMap.put(row.getId(), node);
+ }
+ for (MpPassAccessArea row : rows) {
+ MiniPassAccessAreaTreeVo current = nodeMap.get(row.getId());
+ if (row.getPid() == null || row.getPid() == 0) {
+ roots.add(current);
+ continue;
+ }
+ MiniPassAccessAreaTreeVo parent = nodeMap.get(row.getPid());
+ if (parent == null) {
+ roots.add(current);
+ continue;
+ }
+ parent.getChildren().add(current);
+ }
+ return roots;
+ }
+
+ public List<MiniVisitedUserVo> searchVisitedUsersByExactName(String realName) {
+ String name = realName == null ? "" : realName.trim();
+ if (name.isEmpty()) {
+ return List.of();
+ }
+ List<MpUser> users =
+ mpUserMapper.selectList(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getDelFlag, 0)
+ .eq(MpUser::getStatus, 1)
+ .eq(MpUser::getUserType, MiniUserType.EMPLOYEE)
+ .eq(MpUser::getNickName, name)
+ .orderByAsc(MpUser::getId));
+ if (users.isEmpty()) {
+ return List.of();
+ }
+
+ List<Long> deptIds =
+ users.stream().map(MpUser::getDeptId).filter(id -> id != null && id > 0).distinct().toList();
+ Map<Long, String> deptNameMap = new HashMap<>();
+ if (!deptIds.isEmpty()) {
+ List<SysDept> depts =
+ sysDeptMapper.selectList(
+ new LambdaQueryWrapper<SysDept>()
+ .eq(SysDept::getDelFlag, 0)
+ .in(SysDept::getId, deptIds));
+ deptNameMap =
+ depts.stream().collect(Collectors.toMap(SysDept::getId, d -> d.getDeptName() == null ? "" : d.getDeptName(), (a, b) -> a));
+ }
+ final Map<Long, String> finalDeptNameMap = deptNameMap;
+ return users.stream().map(u -> toVo(u, finalDeptNameMap)).collect(Collectors.toList());
+ }
+
+ public List<MiniLocalUnitVo> searchLocalUnitsByExactName(String unitName) {
+ String name = unitName == null ? "" : unitName.trim();
+ if (name.isEmpty()) {
+ return List.of();
+ }
+ return mpPassLocalUnitMapper.selectList(
+ new LambdaQueryWrapper<MpPassLocalUnit>()
+ .eq(MpPassLocalUnit::getDelFlag, 0)
+ .eq(MpPassLocalUnit::getUnitName, name)
+ .orderByAsc(MpPassLocalUnit::getId))
+ .stream()
+ .map(this::toLocalUnitVo)
+ .toList();
+ }
+
+ private MiniVisitedUserVo toVo(MpUser user, Map<Long, String> deptNameMap) {
+ MiniVisitedUserVo vo = new MiniVisitedUserVo();
+ vo.setId(user.getId());
+ vo.setRealName(user.getNickName());
+ vo.setDeptName(user.getDeptId() == null ? "" : deptNameMap.getOrDefault(user.getDeptId(), ""));
+ return vo;
+ }
+
+ private MiniVisitReasonVo toVisitReasonVo(BizVisitReason row) {
+ MiniVisitReasonVo vo = new MiniVisitReasonVo();
+ vo.setId(row.getId());
+ vo.setReasonCode(row.getReasonCode());
+ vo.setReasonName(row.getReasonName());
+ return vo;
+ }
+
+ private MiniFieldConfigVo toFieldConfigVo(BizFieldConfig row) {
+ MiniFieldConfigVo vo = new MiniFieldConfigVo();
+ vo.setFieldCode(row.getFieldCode());
+ vo.setFieldName(row.getFieldName());
+ vo.setVisible(row.getVisible());
+ vo.setRequiredFlag(row.getRequiredFlag());
+ vo.setSortOrder(row.getSortOrder());
+ return vo;
+ }
+
+ private MiniLocalUnitVo toLocalUnitVo(MpPassLocalUnit row) {
+ MiniLocalUnitVo vo = new MiniLocalUnitVo();
+ vo.setId(row.getId());
+ vo.setUnitName(row.getUnitName());
+ return vo;
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/MiniSystemSettingApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/MiniSystemSettingApplicationService.java
new file mode 100644
index 0000000..2427b63
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/MiniSystemSettingApplicationService.java
@@ -0,0 +1,43 @@
+package com.sinata.appointment.service;
+
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.exception.BizException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+@Service
+@RequiredArgsConstructor
+public class MiniSystemSettingApplicationService {
+
+ private final SysBizSettingCacheService sysBizSettingCacheService;
+
+ public Map<String, String> getByCodeOrCodes(String code, String codes) {
+ LinkedHashSet<String> keys = new LinkedHashSet<>();
+ if (StringUtils.hasText(code)) {
+ keys.add(code.trim());
+ }
+ if (StringUtils.hasText(codes)) {
+ String[] arr = codes.split(",");
+ for (String s : arr) {
+ if (StringUtils.hasText(s)) {
+ keys.add(s.trim());
+ }
+ }
+ }
+ if (keys.isEmpty()) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "请传 code 或 codes");
+ }
+ List<String> keyList = new ArrayList<>(keys);
+ LinkedHashMap<String, String> out = new LinkedHashMap<>();
+ for (String k : keyList) {
+ out.put(k, sysBizSettingCacheService.getValue(k));
+ }
+ return out;
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/MiniVisitorApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/MiniVisitorApplicationService.java
new file mode 100644
index 0000000..70ad6c8
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/MiniVisitorApplicationService.java
@@ -0,0 +1,250 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.sinata.appointment.constants.AppointmentFieldCodes;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.dto.MiniVisitorCreateRequest;
+import com.sinata.appointment.dto.MiniVisitorDetailVo;
+import com.sinata.appointment.dto.MiniVisitorListVo;
+import com.sinata.appointment.dto.MiniVisitorUpdateRequest;
+import com.sinata.appointment.entity.BizFieldConfig;
+import com.sinata.appointment.entity.MpUser;
+import com.sinata.appointment.entity.MpVisitor;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.mapper.BizFieldConfigMapper;
+import com.sinata.appointment.mapper.MpVisitorMapper;
+import com.sinata.appointment.util.IdCardUtil;
+import com.sinata.appointment.util.SensitiveDataUtil;
+import com.sinata.appointment.util.UuidAssert;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+@Service
+@RequiredArgsConstructor
+public class MiniVisitorApplicationService {
+
+ private final MiniAuthApplicationService miniAuthApplicationService;
+ private final MpVisitorMapper mpVisitorMapper;
+ private final BizFieldConfigMapper bizFieldConfigMapper;
+
+ @Transactional(rollbackFor = Exception.class)
+ public void create(MiniVisitorCreateRequest req) {
+ MpUser current = miniAuthApplicationService.currentMpUser();
+ Set<String> requiredCodes = loadRequiredMpVisitorFieldCodes();
+ VisitorNormalizedInput input =
+ normalizeAndValidate(
+ req.getVisitorName(),
+ req.getVisitorMobile(),
+ req.getVisitorCompany(),
+ req.getIdCard(),
+ req.getGender(),
+ requiredCodes);
+
+ MpVisitor row = new MpVisitor();
+ row.setMpUserId(current.getId());
+ // 兼容当前表结构 NOT NULL:未填字段统一落空字符串
+ row.setVisitorName(input.name() == null ? "" : input.name());
+ row.setVisitorMobile(input.mobile() == null ? "" : input.mobile());
+ row.setVisitorCompany(input.company() == null ? "" : input.company());
+ row.setIdCardCipher(
+ StringUtils.hasText(input.idCard())
+ ? SensitiveDataUtil.encryptIdCard(input.idCard())
+ : "");
+ row.setGender(input.gender() == null ? "" : input.gender());
+ mpVisitorMapper.insert(row);
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void update(Long id, String uuid, MiniVisitorUpdateRequest req) {
+ MpUser current = miniAuthApplicationService.currentMpUser();
+ MpVisitor row = loadOwnedVisitor(id, uuid, current.getId());
+ Set<String> requiredCodes = loadRequiredMpVisitorFieldCodes();
+ VisitorNormalizedInput input =
+ normalizeAndValidate(
+ req.getVisitorName(),
+ req.getVisitorMobile(),
+ req.getVisitorCompany(),
+ req.getIdCard(),
+ req.getGender(),
+ requiredCodes);
+ row.setVisitorName(input.name() == null ? "" : input.name());
+ row.setVisitorMobile(input.mobile() == null ? "" : input.mobile());
+ row.setVisitorCompany(input.company() == null ? "" : input.company());
+ row.setIdCardCipher(
+ StringUtils.hasText(input.idCard())
+ ? SensitiveDataUtil.encryptIdCard(input.idCard())
+ : "");
+ row.setGender(input.gender() == null ? "" : input.gender());
+ mpVisitorMapper.updateById(row);
+ }
+
+ public MiniVisitorDetailVo detail(Long id, String uuid) {
+ MpUser current = miniAuthApplicationService.currentMpUser();
+ MpVisitor row = loadOwnedVisitor(id, uuid, current.getId());
+ MiniVisitorDetailVo vo = new MiniVisitorDetailVo();
+ vo.setId(row.getId());
+ vo.setUuid(row.getUuid());
+ vo.setVisitorName(row.getVisitorName());
+ vo.setVisitorMobile(row.getVisitorMobile());
+ vo.setVisitorCompany(row.getVisitorCompany());
+ vo.setIdCard(
+ StringUtils.hasText(row.getIdCardCipher())
+ ? SensitiveDataUtil.decryptIdCard(row.getIdCardCipher())
+ : "");
+ vo.setGender(row.getGender());
+ return vo;
+ }
+
+ public List<MiniVisitorListVo> listMine() {
+ MpUser current = miniAuthApplicationService.currentMpUser();
+ Set<String> requiredCodes = loadRequiredMpVisitorFieldCodes();
+ List<MpVisitor> rows =
+ mpVisitorMapper.selectList(
+ new LambdaQueryWrapper<MpVisitor>()
+ .eq(MpVisitor::getMpUserId, current.getId())
+ .eq(MpVisitor::getDelFlag, 0)
+ .orderByDesc(MpVisitor::getCreateTime)
+ .orderByDesc(MpVisitor::getId));
+ return rows.stream().map(row -> toListVo(row, requiredCodes)).collect(Collectors.toList());
+ }
+
+ private MiniVisitorListVo toListVo(MpVisitor row, Set<String> requiredCodes) {
+ MiniVisitorListVo vo = new MiniVisitorListVo();
+ vo.setId(row.getId());
+ vo.setUuid(row.getUuid());
+ vo.setVisitorName(row.getVisitorName());
+ vo.setGender(row.getGender());
+ if (StringUtils.hasText(row.getIdCardCipher())) {
+ String plain = SensitiveDataUtil.decryptIdCard(row.getIdCardCipher());
+ vo.setIdCardMasked(SensitiveDataUtil.maskIdCard(plain));
+ } else {
+ vo.setIdCardMasked(null);
+ }
+ vo.setVisitorCompany(row.getVisitorCompany());
+ vo.setMissingFieldCodes(resolveMissingFieldCodes(row, requiredCodes));
+ return vo;
+ }
+
+ private List<String> resolveMissingFieldCodes(MpVisitor row, Set<String> requiredCodes) {
+ List<String> missing = new ArrayList<>();
+ for (String code : requiredCodes) {
+ if (AppointmentFieldCodes.VISITOR_NAME.equals(code)
+ && !StringUtils.hasText(trim(row.getVisitorName()))) {
+ missing.add(code);
+ } else if (AppointmentFieldCodes.ID_CARD.equals(code)
+ && !StringUtils.hasText(trim(row.getIdCardCipher()))) {
+ missing.add(code);
+ } else if (AppointmentFieldCodes.GENDER.equals(code)
+ && !StringUtils.hasText(trim(row.getGender()))) {
+ missing.add(code);
+ } else if (AppointmentFieldCodes.COMPANY.equals(code)
+ && !StringUtils.hasText(trim(row.getVisitorCompany()))) {
+ missing.add(code);
+ } else if (AppointmentFieldCodes.MOBILE.equals(code)
+ && !StringUtils.hasText(trim(row.getVisitorMobile()))) {
+ missing.add(code);
+ }
+ }
+ return missing;
+ }
+
+ private Set<String> loadRequiredMpVisitorFieldCodes() {
+ List<BizFieldConfig> rows =
+ bizFieldConfigMapper.selectList(
+ new LambdaQueryWrapper<BizFieldConfig>()
+ .eq(BizFieldConfig::getDelFlag, 0)
+ .eq(BizFieldConfig::getVisible, 1)
+ .eq(BizFieldConfig::getRequiredFlag, 1));
+ Set<String> supported =
+ Set.of(
+ AppointmentFieldCodes.VISITOR_NAME,
+ AppointmentFieldCodes.ID_CARD,
+ AppointmentFieldCodes.GENDER,
+ AppointmentFieldCodes.COMPANY,
+ AppointmentFieldCodes.MOBILE);
+ return rows.stream()
+ .map(BizFieldConfig::getFieldCode)
+ .filter(StringUtils::hasText)
+ .map(String::trim)
+ .filter(supported::contains)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ }
+
+ private static String trim(String s) {
+ return s == null ? null : s.trim();
+ }
+
+ private static String trimToNull(String s) {
+ String t = trim(s);
+ return (t == null || t.isEmpty()) ? null : t;
+ }
+
+ private MpVisitor loadOwnedVisitor(Long visitorId, String uuid, Long mpUserId) {
+ MpVisitor row = mpVisitorMapper.selectById(visitorId);
+ if (row == null || (row.getDelFlag() != null && row.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.ORDER_NOT_FOUND, "访客不存在");
+ }
+ UuidAssert.match(row, uuid);
+ if (!mpUserId.equals(row.getMpUserId())) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "无权操作该访客");
+ }
+ return row;
+ }
+
+ private VisitorNormalizedInput normalizeAndValidate(
+ String visitorName,
+ String visitorMobile,
+ String visitorCompany,
+ String idCard,
+ String gender,
+ Set<String> requiredCodes) {
+ String name = trimToNull(visitorName);
+ String mobile = trimToNull(visitorMobile);
+ String company = trimToNull(visitorCompany);
+ String idCardPlain = trimToNull(idCard);
+ String genderText = trimToNull(gender);
+
+ if (requiredCodes.contains(AppointmentFieldCodes.VISITOR_NAME) && !StringUtils.hasText(name)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "来访人姓名不能为空");
+ }
+ if (StringUtils.hasText(name) && name.length() > 30) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "来访人姓名不能超过30个字");
+ }
+
+ if (requiredCodes.contains(AppointmentFieldCodes.MOBILE) && !StringUtils.hasText(mobile)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "来访人电话不能为空");
+ }
+ if (StringUtils.hasText(mobile) && !mobile.matches("^\\d{11}$")) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "来访人电话须为11位数字");
+ }
+
+ if (requiredCodes.contains(AppointmentFieldCodes.COMPANY) && !StringUtils.hasText(company)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "来访人单位不能为空");
+ }
+
+ if (requiredCodes.contains(AppointmentFieldCodes.ID_CARD) && !StringUtils.hasText(idCardPlain)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "身份证号码不能为空");
+ }
+ if (StringUtils.hasText(idCardPlain) && !IdCardUtil.isValidMainland18(idCardPlain)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "身份证号码格式不正确");
+ }
+
+ if (requiredCodes.contains(AppointmentFieldCodes.GENDER) && !StringUtils.hasText(genderText)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "性别不能为空");
+ }
+ if (StringUtils.hasText(genderText) && !"男".equals(genderText) && !"女".equals(genderText)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "性别仅支持男/女");
+ }
+ return new VisitorNormalizedInput(name, mobile, company, idCardPlain, genderText);
+ }
+
+ private record VisitorNormalizedInput(
+ String name, String mobile, String company, String idCard, String gender) {}
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/OpLogApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/OpLogApplicationService.java
new file mode 100644
index 0000000..46365a3
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/OpLogApplicationService.java
@@ -0,0 +1,38 @@
+package com.sinata.appointment.service;
+
+import com.sinata.appointment.entity.BizOpLog;
+import com.sinata.appointment.mapper.BizOpLogMapper;
+import java.time.LocalDateTime;
+import lombok.RequiredArgsConstructor;
+import org.slf4j.MDC;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+@Service
+@RequiredArgsConstructor
+public class OpLogApplicationService {
+
+ public static final String MDC_TRACE = "traceId";
+
+ private final BizOpLogMapper bizOpLogMapper;
+
+ @Async
+ public void recordAsync(
+ Long operatorUserId,
+ String module,
+ String action,
+ String resourceType,
+ String resourceId,
+ String detailJson) {
+ BizOpLog log = new BizOpLog();
+ log.setOperatorUserId(operatorUserId);
+ log.setModule(module);
+ log.setAction(action);
+ log.setResourceType(resourceType);
+ log.setResourceId(resourceId);
+ log.setDetailJson(detailJson);
+ log.setOpTime(LocalDateTime.now());
+ log.setTraceId(MDC.get(MDC_TRACE));
+ bizOpLogMapper.insert(log);
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/PassAreaManageApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/PassAreaManageApplicationService.java
new file mode 100644
index 0000000..3302c96
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/PassAreaManageApplicationService.java
@@ -0,0 +1,192 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.sinata.appointment.constants.CacheNames;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.dto.PassAreaCreateRequest;
+import com.sinata.appointment.dto.PassAreaDetailVo;
+import com.sinata.appointment.dto.PassAreaListVo;
+import com.sinata.appointment.dto.PassAreaUpdateRequest;
+import com.sinata.appointment.entity.MpPassAccessArea;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.mapper.MpPassAccessAreaMapper;
+import com.sinata.appointment.util.UuidAssert;
+import java.util.Map;
+import org.springframework.cache.Cache;
+import org.springframework.cache.CacheManager;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+@Service
+public class PassAreaManageApplicationService {
+
+ private final MpPassAccessAreaMapper mpPassAccessAreaMapper;
+ private final CacheManager cacheManager;
+
+ public PassAreaManageApplicationService(
+ MpPassAccessAreaMapper mpPassAccessAreaMapper, CacheManager cacheManager) {
+ this.mpPassAccessAreaMapper = mpPassAccessAreaMapper;
+ this.cacheManager = cacheManager;
+ }
+
+ public IPage<PassAreaListVo> page(long page, long size, Long pid, String areaName) {
+ long p = pid == null ? 0L : pid;
+ LambdaQueryWrapper<MpPassAccessArea> q =
+ new LambdaQueryWrapper<MpPassAccessArea>()
+ .eq(MpPassAccessArea::getDelFlag, 0)
+ .eq(MpPassAccessArea::getPid, p)
+ .like(StringUtils.hasText(areaName), MpPassAccessArea::getAreaName, trim(areaName))
+ .orderByDesc(MpPassAccessArea::getCreateTime);
+ Page<MpPassAccessArea> pg = mpPassAccessAreaMapper.selectPage(new Page<>(page, size), q);
+ return pg.convert(this::toListVo);
+ }
+
+ public PassAreaDetailVo detail(Long id, String uuid) {
+ MpPassAccessArea e = loadActive(id, uuid);
+ PassAreaDetailVo vo = new PassAreaDetailVo();
+ vo.setId(e.getId());
+ vo.setUuid(e.getUuid());
+ vo.setPid(e.getPid());
+ vo.setAreaName(e.getAreaName());
+ vo.setRemark(e.getRemark());
+ vo.setAreaDesc(e.getAreaDesc());
+ if (e.getPid() != null && e.getPid() > 0) {
+ MpPassAccessArea parent = mpPassAccessAreaMapper.selectById(e.getPid());
+ if (parent != null
+ && (parent.getDelFlag() == null || parent.getDelFlag() == 0)
+ && StringUtils.hasText(parent.getAreaName())) {
+ vo.setParentAreaName(parent.getAreaName());
+ }
+ }
+ return vo;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public Map<String, Object> create(PassAreaCreateRequest req) {
+ long pid = req.getPid() == null ? 0L : req.getPid();
+ validateParentPid(pid);
+ String name = req.getAreaName().trim();
+ assertNameUniqueUnderPid(pid, name, null);
+ MpPassAccessArea e = new MpPassAccessArea();
+ e.setPid(pid);
+ e.setAreaName(name);
+ e.setRemark(trimToNull(req.getRemark()));
+ e.setAreaDesc(trimToNull(req.getAreaDesc()));
+ mpPassAccessAreaMapper.insert(e);
+ MpPassAccessArea fresh = mpPassAccessAreaMapper.selectById(e.getId());
+ if (fresh == null || !StringUtils.hasText(fresh.getUuid())) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "创建后读取失败");
+ }
+ evictMiniPassAccessAreaTreeCache();
+ return Map.of("id", fresh.getId(), "uuid", fresh.getUuid());
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void update(Long id, PassAreaUpdateRequest req) {
+ MpPassAccessArea e = loadActive(id, req.getUuid());
+ String name = req.getAreaName().trim();
+ long targetPid;
+ if (e.getPid() == null || e.getPid() == 0) {
+ targetPid = 0L;
+ } else {
+ targetPid = req.getPid() != null ? req.getPid() : e.getPid();
+ validateParentPid(targetPid);
+ }
+ assertNameUniqueUnderPid(targetPid, name, id);
+ e.setPid(targetPid);
+ e.setAreaName(name);
+ if (req.getRemark() != null) {
+ e.setRemark(trimToNull(req.getRemark()));
+ }
+ e.setAreaDesc(trimToNull(req.getAreaDesc()));
+ mpPassAccessAreaMapper.updateById(e);
+ evictMiniPassAccessAreaTreeCache();
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void delete(Long id, String uuid) {
+ MpPassAccessArea e = loadActive(id, uuid);
+ long child =
+ mpPassAccessAreaMapper.selectCount(
+ new LambdaQueryWrapper<MpPassAccessArea>()
+ .eq(MpPassAccessArea::getDelFlag, 0)
+ .eq(MpPassAccessArea::getPid, e.getId()));
+ if (child > 0) {
+ throw new BizException(BizErrorCode.DEPT_HAS_CHILDREN, "请先删除子区域");
+ }
+ int affected = mpPassAccessAreaMapper.deleteById(e.getId());
+ if (affected <= 0) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "删除失败,请刷新后重试");
+ }
+ evictMiniPassAccessAreaTreeCache();
+ }
+
+ private void evictMiniPassAccessAreaTreeCache() {
+ Cache cache = cacheManager.getCache(CacheNames.MINI_LOOKUP);
+ if (cache != null) {
+ cache.evict(CacheNames.MINI_LOOKUP_PASS_ACCESS_AREA_TREE_KEY);
+ }
+ }
+
+ private void validateParentPid(long pid) {
+ if (pid == 0) {
+ return;
+ }
+ MpPassAccessArea parent = mpPassAccessAreaMapper.selectById(pid);
+ if (parent == null || (parent.getDelFlag() != null && parent.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "上级区域不存在");
+ }
+ if (parent.getPid() == null || parent.getPid() != 0) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "仅允许两级:上级须为一级区域");
+ }
+ }
+
+ private void assertNameUniqueUnderPid(long pid, String name, Long excludeId) {
+ LambdaQueryWrapper<MpPassAccessArea> w =
+ new LambdaQueryWrapper<MpPassAccessArea>()
+ .eq(MpPassAccessArea::getDelFlag, 0)
+ .eq(MpPassAccessArea::getPid, pid)
+ .eq(MpPassAccessArea::getAreaName, name);
+ if (excludeId != null) {
+ w.ne(MpPassAccessArea::getId, excludeId);
+ }
+ if (mpPassAccessAreaMapper.selectCount(w) > 0) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "同级下区域名称已存在");
+ }
+ }
+
+ private MpPassAccessArea loadActive(Long id, String uuid) {
+ MpPassAccessArea e = mpPassAccessAreaMapper.selectById(id);
+ if (e == null || (e.getDelFlag() != null && e.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "资源不存在或无权访问");
+ }
+ UuidAssert.match(e, uuid);
+ return e;
+ }
+
+ private PassAreaListVo toListVo(MpPassAccessArea e) {
+ PassAreaListVo vo = new PassAreaListVo();
+ vo.setId(e.getId());
+ vo.setUuid(e.getUuid());
+ vo.setPid(e.getPid());
+ vo.setAreaName(e.getAreaName());
+ vo.setAreaDesc(e.getAreaDesc());
+ vo.setCreateTime(e.getCreateTime());
+ return vo;
+ }
+
+ private static String trim(String s) {
+ return s == null ? null : s.trim();
+ }
+
+ private static String trimToNull(String s) {
+ if (!StringUtils.hasText(s)) {
+ return null;
+ }
+ String t = s.trim();
+ return t.isEmpty() ? null : t;
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/PostApprovalOrchestrationService.java b/backend/src/main/java/com/sinata/appointment/service/PostApprovalOrchestrationService.java
new file mode 100644
index 0000000..2af6630
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/PostApprovalOrchestrationService.java
@@ -0,0 +1,277 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.sinata.appointment.constants.AppointmentBizConstants;
+import com.sinata.appointment.constants.AppointmentOrderStatus;
+import com.sinata.appointment.constants.ApprovalInstanceStatus;
+import com.sinata.appointment.constants.IntegrationConstant;
+import com.sinata.appointment.constants.QrcodeTicketStatus;
+import com.sinata.appointment.constants.SystemSettingKeys;
+import com.sinata.appointment.entity.ApprovalInstance;
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.entity.AppointmentVisitor;
+import com.sinata.appointment.entity.BizVisitReason;
+import com.sinata.appointment.entity.MpUser;
+import com.sinata.appointment.entity.MpVisitor;
+import com.sinata.appointment.entity.QrcodeTicket;
+import com.sinata.appointment.mapper.AppointmentOrderMapper;
+import com.sinata.appointment.mapper.AppointmentVisitorMapper;
+import com.sinata.appointment.mapper.ApprovalInstanceMapper;
+import com.sinata.appointment.mapper.BizVisitReasonMapper;
+import com.sinata.appointment.mapper.MpUserMapper;
+import com.sinata.appointment.mapper.MpVisitorMapper;
+import com.sinata.appointment.mapper.QrcodeTicketMapper;
+import com.sinata.appointment.util.SensitiveDataUtil;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+/**
+ * 审批通过后的编排:出码、通知(异步由集成服务触发)。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class PostApprovalOrchestrationService {
+
+ private static final int DEFAULT_MAX_CONSECUTIVE_DAYS = 7;
+
+ private final IntegrationHikvisionService integrationHikvisionService;
+ private final IntegrationWechatService integrationWechatService;
+ private final QrcodeTicketMapper qrcodeTicketMapper;
+ private final AppointmentOrderMapper appointmentOrderMapper;
+ private final ApprovalInstanceMapper approvalInstanceMapper;
+ private final AppointmentVisitorMapper appointmentVisitorMapper;
+ private final MpVisitorMapper mpVisitorMapper;
+ private final MpUserMapper mpUserMapper;
+ private final BizVisitReasonMapper bizVisitReasonMapper;
+ private final SysBizSettingCacheService sysBizSettingCacheService;
+
+ @Transactional(rollbackFor = Exception.class)
+ public void onOrderFullyApproved(AppointmentOrder order) {
+ ApprovalInstance latestInstance =
+ approvalInstanceMapper.selectOne(
+ new LambdaQueryWrapper<ApprovalInstance>()
+ .eq(ApprovalInstance::getDelFlag, 0)
+ .eq(ApprovalInstance::getOrderId, order.getId())
+ .orderByDesc(ApprovalInstance::getId)
+ .last("limit 1"));
+ if (latestInstance != null
+ && !ApprovalInstanceStatus.APPROVED.equals(latestInstance.getStatus())) {
+ log.info(
+ "{} orderId={} skipped, instanceStatus={}",
+ IntegrationConstant.HIKVISION_QRCODE_EVENT,
+ order.getId(),
+ latestInstance.getStatus());
+ return;
+ }
+
+ order.setStatus(AppointmentOrderStatus.APPROVED);
+ appointmentOrderMapper.updateById(order);
+
+ List<AppointmentVisitor> visitors =
+ appointmentVisitorMapper.selectList(
+ new LambdaQueryWrapper<AppointmentVisitor>()
+ .eq(AppointmentVisitor::getDelFlag, 0)
+ .eq(AppointmentVisitor::getOrderId, order.getId())
+ .orderByAsc(AppointmentVisitor::getId));
+ int validMinutes = getMaxConsecutiveDays() * 24 * 60;
+ LocalDateTime generateTime = resolveGenerateTime(order);
+ LocalDateTime expireTime = generateTime.plusMinutes(validMinutes);
+ String visitPurpose = buildVisitPurpose(order);
+ String areas = normalizeAreas(order.getPassAccessAreaNames());
+ MpUser visitedUser = loadVisitedUser(order.getVisitedUserId());
+
+ int createdCount = 0;
+ for (AppointmentVisitor visitor : visitors) {
+ QrcodeTicket existed = findActiveUnusedTicket(order.getId(), visitor.getMpVisitorId());
+ if (existed != null) {
+ log.info(
+ "{} orderId={} mpVisitorId={} reuse ticketId={}",
+ IntegrationConstant.HIKVISION_QRCODE_EVENT,
+ order.getId(),
+ visitor.getMpVisitorId(),
+ existed.getId());
+ continue;
+ }
+ MpVisitor visitorProfile = loadVisitor(visitor.getMpVisitorId());
+ String ticketUuid = UUID.randomUUID().toString().replace("-", "");
+ String visitorIdCardPlain = resolveVisitorIdCardPlain(visitor, visitorProfile);
+ String payload =
+ integrationHikvisionService.requestQrcodePayload(
+ order,
+ visitor,
+ visitorProfile,
+ visitedUser,
+ visitPurpose,
+ areas,
+ generateTime,
+ expireTime,
+ validMinutes,
+ ticketUuid,
+ visitorIdCardPlain);
+ QrcodeTicket ticket = new QrcodeTicket();
+ ticket.setUuid(ticketUuid);
+ ticket.setOrderId(order.getId());
+ ticket.setMpVisitorId(visitor.getMpVisitorId());
+ ticket.setPayload(payload);
+ ticket.setTicketStatus(QrcodeTicketStatus.UNUSED);
+ ticket.setGenerateTime(generateTime);
+ ticket.setExpireTime(expireTime);
+ qrcodeTicketMapper.insert(ticket);
+ createdCount++;
+ log.info(
+ "{} raw-payload orderId={} mpVisitorId={} payload={}",
+ IntegrationConstant.HIKVISION_QRCODE_EVENT,
+ order.getId(),
+ visitor.getMpVisitorId(),
+ payload);
+ }
+
+ if (visitors.isEmpty()) {
+ log.info("{} orderId={} no visitors found, skip qrcode creation", IntegrationConstant.HIKVISION_QRCODE_EVENT, order.getId());
+ }
+
+ try {
+ integrationWechatService.notifyApprovalFinalResult(order, true);
+ } catch (Exception e) {
+ log.warn("{} orderId={} failed: {}", IntegrationConstant.WECHAT_NOTIFY_EVENT, order.getId(), e.getMessage());
+ }
+ log.info(
+ "{} orderId={} createdTicketCount={}",
+ IntegrationConstant.HIKVISION_QRCODE_EVENT,
+ order.getId(),
+ createdCount);
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void onOrderRejected(AppointmentOrder order, String reason) {
+ order.setStatus(AppointmentOrderStatus.REJECTED);
+ order.setRejectReason(reason);
+ appointmentOrderMapper.updateById(order);
+ try {
+ integrationWechatService.notifyApprovalFinalResult(order, false);
+ } catch (Exception e) {
+ log.warn(
+ "{} orderId={} failed: {}",
+ IntegrationConstant.WECHAT_NOTIFY_EVENT,
+ order.getId(),
+ e.getMessage());
+ }
+ }
+
+ private QrcodeTicket findActiveUnusedTicket(Long orderId, Long mpVisitorId) {
+ LambdaQueryWrapper<QrcodeTicket> query =
+ new LambdaQueryWrapper<QrcodeTicket>()
+ .eq(QrcodeTicket::getDelFlag, 0)
+ .eq(QrcodeTicket::getOrderId, orderId)
+ .eq(QrcodeTicket::getTicketStatus, QrcodeTicketStatus.UNUSED)
+ .orderByDesc(QrcodeTicket::getId)
+ .last("limit 1");
+ if (mpVisitorId == null) {
+ query.isNull(QrcodeTicket::getMpVisitorId);
+ } else {
+ query.eq(QrcodeTicket::getMpVisitorId, mpVisitorId);
+ }
+ return qrcodeTicketMapper.selectOne(query);
+ }
+
+ private LocalDateTime resolveGenerateTime(AppointmentOrder order) {
+ LocalDate visitDate = order.getVisitStartDate() != null ? order.getVisitStartDate() : order.getVisitEndDate();
+ if (visitDate == null) {
+ visitDate = LocalDate.now();
+ }
+ return visitDate.atStartOfDay();
+ }
+
+ private int getMaxConsecutiveDays() {
+ String raw = trimToNull(sysBizSettingCacheService.getValue(SystemSettingKeys.BOOKING_MAX_CONSECUTIVE_DAYS));
+ if (!StringUtils.hasText(raw)) {
+ return DEFAULT_MAX_CONSECUTIVE_DAYS;
+ }
+ try {
+ return Math.max(1, Integer.parseInt(raw));
+ } catch (NumberFormatException ex) {
+ return DEFAULT_MAX_CONSECUTIVE_DAYS;
+ }
+ }
+
+ private String buildVisitPurpose(AppointmentOrder order) {
+ String code = trimToNull(order.getVisitReasonCode());
+ if (!StringUtils.hasText(code)) {
+ return "";
+ }
+ if (AppointmentBizConstants.VISIT_REASON_OTHER.equalsIgnoreCase(code)) {
+ String other = trimToNull(order.getVisitReasonOther());
+ return StringUtils.hasText(other) ? "其他-" + other : "其他";
+ }
+ BizVisitReason reason =
+ bizVisitReasonMapper.selectOne(
+ new LambdaQueryWrapper<BizVisitReason>()
+ .eq(BizVisitReason::getDelFlag, 0)
+ .eq(BizVisitReason::getReasonCode, code)
+ .last("limit 1"));
+ return reason != null && StringUtils.hasText(reason.getReasonName()) ? reason.getReasonName().trim() : code;
+ }
+
+ private String normalizeAreas(String rawJson) {
+ String raw = trimToNull(rawJson);
+ if (!StringUtils.hasText(raw)) {
+ return "";
+ }
+ String cleaned = raw.trim();
+ if (cleaned.startsWith("[") && cleaned.endsWith("]")) {
+ cleaned = cleaned.substring(1, cleaned.length() - 1);
+ }
+ cleaned = cleaned.replace("\"", "").trim();
+ while (cleaned.endsWith(",")) {
+ cleaned = cleaned.substring(0, cleaned.length() - 1).trim();
+ }
+ return cleaned;
+ }
+
+ private MpUser loadVisitedUser(Long visitedUserId) {
+ if (visitedUserId == null) {
+ return null;
+ }
+ return mpUserMapper.selectOne(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getDelFlag, 0)
+ .eq(MpUser::getId, visitedUserId)
+ .last("limit 1"));
+ }
+
+ private MpVisitor loadVisitor(Long mpVisitorId) {
+ if (mpVisitorId == null) {
+ return null;
+ }
+ return mpVisitorMapper.selectOne(
+ new LambdaQueryWrapper<MpVisitor>()
+ .eq(MpVisitor::getDelFlag, 0)
+ .eq(MpVisitor::getId, mpVisitorId)
+ .last("limit 1"));
+ }
+
+ private String resolveVisitorIdCardPlain(AppointmentVisitor visitor, MpVisitor visitorProfile) {
+ if (StringUtils.hasText(visitor.getIdCardCipher())) {
+ return SensitiveDataUtil.decryptIdCard(visitor.getIdCardCipher());
+ }
+ if (visitorProfile != null && StringUtils.hasText(visitorProfile.getIdCardCipher())) {
+ return SensitiveDataUtil.decryptIdCard(visitorProfile.getIdCardCipher());
+ }
+ return null;
+ }
+
+ private String trimToNull(String value) {
+ if (!StringUtils.hasText(value)) {
+ return null;
+ }
+ return value.trim();
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/StaffGroupManageApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/StaffGroupManageApplicationService.java
new file mode 100644
index 0000000..9031d30
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/StaffGroupManageApplicationService.java
@@ -0,0 +1,407 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.constants.MiniUserType;
+import com.sinata.appointment.dto.StaffGroupAppendMembersRequest;
+import com.sinata.appointment.dto.StaffGroupCreateRequest;
+import com.sinata.appointment.dto.StaffGroupDetailVo;
+import com.sinata.appointment.dto.StaffGroupListVo;
+import com.sinata.appointment.dto.StaffGroupMemberVo;
+import com.sinata.appointment.dto.StaffGroupUpdateRequest;
+import com.sinata.appointment.entity.MpStaffGroup;
+import com.sinata.appointment.entity.MpStaffGroupMember;
+import com.sinata.appointment.entity.MpUser;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.mapper.MpStaffGroupMapper;
+import com.sinata.appointment.mapper.MpStaffGroupMemberMapper;
+import com.sinata.appointment.mapper.MpUserMapper;
+import com.sinata.appointment.mapper.SysDeptMapper;
+import com.sinata.appointment.util.MobileMaskUtil;
+import com.sinata.appointment.util.UuidAssert;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+@Service
+public class StaffGroupManageApplicationService {
+
+ private final MpStaffGroupMapper mpStaffGroupMapper;
+ private final MpStaffGroupMemberMapper mpStaffGroupMemberMapper;
+ private final MpUserMapper mpUserMapper;
+ private final SysDeptMapper sysDeptMapper;
+
+ public StaffGroupManageApplicationService(
+ MpStaffGroupMapper mpStaffGroupMapper,
+ MpStaffGroupMemberMapper mpStaffGroupMemberMapper,
+ MpUserMapper mpUserMapper,
+ SysDeptMapper sysDeptMapper) {
+ this.mpStaffGroupMapper = mpStaffGroupMapper;
+ this.mpStaffGroupMemberMapper = mpStaffGroupMemberMapper;
+ this.mpUserMapper = mpUserMapper;
+ this.sysDeptMapper = sysDeptMapper;
+ }
+
+ public IPage<StaffGroupListVo> page(
+ long page, long size, String groupName, String leaderMobile) {
+ LambdaQueryWrapper<MpStaffGroup> q =
+ new LambdaQueryWrapper<MpStaffGroup>()
+ .eq(MpStaffGroup::getDelFlag, 0)
+ .like(StringUtils.hasText(groupName), MpStaffGroup::getGroupName, groupName)
+ .orderByDesc(MpStaffGroup::getCreateTime);
+
+ if (StringUtils.hasText(leaderMobile)) {
+ String kw = leaderMobile.trim();
+ List<MpUser> leaders =
+ mpUserMapper.selectList(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getDelFlag, 0)
+ .eq(MpUser::getUserType, MiniUserType.EMPLOYEE)
+ .like(MpUser::getMobile, kw));
+ if (leaders.isEmpty()) {
+ Page<StaffGroupListVo> empty = new Page<>(page, size, 0);
+ empty.setRecords(List.of());
+ return empty;
+ }
+ List<Long> leaderIds = leaders.stream().map(MpUser::getId).toList();
+ q.in(MpStaffGroup::getLeaderMpUserId, leaderIds);
+ }
+
+ Page<MpStaffGroup> p = mpStaffGroupMapper.selectPage(new Page<>(page, size), q);
+
+ // preload leaders and counts
+ Set<Long> leaderIds = new HashSet<>();
+ Set<Long> groupIds = new HashSet<>();
+ for (MpStaffGroup g : p.getRecords()) {
+ groupIds.add(g.getId());
+ if (g.getLeaderMpUserId() != null) {
+ leaderIds.add(g.getLeaderMpUserId());
+ }
+ }
+ Map<Long, MpUser> leaderMap = mpUsersByIds(leaderIds);
+ Map<Long, Integer> countMap = memberCountByGroupIds(groupIds);
+
+ return p.convert(
+ g -> {
+ StaffGroupListVo vo = new StaffGroupListVo();
+ vo.setId(g.getId());
+ vo.setUuid(g.getUuid());
+ vo.setGroupName(g.getGroupName());
+ MpUser leader = g.getLeaderMpUserId() != null ? leaderMap.get(g.getLeaderMpUserId()) : null;
+ vo.setLeaderName(leader != null ? leader.getNickName() : null);
+ vo.setLeaderMobileMasked(leader != null ? MobileMaskUtil.mask(leader.getMobile()) : null);
+ vo.setMemberCount(countMap.getOrDefault(g.getId(), 0));
+ vo.setCreateTime(g.getCreateTime());
+ return vo;
+ });
+ }
+
+ public StaffGroupDetailVo detail(Long id, String uuid) {
+ MpStaffGroup g = loadActiveWithUuid(id, uuid);
+ StaffGroupDetailVo vo = new StaffGroupDetailVo();
+ vo.setId(g.getId());
+ vo.setUuid(g.getUuid());
+ vo.setGroupName(g.getGroupName());
+ vo.setLeaderMpUserId(g.getLeaderMpUserId());
+ vo.setCreateTime(g.getCreateTime());
+
+ // members
+ List<MpStaffGroupMember> rels =
+ mpStaffGroupMemberMapper.selectList(
+ new LambdaQueryWrapper<MpStaffGroupMember>()
+ .eq(MpStaffGroupMember::getDelFlag, 0)
+ .eq(MpStaffGroupMember::getGroupId, g.getId())
+ .orderByAsc(MpStaffGroupMember::getId));
+ Set<Long> memberIds = new HashSet<>();
+ for (MpStaffGroupMember r : rels) {
+ memberIds.add(r.getMpUserId());
+ }
+ Map<Long, MpUser> memberMap = mpUsersByIds(memberIds);
+ Map<Long, String> deptNames = deptNamesByMemberMap(memberMap);
+ List<StaffGroupMemberVo> members = new ArrayList<>();
+ for (MpStaffGroupMember r : rels) {
+ MpUser u = memberMap.get(r.getMpUserId());
+ if (u == null) {
+ continue;
+ }
+ StaffGroupMemberVo m = new StaffGroupMemberVo();
+ m.setId(u.getId());
+ m.setUuid(u.getUuid());
+ m.setRealName(u.getNickName());
+ m.setStaffNo(u.getStaffNo());
+ m.setDeptId(u.getDeptId());
+ m.setDeptName(u.getDeptId() != null ? deptNames.get(u.getDeptId()) : null);
+ members.add(m);
+ }
+ vo.setMembers(members);
+ vo.setMemberCount(members.size());
+
+ // leader display
+ if (g.getLeaderMpUserId() != null) {
+ MpUser leader = memberMap.get(g.getLeaderMpUserId());
+ if (leader == null) {
+ leader = mpUserMapper.selectById(g.getLeaderMpUserId());
+ }
+ if (leader != null) {
+ vo.setLeaderName(leader.getNickName());
+ vo.setLeaderMobile(leader.getMobile());
+ vo.setLeaderMobileMasked(MobileMaskUtil.mask(leader.getMobile()));
+ }
+ }
+ return vo;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public Map<String, Object> create(StaffGroupCreateRequest req) {
+ String name = req.getGroupName().trim();
+ List<Long> memberIds = dedup(req.getMemberIds());
+ Long leaderId = req.getLeaderMpUserId();
+
+ if (memberIds.isEmpty()) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "关联人员不能为空");
+ }
+ if (leaderId == null) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "负责人不能为空");
+ }
+ assertLeaderHasUsableMobile(leaderId);
+ validateMembers(memberIds);
+ if (!memberIds.contains(leaderId)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "负责人必须在关联人员中");
+ }
+
+ MpStaffGroup g = new MpStaffGroup();
+ g.setGroupName(name);
+ g.setLeaderMpUserId(leaderId);
+ mpStaffGroupMapper.insert(g);
+
+ saveMembers(g.getId(), memberIds);
+ MpStaffGroup fresh = mpStaffGroupMapper.selectById(g.getId());
+ return Map.of("id", fresh.getId(), "uuid", fresh.getUuid());
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void update(Long id, StaffGroupUpdateRequest req) {
+ MpStaffGroup g = loadActiveWithUuid(id, req.getUuid());
+ String name = req.getGroupName().trim();
+ List<Long> memberIds = dedup(req.getMemberIds());
+ Long leaderId = req.getLeaderMpUserId();
+
+ if (memberIds.isEmpty()) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "关联人员不能为空");
+ }
+ if (leaderId == null) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "负责人不能为空");
+ }
+ assertLeaderHasUsableMobile(leaderId);
+ validateMembers(memberIds);
+ if (!memberIds.contains(leaderId)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "负责人必须在关联人员中");
+ }
+
+ g.setGroupName(name);
+ g.setLeaderMpUserId(leaderId);
+ mpStaffGroupMapper.updateById(g);
+
+ deleteMembersPhysically(g.getId());
+ saveMembers(g.getId(), memberIds);
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void delete(Long id, String uuid) {
+ MpStaffGroup g = loadActiveWithUuid(id, uuid);
+ int affected = mpStaffGroupMapper.deleteById(g.getId());
+ if (affected <= 0) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "删除失败,请刷新后重试");
+ }
+ deleteMembersPhysically(g.getId());
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void appendMembers(Long id, StaffGroupAppendMembersRequest req) {
+ MpStaffGroup g = loadActiveWithUuid(id, req.getUuid());
+ List<Long> ids = dedup(req.getMemberIds());
+ if (ids.isEmpty()) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "请至少选择一名成员");
+ }
+ validateMembers(ids);
+ Set<Long> existing = existingMemberMpUserIds(g.getId());
+ int added = 0;
+ for (Long mid : ids) {
+ if (existing.contains(mid)) {
+ continue;
+ }
+ MpStaffGroupMember rel = new MpStaffGroupMember();
+ rel.setGroupId(g.getId());
+ rel.setMpUserId(mid);
+ mpStaffGroupMemberMapper.insert(rel);
+ existing.add(mid);
+ added++;
+ }
+ if (added == 0) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "所选成员均已在分组中");
+ }
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void removeMember(Long groupId, String uuid, Long mpUserId) {
+ MpStaffGroup g = loadActiveWithUuid(groupId, uuid);
+ if (mpUserId == null) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "成员无效");
+ }
+ if (g.getLeaderMpUserId() != null && g.getLeaderMpUserId().equals(mpUserId)) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "不能移除负责人");
+ }
+ MpStaffGroupMember one =
+ mpStaffGroupMemberMapper.selectOne(
+ new LambdaQueryWrapper<MpStaffGroupMember>()
+ .eq(MpStaffGroupMember::getGroupId, g.getId())
+ .eq(MpStaffGroupMember::getMpUserId, mpUserId)
+ .eq(MpStaffGroupMember::getDelFlag, 0));
+ if (one == null) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "该成员不在分组中");
+ }
+ mpStaffGroupMemberMapper.deletePhysicalByGroupAndUser(g.getId(), mpUserId);
+ }
+
+ private Set<Long> existingMemberMpUserIds(Long groupId) {
+ List<MpStaffGroupMember> rels =
+ mpStaffGroupMemberMapper.selectList(
+ new LambdaQueryWrapper<MpStaffGroupMember>()
+ .eq(MpStaffGroupMember::getGroupId, groupId)
+ .eq(MpStaffGroupMember::getDelFlag, 0));
+ Set<Long> set = new HashSet<>();
+ for (MpStaffGroupMember r : rels) {
+ if (r.getMpUserId() != null) {
+ set.add(r.getMpUserId());
+ }
+ }
+ return set;
+ }
+
+ private void deleteMembersPhysically(Long groupId) {
+ mpStaffGroupMemberMapper.deletePhysicalByGroupId(groupId);
+ }
+
+ private void assertLeaderHasUsableMobile(Long leaderMpUserId) {
+ MpUser u = mpUserMapper.selectById(leaderMpUserId);
+ if (u == null || (u.getDelFlag() != null && u.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "负责人不存在");
+ }
+ if (!MiniUserType.EMPLOYEE.equals(u.getUserType())) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "负责人须为员工");
+ }
+ if (u.getStatus() != null && u.getStatus() == 0) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "负责人已禁用");
+ }
+ if (!StringUtils.hasText(u.getMobile())) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "负责人联系电话不能为空");
+ }
+ }
+
+ private MpStaffGroup loadActiveWithUuid(Long id, String uuid) {
+ MpStaffGroup g = mpStaffGroupMapper.selectById(id);
+ if (g == null || (g.getDelFlag() != null && g.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "资源不存在或无权访问");
+ }
+ UuidAssert.match(g, uuid);
+ return g;
+ }
+
+ private Map<Long, MpUser> mpUsersByIds(Set<Long> ids) {
+ Map<Long, MpUser> map = new HashMap<>();
+ if (ids == null || ids.isEmpty()) {
+ return map;
+ }
+ List<MpUser> users =
+ mpUserMapper.selectList(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getDelFlag, 0)
+ .in(MpUser::getId, ids));
+ for (MpUser u : users) {
+ map.put(u.getId(), u);
+ }
+ return map;
+ }
+
+ private Map<Long, Integer> memberCountByGroupIds(Set<Long> groupIds) {
+ Map<Long, Integer> map = new HashMap<>();
+ if (groupIds == null || groupIds.isEmpty()) {
+ return map;
+ }
+ List<MpStaffGroupMember> rels =
+ mpStaffGroupMemberMapper.selectList(
+ new LambdaQueryWrapper<MpStaffGroupMember>()
+ .eq(MpStaffGroupMember::getDelFlag, 0)
+ .in(MpStaffGroupMember::getGroupId, groupIds));
+ for (MpStaffGroupMember r : rels) {
+ map.put(r.getGroupId(), map.getOrDefault(r.getGroupId(), 0) + 1);
+ }
+ return map;
+ }
+
+ private Map<Long, String> deptNamesByMemberMap(Map<Long, MpUser> memberMap) {
+ Set<Long> deptIds = new HashSet<>();
+ for (MpUser u : memberMap.values()) {
+ if (u.getDeptId() != null) {
+ deptIds.add(u.getDeptId());
+ }
+ }
+ if (deptIds.isEmpty()) {
+ return new HashMap<>();
+ }
+ Map<Long, String> out = new HashMap<>();
+ List<com.sinata.appointment.entity.SysDept> depts =
+ sysDeptMapper.selectBatchIds(deptIds);
+ for (com.sinata.appointment.entity.SysDept d : depts) {
+ out.put(d.getId(), d.getDeptName());
+ }
+ return out;
+ }
+
+ private static List<Long> dedup(List<Long> ids) {
+ if (ids == null || ids.isEmpty()) {
+ return List.of();
+ }
+ Set<Long> set = new java.util.LinkedHashSet<>(ids);
+ return new java.util.ArrayList<>(set);
+ }
+
+ private void validateMembers(List<Long> memberIds) {
+ if (memberIds == null) {
+ return;
+ }
+ for (Long id : memberIds) {
+ MpUser u = mpUserMapper.selectById(id);
+ if (u == null || (u.getDelFlag() != null && u.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "关联人员不存在: " + id);
+ }
+ if (!MiniUserType.EMPLOYEE.equals(u.getUserType())) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "仅允许绑定员工: " + id);
+ }
+ if (u.getStatus() != null && u.getStatus() == 0) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "员工已禁用: " + id);
+ }
+ }
+ }
+
+ private void saveMembers(Long groupId, List<Long> memberIds) {
+ if (memberIds == null || memberIds.isEmpty()) {
+ return;
+ }
+ for (Long mid : memberIds) {
+ MpStaffGroupMember rel = new MpStaffGroupMember();
+ rel.setGroupId(groupId);
+ rel.setMpUserId(mid);
+ mpStaffGroupMemberMapper.insert(rel);
+ }
+ }
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/service/StaffManageApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/StaffManageApplicationService.java
new file mode 100644
index 0000000..3b5e5b0
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/StaffManageApplicationService.java
@@ -0,0 +1,860 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.constants.MiniUserType;
+import com.sinata.appointment.constants.MpStaffConstants;
+import com.sinata.appointment.config.MiniStpUtil;
+import com.sinata.appointment.dto.StaffCreateRequest;
+import com.sinata.appointment.dto.StaffDetailVo;
+import com.sinata.appointment.dto.StaffImportExcelRow;
+import com.sinata.appointment.dto.StaffImportResultVo;
+import com.sinata.appointment.dto.StaffListVo;
+import com.sinata.appointment.dto.StaffStatusPatchRequest;
+import com.sinata.appointment.dto.StaffUpdateRequest;
+import com.sinata.appointment.entity.MpStaffGroup;
+import com.sinata.appointment.entity.MpStaffGroupMember;
+import com.sinata.appointment.entity.MpUser;
+import com.sinata.appointment.entity.SysDept;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.mapper.MpStaffGroupMapper;
+import com.sinata.appointment.mapper.MpStaffGroupMemberMapper;
+import com.sinata.appointment.mapper.MpUserMapper;
+import com.sinata.appointment.mapper.SysDeptMapper;
+import com.sinata.appointment.util.MobileMaskUtil;
+import com.sinata.appointment.util.PhoneNormalize;
+import com.sinata.appointment.util.UuidAssert;
+import com.alibaba.excel.EasyExcel;
+import com.alibaba.excel.context.AnalysisContext;
+import com.alibaba.excel.event.AnalysisEventListener;
+import com.alibaba.excel.support.ExcelTypeEnum;
+import com.alibaba.excel.write.handler.WorkbookWriteHandler;
+import com.alibaba.excel.write.handler.SheetWriteHandler;
+import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder;
+import com.alibaba.excel.write.metadata.holder.WriteSheetHolder;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+import java.util.stream.Collectors;
+import org.apache.poi.ss.usermodel.DataValidation;
+import org.apache.poi.ss.usermodel.DataValidationConstraint;
+import org.apache.poi.ss.usermodel.DataValidationHelper;
+import org.apache.poi.ss.usermodel.Name;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.ss.util.CellRangeAddressList;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
+import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
+
+@Service
+public class StaffManageApplicationService {
+
+ private static final long AVATAR_MAX_SIZE = 5L * 1024 * 1024;
+ private static final Set<String> AVATAR_EXTENSIONS =
+ Set.of(".jpg", ".jpeg", ".png", ".webp", ".gif");
+
+ private final MpUserMapper mpUserMapper;
+ private final SysDeptMapper sysDeptMapper;
+ private final MpStaffGroupMemberMapper mpStaffGroupMemberMapper;
+ private final MpStaffGroupMapper mpStaffGroupMapper;
+
+ public StaffManageApplicationService(
+ MpUserMapper mpUserMapper,
+ SysDeptMapper sysDeptMapper,
+ MpStaffGroupMemberMapper mpStaffGroupMemberMapper,
+ MpStaffGroupMapper mpStaffGroupMapper) {
+ this.mpUserMapper = mpUserMapper;
+ this.sysDeptMapper = sysDeptMapper;
+ this.mpStaffGroupMemberMapper = mpStaffGroupMemberMapper;
+ this.mpStaffGroupMapper = mpStaffGroupMapper;
+ }
+
+ public String uploadAvatar(MultipartFile file) {
+ if (file == null || file.isEmpty()) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "头像文件不能为空");
+ }
+ if (file.getSize() > AVATAR_MAX_SIZE) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "头像文件不能超过5MB");
+ }
+ String ext = normalizeImageExtension(file.getOriginalFilename(), file.getContentType());
+ Path dir = resolveAvatarUploadDir();
+ String fileName = UUID.randomUUID().toString().replace("-", "") + ext;
+ Path target = dir.resolve(fileName);
+ try {
+ Files.createDirectories(dir);
+ Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);
+ } catch (IOException ex) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "头像上传失败");
+ }
+ return ServletUriComponentsBuilder.fromCurrentContextPath()
+ .path("/uploads/avatars/")
+ .path(fileName)
+ .toUriString();
+ }
+
+ public IPage<StaffListVo> page(
+ long page,
+ long size,
+ Long deptId,
+ String realName,
+ String staffNo,
+ String mobile,
+ String position,
+ Integer status,
+ Long excludeStaffGroupId,
+ Long staffGroupId) {
+ LambdaQueryWrapper<MpUser> q = new LambdaQueryWrapper<>();
+ q.eq(MpUser::getUserType, MiniUserType.EMPLOYEE).eq(MpUser::getDelFlag, 0);
+ if (deptId != null) {
+ Set<Long> deptScope = selfAndDescendantDeptIds(deptId);
+ q.in(MpUser::getDeptId, deptScope);
+ }
+ q.like(StringUtils.hasText(realName), MpUser::getNickName, realName != null ? realName.trim() : null);
+ q.like(StringUtils.hasText(staffNo), MpUser::getStaffNo, staffNo != null ? staffNo.trim() : null);
+ q.like(StringUtils.hasText(mobile), MpUser::getMobile, mobile != null ? mobile.trim() : null);
+ if ("LEADER".equalsIgnoreCase(position)) {
+ q.eq(MpUser::getDeptLeaderFlag, true);
+ } else if ("STAFF".equalsIgnoreCase(position)) {
+ q.and(
+ w ->
+ w.eq(MpUser::getDeptLeaderFlag, false)
+ .or()
+ .isNull(MpUser::getDeptLeaderFlag));
+ }
+ q.eq(status != null, MpUser::getStatus, status);
+ if (staffGroupId != null) {
+ Set<Long> inGroup = mpUserIdsInStaffGroup(staffGroupId);
+ if (inGroup.isEmpty()) {
+ Page<StaffListVo> empty = new Page<>(page, size, 0);
+ empty.setRecords(Collections.emptyList());
+ return empty;
+ }
+ q.in(MpUser::getId, inGroup);
+ }
+ if (excludeStaffGroupId != null) {
+ Set<Long> inGroup = mpUserIdsInStaffGroup(excludeStaffGroupId);
+ if (!inGroup.isEmpty()) {
+ q.notIn(MpUser::getId, inGroup);
+ }
+ }
+ q.orderByDesc(MpUser::getCreateTime);
+ Page<MpUser> p = mpUserMapper.selectPage(new Page<>(page, size), q);
+ Set<Long> deptIds = new HashSet<>();
+ Set<Long> userIds = new HashSet<>();
+ for (MpUser u : p.getRecords()) {
+ if (u.getDeptId() != null) {
+ deptIds.add(u.getDeptId());
+ }
+ userIds.add(u.getId());
+ }
+ Map<Long, String> deptNames = deptNamesByIds(deptIds);
+ Map<Long, String> groupDisplay = groupDisplayByMpUserIds(userIds);
+ return p.convert(u -> toListVo(u, deptNames, groupDisplay.getOrDefault(u.getId(), "—")));
+ }
+
+ private Set<Long> mpUserIdsInStaffGroup(Long staffGroupId) {
+ List<MpStaffGroupMember> rels =
+ mpStaffGroupMemberMapper.selectList(
+ new LambdaQueryWrapper<MpStaffGroupMember>()
+ .eq(MpStaffGroupMember::getGroupId, staffGroupId)
+ .eq(MpStaffGroupMember::getDelFlag, 0));
+ return rels.stream()
+ .map(MpStaffGroupMember::getMpUserId)
+ .filter(Objects::nonNull)
+ .collect(Collectors.toSet());
+ }
+
+ /** 指定部门及其全部子部门 id(含自身),用于左侧部门树筛选员工。 */
+ private Set<Long> selfAndDescendantDeptIds(Long rootDeptId) {
+ List<SysDept> all =
+ sysDeptMapper.selectList(
+ new LambdaQueryWrapper<SysDept>().eq(SysDept::getDelFlag, 0));
+ Map<Long, List<SysDept>> byParent = new HashMap<>();
+ for (SysDept d : all) {
+ long pid = d.getParentId() == null ? 0L : d.getParentId();
+ byParent.computeIfAbsent(pid, k -> new ArrayList<>()).add(d);
+ }
+ Set<Long> result = new HashSet<>();
+ ArrayDeque<Long> queue = new ArrayDeque<>();
+ queue.add(rootDeptId);
+ while (!queue.isEmpty()) {
+ Long id = queue.poll();
+ if (!result.add(id)) {
+ continue;
+ }
+ for (SysDept child : byParent.getOrDefault(id, List.of())) {
+ queue.add(child.getId());
+ }
+ }
+ return result;
+ }
+
+ private List<String> loadActiveGroupNames() {
+ List<MpStaffGroup> list =
+ mpStaffGroupMapper.selectList(
+ new LambdaQueryWrapper<MpStaffGroup>()
+ .eq(MpStaffGroup::getDelFlag, 0)
+ .orderByAsc(MpStaffGroup::getGroupName));
+ List<String> names = new ArrayList<>();
+ for (MpStaffGroup g : list) {
+ if (StringUtils.hasText(g.getGroupName())) {
+ names.add(g.getGroupName().trim());
+ }
+ }
+ return names;
+ }
+
+ private Map<Long, String> groupDisplayByMpUserIds(Set<Long> mpUserIds) {
+ Map<Long, String> out = new HashMap<>();
+ if (mpUserIds == null || mpUserIds.isEmpty()) {
+ return out;
+ }
+ List<MpStaffGroupMember> rels =
+ mpStaffGroupMemberMapper.selectList(
+ new LambdaQueryWrapper<MpStaffGroupMember>()
+ .eq(MpStaffGroupMember::getDelFlag, 0)
+ .in(MpStaffGroupMember::getMpUserId, mpUserIds));
+ if (rels.isEmpty()) {
+ return out;
+ }
+ Set<Long> groupIds = new HashSet<>();
+ for (MpStaffGroupMember r : rels) {
+ groupIds.add(r.getGroupId());
+ }
+ Map<Long, String> gidToName = new HashMap<>();
+ if (!groupIds.isEmpty()) {
+ List<MpStaffGroup> groups =
+ mpStaffGroupMapper.selectList(
+ new LambdaQueryWrapper<MpStaffGroup>()
+ .eq(MpStaffGroup::getDelFlag, 0)
+ .in(MpStaffGroup::getId, groupIds));
+ for (MpStaffGroup g : groups) {
+ if (StringUtils.hasText(g.getGroupName())) {
+ gidToName.put(g.getId(), g.getGroupName().trim());
+ }
+ }
+ }
+ Map<Long, List<String>> uidToNames = new HashMap<>();
+ for (MpStaffGroupMember r : rels) {
+ String gn = gidToName.get(r.getGroupId());
+ if (!StringUtils.hasText(gn)) {
+ continue;
+ }
+ uidToNames.computeIfAbsent(r.getMpUserId(), k -> new ArrayList<>()).add(gn);
+ }
+ for (Map.Entry<Long, List<String>> e : uidToNames.entrySet()) {
+ List<String> distinct = e.getValue().stream().distinct().toList();
+ out.put(e.getKey(), String.join("、", distinct));
+ }
+ return out;
+ }
+
+ private List<String> loadGroupNamesForUser(Long mpUserId) {
+ List<MpStaffGroupMember> rels =
+ mpStaffGroupMemberMapper.selectList(
+ new LambdaQueryWrapper<MpStaffGroupMember>()
+ .eq(MpStaffGroupMember::getDelFlag, 0)
+ .eq(MpStaffGroupMember::getMpUserId, mpUserId));
+ if (rels.isEmpty()) {
+ return List.of();
+ }
+ Set<Long> groupIds =
+ rels.stream().map(MpStaffGroupMember::getGroupId).collect(Collectors.toSet());
+ List<MpStaffGroup> groups =
+ mpStaffGroupMapper.selectList(
+ new LambdaQueryWrapper<MpStaffGroup>()
+ .eq(MpStaffGroup::getDelFlag, 0)
+ .in(MpStaffGroup::getId, groupIds)
+ .orderByAsc(MpStaffGroup::getGroupName));
+ List<String> names = new ArrayList<>();
+ for (MpStaffGroup g : groups) {
+ if (StringUtils.hasText(g.getGroupName())) {
+ names.add(g.getGroupName().trim());
+ }
+ }
+ return names;
+ }
+
+ private void attachStaffToGroupsByNames(Long mpUserId, String rawGroup) {
+ if (!StringUtils.hasText(rawGroup) || mpUserId == null) {
+ return;
+ }
+ String[] parts = rawGroup.split("[,,;;、\\s]+");
+ Set<String> seen = new HashSet<>();
+ for (String part : parts) {
+ if (!StringUtils.hasText(part)) {
+ continue;
+ }
+ String name = part.trim();
+ if (!seen.add(name)) {
+ continue;
+ }
+ MpStaffGroup g =
+ mpStaffGroupMapper.selectOne(
+ new LambdaQueryWrapper<MpStaffGroup>()
+ .eq(MpStaffGroup::getDelFlag, 0)
+ .eq(MpStaffGroup::getGroupName, name));
+ if (g == null) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "所属分组不存在: " + name);
+ }
+ MpStaffGroupMember exist =
+ mpStaffGroupMemberMapper.selectOne(
+ new LambdaQueryWrapper<MpStaffGroupMember>()
+ .eq(MpStaffGroupMember::getGroupId, g.getId())
+ .eq(MpStaffGroupMember::getMpUserId, mpUserId)
+ .eq(MpStaffGroupMember::getDelFlag, 0));
+ if (exist != null) {
+ continue;
+ }
+ MpStaffGroupMember rel = new MpStaffGroupMember();
+ rel.setGroupId(g.getId());
+ rel.setMpUserId(mpUserId);
+ mpStaffGroupMemberMapper.insert(rel);
+ }
+ }
+
+ public StaffDetailVo detail(Long id, String uuid) {
+ MpUser u = loadEmployeeWithUuid(id, uuid);
+ return toDetailVo(u);
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public Map<String, Object> create(StaffCreateRequest req) {
+ String mobileRaw = req.getMobile().trim();
+ String mobileNorm = PhoneNormalize.mainland(mobileRaw);
+ assertMobileFormat(mobileNorm);
+ assertStaffNoUnique(trimToNull(req.getStaffNo()), null);
+ validateDept(req.getDeptId());
+
+ MpUser exist = findActiveByNormalizedMobile(mobileNorm);
+ if (exist != null) {
+ if (MiniUserType.EMPLOYEE.equals(exist.getUserType())) {
+ throw new BizException(BizErrorCode.MP_STAFF_MOBILE_EXISTS, "手机号已存在");
+ }
+ if (MiniUserType.VISITOR.equals(exist.getUserType())) {
+ upgradeVisitorToEmployee(exist, req, mobileRaw);
+ return Map.of("id", exist.getId(), "uuid", exist.getUuid());
+ }
+ }
+
+ MpUser u = new MpUser();
+ u.setOpenId(MpStaffConstants.newPlaceholderOpenId());
+ u.setUserType(MiniUserType.EMPLOYEE);
+ u.setNickName(trimName(req.getNickName()));
+ u.setStaffNo(trimToNull(req.getStaffNo()));
+ u.setMobile(mobileRaw);
+ u.setAvatarUrl(trimToNull(req.getAvatarUrl()));
+ u.setDeptId(req.getDeptId());
+ u.setDeptLeaderFlag(Boolean.TRUE.equals(req.getDeptLeader()));
+ u.setStatus(1);
+ mpUserMapper.insert(u);
+ MpUser fresh = mpUserMapper.selectById(u.getId());
+ if (fresh == null || !StringUtils.hasText(fresh.getUuid())) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "创建后读取员工失败");
+ }
+ return Map.of("id", fresh.getId(), "uuid", fresh.getUuid());
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void update(Long id, StaffUpdateRequest req) {
+ MpUser u = loadEmployeeWithUuid(id, req.getUuid());
+ String mobileRaw = req.getMobile().trim();
+ String mobileNorm = PhoneNormalize.mainland(mobileRaw);
+ assertMobileFormat(mobileNorm);
+ assertStaffNoUnique(trimToNull(req.getStaffNo()), u.getId());
+ validateDept(req.getDeptId());
+
+ String oldNorm = PhoneNormalize.mainland(u.getMobile() == null ? "" : u.getMobile());
+ if (!mobileNorm.equals(oldNorm)) {
+ MpUser other = findActiveByNormalizedMobile(mobileNorm);
+ if (other != null && !other.getId().equals(u.getId())) {
+ if (MiniUserType.EMPLOYEE.equals(other.getUserType())) {
+ throw new BizException(BizErrorCode.MP_STAFF_MOBILE_EXISTS, "手机号已存在");
+ }
+ throw new BizException(BizErrorCode.MP_STAFF_MOBILE_EXISTS, "手机号已存在");
+ }
+ }
+
+ u.setNickName(trimName(req.getNickName()));
+ u.setStaffNo(trimToNull(req.getStaffNo()));
+ u.setAvatarUrl(trimToNull(req.getAvatarUrl()));
+ u.setMobile(mobileRaw);
+ u.setDeptId(req.getDeptId());
+ u.setDeptLeaderFlag(Boolean.TRUE.equals(req.getDeptLeader()));
+ mpUserMapper.updateById(u);
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void patchStatus(Long id, StaffStatusPatchRequest req) {
+ MpUser u = loadEmployeeWithUuid(id, req.getUuid());
+ int st = req.getStatus();
+ if (st != 0 && st != 1) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "状态无效");
+ }
+ u.setStatus(st);
+ mpUserMapper.updateById(u);
+ if (st == 0) {
+ kickMiniUserOffline(u.getId());
+ }
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void delete(Long id, String uuid) {
+ MpUser u = loadEmployeeWithUuid(id, uuid);
+ mpStaffGroupMemberMapper.delete(
+ new LambdaQueryWrapper<MpStaffGroupMember>()
+ .eq(MpStaffGroupMember::getMpUserId, u.getId())
+ .eq(MpStaffGroupMember::getDelFlag, 0));
+ u.setUserType(MiniUserType.VISITOR);
+ u.setDeptId(null);
+ u.setDeptLeaderFlag(false);
+ u.setStaffNo(null);
+ mpUserMapper.updateById(u);
+ kickMiniUserOffline(u.getId());
+ }
+
+ private Path resolveAvatarUploadDir() {
+ return Path.of(System.getProperty("user.dir"), "uploads", "avatars")
+ .toAbsolutePath()
+ .normalize();
+ }
+
+ private String normalizeImageExtension(String originalFilename, String contentType) {
+ String ext = "";
+ if (StringUtils.hasText(originalFilename)) {
+ int idx = originalFilename.lastIndexOf('.');
+ if (idx >= 0) {
+ ext = originalFilename.substring(idx).toLowerCase();
+ }
+ }
+ if (!AVATAR_EXTENSIONS.contains(ext)) {
+ ext = switch (contentType == null ? "" : contentType.toLowerCase()) {
+ case "image/jpeg", "image/jpg" -> ".jpg";
+ case "image/png" -> ".png";
+ case "image/webp" -> ".webp";
+ case "image/gif" -> ".gif";
+ default -> "";
+ };
+ }
+ if (!AVATAR_EXTENSIONS.contains(ext)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "仅支持 jpg、png、webp、gif 图片");
+ }
+ return ext;
+ }
+
+ /** 清除小程序端会话(仅 mini 域)。 */
+ private void kickMiniUserOffline(Long mpUserId) {
+ try {
+ MiniStpUtil.logout(mpUserId);
+ } catch (Exception ignored) {
+ }
+ }
+
+ public byte[] importTemplateExcel() {
+ List<SysDept> depts =
+ sysDeptMapper.selectList(
+ new LambdaQueryWrapper<SysDept>()
+ .eq(SysDept::getDelFlag, 0)
+ .eq(SysDept::getStatus, 1)
+ .orderByAsc(SysDept::getSortOrder)
+ .orderByAsc(SysDept::getId));
+ List<String> deptNames =
+ depts.stream()
+ .map(SysDept::getDeptName)
+ .filter(StringUtils::hasText)
+ .map(String::trim)
+ .toList();
+ List<String> groupNames = loadActiveGroupNames();
+
+ List<StaffImportExcelRow> demo = new ArrayList<>();
+ StaffImportExcelRow ex = new StaffImportExcelRow();
+ ex.setRealName("张三");
+ ex.setStaffNo("EMP001");
+ ex.setMobile("13800138000");
+ if (!deptNames.isEmpty()) {
+ ex.setDept(deptNames.get(0));
+ }
+ ex.setPosition("员工");
+ if (!groupNames.isEmpty()) {
+ ex.setGroup(groupNames.get(0));
+ }
+ demo.add(ex);
+
+ try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
+ // 顶层不要绑定 StaffImportExcelRow.class,否则「字典」sheet 也会被写入员工列表头,下拉会含表头文案
+ var writer =
+ EasyExcel.write(bos)
+ .excelType(ExcelTypeEnum.XLSX)
+ .registerWriteHandler(new HideDictSheetHandler())
+ .build();
+ try {
+ var dictSheet = EasyExcel.writerSheet("字典").needHead(false).build();
+ writer.write(buildDictRows(deptNames, groupNames), dictSheet);
+
+ var dataSheet =
+ EasyExcel.writerSheet("员工导入")
+ .head(StaffImportExcelRow.class)
+ .registerWriteHandler(
+ new StaffTemplateDropdownHandler(deptNames, groupNames))
+ .build();
+ writer.write(demo, dataSheet);
+ } finally {
+ writer.finish();
+ }
+ return bos.toByteArray();
+ } catch (Exception e) {
+ return new byte[0];
+ }
+ }
+
+ public StaffImportResultVo importExcel(MultipartFile file) {
+ StaffImportResultVo vo = new StaffImportResultVo();
+ if (file == null || file.isEmpty()) {
+ vo.getErrors().add("文件为空");
+ return vo;
+ }
+ Map<String, Long> deptNameToId = new HashMap<>();
+ List<SysDept> depts =
+ sysDeptMapper.selectList(
+ new LambdaQueryWrapper<SysDept>()
+ .eq(SysDept::getDelFlag, 0)
+ .eq(SysDept::getStatus, 1));
+ for (SysDept d : depts) {
+ if (StringUtils.hasText(d.getDeptName())) {
+ deptNameToId.put(d.getDeptName().trim(), d.getId());
+ }
+ }
+
+ try (InputStream in = file.getInputStream()) {
+ EasyExcel.read(in, StaffImportExcelRow.class, new AnalysisEventListener<StaffImportExcelRow>() {
+ int rowIndex = 1; // header is 0
+
+ @Override
+ public void invoke(StaffImportExcelRow row, AnalysisContext context) {
+ rowIndex++;
+ try {
+ handleImportRow(rowIndex, row, deptNameToId);
+ vo.setSuccess(vo.getSuccess() + 1);
+ } catch (StopRowException ignored) {
+ } catch (BizException e) {
+ vo.setFail(vo.getFail() + 1);
+ vo.getErrors().add("第" + rowIndex + "行: " + e.getMessage());
+ } catch (Exception e) {
+ vo.setFail(vo.getFail() + 1);
+ vo.getErrors().add("第" + rowIndex + "行: 解析失败");
+ }
+ }
+
+ @Override
+ public void doAfterAllAnalysed(AnalysisContext context) {}
+ })
+ .sheet(0)
+ .doRead();
+ } catch (Exception e) {
+ vo.getErrors().add("读取文件失败: " + e.getMessage());
+ }
+ return vo;
+ }
+
+ private void handleImportRow(int lineNo, StaffImportExcelRow row, Map<String, Long> deptNameToId) {
+ String name = row.getRealName() == null ? "" : row.getRealName().trim();
+ String staffNo = row.getStaffNo() == null ? "" : row.getStaffNo().trim();
+ String mobile = row.getMobile() == null ? "" : row.getMobile().trim();
+ String dept = row.getDept() == null ? "" : row.getDept().trim();
+ String position = row.getPosition() == null ? "" : row.getPosition().trim();
+ String groupCell = row.getGroup() == null ? "" : row.getGroup().trim();
+
+ if (!StringUtils.hasText(name)
+ && !StringUtils.hasText(staffNo)
+ && !StringUtils.hasText(mobile)
+ && !StringUtils.hasText(dept)
+ && !StringUtils.hasText(position)
+ && !StringUtils.hasText(groupCell)) {
+ throw new StopRowException();
+ }
+
+ if (!StringUtils.hasText(name) || !StringUtils.hasText(mobile) || !StringUtils.hasText(dept)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "员工姓名、联系方式、所属部门不能为空");
+ }
+
+ Long deptId = parseDeptId(dept, deptNameToId);
+ boolean leader = parseDeptLeader(position);
+
+ StaffCreateRequest req = new StaffCreateRequest();
+ req.setNickName(name);
+ req.setStaffNo(StringUtils.hasText(staffNo) ? staffNo : null);
+ req.setMobile(mobile);
+ req.setDeptId(deptId);
+ req.setDeptLeader(leader);
+ Map<String, Object> created = create(req);
+ Object idObj = created.get("id");
+ Long uid = idObj instanceof Number ? ((Number) idObj).longValue() : null;
+ if (uid != null && StringUtils.hasText(groupCell)) {
+ attachStaffToGroupsByNames(uid, groupCell);
+ }
+ }
+
+ private static final class StopRowException extends RuntimeException {}
+
+ private static List<List<String>> buildDictRows(List<String> deptNames, List<String> groupNames) {
+ int max = Math.max(Math.max(deptNames.size(), groupNames.size()), 2);
+ List<List<String>> rows = new ArrayList<>();
+ for (int i = 0; i < max; i++) {
+ String dept = i < deptNames.size() ? deptNames.get(i) : "";
+ String pos = i == 0 ? "部门负责人" : (i == 1 ? "员工" : "");
+ String grp = i < groupNames.size() ? groupNames.get(i) : "";
+ rows.add(java.util.Arrays.asList(dept, pos, grp));
+ }
+ return rows;
+ }
+
+ private static final class HideDictSheetHandler implements WorkbookWriteHandler {
+ @Override
+ public void afterWorkbookCreate(WriteWorkbookHolder writeWorkbookHolder) {
+ Workbook wb = writeWorkbookHolder.getWorkbook();
+ int idx = wb.getSheetIndex("字典");
+ if (idx >= 0) {
+ wb.setSheetHidden(idx, true);
+ }
+ }
+ }
+
+ private static final class StaffTemplateDropdownHandler implements SheetWriteHandler {
+ private final List<String> deptNames;
+ private final List<String> groupNames;
+
+ private StaffTemplateDropdownHandler(List<String> deptNames, List<String> groupNames) {
+ this.deptNames = deptNames != null ? deptNames : List.of();
+ this.groupNames = groupNames != null ? groupNames : List.of();
+ }
+
+ @Override
+ public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) {
+ Sheet sheet = writeSheetHolder.getSheet();
+ Workbook wb = writeWorkbookHolder.getWorkbook();
+
+ if (!deptNames.isEmpty()) {
+ Name deptRange = wb.createName();
+ deptRange.setNameName("dept_list");
+ deptRange.setRefersToFormula("字典!$A$1:$A$" + deptNames.size());
+ }
+ Name posRange = wb.createName();
+ posRange.setNameName("pos_list");
+ posRange.setRefersToFormula("字典!$B$1:$B$2");
+ if (!groupNames.isEmpty()) {
+ Name groupRange = wb.createName();
+ groupRange.setNameName("group_list");
+ groupRange.setRefersToFormula("字典!$C$1:$C$" + groupNames.size());
+ }
+
+ DataValidationHelper helper = sheet.getDataValidationHelper();
+ int startRow = 1;
+ int endRow = 500;
+
+ if (!deptNames.isEmpty()) {
+ DataValidationConstraint deptConstraint = helper.createFormulaListConstraint("dept_list");
+ CellRangeAddressList deptAddr = new CellRangeAddressList(startRow, endRow, 3, 3);
+ DataValidation dvDept = helper.createValidation(deptConstraint, deptAddr);
+ dvDept.setShowErrorBox(true);
+ sheet.addValidationData(dvDept);
+ }
+
+ DataValidationConstraint posConstraint = helper.createFormulaListConstraint("pos_list");
+ CellRangeAddressList posAddr = new CellRangeAddressList(startRow, endRow, 4, 4);
+ DataValidation dvPos = helper.createValidation(posConstraint, posAddr);
+ dvPos.setShowErrorBox(true);
+ sheet.addValidationData(dvPos);
+
+ if (!groupNames.isEmpty()) {
+ DataValidationConstraint gConstraint = helper.createFormulaListConstraint("group_list");
+ CellRangeAddressList gAddr = new CellRangeAddressList(startRow, endRow, 5, 5);
+ DataValidation dvG = helper.createValidation(gConstraint, gAddr);
+ dvG.setShowErrorBox(true);
+ sheet.addValidationData(dvG);
+ }
+ }
+ }
+
+ private static Long parseDeptId(String dept, Map<String, Long> deptNameToId) {
+ if (!StringUtils.hasText(dept)) {
+ return null;
+ }
+ String t = dept.trim();
+ try {
+ return Long.parseLong(t);
+ } catch (Exception ignored) {
+ }
+ Long id = deptNameToId.get(t);
+ if (id == null) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "所属部门不存在: " + t);
+ }
+ return id;
+ }
+
+ private static boolean parseDeptLeader(String position) {
+ if (!StringUtils.hasText(position)) {
+ return false;
+ }
+ String t = position.trim();
+ if ("部门负责人".equals(t) || "LEADER".equalsIgnoreCase(t)) {
+ return true;
+ }
+ if ("员工".equals(t) || "STAFF".equalsIgnoreCase(t)) {
+ return false;
+ }
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "所属职位无效: " + t);
+ }
+
+ private void upgradeVisitorToEmployee(MpUser exist, StaffCreateRequest req, String mobileRaw) {
+ assertStaffNoUnique(trimToNull(req.getStaffNo()), exist.getId());
+ validateDept(req.getDeptId());
+ exist.setUserType(MiniUserType.EMPLOYEE);
+ exist.setNickName(trimName(req.getNickName()));
+ exist.setStaffNo(trimToNull(req.getStaffNo()));
+ exist.setAvatarUrl(trimToNull(req.getAvatarUrl()));
+ exist.setMobile(mobileRaw);
+ exist.setDeptId(req.getDeptId());
+ exist.setDeptLeaderFlag(Boolean.TRUE.equals(req.getDeptLeader()));
+ exist.setStatus(1);
+ mpUserMapper.updateById(exist);
+ }
+
+ private MpUser findActiveByNormalizedMobile(String norm) {
+ if (!StringUtils.hasText(norm)) {
+ return null;
+ }
+ List<MpUser> list =
+ mpUserMapper.selectList(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getDelFlag, 0)
+ .isNotNull(MpUser::getMobile));
+ for (MpUser u : list) {
+ if (norm.equals(PhoneNormalize.mainland(u.getMobile()))) {
+ return u;
+ }
+ }
+ return null;
+ }
+
+ private void assertStaffNoUnique(String staffNo, Long excludeUserId) {
+ if (!StringUtils.hasText(staffNo)) {
+ return;
+ }
+ MpUser o =
+ mpUserMapper.selectOne(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getStaffNo, staffNo)
+ .eq(MpUser::getDelFlag, 0));
+ if (o != null && (excludeUserId == null || !o.getId().equals(excludeUserId))) {
+ throw new BizException(BizErrorCode.MP_STAFF_NO_EXISTS, "员工编号已存在");
+ }
+ }
+
+ private void validateDept(Long deptId) {
+ SysDept d = sysDeptMapper.selectById(deptId);
+ if (d == null || d.getDelFlag() != null && d.getDelFlag() == 1) {
+ throw new BizException(BizErrorCode.DEPT_PARENT_INVALID, "部门不存在");
+ }
+ if (d.getStatus() == null || d.getStatus() != 1) {
+ throw new BizException(BizErrorCode.DEPT_PARENT_INVALID, "部门已禁用");
+ }
+ }
+
+ private static void assertMobileFormat(String norm) {
+ if (norm.length() != 11 || !norm.chars().allMatch(Character::isDigit)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "手机号须为11位数字");
+ }
+ }
+
+ private MpUser loadEmployeeWithUuid(long id, String uuid) {
+ MpUser u = mpUserMapper.selectById(id);
+ if (u == null || u.getDelFlag() != null && u.getDelFlag() == 1) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "资源不存在或无权访问");
+ }
+ if (!MiniUserType.EMPLOYEE.equals(u.getUserType())) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "资源不存在或无权访问");
+ }
+ UuidAssert.match(u, uuid);
+ return u;
+ }
+
+ private Map<Long, String> deptNamesByIds(Set<Long> ids) {
+ if (ids.isEmpty()) {
+ return Map.of();
+ }
+ List<SysDept> depts =
+ sysDeptMapper.selectList(
+ new LambdaQueryWrapper<SysDept>().in(SysDept::getId, ids).eq(SysDept::getDelFlag, 0));
+ Map<Long, String> m = new HashMap<>();
+ for (SysDept d : depts) {
+ m.put(d.getId(), d.getDeptName());
+ }
+ return m;
+ }
+
+ private StaffListVo toListVo(MpUser u, Map<Long, String> deptNames, String groupDisplay) {
+ StaffListVo vo = new StaffListVo();
+ vo.setId(u.getId());
+ vo.setUuid(u.getUuid());
+ vo.setRealName(u.getNickName());
+ vo.setAvatarUrl(u.getAvatarUrl());
+ vo.setStaffNo(u.getStaffNo());
+ vo.setMobileMasked(MobileMaskUtil.mask(u.getMobile()));
+ vo.setDeptId(u.getDeptId());
+ vo.setDeptName(
+ u.getDeptId() == null ? "—" : deptNames.getOrDefault(u.getDeptId(), "—"));
+ vo.setPositionLabel(Boolean.TRUE.equals(u.getDeptLeaderFlag()) ? "部门负责人" : "员工");
+ vo.setGroupDisplay(groupDisplay);
+ vo.setStatus(u.getStatus() == null ? 1 : u.getStatus());
+ vo.setCreateTime(u.getCreateTime());
+ return vo;
+ }
+
+ private StaffDetailVo toDetailVo(MpUser u) {
+ StaffDetailVo vo = new StaffDetailVo();
+ vo.setId(u.getId());
+ vo.setUuid(u.getUuid());
+ vo.setNickName(u.getNickName());
+ vo.setStaffNo(u.getStaffNo());
+ vo.setAvatarUrl(u.getAvatarUrl());
+ vo.setMobile(u.getMobile());
+ vo.setDeptId(u.getDeptId());
+ vo.setDeptLeader(Boolean.TRUE.equals(u.getDeptLeaderFlag()));
+ vo.setStatus(u.getStatus() == null ? 1 : u.getStatus());
+ vo.setGroupNames(loadGroupNamesForUser(u.getId()));
+ return vo;
+ }
+
+ private static String trimName(String s) {
+ if (!StringUtils.hasText(s)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "员工名称不能为空");
+ }
+ return s.trim();
+ }
+
+ private static String trimToNull(String s) {
+ if (!StringUtils.hasText(s)) {
+ return null;
+ }
+ String t = s.trim();
+ return t.isEmpty() ? null : t;
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/SysBizSettingCacheService.java b/backend/src/main/java/com/sinata/appointment/service/SysBizSettingCacheService.java
new file mode 100644
index 0000000..ba27386
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/SysBizSettingCacheService.java
@@ -0,0 +1,31 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.sinata.appointment.constants.CacheNames;
+import com.sinata.appointment.entity.SysBizSetting;
+import com.sinata.appointment.mapper.SysBizSettingMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Service;
+
+/** 系统配置缓存(Redis + Spring Cache) */
+@Service
+@RequiredArgsConstructor
+public class SysBizSettingCacheService {
+
+ private final SysBizSettingMapper sysBizSettingMapper;
+
+ @Cacheable(cacheNames = CacheNames.SYS_BIZ_SETTING, key = "#key")
+ public String getValue(String key) {
+ SysBizSetting s =
+ sysBizSettingMapper.selectOne(
+ new LambdaQueryWrapper<SysBizSetting>().eq(SysBizSetting::getSettingKey, key));
+ return s == null ? null : s.getSettingValue();
+ }
+
+ @CacheEvict(cacheNames = CacheNames.SYS_BIZ_SETTING, key = "#key")
+ public void evict(String key) {
+ // use spring cache eviction
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/SysRoleManageApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/SysRoleManageApplicationService.java
new file mode 100644
index 0000000..1416ccf
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/SysRoleManageApplicationService.java
@@ -0,0 +1,249 @@
+package com.sinata.appointment.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.dto.MenuTreeNodeVo;
+import com.sinata.appointment.dto.RoleManageCreateRequest;
+import com.sinata.appointment.dto.RoleManageDetailVo;
+import com.sinata.appointment.dto.RoleManageListVo;
+import com.sinata.appointment.dto.RoleManageUpdateRequest;
+import com.sinata.appointment.dto.RoleUserCountRow;
+import com.sinata.appointment.entity.SysMenu;
+import com.sinata.appointment.entity.SysRole;
+import com.sinata.appointment.entity.SysRoleMenu;
+import com.sinata.appointment.entity.SysUserRole;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.mapper.SysMenuMapper;
+import com.sinata.appointment.mapper.SysRoleMapper;
+import com.sinata.appointment.mapper.SysRoleMenuMapper;
+import com.sinata.appointment.mapper.SysUserRoleMapper;
+import com.sinata.appointment.util.UuidAssert;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.UUID;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+@Service
+@RequiredArgsConstructor
+public class SysRoleManageApplicationService {
+
+ private final SysRoleMapper sysRoleMapper;
+ private final SysMenuMapper sysMenuMapper;
+ private final SysRoleMenuMapper sysRoleMenuMapper;
+ private final SysUserRoleMapper sysUserRoleMapper;
+
+ public List<MenuTreeNodeVo> menuTree() {
+ List<SysMenu> all =
+ sysMenuMapper.selectList(
+ new LambdaQueryWrapper<SysMenu>()
+ .eq(SysMenu::getDelFlag, 0)
+ .orderByAsc(SysMenu::getSortOrder)
+ .orderByAsc(SysMenu::getId));
+ Map<Long, MenuTreeNodeVo> nodes = new LinkedHashMap<>();
+ for (SysMenu m : all) {
+ MenuTreeNodeVo n = new MenuTreeNodeVo();
+ n.setId(m.getId());
+ n.setLabel(m.getMenuName());
+ n.setMenuType(m.getMenuType());
+ n.setPermCode(m.getPermCode());
+ n.setRoutePath(m.getRoutePath());
+ nodes.put(m.getId(), n);
+ }
+ List<MenuTreeNodeVo> roots = new ArrayList<>();
+ for (SysMenu m : all) {
+ MenuTreeNodeVo n = nodes.get(m.getId());
+ long pid = m.getPid() == null ? 0L : m.getPid();
+ if (pid == 0L) {
+ roots.add(n);
+ } else {
+ MenuTreeNodeVo p = nodes.get(pid);
+ if (p != null) {
+ p.getChildren().add(n);
+ } else {
+ roots.add(n);
+ }
+ }
+ }
+ return roots;
+ }
+
+ public IPage<RoleManageListVo> pageRoles(long page, long size, String roleName) {
+ LambdaQueryWrapper<SysRole> q = new LambdaQueryWrapper<>();
+ q.eq(SysRole::getDelFlag, 0);
+ if (StringUtils.hasText(roleName)) {
+ q.like(SysRole::getRoleName, roleName.trim());
+ }
+ q.orderByDesc(SysRole::getId);
+ Page<SysRole> p = sysRoleMapper.selectPage(new Page<>(page, size), q);
+ List<Long> ids = p.getRecords().stream().map(SysRole::getId).toList();
+ Map<Long, Long> countMap = new HashMap<>();
+ if (!ids.isEmpty()) {
+ for (RoleUserCountRow row : sysUserRoleMapper.countUsersGroupedByRoleId(ids)) {
+ long c = row.getUserCount() == null ? 0L : row.getUserCount();
+ countMap.put(row.getRoleId(), c);
+ }
+ }
+ return p.convert(
+ r -> {
+ RoleManageListVo vo = new RoleManageListVo();
+ vo.setId(r.getId());
+ vo.setUuid(r.getUuid());
+ vo.setRoleName(r.getRoleName());
+ vo.setRemark(r.getRemark());
+ vo.setCreateTime(r.getCreateTime());
+ vo.setUserCount(countMap.getOrDefault(r.getId(), 0L));
+ return vo;
+ });
+ }
+
+ public RoleManageDetailVo getDetail(Long id, String uuid) {
+ SysRole r = loadActiveWithUuid(id, uuid);
+ RoleManageDetailVo vo = new RoleManageDetailVo();
+ vo.setId(r.getId());
+ vo.setUuid(r.getUuid());
+ vo.setRoleName(r.getRoleName());
+ vo.setRemark(r.getRemark());
+ vo.setCreateTime(r.getCreateTime());
+ vo.setMenuIds(listMenuIdsForRole(id));
+ return vo;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public Map<String, Object> create(RoleManageCreateRequest req) {
+ String code = generateUniqueRoleCode();
+ SysRole r = new SysRole();
+ r.setRoleCode(code);
+ r.setRoleName(req.getRoleName().trim());
+ r.setRemark(trimToNull(req.getRemark()));
+ sysRoleMapper.insert(r);
+ SysRole fresh = sysRoleMapper.selectById(r.getId());
+ if (fresh == null || !StringUtils.hasText(fresh.getUuid())) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "创建后读取角色失败");
+ }
+ replaceRoleMenus(fresh.getId(), req.getMenuIds());
+ Map<String, Object> m = new HashMap<>();
+ m.put("id", fresh.getId());
+ m.put("uuid", fresh.getUuid());
+ return m;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void update(Long id, RoleManageUpdateRequest req) {
+ SysRole r = loadActiveWithUuid(id, req.getUuid());
+ r.setRoleName(req.getRoleName().trim());
+ r.setRemark(trimToNull(req.getRemark()));
+ sysRoleMapper.updateById(r);
+ replaceRoleMenus(id, req.getMenuIds());
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void delete(Long id, String uuid) {
+ SysRole r = loadActiveWithUuid(id, uuid);
+ long n =
+ sysUserRoleMapper.selectCount(
+ new LambdaQueryWrapper<SysUserRole>()
+ .eq(SysUserRole::getRoleId, id)
+ .eq(SysUserRole::getDelFlag, 0));
+ if (n > 0) {
+ throw new BizException(BizErrorCode.ROLE_HAS_BOUND_USERS, "该角色下仍有关联账号,无法删除");
+ }
+ sysRoleMenuMapper.delete(
+ new LambdaQueryWrapper<SysRoleMenu>()
+ .eq(SysRoleMenu::getRoleId, id)
+ .eq(SysRoleMenu::getDelFlag, 0));
+ int affected = sysRoleMapper.deleteById(r.getId());
+ if (affected <= 0) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "删除失败,请刷新后重试");
+ }
+ }
+
+ private List<Long> listMenuIdsForRole(Long roleId) {
+ return sysRoleMenuMapper
+ .selectList(
+ new LambdaQueryWrapper<SysRoleMenu>()
+ .eq(SysRoleMenu::getRoleId, roleId)
+ .eq(SysRoleMenu::getDelFlag, 0))
+ .stream()
+ .map(SysRoleMenu::getMenuId)
+ .toList();
+ }
+
+ private void replaceRoleMenus(Long roleId, List<Long> menuIds) {
+ validateMenuIds(menuIds);
+ sysRoleMenuMapper.delete(
+ new LambdaQueryWrapper<SysRoleMenu>()
+ .eq(SysRoleMenu::getRoleId, roleId)
+ .eq(SysRoleMenu::getDelFlag, 0));
+ if (menuIds == null || menuIds.isEmpty()) {
+ return;
+ }
+ for (Long mid : menuIds.stream().distinct().toList()) {
+ SysRoleMenu rm = new SysRoleMenu();
+ rm.setRoleId(roleId);
+ rm.setMenuId(mid);
+ sysRoleMenuMapper.insert(rm);
+ }
+ }
+
+ private void validateMenuIds(List<Long> menuIds) {
+ if (menuIds == null || menuIds.isEmpty()) {
+ return;
+ }
+ List<Long> distinct = menuIds.stream().distinct().toList();
+ Long cnt =
+ sysMenuMapper.selectCount(
+ new LambdaQueryWrapper<SysMenu>()
+ .in(SysMenu::getId, distinct)
+ .eq(SysMenu::getDelFlag, 0));
+ if (!Objects.equals(cnt, (long) distinct.size())) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "存在无效的菜单 id");
+ }
+ }
+
+ private SysRole loadActiveWithUuid(Long id, String uuid) {
+ SysRole r = sysRoleMapper.selectById(id);
+ if (r == null || r.getDelFlag() != null && r.getDelFlag() == 1) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "资源不存在或无权访问");
+ }
+ UuidAssert.match(r, uuid);
+ return r;
+ }
+
+ private String generateUniqueRoleCode() {
+ for (int i = 0; i < 20; i++) {
+ String code =
+ "C"
+ + UUID.randomUUID()
+ .toString()
+ .replace("-", "")
+ .substring(0, 12)
+ .toUpperCase();
+ long exists =
+ sysRoleMapper.selectCount(
+ new LambdaQueryWrapper<SysRole>()
+ .eq(SysRole::getRoleCode, code)
+ .eq(SysRole::getDelFlag, 0));
+ if (exists == 0) {
+ return code;
+ }
+ }
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "无法生成唯一角色编码");
+ }
+
+ private static String trimToNull(String s) {
+ if (!StringUtils.hasText(s)) {
+ return null;
+ }
+ String t = s.trim();
+ return t.isEmpty() ? null : t;
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/SysUserApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/SysUserApplicationService.java
new file mode 100644
index 0000000..b0d34d5
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/SysUserApplicationService.java
@@ -0,0 +1,259 @@
+package com.sinata.appointment.service;
+
+import cn.dev33.satoken.stp.StpUtil;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.dto.ResetPasswordRequest;
+import com.sinata.appointment.dto.SysUserCreateRequest;
+import com.sinata.appointment.dto.SysUserDetailVo;
+import com.sinata.appointment.dto.SysUserListVo;
+import com.sinata.appointment.dto.SysUserUpdateRequest;
+import com.sinata.appointment.dto.UserRoleLabelRow;
+import com.sinata.appointment.dto.UserStatusPatchRequest;
+import com.sinata.appointment.entity.SysRole;
+import com.sinata.appointment.entity.SysUser;
+import com.sinata.appointment.entity.SysUserRole;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.mapper.SysRoleMapper;
+import com.sinata.appointment.mapper.SysUserMapper;
+import com.sinata.appointment.mapper.SysUserRoleMapper;
+import com.sinata.appointment.util.MobileMaskUtil;
+import com.sinata.appointment.util.UuidAssert;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.stream.Collectors;
+import lombok.RequiredArgsConstructor;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+@Service
+@RequiredArgsConstructor
+public class SysUserApplicationService {
+
+ private final SysUserMapper sysUserMapper;
+ private final SysRoleMapper sysRoleMapper;
+ private final SysUserRoleMapper sysUserRoleMapper;
+ private final BCryptPasswordEncoder bcrypt;
+
+ public IPage<SysUserListVo> pageUsers(
+ long page,
+ long size,
+ String realName,
+ String mobile,
+ Long roleId,
+ Integer status) {
+ LambdaQueryWrapper<SysUser> q = new LambdaQueryWrapper<>();
+ q.eq(SysUser::getDelFlag, 0);
+ if (StringUtils.hasText(realName)) {
+ q.like(SysUser::getRealName, realName.trim());
+ }
+ if (StringUtils.hasText(mobile)) {
+ q.like(SysUser::getMobile, mobile.trim());
+ }
+ if (roleId != null) {
+ q.apply(
+ "id IN (SELECT user_id FROM sys_user_role WHERE role_id = {0} AND del_flag = 0)",
+ roleId);
+ }
+ if (status != null) {
+ q.eq(SysUser::getStatus, status);
+ }
+ q.orderByDesc(SysUser::getCreateTime);
+ Page<SysUser> p = sysUserMapper.selectPage(new Page<>(page, size), q);
+
+ List<Long> userIds = p.getRecords().stream().map(SysUser::getId).toList();
+ Map<Long, String> roleLine = roleDisplayByUserIds(userIds);
+
+ return p.convert(
+ u -> {
+ SysUserListVo vo = new SysUserListVo();
+ vo.setId(u.getId());
+ vo.setUuid(u.getUuid());
+ vo.setUsername(u.getUsername());
+ vo.setRealName(u.getRealName());
+ vo.setMobileMasked(MobileMaskUtil.mask(u.getMobile()));
+ vo.setRolesDisplay(roleLine.getOrDefault(u.getId(), "—"));
+ vo.setStatus(u.getStatus());
+ vo.setCreateTime(u.getCreateTime());
+ return vo;
+ });
+ }
+
+ private Map<Long, String> roleDisplayByUserIds(List<Long> userIds) {
+ if (userIds.isEmpty()) {
+ return Map.of();
+ }
+ List<UserRoleLabelRow> rows = sysUserMapper.listRoleLabelsByUserIds(userIds);
+ return rows.stream()
+ .collect(
+ Collectors.groupingBy(
+ UserRoleLabelRow::getUserId,
+ Collectors.mapping(
+ UserRoleLabelRow::getRoleName,
+ Collectors.joining("、"))));
+ }
+
+ public SysUserDetailVo getDetail(Long id, String uuid) {
+ SysUser u = loadActiveWithUuid(id, uuid);
+ SysUserDetailVo vo = new SysUserDetailVo();
+ vo.setId(u.getId());
+ vo.setUuid(u.getUuid());
+ vo.setUsername(u.getUsername());
+ vo.setRealName(u.getRealName());
+ vo.setMobile(u.getMobile());
+ vo.setAvatarUrl(u.getAvatarUrl());
+ vo.setStatus(u.getStatus());
+ vo.setRoleIds(listRoleIds(id));
+ return vo;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public Map<String, Object> createUser(SysUserCreateRequest req) {
+ long exists =
+ sysUserMapper.selectCount(
+ new LambdaQueryWrapper<SysUser>()
+ .eq(SysUser::getUsername, req.getUsername().trim())
+ .eq(SysUser::getDelFlag, 0));
+ if (exists > 0) {
+ throw new BizException(BizErrorCode.DUPLICATE_USERNAME, "登录账号已存在");
+ }
+ validateRoleIds(req.getRoleIds());
+
+ SysUser u = new SysUser();
+ u.setUsername(req.getUsername().trim());
+ u.setPasswordHash(bcrypt.encode(req.getPassword()));
+ u.setRealName(trimToNull(req.getRealName()));
+ u.setMobile(trimToNull(req.getMobile()));
+ u.setAvatarUrl(trimToNull(req.getAvatarUrl()));
+ u.setStatus(1);
+ sysUserMapper.insert(u);
+ SysUser fresh = sysUserMapper.selectById(u.getId());
+ if (fresh == null || !StringUtils.hasText(fresh.getUuid())) {
+ throw new BizException(BizErrorCode.AUTH_FORBIDDEN, "创建后读取用户失败");
+ }
+ replaceRoles(fresh.getId(), req.getRoleIds());
+ Map<String, Object> m = new HashMap<>();
+ m.put("id", fresh.getId());
+ m.put("uuid", fresh.getUuid());
+ return m;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void updateUser(Long id, SysUserUpdateRequest req) {
+ SysUser u = loadActiveWithUuid(id, req.getUuid());
+ validateRoleIds(req.getRoleIds());
+ u.setRealName(trimToNull(req.getRealName()));
+ u.setMobile(trimToNull(req.getMobile()));
+ u.setAvatarUrl(trimToNull(req.getAvatarUrl()));
+ sysUserMapper.updateById(u);
+ replaceRoles(id, req.getRoleIds());
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void resetPassword(Long id, ResetPasswordRequest req) {
+ if (!Objects.equals(req.getNewPassword(), req.getNewPasswordConfirm())) {
+ throw new BizException(BizErrorCode.PASSWORD_CONFIRM_MISMATCH, "两次输入的新密码不一致");
+ }
+ SysUser u = loadActiveWithUuid(id, req.getUuid());
+ u.setPasswordHash(bcrypt.encode(req.getNewPassword()));
+ sysUserMapper.updateById(u);
+ kickSysUserOffline(id);
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void patchStatus(Long id, UserStatusPatchRequest req, long operatorUserId) {
+ int st = req.getStatus();
+ if (st != 0 && st != 1) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "状态无效");
+ }
+ if (st == 0 && id == operatorUserId) {
+ throw new BizException(BizErrorCode.USER_SELF_FORBIDDEN, "不能禁用自己的账号");
+ }
+ SysUser u = loadActiveWithUuid(id, req.getUuid());
+ u.setStatus(st);
+ sysUserMapper.updateById(u);
+ if (st == 0) {
+ kickSysUserOffline(id);
+ }
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void deleteUser(Long id, String uuid, long operatorUserId) {
+ if (id == operatorUserId) {
+ throw new BizException(BizErrorCode.USER_SELF_FORBIDDEN, "不能删除自己的账号");
+ }
+ SysUser u = loadActiveWithUuid(id, uuid);
+ sysUserRoleMapper.delete(
+ new LambdaQueryWrapper<SysUserRole>()
+ .eq(SysUserRole::getUserId, id)
+ .eq(SysUserRole::getDelFlag, 0));
+ int affected = sysUserMapper.deleteById(u.getId());
+ if (affected <= 0) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "删除失败,请刷新后重试");
+ }
+ kickSysUserOffline(id);
+ }
+
+ private static void kickSysUserOffline(Long sysUserId) {
+ StpUtil.kickout(sysUserId);
+ }
+
+ private SysUser loadActiveWithUuid(Long id, String uuid) {
+ SysUser u = sysUserMapper.selectById(id);
+ if (u == null || u.getDelFlag() != null && u.getDelFlag() == 1) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "资源不存在或无权访问");
+ }
+ UuidAssert.match(u, uuid);
+ return u;
+ }
+
+ private void validateRoleIds(List<Long> roleIds) {
+ if (roleIds == null || roleIds.isEmpty()) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "至少选择一个角色");
+ }
+ for (Long rid : roleIds) {
+ SysRole r = sysRoleMapper.selectById(rid);
+ if (r == null || r.getDelFlag() != null && r.getDelFlag() == 1) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "角色不存在: " + rid);
+ }
+ }
+ }
+
+ private List<Long> listRoleIds(Long userId) {
+ return sysUserRoleMapper
+ .selectList(
+ new LambdaQueryWrapper<SysUserRole>()
+ .eq(SysUserRole::getUserId, userId)
+ .eq(SysUserRole::getDelFlag, 0))
+ .stream()
+ .map(SysUserRole::getRoleId)
+ .toList();
+ }
+
+ private void replaceRoles(Long userId, List<Long> roleIds) {
+ sysUserRoleMapper.delete(
+ new LambdaQueryWrapper<SysUserRole>()
+ .eq(SysUserRole::getUserId, userId)
+ .eq(SysUserRole::getDelFlag, 0));
+ for (Long rid : roleIds) {
+ SysUserRole ur = new SysUserRole();
+ ur.setUserId(userId);
+ ur.setRoleId(rid);
+ sysUserRoleMapper.insert(ur);
+ }
+ }
+
+ private static String trimToNull(String s) {
+ if (!StringUtils.hasText(s)) {
+ return null;
+ }
+ String t = s.trim();
+ return t.isEmpty() ? null : t;
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/service/VisitorUserManageApplicationService.java b/backend/src/main/java/com/sinata/appointment/service/VisitorUserManageApplicationService.java
new file mode 100644
index 0000000..af0883c
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/service/VisitorUserManageApplicationService.java
@@ -0,0 +1,294 @@
+package com.sinata.appointment.service;
+
+import com.alibaba.excel.EasyExcel;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.sinata.appointment.config.MiniStpUtil;
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.constants.MiniUserType;
+import com.sinata.appointment.dto.VisitorUserDetailVo;
+import com.sinata.appointment.dto.VisitorUserExportRow;
+import com.sinata.appointment.dto.VisitorUserListVo;
+import com.sinata.appointment.dto.VisitorUserStatusPatchRequest;
+import com.sinata.appointment.dto.VisitorUserUpdateRequest;
+import com.sinata.appointment.entity.AppointmentOrder;
+import com.sinata.appointment.entity.MpUser;
+import com.sinata.appointment.exception.BizException;
+import com.sinata.appointment.mapper.AppointmentOrderMapper;
+import com.sinata.appointment.mapper.MpUserMapper;
+import com.sinata.appointment.util.MobileMaskUtil;
+import com.sinata.appointment.util.PhoneNormalize;
+import com.sinata.appointment.util.UuidAssert;
+import java.io.ByteArrayOutputStream;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+@Service
+@RequiredArgsConstructor
+public class VisitorUserManageApplicationService {
+
+ private static final DateTimeFormatter EXPORT_TIME_FORMATTER =
+ DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
+ private final MpUserMapper mpUserMapper;
+ private final AppointmentOrderMapper appointmentOrderMapper;
+
+ public IPage<VisitorUserListVo> page(
+ long page,
+ long size,
+ String userName,
+ String mobile,
+ String companyName,
+ LocalDate registerStartDate,
+ LocalDate registerEndDate,
+ Integer status) {
+ Page<MpUser> result =
+ mpUserMapper.selectPage(
+ new Page<>(page, size),
+ buildVisitorQuery(
+ userName, mobile, companyName, registerStartDate, registerEndDate, status));
+ List<Long> userIds = result.getRecords().stream().map(MpUser::getId).filter(Objects::nonNull).toList();
+ Map<Long, Long> bookingCountMap = loadBookingCountMap(userIds);
+ return result.convert(user -> toListVo(user, bookingCountMap));
+ }
+
+ public byte[] exportExcel(
+ String userName,
+ String mobile,
+ String companyName,
+ LocalDate registerStartDate,
+ LocalDate registerEndDate,
+ Integer status) {
+ List<MpUser> users =
+ mpUserMapper.selectList(
+ buildVisitorQuery(
+ userName, mobile, companyName, registerStartDate, registerEndDate, status));
+ List<Long> userIds = users.stream().map(MpUser::getId).filter(Objects::nonNull).toList();
+ Map<Long, Long> bookingCountMap = loadBookingCountMap(userIds);
+ List<VisitorUserExportRow> rows =
+ users.stream().map(user -> toExportRow(user, bookingCountMap)).toList();
+ try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
+ EasyExcel.write(bos, VisitorUserExportRow.class).sheet("访客信息").doWrite(rows);
+ return bos.toByteArray();
+ } catch (Exception e) {
+ return new byte[0];
+ }
+ }
+
+ public VisitorUserDetailVo detail(Long id, String uuid) {
+ MpUser user = loadVisitorUserWithUuid(id, uuid);
+ VisitorUserDetailVo vo = new VisitorUserDetailVo();
+ vo.setId(user.getId());
+ vo.setUuid(user.getUuid());
+ vo.setUserName(firstNonBlank(user.getNickName(), "—"));
+ vo.setMobile(user.getMobile());
+ vo.setGender(trimToNull(user.getGender()));
+ vo.setCompanyName(trimToNull(user.getCompanyName()));
+ vo.setStatus(user.getStatus() == null ? 1 : user.getStatus());
+ return vo;
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void update(Long id, VisitorUserUpdateRequest request) {
+ MpUser user = loadVisitorUserWithUuid(id, request.getUuid());
+ String userName = trimRequired(request.getUserName(), "用户名称不能为空");
+ if (userName.length() > 30) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "用户名称不能超过30个字");
+ }
+ String mobile = trimRequired(request.getMobile(), "用户电话不能为空");
+ if (!mobile.matches("^\\d{11}$")) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "用户电话须为11位数字");
+ }
+ assertMobileUnique(mobile, user.getId());
+ String gender = trimToNull(request.getGender());
+ if (StringUtils.hasText(gender) && !"男".equals(gender) && !"女".equals(gender)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "性别仅支持男/女");
+ }
+ String companyName = trimToNull(request.getCompanyName());
+
+ user.setNickName(userName);
+ user.setMobile(mobile);
+ user.setGender(gender);
+ user.setCompanyName(companyName);
+ mpUserMapper.updateById(user);
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void patchStatus(Long id, VisitorUserStatusPatchRequest request) {
+ MpUser user = loadVisitorUserWithUuid(id, request.getUuid());
+ Integer status = request.getStatus();
+ if (status == null || (status != 0 && status != 1)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, "状态无效");
+ }
+ user.setStatus(status);
+ mpUserMapper.updateById(user);
+ if (status == 0) {
+ kickMiniUserOffline(user.getId());
+ }
+ }
+
+ @Transactional(rollbackFor = Exception.class)
+ public void delete(Long id, String uuid) {
+ MpUser user = loadVisitorUserWithUuid(id, uuid);
+ int affected = mpUserMapper.deleteById(user.getId());
+ if (affected <= 0) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "删除失败,请刷新后重试");
+ }
+ kickMiniUserOffline(user.getId());
+ }
+
+ private Map<Long, Long> loadBookingCountMap(List<Long> userIds) {
+ Map<Long, Long> result = new HashMap<>();
+ if (userIds == null || userIds.isEmpty()) {
+ return result;
+ }
+ List<AppointmentOrder> rows =
+ appointmentOrderMapper.selectList(
+ new LambdaQueryWrapper<AppointmentOrder>()
+ .eq(AppointmentOrder::getDelFlag, 0)
+ .in(AppointmentOrder::getSubmitterId, userIds));
+ for (AppointmentOrder row : rows) {
+ if (row.getSubmitterId() != null) {
+ result.merge(row.getSubmitterId(), 1L, Long::sum);
+ }
+ }
+ return result;
+ }
+
+ private VisitorUserListVo toListVo(MpUser user, Map<Long, Long> bookingCountMap) {
+ VisitorUserListVo vo = new VisitorUserListVo();
+ vo.setId(user.getId());
+ vo.setUuid(user.getUuid());
+ vo.setUserName(firstNonBlank(user.getNickName(), "—"));
+ vo.setGender(defaultText(trimToNull(user.getGender())));
+ vo.setMobileMasked(MobileMaskUtil.mask(user.getMobile()));
+ vo.setCompanyName(defaultText(trimToNull(user.getCompanyName())));
+ vo.setBookingCount(bookingCountMap.getOrDefault(user.getId(), 0L));
+ vo.setStatus(user.getStatus() == null ? 1 : user.getStatus());
+ vo.setCreateTime(user.getCreateTime());
+ return vo;
+ }
+
+ private VisitorUserExportRow toExportRow(MpUser user, Map<Long, Long> bookingCountMap) {
+ VisitorUserExportRow row = new VisitorUserExportRow();
+ row.setUserName(firstNonBlank(user.getNickName(), "—"));
+ row.setGender(defaultText(trimToNull(user.getGender())));
+ row.setMobile(defaultText(trimToNull(user.getMobile())));
+ row.setCompanyName(defaultText(trimToNull(user.getCompanyName())));
+ row.setBookingCount(bookingCountMap.getOrDefault(user.getId(), 0L));
+ row.setStatusText(user.getStatus() != null && user.getStatus() == 0 ? "禁用" : "启用");
+ row.setCreateTime(formatDateTime(user.getCreateTime()));
+ return row;
+ }
+
+ private LambdaQueryWrapper<MpUser> buildVisitorQuery(
+ String userName,
+ String mobile,
+ String companyName,
+ LocalDate registerStartDate,
+ LocalDate registerEndDate,
+ Integer status) {
+ LambdaQueryWrapper<MpUser> query = new LambdaQueryWrapper<>();
+ query.eq(MpUser::getDelFlag, 0).eq(MpUser::getUserType, MiniUserType.VISITOR);
+ query.like(StringUtils.hasText(userName), MpUser::getNickName, trim(userName));
+ query.like(StringUtils.hasText(mobile), MpUser::getMobile, trim(mobile));
+ query.like(StringUtils.hasText(companyName), MpUser::getCompanyName, trim(companyName));
+ query.eq(status != null, MpUser::getStatus, status);
+ query.ge(registerStartDate != null, MpUser::getCreateTime, atStart(registerStartDate));
+ query.lt(registerEndDate != null, MpUser::getCreateTime, atNextDay(registerEndDate));
+ query.orderByDesc(MpUser::getCreateTime).orderByDesc(MpUser::getId);
+ return query;
+ }
+
+ private void assertMobileUnique(String mobileRaw, Long excludeUserId) {
+ String normalized = PhoneNormalize.mainland(mobileRaw);
+ List<MpUser> rows =
+ mpUserMapper.selectList(
+ new LambdaQueryWrapper<MpUser>()
+ .eq(MpUser::getDelFlag, 0)
+ .isNotNull(MpUser::getMobile));
+ for (MpUser row : rows) {
+ if (excludeUserId != null && excludeUserId.equals(row.getId())) {
+ continue;
+ }
+ if (normalized.equals(PhoneNormalize.mainland(row.getMobile()))) {
+ throw new BizException(BizErrorCode.MP_STAFF_MOBILE_EXISTS, "手机号已存在");
+ }
+ }
+ }
+
+ private MpUser loadVisitorUserWithUuid(Long id, String uuid) {
+ MpUser user = mpUserMapper.selectById(id);
+ if (user == null || (user.getDelFlag() != null && user.getDelFlag() == 1)) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "资源不存在或无权访问");
+ }
+ if (!MiniUserType.VISITOR.equals(user.getUserType())) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "资源不存在或无权访问");
+ }
+ UuidAssert.match(user, uuid);
+ return user;
+ }
+
+ private static void kickMiniUserOffline(Long mpUserId) {
+ try {
+ MiniStpUtil.kickout(mpUserId);
+ } catch (Exception ignored) {
+ }
+ }
+
+ private static LocalDateTime atStart(LocalDate date) {
+ return date == null ? null : date.atStartOfDay();
+ }
+
+ private static LocalDateTime atNextDay(LocalDate date) {
+ return date == null ? null : date.plusDays(1).atStartOfDay();
+ }
+
+ private static String trim(String value) {
+ return value == null ? null : value.trim();
+ }
+
+ private static String trimToNull(String value) {
+ String trimmed = trim(value);
+ return StringUtils.hasText(trimmed) ? trimmed : null;
+ }
+
+ private static String trimRequired(String value, String message) {
+ String trimmed = trimToNull(value);
+ if (!StringUtils.hasText(trimmed)) {
+ throw new BizException(BizErrorCode.AUTH_INVALID_CREDENTIALS, message);
+ }
+ return trimmed;
+ }
+
+ private static String defaultText(String value) {
+ return StringUtils.hasText(value) ? value : "—";
+ }
+
+ private static String formatDateTime(LocalDateTime value) {
+ return value == null ? "—" : EXPORT_TIME_FORMATTER.format(value);
+ }
+
+ @SafeVarargs
+ private static String firstNonBlank(String... values) {
+ if (values == null) {
+ return null;
+ }
+ for (String value : values) {
+ if (StringUtils.hasText(value)) {
+ return value.trim();
+ }
+ }
+ return null;
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/util/IdCardUtil.java b/backend/src/main/java/com/sinata/appointment/util/IdCardUtil.java
new file mode 100644
index 0000000..ffe1ad1
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/util/IdCardUtil.java
@@ -0,0 +1,29 @@
+package com.sinata.appointment.util;
+
+import java.util.Locale;
+
+/** 中国大陆身份证号基础校验(18 位) */
+public final class IdCardUtil {
+
+ private static final int[] WEIGHTS = {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2};
+ private static final char[] CHECK_CODES = {'1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'};
+
+ private IdCardUtil() {}
+
+ public static boolean isValidMainland18(String idCard) {
+ if (idCard == null) {
+ return false;
+ }
+ String s = idCard.trim().toUpperCase(Locale.ROOT);
+ if (!s.matches("^[0-9]{17}[0-9X]$")) {
+ return false;
+ }
+ int sum = 0;
+ for (int i = 0; i < 17; i++) {
+ sum += (s.charAt(i) - '0') * WEIGHTS[i];
+ }
+ int idx = sum % 11;
+ return CHECK_CODES[idx] == s.charAt(17);
+ }
+}
+
diff --git a/backend/src/main/java/com/sinata/appointment/util/MobileMaskUtil.java b/backend/src/main/java/com/sinata/appointment/util/MobileMaskUtil.java
new file mode 100644
index 0000000..6cfc601
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/util/MobileMaskUtil.java
@@ -0,0 +1,19 @@
+package com.sinata.appointment.util;
+
+/** 列表等场景手机号脱敏:保留前 3、后 4,中间 * */
+public final class MobileMaskUtil {
+
+ private MobileMaskUtil() {}
+
+ public static String mask(String mobile) {
+ if (mobile == null || mobile.isBlank()) {
+ return "—";
+ }
+ String t = mobile.trim();
+ int n = t.length();
+ if (n <= 7) {
+ return n <= 3 ? "***" : t.substring(0, Math.min(3, n)) + "*".repeat(Math.max(1, n - 3));
+ }
+ return t.substring(0, 3) + "*".repeat(n - 7) + t.substring(n - 4);
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/util/OrderNoGenerator.java b/backend/src/main/java/com/sinata/appointment/util/OrderNoGenerator.java
new file mode 100644
index 0000000..6e55bb0
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/util/OrderNoGenerator.java
@@ -0,0 +1,17 @@
+package com.sinata.appointment.util;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public final class OrderNoGenerator {
+
+ private static final AtomicInteger SEQ = new AtomicInteger(0);
+ private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+
+ private OrderNoGenerator() {}
+
+ public static String next() {
+ return "ORD" + LocalDateTime.now().format(FMT) + String.format("%04d", SEQ.incrementAndGet() % 10000);
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/util/PhoneNormalize.java b/backend/src/main/java/com/sinata/appointment/util/PhoneNormalize.java
new file mode 100644
index 0000000..3de0066
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/util/PhoneNormalize.java
@@ -0,0 +1,20 @@
+package com.sinata.appointment.util;
+
+public final class PhoneNormalize {
+
+ private PhoneNormalize() {}
+
+ /** 大陆手机号比较用:去空格、去 +86/86 前缀 */
+ public static String mainland(String raw) {
+ if (raw == null) {
+ return "";
+ }
+ String s = raw.replaceAll("\\s+", "");
+ if (s.startsWith("+86")) {
+ s = s.substring(3);
+ } else if (s.startsWith("86") && s.length() == 13) {
+ s = s.substring(2);
+ }
+ return s;
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/util/SensitiveDataUtil.java b/backend/src/main/java/com/sinata/appointment/util/SensitiveDataUtil.java
new file mode 100644
index 0000000..f97588d
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/util/SensitiveDataUtil.java
@@ -0,0 +1,42 @@
+package com.sinata.appointment.util;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+
+/**
+ * 敏感字段占位处理:生产环境应替换为 KMS + 对称加密。
+ */
+public final class SensitiveDataUtil {
+
+ private static final String PREFIX = "ENC:";
+
+ private SensitiveDataUtil() {}
+
+ public static String maskIdCard(String plain) {
+ if (plain == null || plain.length() < 8) {
+ return "****";
+ }
+ int starLen = plain.length() - 8;
+ String stars = "*".repeat(Math.max(starLen, 1));
+ return plain.substring(0, 4) + stars + plain.substring(plain.length() - 4);
+ }
+
+ public static String encryptIdCard(String plain) {
+ if (plain == null || plain.isBlank()) {
+ return null;
+ }
+ return PREFIX + Base64.getEncoder().encodeToString(plain.getBytes(StandardCharsets.UTF_8));
+ }
+
+ public static String decryptIdCard(String cipher) {
+ if (cipher == null || cipher.isBlank()) {
+ return null;
+ }
+ String t = cipher.trim();
+ if (t.startsWith(PREFIX)) {
+ String raw = t.substring(PREFIX.length());
+ return new String(Base64.getDecoder().decode(raw), StandardCharsets.UTF_8);
+ }
+ return t;
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/util/UuidAssert.java b/backend/src/main/java/com/sinata/appointment/util/UuidAssert.java
new file mode 100644
index 0000000..d3a366d
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/util/UuidAssert.java
@@ -0,0 +1,23 @@
+package com.sinata.appointment.util;
+
+import com.sinata.appointment.constants.BizErrorCode;
+import com.sinata.appointment.entity.BaseEntity;
+import com.sinata.appointment.exception.BizException;
+import org.springframework.util.StringUtils;
+
+/**
+ * 单条资源读写防水平越权:除 id 外须校验与库中一致的 uuid(不明示是 id 错还是 uuid 错)。
+ */
+public final class UuidAssert {
+
+ private UuidAssert() {}
+
+ public static void match(BaseEntity entity, String submittedUuid) {
+ if (entity == null
+ || !StringUtils.hasText(submittedUuid)
+ || !StringUtils.hasText(entity.getUuid())
+ || !entity.getUuid().equals(submittedUuid.trim())) {
+ throw new BizException(BizErrorCode.RESOURCE_VERIFY_FAILED, "资源不存在或无权访问");
+ }
+ }
+}
diff --git a/backend/src/main/java/com/sinata/appointment/validation/ChangePasswordGroup.java b/backend/src/main/java/com/sinata/appointment/validation/ChangePasswordGroup.java
new file mode 100644
index 0000000..296d63e
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/validation/ChangePasswordGroup.java
@@ -0,0 +1,4 @@
+package com.sinata.appointment.validation;
+
+/** 校验分组:修改密码 */
+public interface ChangePasswordGroup {}
diff --git a/backend/src/main/java/com/sinata/appointment/validation/CreateGroup.java b/backend/src/main/java/com/sinata/appointment/validation/CreateGroup.java
new file mode 100644
index 0000000..8e0ab39
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/validation/CreateGroup.java
@@ -0,0 +1,4 @@
+package com.sinata.appointment.validation;
+
+/** 校验分组:新增 */
+public interface CreateGroup {}
diff --git a/backend/src/main/java/com/sinata/appointment/validation/DeleteGroup.java b/backend/src/main/java/com/sinata/appointment/validation/DeleteGroup.java
new file mode 100644
index 0000000..cfdaf64
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/validation/DeleteGroup.java
@@ -0,0 +1,4 @@
+package com.sinata.appointment.validation;
+
+/** 校验分组:删除 */
+public interface DeleteGroup {}
diff --git a/backend/src/main/java/com/sinata/appointment/validation/UpdateGroup.java b/backend/src/main/java/com/sinata/appointment/validation/UpdateGroup.java
new file mode 100644
index 0000000..5100b7d
--- /dev/null
+++ b/backend/src/main/java/com/sinata/appointment/validation/UpdateGroup.java
@@ -0,0 +1,4 @@
+package com.sinata.appointment.validation;
+
+/** 校验分组:更新 */
+public interface UpdateGroup {}
diff --git a/backend/src/main/resources/application-local.yml b/backend/src/main/resources/application-local.yml
new file mode 100644
index 0000000..f5adf86
--- /dev/null
+++ b/backend/src/main/resources/application-local.yml
@@ -0,0 +1,17 @@
+# 与仓库根目录 docker-compose.yml 对齐:MySQL 8(root/root)、Redis 无密码
+# 用法:docker compose up -d mysql redis
+# mvn spring-boot:run -Dspring-boot.run.profiles=local
+spring:
+ datasource:
+ url: jdbc:mysql://127.0.0.1:3306/appointment?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
+ username: root
+ password: root
+ data:
+ redis:
+ host: 127.0.0.1
+ password: ""
+
+sa-token:
+ alone-redis:
+ host: 127.0.0.1
+ password: ""
diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml
new file mode 100644
index 0000000..465b1c7
--- /dev/null
+++ b/backend/src/main/resources/application.yml
@@ -0,0 +1,100 @@
+spring:
+ application:
+ name: appointment-backend
+ datasource:
+ url: jdbc:mysql://192.168.110.80:3306/appointment?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
+ username: root
+ password: 123456
+ driver-class-name: com.mysql.cj.jdbc.Driver
+ rabbitmq:
+ host: localhost
+ port: 5672
+ username: guest
+ password: guest
+ cache:
+ type: redis
+ # Spring Boot 3:Redis 配置前缀为 spring.data.redis(spring.redis.* 已移除,沿用会导致连默认 localhost)
+ data:
+ redis:
+ database: 2
+ host: 192.168.110.80
+ port: 6379
+ password: 123456
+ timeout: 10s
+ lettuce:
+ pool:
+ max-active: 200
+ max-wait: -1ms
+ max-idle: 10
+ min-idle: 0
+
+# 无 RabbitMQ 时排除自动配置,避免启动失败
+ autoconfigure:
+ exclude:
+ - org.springframework.boot.autoconfigure.amqp.RabbitAutoConfiguration
+
+mybatis-plus:
+ configuration:
+ map-underscore-to-camel-case: true
+ global-config:
+ db-config:
+ logic-delete-field: delFlag
+ logic-not-delete-value: 0
+ logic-delete-value: 1
+
+sa-token:
+ token-name: Authorization
+ is-read-header: true
+ timeout: 864000
+ # token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
+ active-timeout: -1
+ # 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
+ is-concurrent: false
+ # 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
+ is-share: false
+ # token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
+ token-style: uuid
+ # 是否输出操作日志
+ is-log: true
+ # 独立 Redis(须引入 sa-token-alone-redis);与 spring.data.redis 互不干扰,可指向专用实例
+ alone-redis:
+ # Redis数据库索引 (默认为0)
+ database: 3
+ # Redis服务器地址
+ host: 192.168.110.80
+ # Redis服务器连接端口
+ port: 6379
+ # Redis服务器连接密码(默认为空)
+ password: 123456
+ # 连接超时时间
+ timeout: 10s
+ lettuce:
+ pool:
+ max-active: 64
+ max-wait: -1ms
+ max-idle: 16
+ min-idle: 4
+
+# 微信小程序(真实登录时设 stub: false 并配置 app-id / app-secret)
+wechat:
+ miniapp:
+ app-id: ${WECHAT_MINI_APPID:}
+ app-secret: ${WECHAT_MINI_SECRET:}
+ stub: ${WECHAT_MINI_STUB:true}
+
+springdoc:
+ api-docs:
+ path: /v3/api-docs
+
+server:
+ port: 8880
+
+logging:
+ pattern:
+ level: "%5p [${spring.application.name:},%X{traceId:-}]"
+
+security:
+ admin-login-crypto:
+ key: ${ADMIN_LOGIN_AES_KEY:SinataAdminKey01}
+ iv: ${ADMIN_LOGIN_AES_IV:SinataAdminIv001}
+ max-skew-millis: ${ADMIN_LOGIN_AES_MAX_SKEW_MILLIS:60000}
diff --git a/backend/src/main/resources/db/migration/COMMENT_RULES.md b/backend/src/main/resources/db/migration/COMMENT_RULES.md
new file mode 100644
index 0000000..03e9fd1
--- /dev/null
+++ b/backend/src/main/resources/db/migration/COMMENT_RULES.md
@@ -0,0 +1,9 @@
+# SQL 注释规范
+
+- 所有新增表必须写表级 `COMMENT`。
+- 所有新增表字段必须写 `COMMENT`。
+- 所有 `ALTER TABLE ... ADD COLUMN` 新增字段必须写 `COMMENT`。
+- 涉及字段类型变更时,`MODIFY COLUMN` 需保留并补全 `COMMENT`。
+- 统一中文语义,遵循:
+ - `ID/UUID/版本/审计字段/逻辑删除` 固定描述
+ - 业务字段说明“用途 + 枚举含义(如有)”
diff --git a/backend/src/main/resources/db/migration/V10__sys_biz_setting.sql b/backend/src/main/resources/db/migration/V10__sys_biz_setting.sql
new file mode 100644
index 0000000..ab7e87c
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V10__sys_biz_setting.sql
@@ -0,0 +1,10 @@
+-- 系统级键值配置(如访客主管部门 id)
+CREATE TABLE IF NOT EXISTS sys_biz_setting (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ setting_key VARCHAR(64) NOT NULL,
+ setting_value VARCHAR(64) NULL,
+ UNIQUE KEY uk_sys_biz_setting_key (setting_key)
+);
+
+INSERT IGNORE INTO sys_biz_setting (setting_key, setting_value)
+VALUES ('VISIT_COMPETENT_DEPT_ID', NULL);
diff --git a/backend/src/main/resources/db/migration/V11__booking_settings.sql b/backend/src/main/resources/db/migration/V11__booking_settings.sql
new file mode 100644
index 0000000..fcd74e6
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V11__booking_settings.sql
@@ -0,0 +1,119 @@
+-- 预约策略键值 + 字段展示长度 + 预约字段种子
+
+ALTER TABLE sys_biz_setting MODIFY COLUMN setting_value VARCHAR(4000) NULL;
+
+INSERT IGNORE INTO sys_biz_setting (setting_key, setting_value) VALUES
+('BOOKING_RANGE_MODE', 'SINGLE'),
+('BOOKING_MAX_CONSECUTIVE_DAYS', '7'),
+('BOOKING_HOLIDAY_DATES', '[]'),
+('VEHICLE_BARRIER_GATE_ENABLED', '0');
+
+UPDATE biz_field_config SET field_name = '身份证号码', sort_order = 90, visible = 1, required_flag = 1, del_flag = 0
+WHERE field_code = 'idCard';
+
+UPDATE biz_field_config SET field_name = '来访单位', sort_order = 125, visible = 1, required_flag = 0, del_flag = 0
+WHERE field_code = 'visitorOrg';
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'visit_type', '来访类型', 1, 1, 10, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'visit_type' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'respondent', '受访人', 1, 1, 20, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'respondent' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'visit_reason', '来访事由', 1, 1, 30, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'visit_reason' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'visit_start_time', '来访时间', 1, 1, 40, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'visit_start_time' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'visit_end_time', '结束来访时间', 1, 1, 50, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'visit_end_time' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'license_plate', '车牌号', 1, 1, 60, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'license_plate' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'driver', '驾驶员', 1, 1, 70, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'driver' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'visitor_name', '访客姓名', 1, 1, 80, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'visitor_name' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'gender', '性别', 1, 1, 100, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'gender' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'company', '所属公司', 1, 1, 110, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'company' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'mobile', '联系电话', 1, 1, 120, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'mobile' AND del_flag = 0);
+
+-- 预约策略键值 + 字段展示长度 + 预约字段种子
+
+ALTER TABLE sys_biz_setting MODIFY COLUMN setting_value VARCHAR(4000) NULL;
+
+INSERT IGNORE INTO sys_biz_setting (setting_key, setting_value) VALUES
+('BOOKING_RANGE_MODE', 'SINGLE'),
+('BOOKING_MAX_CONSECUTIVE_DAYS', '7'),
+('BOOKING_HOLIDAY_DATES', '[]'),
+('VEHICLE_BARRIER_GATE_ENABLED', '0');
+
+UPDATE biz_field_config SET field_name = '身份证号码', sort_order = 90, visible = 1, required_flag = 1, del_flag = 0
+WHERE field_code = 'idCard';
+
+UPDATE biz_field_config SET field_name = '来访单位', sort_order = 125, visible = 1, required_flag = 0, del_flag = 0
+WHERE field_code = 'visitorOrg';
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'visit_type', '来访类型', 1, 1, 10, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'visit_type' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'respondent', '受访人', 1, 1, 20, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'respondent' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'visit_reason', '来访事由', 1, 1, 30, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'visit_reason' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'visit_start_time', '来访时间', 1, 1, 40, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'visit_start_time' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'visit_end_time', '结束来访时间', 1, 1, 50, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'visit_end_time' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'license_plate', '车牌号', 1, 1, 60, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'license_plate' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'driver', '驾驶员', 1, 1, 70, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'driver' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'visitor_name', '访客姓名', 1, 1, 80, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'visitor_name' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'gender', '性别', 1, 1, 100, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'gender' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'company', '所属公司', 1, 1, 110, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'company' AND del_flag = 0);
+
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 'mobile', '联系电话', 1, 1, 120, 0
+FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM biz_field_config WHERE field_code = 'mobile' AND del_flag = 0);
diff --git a/backend/src/main/resources/db/migration/V12__booking_holiday_table.sql b/backend/src/main/resources/db/migration/V12__booking_holiday_table.sql
new file mode 100644
index 0000000..ea139ab
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V12__booking_holiday_table.sql
@@ -0,0 +1,18 @@
+-- 节假日/高温假:单独表存储,避免 setting_value 过大
+
+CREATE TABLE IF NOT EXISTS biz_holiday_date (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+
+ holiday_date DATE NOT NULL COMMENT '节假日/高温假日期',
+
+ UNIQUE KEY uk_biz_holiday_date_uuid (uuid),
+ UNIQUE KEY uk_biz_holiday_date (holiday_date)
+);
+
diff --git a/backend/src/main/resources/db/migration/V13__visit_reason_manage.sql b/backend/src/main/resources/db/migration/V13__visit_reason_manage.sql
new file mode 100644
index 0000000..24af4a3
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V13__visit_reason_manage.sql
@@ -0,0 +1,4 @@
+-- 来访事由管理页分页查询索引优化
+ALTER TABLE biz_visit_reason
+ ADD INDEX idx_biz_visit_reason_del_create (del_flag, create_time, id);
+
diff --git a/backend/src/main/resources/db/migration/V14__content_settings.sql b/backend/src/main/resources/db/migration/V14__content_settings.sql
new file mode 100644
index 0000000..4ec861f
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V14__content_settings.sql
@@ -0,0 +1,12 @@
+-- 内容设置富文本需要较长文本,扩展 setting_value 容量
+ALTER TABLE sys_biz_setting
+ MODIFY COLUMN setting_value TEXT NULL;
+
+-- 内容设置键初始化
+INSERT IGNORE INTO sys_biz_setting (setting_key, setting_value)
+VALUES
+ ('CONTENT_ABOUT_US_HTML', ''),
+ ('CONTENT_PARK_NOTICE_HTML', ''),
+ ('CONTENT_PARK_FLOW_HTML', ''),
+ ('CONTENT_ABOUT_US_ENABLED', '0');
+
diff --git a/backend/src/main/resources/db/migration/V15__visit_reason_backfill_uuid.sql b/backend/src/main/resources/db/migration/V15__visit_reason_backfill_uuid.sql
new file mode 100644
index 0000000..957c716
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V15__visit_reason_backfill_uuid.sql
@@ -0,0 +1,5 @@
+-- 兼容历史数据:回填来访事由空 uuid,避免删除/编辑时的 id+uuid 校验失败
+UPDATE biz_visit_reason
+SET uuid = REPLACE(UUID(), '-', '')
+WHERE uuid IS NULL OR TRIM(uuid) = '';
+
diff --git a/backend/src/main/resources/db/migration/V16__mini_visitor.sql b/backend/src/main/resources/db/migration/V16__mini_visitor.sql
new file mode 100644
index 0000000..d67afae
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V16__mini_visitor.sql
@@ -0,0 +1,19 @@
+CREATE TABLE IF NOT EXISTS mp_visitor (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ mp_user_id BIGINT NOT NULL,
+ visitor_name VARCHAR(30) NOT NULL,
+ visitor_mobile VARCHAR(11) NOT NULL,
+ visitor_company VARCHAR(128) NOT NULL,
+ id_card_cipher VARCHAR(256) NOT NULL,
+ gender VARCHAR(2) NOT NULL,
+ UNIQUE KEY uk_mp_visitor_uuid (uuid),
+ KEY idx_mp_visitor_user_del (mp_user_id, del_flag, id)
+);
+
diff --git a/backend/src/main/resources/db/migration/V17__column_comments.sql b/backend/src/main/resources/db/migration/V17__column_comments.sql
new file mode 100644
index 0000000..c9fc692
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V17__column_comments.sql
@@ -0,0 +1,347 @@
+-- 统一补齐历史字段注释(不改旧迁移,避免 checksum 变化)
+
+ALTER TABLE sys_dept
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN parent_id BIGINT NOT NULL DEFAULT 0 COMMENT '父级部门ID,0为根',
+ MODIFY COLUMN dept_name VARCHAR(128) NOT NULL COMMENT '部门名称',
+ MODIFY COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值',
+ MODIFY COLUMN status INT NOT NULL DEFAULT 1 COMMENT '状态(1启用 0禁用)';
+
+ALTER TABLE sys_user
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN username VARCHAR(64) NOT NULL COMMENT '登录账号',
+ MODIFY COLUMN password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希',
+ MODIFY COLUMN real_name VARCHAR(64) NULL COMMENT '姓名',
+ MODIFY COLUMN mobile VARCHAR(32) NULL COMMENT '手机号',
+ MODIFY COLUMN avatar_url VARCHAR(512) NULL COMMENT '头像URL',
+ MODIFY COLUMN status INT NOT NULL DEFAULT 1 COMMENT '状态(1启用 0禁用)';
+
+ALTER TABLE sys_role
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN role_code VARCHAR(64) NOT NULL COMMENT '角色编码',
+ MODIFY COLUMN role_name VARCHAR(128) NOT NULL COMMENT '角色名称',
+ MODIFY COLUMN remark VARCHAR(512) NULL COMMENT '备注';
+
+ALTER TABLE sys_user_role
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN user_id BIGINT NOT NULL COMMENT '用户ID',
+ MODIFY COLUMN role_id BIGINT NOT NULL COMMENT '角色ID';
+
+ALTER TABLE appointment_order
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN order_no VARCHAR(64) NOT NULL COMMENT '预约单号',
+ MODIFY COLUMN visit_type VARCHAR(32) NOT NULL COMMENT '访问类型(VISITOR/VEHICLE)',
+ MODIFY COLUMN visited_user_id BIGINT NOT NULL COMMENT '被访人用户ID',
+ MODIFY COLUMN visited_user_name VARCHAR(64) NULL COMMENT '被访人姓名快照',
+ MODIFY COLUMN visited_dept_id BIGINT NULL COMMENT '被访人所属部门ID',
+ MODIFY COLUMN visit_reason_code VARCHAR(64) NULL COMMENT '来访事由编码',
+ MODIFY COLUMN visitor_org VARCHAR(255) NULL COMMENT '来访单位',
+ MODIFY COLUMN visit_start_date DATE NOT NULL COMMENT '来访开始日期',
+ MODIFY COLUMN visit_end_date DATE NOT NULL COMMENT '来访结束日期',
+ MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '预约状态',
+ MODIFY COLUMN holiday_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否节假日访问',
+ MODIFY COLUMN key_area_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否涉及要害部位',
+ MODIFY COLUMN submitter_open_id VARCHAR(128) NULL COMMENT '提交人标识(openId)',
+ MODIFY COLUMN reject_reason VARCHAR(512) NULL COMMENT '拒绝原因';
+
+ALTER TABLE appointment_visitor
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '预约单ID',
+ MODIFY COLUMN name VARCHAR(64) NOT NULL COMMENT '访客姓名',
+ MODIFY COLUMN id_card_cipher VARCHAR(512) NULL COMMENT '身份证密文',
+ MODIFY COLUMN mobile VARCHAR(32) NULL COMMENT '访客手机号',
+ MODIFY COLUMN driver_name VARCHAR(64) NULL COMMENT '驾驶员姓名',
+ MODIFY COLUMN plate_no VARCHAR(32) NULL COMMENT '车牌号';
+
+ALTER TABLE approval_instance
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '预约单ID',
+ MODIFY COLUMN current_node_code VARCHAR(64) NULL COMMENT '当前审批节点编码',
+ MODIFY COLUMN status VARCHAR(32) NOT NULL COMMENT '审批实例状态',
+ MODIFY COLUMN finished_at DATETIME NULL COMMENT '审批完成时间';
+
+ALTER TABLE approval_task
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN instance_id BIGINT NOT NULL COMMENT '审批实例ID',
+ MODIFY COLUMN node_code VARCHAR(64) NOT NULL COMMENT '审批节点编码',
+ MODIFY COLUMN group_key VARCHAR(128) NOT NULL COMMENT '并行组标识',
+ MODIFY COLUMN assignee_user_id BIGINT NOT NULL COMMENT '待办处理人用户ID',
+ MODIFY COLUMN task_status VARCHAR(32) NOT NULL COMMENT '任务状态',
+ MODIFY COLUMN comment VARCHAR(512) NULL COMMENT '审批意见';
+
+ALTER TABLE biz_visit_reason
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN reason_code VARCHAR(64) NOT NULL COMMENT '事由编码',
+ MODIFY COLUMN reason_name VARCHAR(128) NOT NULL COMMENT '事由名称',
+ MODIFY COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值',
+ MODIFY COLUMN enabled INT NOT NULL DEFAULT 1 COMMENT '是否启用(1是 0否)';
+
+ALTER TABLE biz_field_config
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN field_code VARCHAR(64) NOT NULL COMMENT '字段编码',
+ MODIFY COLUMN field_name VARCHAR(128) NOT NULL COMMENT '字段名称',
+ MODIFY COLUMN visible INT NOT NULL DEFAULT 1 COMMENT '是否显示(1是 0否)',
+ MODIFY COLUMN required_flag INT NOT NULL DEFAULT 0 COMMENT '是否必填(1是 0否)',
+ MODIFY COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值';
+
+ALTER TABLE biz_approval_node_config
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN node_code VARCHAR(64) NOT NULL COMMENT '审批节点编码',
+ MODIFY COLUMN node_name VARCHAR(128) NOT NULL COMMENT '审批节点名称',
+ MODIFY COLUMN enabled INT NOT NULL DEFAULT 1 COMMENT '是否启用(1是 0否)',
+ MODIFY COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值';
+
+ALTER TABLE biz_op_log
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN operator_user_id BIGINT NULL COMMENT '操作人用户ID',
+ MODIFY COLUMN module VARCHAR(64) NOT NULL COMMENT '操作模块',
+ MODIFY COLUMN action VARCHAR(64) NOT NULL COMMENT '操作动作',
+ MODIFY COLUMN resource_type VARCHAR(64) NULL COMMENT '资源类型',
+ MODIFY COLUMN resource_id VARCHAR(128) NULL COMMENT '资源标识',
+ MODIFY COLUMN detail_json TEXT NULL COMMENT '操作详情JSON',
+ MODIFY COLUMN op_time DATETIME NOT NULL COMMENT '操作时间',
+ MODIFY COLUMN trace_id VARCHAR(64) NULL COMMENT '链路追踪ID';
+
+ALTER TABLE qrcode_ticket
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN order_id BIGINT NOT NULL COMMENT '预约单ID',
+ MODIFY COLUMN payload TEXT NOT NULL COMMENT '二维码载荷内容',
+ MODIFY COLUMN ticket_status VARCHAR(32) NOT NULL COMMENT '二维码状态',
+ MODIFY COLUMN used_at DATETIME NULL COMMENT '核销时间';
+
+ALTER TABLE mp_user
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN open_id VARCHAR(64) NOT NULL COMMENT '微信openId',
+ MODIFY COLUMN union_id VARCHAR(64) NULL COMMENT '微信unionId',
+ MODIFY COLUMN user_type VARCHAR(32) NOT NULL COMMENT '用户类型(VISITOR/EMPLOYEE)',
+ MODIFY COLUMN dept_id BIGINT NULL COMMENT '员工所属部门ID(访客为空)',
+ MODIFY COLUMN dept_leader_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否部门负责人',
+ MODIFY COLUMN nick_name VARCHAR(128) NULL COMMENT '昵称',
+ MODIFY COLUMN avatar_url VARCHAR(512) NULL COMMENT '头像URL',
+ MODIFY COLUMN mobile VARCHAR(32) NULL COMMENT '手机号',
+ MODIFY COLUMN staff_no VARCHAR(64) NULL COMMENT '员工编号',
+ MODIFY COLUMN status INT NOT NULL DEFAULT 1 COMMENT '状态(1启用 0禁用)';
+
+ALTER TABLE sys_menu
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN pid BIGINT NOT NULL DEFAULT 0 COMMENT '父级菜单ID,0为根',
+ MODIFY COLUMN menu_type TINYINT NOT NULL COMMENT '菜单类型(1板块 2页面 3权限)',
+ MODIFY COLUMN menu_name VARCHAR(128) NOT NULL COMMENT '菜单名称',
+ MODIFY COLUMN perm_code VARCHAR(128) NULL COMMENT '权限编码',
+ MODIFY COLUMN sort_order INT NOT NULL DEFAULT 0 COMMENT '排序值',
+ MODIFY COLUMN route_path VARCHAR(255) NULL COMMENT '前端路由路径';
+
+ALTER TABLE sys_role_menu
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN role_id BIGINT NOT NULL COMMENT '角色ID',
+ MODIFY COLUMN menu_id BIGINT NOT NULL COMMENT '菜单ID';
+
+ALTER TABLE mp_staff_group
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN group_name VARCHAR(128) NOT NULL COMMENT '分组名称',
+ MODIFY COLUMN leader_mp_user_id BIGINT NULL COMMENT '负责人mp_user.id';
+
+ALTER TABLE mp_staff_group_member
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN group_id BIGINT NOT NULL COMMENT '分组ID(mp_staff_group.id)',
+ MODIFY COLUMN mp_user_id BIGINT NOT NULL COMMENT '员工ID(mp_user.id)';
+
+ALTER TABLE pass_access_area
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN pid BIGINT NOT NULL DEFAULT 0 COMMENT '父区域ID,0为一级区域',
+ MODIFY COLUMN area_name VARCHAR(128) NOT NULL COMMENT '区域名称',
+ MODIFY COLUMN remark VARCHAR(512) NULL COMMENT '备注',
+ MODIFY COLUMN area_desc TEXT NULL COMMENT '区域说明';
+
+ALTER TABLE pass_local_unit
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN unit_name VARCHAR(128) NOT NULL COMMENT '属地单位名称',
+ MODIFY COLUMN remark VARCHAR(512) NULL COMMENT '备注',
+ MODIFY COLUMN approver_type TINYINT NOT NULL COMMENT '审批人类型(1部门 2人员分组 3人员)',
+ MODIFY COLUMN approver_group_id BIGINT NULL COMMENT '审批分组ID(mp_staff_group.id)',
+ MODIFY COLUMN approver_dept_ids TEXT NULL COMMENT '审批部门ID列表JSON';
+
+ALTER TABLE pass_local_unit_member
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN unit_id BIGINT NOT NULL COMMENT '属地单位ID(pass_local_unit.id)',
+ MODIFY COLUMN mp_user_id BIGINT NOT NULL COMMENT '小程序用户ID(mp_user.id)';
+
+ALTER TABLE sys_biz_setting
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN setting_key VARCHAR(64) NOT NULL COMMENT '配置键',
+ MODIFY COLUMN setting_value TEXT NULL COMMENT '配置值';
+
+ALTER TABLE biz_holiday_date
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN holiday_date DATE NOT NULL COMMENT '节假日/高温假日期';
+
+ALTER TABLE mp_visitor
+ MODIFY COLUMN id BIGINT AUTO_INCREMENT COMMENT '主键ID',
+ MODIFY COLUMN uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ MODIFY COLUMN version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ MODIFY COLUMN create_id BIGINT NULL COMMENT '创建人ID',
+ MODIFY COLUMN create_time DATETIME NULL COMMENT '创建时间',
+ MODIFY COLUMN update_id BIGINT NULL COMMENT '更新人ID',
+ MODIFY COLUMN update_time DATETIME NULL COMMENT '更新时间',
+ MODIFY COLUMN del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ MODIFY COLUMN mp_user_id BIGINT NOT NULL COMMENT '小程序用户ID',
+ MODIFY COLUMN visitor_name VARCHAR(30) NOT NULL COMMENT '来访人姓名',
+ MODIFY COLUMN visitor_mobile VARCHAR(11) NOT NULL COMMENT '来访人电话',
+ MODIFY COLUMN visitor_company VARCHAR(128) NOT NULL COMMENT '来访人单位',
+ MODIFY COLUMN id_card_cipher VARCHAR(256) NOT NULL COMMENT '身份证密文',
+ MODIFY COLUMN gender VARCHAR(2) NOT NULL COMMENT '性别(男/女)';
+
diff --git a/backend/src/main/resources/db/migration/V18__mini_appointment_enhance.sql b/backend/src/main/resources/db/migration/V18__mini_appointment_enhance.sql
new file mode 100644
index 0000000..9b1bf9f
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V18__mini_appointment_enhance.sql
@@ -0,0 +1,27 @@
+ALTER TABLE appointment_order
+ ADD COLUMN visit_reason_other VARCHAR(255) NULL COMMENT '来访事由自定义内容' AFTER visit_reason_code,
+ ADD COLUMN visit_time_mode VARCHAR(16) NULL COMMENT '拜访时间模式(SINGLE/MULTI)' AFTER visitor_org,
+ ADD COLUMN visit_time_slot VARCHAR(16) NULL COMMENT '单日时段(ALL_DAY/AM/PM)' AFTER visit_time_mode,
+ ADD COLUMN person_entry_flag TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否有人员进场' AFTER key_area_flag,
+ ADD COLUMN vehicle_entry_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否有车辆进场' AFTER person_entry_flag;
+
+ALTER TABLE appointment_visitor
+ ADD COLUMN mp_visitor_id BIGINT NULL COMMENT '访客ID(mp_visitor.id)' AFTER order_id;
+
+CREATE TABLE IF NOT EXISTS appointment_vehicle (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
+ uuid VARCHAR(64) NOT NULL COMMENT '业务UUID',
+ version BIGINT NOT NULL DEFAULT 1 COMMENT '乐观锁版本',
+ create_id BIGINT NULL COMMENT '创建人ID',
+ create_time DATETIME NULL COMMENT '创建时间',
+ update_id BIGINT NULL COMMENT '更新人ID',
+ update_time DATETIME NULL COMMENT '更新时间',
+ del_flag INT NOT NULL DEFAULT 0 COMMENT '逻辑删除标记(0未删 1已删)',
+ order_id BIGINT NOT NULL COMMENT '预约单ID',
+ plate_no VARCHAR(32) NOT NULL COMMENT '车牌号',
+ driver_visitor_id BIGINT NOT NULL COMMENT '驾驶员访客ID(mp_visitor.id)',
+ driver_name VARCHAR(64) NULL COMMENT '驾驶员姓名快照',
+ UNIQUE KEY uk_appointment_vehicle_uuid (uuid),
+ KEY idx_appointment_vehicle_order (order_id)
+) COMMENT='预约车辆明细';
+
diff --git a/backend/src/main/resources/db/migration/V19__table_comments.sql b/backend/src/main/resources/db/migration/V19__table_comments.sql
new file mode 100644
index 0000000..d47c46e
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V19__table_comments.sql
@@ -0,0 +1,35 @@
+-- 统一补齐历史表注释(不改旧迁移,避免 checksum 变化)
+
+ALTER TABLE sys_dept COMMENT = '系统部门表';
+ALTER TABLE sys_user COMMENT = '系统用户表';
+ALTER TABLE sys_role COMMENT = '系统角色表';
+ALTER TABLE sys_user_role COMMENT = '系统用户角色关联表';
+
+ALTER TABLE appointment_order COMMENT = '预约单主表';
+ALTER TABLE appointment_visitor COMMENT = '预约进场人员明细表';
+ALTER TABLE appointment_vehicle COMMENT = '预约进场车辆明细表';
+
+ALTER TABLE approval_instance COMMENT = '审批实例表';
+ALTER TABLE approval_task COMMENT = '审批任务表';
+
+ALTER TABLE biz_visit_reason COMMENT = '来访事由配置表';
+ALTER TABLE biz_field_config COMMENT = '预约字段配置表';
+ALTER TABLE biz_approval_node_config COMMENT = '审批节点配置表';
+ALTER TABLE biz_op_log COMMENT = '业务操作日志表';
+ALTER TABLE biz_holiday_date COMMENT = '节假日/高温假日期配置表';
+
+ALTER TABLE qrcode_ticket COMMENT = '预约二维码票据表';
+
+ALTER TABLE mp_user COMMENT = '小程序用户表';
+ALTER TABLE mp_visitor COMMENT = '小程序访客常用信息表';
+ALTER TABLE mp_staff_group COMMENT = '员工分组表';
+ALTER TABLE mp_staff_group_member COMMENT = '员工分组成员表';
+
+ALTER TABLE pass_access_area COMMENT = '通行区域表';
+ALTER TABLE pass_local_unit COMMENT = '属地单位表';
+ALTER TABLE pass_local_unit_member COMMENT = '属地单位成员表';
+
+ALTER TABLE sys_menu COMMENT = '系统菜单与权限点表';
+ALTER TABLE sys_role_menu COMMENT = '系统角色菜单关联表';
+ALTER TABLE sys_biz_setting COMMENT = '系统业务配置表';
+
diff --git a/backend/src/main/resources/db/migration/V1__init.sql b/backend/src/main/resources/db/migration/V1__init.sql
new file mode 100644
index 0000000..fa35f29
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V1__init.sql
@@ -0,0 +1,313 @@
+CREATE TABLE IF NOT EXISTS sys_dept (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ parent_id BIGINT NOT NULL DEFAULT 0,
+ dept_name VARCHAR(128) NOT NULL,
+ sort_order INT NOT NULL DEFAULT 0,
+ UNIQUE KEY uk_sys_dept_uuid (uuid)
+);
+
+CREATE TABLE IF NOT EXISTS sys_user (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ username VARCHAR(64) NOT NULL,
+ password_hash VARCHAR(255) NOT NULL,
+ real_name VARCHAR(64) NULL,
+ mobile VARCHAR(32) NULL,
+ dept_id BIGINT NULL,
+ status INT NOT NULL DEFAULT 1,
+ UNIQUE KEY uk_sys_user_uuid (uuid),
+ UNIQUE KEY uk_sys_user_username (username)
+);
+
+CREATE TABLE IF NOT EXISTS sys_role (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ role_code VARCHAR(64) NOT NULL,
+ role_name VARCHAR(128) NOT NULL,
+ UNIQUE KEY uk_sys_role_uuid (uuid),
+ UNIQUE KEY uk_sys_role_code (role_code)
+);
+
+CREATE TABLE IF NOT EXISTS sys_user_role (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ user_id BIGINT NOT NULL,
+ role_id BIGINT NOT NULL,
+ UNIQUE KEY uk_sys_user_role_uuid (uuid),
+ KEY idx_user_role (user_id, role_id)
+);
+
+CREATE TABLE IF NOT EXISTS appointment_order (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ order_no VARCHAR(64) NOT NULL,
+ visit_type VARCHAR(32) NOT NULL,
+ visited_user_id BIGINT NOT NULL,
+ visited_user_name VARCHAR(64) NULL,
+ visit_reason_code VARCHAR(64) NULL,
+ visitor_org VARCHAR(255) NULL,
+ visit_start_date DATE NOT NULL,
+ visit_end_date DATE NOT NULL,
+ status VARCHAR(32) NOT NULL,
+ holiday_flag TINYINT(1) NOT NULL DEFAULT 0,
+ key_area_flag TINYINT(1) NOT NULL DEFAULT 0,
+ submitter_open_id VARCHAR(128) NULL,
+ reject_reason VARCHAR(512) NULL,
+ UNIQUE KEY uk_appointment_order_uuid (uuid),
+ UNIQUE KEY uk_order_no (order_no),
+ KEY idx_visited_user (visited_user_id),
+ KEY idx_status (status)
+);
+
+CREATE TABLE IF NOT EXISTS appointment_visitor (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ order_id BIGINT NOT NULL,
+ name VARCHAR(64) NOT NULL,
+ id_card_cipher VARCHAR(512) NULL,
+ mobile VARCHAR(32) NULL,
+ driver_name VARCHAR(64) NULL,
+ plate_no VARCHAR(32) NULL,
+ UNIQUE KEY uk_appointment_visitor_uuid (uuid),
+ KEY idx_order (order_id)
+);
+
+CREATE TABLE IF NOT EXISTS approval_instance (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ order_id BIGINT NOT NULL,
+ current_node_code VARCHAR(64) NULL,
+ status VARCHAR(32) NOT NULL,
+ finished_at DATETIME NULL,
+ UNIQUE KEY uk_approval_instance_uuid (uuid),
+ KEY idx_order (order_id)
+);
+
+CREATE TABLE IF NOT EXISTS approval_task (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ instance_id BIGINT NOT NULL,
+ node_code VARCHAR(64) NOT NULL,
+ group_key VARCHAR(128) NOT NULL,
+ assignee_user_id BIGINT NOT NULL,
+ task_status VARCHAR(32) NOT NULL,
+ comment VARCHAR(512) NULL,
+ UNIQUE KEY uk_approval_task_uuid (uuid),
+ KEY idx_instance (instance_id),
+ KEY idx_assignee_status (assignee_user_id, task_status)
+);
+
+CREATE TABLE IF NOT EXISTS biz_visit_reason (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ reason_code VARCHAR(64) NOT NULL,
+ reason_name VARCHAR(128) NOT NULL,
+ sort_order INT NOT NULL DEFAULT 0,
+ enabled INT NOT NULL DEFAULT 1,
+ UNIQUE KEY uk_biz_visit_reason_uuid (uuid),
+ UNIQUE KEY uk_reason_code (reason_code)
+);
+
+CREATE TABLE IF NOT EXISTS biz_field_config (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ field_code VARCHAR(64) NOT NULL,
+ field_name VARCHAR(128) NOT NULL,
+ visible INT NOT NULL DEFAULT 1,
+ required_flag INT NOT NULL DEFAULT 0,
+ sort_order INT NOT NULL DEFAULT 0,
+ UNIQUE KEY uk_biz_field_config_uuid (uuid),
+ UNIQUE KEY uk_field_code (field_code)
+);
+
+CREATE TABLE IF NOT EXISTS biz_approval_node_config (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ node_code VARCHAR(64) NOT NULL,
+ node_name VARCHAR(128) NOT NULL,
+ enabled INT NOT NULL DEFAULT 1,
+ sort_order INT NOT NULL DEFAULT 0,
+ UNIQUE KEY uk_biz_approval_node_uuid (uuid),
+ UNIQUE KEY uk_node_code (node_code)
+);
+
+CREATE TABLE IF NOT EXISTS biz_op_log (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ operator_user_id BIGINT NULL,
+ module VARCHAR(64) NOT NULL,
+ action VARCHAR(64) NOT NULL,
+ resource_type VARCHAR(64) NULL,
+ resource_id VARCHAR(128) NULL,
+ detail_json TEXT NULL,
+ op_time DATETIME NOT NULL,
+ trace_id VARCHAR(64) NULL,
+ UNIQUE KEY uk_biz_op_log_uuid (uuid),
+ KEY idx_op_time (op_time)
+);
+
+CREATE TABLE IF NOT EXISTS qrcode_ticket (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ order_id BIGINT NOT NULL,
+ payload TEXT NOT NULL,
+ ticket_status VARCHAR(32) NOT NULL,
+ used_at DATETIME NULL,
+ UNIQUE KEY uk_qrcode_ticket_uuid (uuid),
+ KEY idx_order (order_id)
+);
+
+-- 默认部门
+INSERT INTO sys_dept (uuid, version, parent_id, dept_name, sort_order, del_flag)
+VALUES (REPLACE(UUID(), '-', ''), 1, 0, '总公司', 0, 0);
+
+SET @dept_id = LAST_INSERT_ID();
+
+-- 角色
+INSERT INTO sys_role (uuid, version, role_code, role_name, del_flag) VALUES
+(REPLACE(UUID(), '-', ''), 1, 'ADMIN', '系统管理员', 0),
+(REPLACE(UUID(), '-', ''), 1, 'HANDLER', '经办人', 0),
+(REPLACE(UUID(), '-', ''), 1, 'DEPT_LEADER', '部门负责人', 0),
+(REPLACE(UUID(), '-', ''), 1, 'VISIT_DEPT', '访客主管部门', 0),
+(REPLACE(UUID(), '-', ''), 1, 'VISIT_DEPT_LEADER', '访客主管部门领导', 0),
+(REPLACE(UUID(), '-', ''), 1, 'KEY_DEPT_LEADER', '要害管理部门领导', 0);
+
+-- 管理员用户(密码:Admin@123456aA,BCrypt)
+INSERT INTO sys_user (uuid, version, username, password_hash, real_name, mobile, dept_id, status, del_flag)
+VALUES (
+ REPLACE(UUID(), '-', ''), 1,
+ 'admin',
+ '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi',
+ '管理员', '13800000000', @dept_id, 1, 0
+);
+
+SET @admin_id = LAST_INSERT_ID();
+
+INSERT INTO sys_user (uuid, version, username, password_hash, real_name, mobile, dept_id, status, del_flag)
+VALUES (
+ REPLACE(UUID(), '-', ''), 1,
+ 'handler',
+ '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi',
+ '经办人A', '13800000001', @dept_id, 1, 0
+);
+SET @handler_id = LAST_INSERT_ID();
+
+INSERT INTO sys_user (uuid, version, username, password_hash, real_name, mobile, dept_id, status, del_flag)
+VALUES (
+ REPLACE(UUID(), '-', ''), 1,
+ 'leader',
+ '$2a$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi',
+ '部门领导', '13800000002', @dept_id, 1, 0
+);
+SET @leader_id = LAST_INSERT_ID();
+
+-- 角色绑定:admin=ADMIN;handler=HANDLER;leader=DEPT_LEADER + HANDLER 便于测试
+INSERT INTO sys_user_role (uuid, version, user_id, role_id, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, @admin_id, id, 0 FROM sys_role WHERE role_code = 'ADMIN';
+
+INSERT INTO sys_user_role (uuid, version, user_id, role_id, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, @handler_id, id, 0 FROM sys_role WHERE role_code = 'HANDLER';
+
+INSERT INTO sys_user_role (uuid, version, user_id, role_id, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, @leader_id, id, 0 FROM sys_role WHERE role_code = 'DEPT_LEADER';
+
+INSERT INTO sys_user_role (uuid, version, user_id, role_id, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, @leader_id, id, 0 FROM sys_role WHERE role_code = 'HANDLER';
+
+-- 来访事由示例
+INSERT INTO biz_visit_reason (uuid, version, reason_code, reason_name, sort_order, enabled, del_flag)
+VALUES (REPLACE(UUID(), '-', ''), 1, 'BUSINESS', '商务洽谈', 1, 1, 0);
+
+-- 预约字段示例
+INSERT INTO biz_field_config (uuid, version, field_code, field_name, visible, required_flag, sort_order, del_flag)
+VALUES
+(REPLACE(UUID(), '-', ''), 1, 'visitorOrg', '来访单位', 1, 0, 1, 0),
+(REPLACE(UUID(), '-', ''), 1, 'idCard', '身份证号', 1, 1, 2, 0);
+
+-- 审批节点(默认全开,按 sort 流转)
+INSERT INTO biz_approval_node_config (uuid, version, node_code, node_name, enabled, sort_order, del_flag)
+VALUES
+(REPLACE(UUID(), '-', ''), 1, 'LEVEL1_HANDLER', '经办人(一级)', 1, 10, 0),
+(REPLACE(UUID(), '-', ''), 1, 'LEVEL2_DEPT_LEADER', '部门负责人(二级)', 1, 20, 0),
+(REPLACE(UUID(), '-', ''), 1, 'LEVEL3_VISIT_DEPT', '访客主管部门(三级)', 0, 30, 0),
+(REPLACE(UUID(), '-', ''), 1, 'LEVEL4_VISIT_DEPT_LEADER', '访客主管部门领导(四级)', 1, 40, 0),
+(REPLACE(UUID(), '-', ''), 1, 'LEVEL5_KEY_DEPT_LEADER', '要害管理部门领导(五级)', 1, 50, 0);
diff --git a/backend/src/main/resources/db/migration/V20__delete_visitor_org_field_config.sql b/backend/src/main/resources/db/migration/V20__delete_visitor_org_field_config.sql
new file mode 100644
index 0000000..36e35c5
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V20__delete_visitor_org_field_config.sql
@@ -0,0 +1,3 @@
+-- 删除冗余预约字段配置:visitorOrg
+DELETE FROM biz_field_config WHERE field_code = 'visitorOrg';
+
diff --git a/backend/src/main/resources/db/migration/V21__approval_node_level_shift.sql b/backend/src/main/resources/db/migration/V21__approval_node_level_shift.sql
new file mode 100644
index 0000000..938c1e7
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V21__approval_node_level_shift.sql
@@ -0,0 +1,58 @@
+-- 审批节点层级调整:
+-- 原 LEVEL5_KEY_DEPT_LEADER -> LEVEL3_LOCAL_DEPT_LEADER(属地管理部门领导(三级))
+-- 原 LEVEL3_VISIT_DEPT -> LEVEL4_VISIT_DEPT
+-- 原 LEVEL4_VISIT_DEPT_LEADER -> LEVEL5_VISIT_DEPT_LEADER
+
+UPDATE biz_approval_node_config
+SET node_code =
+ CASE node_code
+ WHEN 'LEVEL5_KEY_DEPT_LEADER' THEN 'LEVEL3_LOCAL_DEPT_LEADER'
+ WHEN 'LEVEL3_VISIT_DEPT' THEN 'LEVEL4_VISIT_DEPT'
+ WHEN 'LEVEL4_VISIT_DEPT_LEADER' THEN 'LEVEL5_VISIT_DEPT_LEADER'
+ ELSE node_code
+ END,
+ node_name =
+ CASE node_code
+ WHEN 'LEVEL5_KEY_DEPT_LEADER' THEN '属地管理部门领导(三级)'
+ WHEN 'LEVEL3_VISIT_DEPT' THEN '访客主管部门(四级)'
+ WHEN 'LEVEL4_VISIT_DEPT_LEADER' THEN '访客主管部门领导(五级)'
+ ELSE node_name
+ END,
+ sort_order =
+ CASE node_code
+ WHEN 'LEVEL5_KEY_DEPT_LEADER' THEN 30
+ WHEN 'LEVEL3_VISIT_DEPT' THEN 40
+ WHEN 'LEVEL4_VISIT_DEPT_LEADER' THEN 50
+ ELSE sort_order
+ END
+WHERE node_code IN ('LEVEL5_KEY_DEPT_LEADER', 'LEVEL3_VISIT_DEPT', 'LEVEL4_VISIT_DEPT_LEADER');
+
+-- 同步历史审批实例与任务中的节点编码,避免迁移后断链
+UPDATE approval_instance
+SET current_node_code =
+ CASE current_node_code
+ WHEN 'LEVEL5_KEY_DEPT_LEADER' THEN 'LEVEL3_LOCAL_DEPT_LEADER'
+ WHEN 'LEVEL3_VISIT_DEPT' THEN 'LEVEL4_VISIT_DEPT'
+ WHEN 'LEVEL4_VISIT_DEPT_LEADER' THEN 'LEVEL5_VISIT_DEPT_LEADER'
+ ELSE current_node_code
+ END
+WHERE current_node_code IN ('LEVEL5_KEY_DEPT_LEADER', 'LEVEL3_VISIT_DEPT', 'LEVEL4_VISIT_DEPT_LEADER');
+
+UPDATE approval_task
+SET node_code =
+ CASE node_code
+ WHEN 'LEVEL5_KEY_DEPT_LEADER' THEN 'LEVEL3_LOCAL_DEPT_LEADER'
+ WHEN 'LEVEL3_VISIT_DEPT' THEN 'LEVEL4_VISIT_DEPT'
+ WHEN 'LEVEL4_VISIT_DEPT_LEADER' THEN 'LEVEL5_VISIT_DEPT_LEADER'
+ ELSE node_code
+ END,
+ group_key =
+ CASE group_key
+ WHEN 'LEVEL5_KEY_DEPT_LEADER' THEN 'LEVEL3_LOCAL_DEPT_LEADER'
+ WHEN 'LEVEL3_VISIT_DEPT' THEN 'LEVEL4_VISIT_DEPT'
+ WHEN 'LEVEL4_VISIT_DEPT_LEADER' THEN 'LEVEL5_VISIT_DEPT_LEADER'
+ ELSE group_key
+ END
+WHERE node_code IN ('LEVEL5_KEY_DEPT_LEADER', 'LEVEL3_VISIT_DEPT', 'LEVEL4_VISIT_DEPT_LEADER')
+ OR group_key IN ('LEVEL5_KEY_DEPT_LEADER', 'LEVEL3_VISIT_DEPT', 'LEVEL4_VISIT_DEPT_LEADER');
+
diff --git a/backend/src/main/resources/db/migration/V22__mini_handler_approval_fields.sql b/backend/src/main/resources/db/migration/V22__mini_handler_approval_fields.sql
new file mode 100644
index 0000000..d7bda41
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V22__mini_handler_approval_fields.sql
@@ -0,0 +1,6 @@
+ALTER TABLE appointment_order
+ ADD COLUMN pass_access_area_ids TEXT NULL COMMENT '经办人审批通行区域ID列表(JSON)' AFTER reject_reason,
+ ADD COLUMN pass_access_area_names TEXT NULL COMMENT '经办人审批通行区域名称列表(JSON)' AFTER pass_access_area_ids,
+ ADD COLUMN involve_other_local_unit TINYINT(1) NOT NULL DEFAULT 0 COMMENT '经办人审批是否涉及其他属地单位' AFTER pass_access_area_names,
+ ADD COLUMN pass_local_unit_ids TEXT NULL COMMENT '经办人审批其他属地单位ID列表(JSON)' AFTER involve_other_local_unit,
+ ADD COLUMN pass_local_unit_names TEXT NULL COMMENT '经办人审批其他属地单位名称列表(JSON)' AFTER pass_local_unit_ids;
diff --git a/backend/src/main/resources/db/migration/V23__drop_appointment_key_area_flag.sql b/backend/src/main/resources/db/migration/V23__drop_appointment_key_area_flag.sql
new file mode 100644
index 0000000..3ef4931
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V23__drop_appointment_key_area_flag.sql
@@ -0,0 +1,3 @@
+-- 预约单去除历史字段:是否前往要害部位(已由经办人审批字段替代)
+ALTER TABLE appointment_order
+ DROP COLUMN key_area_flag;
diff --git a/backend/src/main/resources/db/migration/V24__rename_submitter_open_id_to_submitter_id.sql b/backend/src/main/resources/db/migration/V24__rename_submitter_open_id_to_submitter_id.sql
new file mode 100644
index 0000000..1b1cd06
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V24__rename_submitter_open_id_to_submitter_id.sql
@@ -0,0 +1,2 @@
+ALTER TABLE appointment_order
+ CHANGE COLUMN submitter_open_id submitter_id BIGINT NULL COMMENT '提交人用户ID(mp_user.id)';
diff --git a/backend/src/main/resources/db/migration/V25__qrcode_ticket_visitor_fields.sql b/backend/src/main/resources/db/migration/V25__qrcode_ticket_visitor_fields.sql
new file mode 100644
index 0000000..5b853a1
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V25__qrcode_ticket_visitor_fields.sql
@@ -0,0 +1,7 @@
+ALTER TABLE qrcode_ticket
+ ADD COLUMN mp_visitor_id BIGINT NULL COMMENT '访客ID(mp_visitor.id)' AFTER order_id,
+ ADD COLUMN generate_time DATETIME NULL COMMENT '二维码生成基准时间' AFTER ticket_status,
+ ADD COLUMN expire_time DATETIME NULL COMMENT '二维码失效时间' AFTER generate_time;
+
+ALTER TABLE qrcode_ticket
+ ADD KEY idx_order_visitor_status (order_id, mp_visitor_id, ticket_status);
diff --git a/backend/src/main/resources/db/migration/V26__mp_user_add_visitor_profile_fields.sql b/backend/src/main/resources/db/migration/V26__mp_user_add_visitor_profile_fields.sql
new file mode 100644
index 0000000..705cda3
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V26__mp_user_add_visitor_profile_fields.sql
@@ -0,0 +1,7 @@
+ALTER TABLE mp_user
+ ADD COLUMN gender VARCHAR(2) NULL COMMENT '性别(男/女)' AFTER mobile,
+ ADD COLUMN company_name VARCHAR(128) NULL COMMENT '所属公司' AFTER gender;
+
+ALTER TABLE mp_user
+ MODIFY COLUMN gender VARCHAR(2) NULL COMMENT '性别(男/女)',
+ MODIFY COLUMN company_name VARCHAR(128) NULL COMMENT '所属公司';
diff --git a/backend/src/main/resources/db/migration/V27__admin_permission_catalog.sql b/backend/src/main/resources/db/migration/V27__admin_permission_catalog.sql
new file mode 100644
index 0000000..5ecc9cc
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V27__admin_permission_catalog.sql
@@ -0,0 +1,391 @@
+-- 重建后台管理端菜单/权限目录
+-- 适用场景:sys_menu 已清空,需重新初始化;会清理 sys_role_menu 中失效菜单关联
+
+DELETE rm
+FROM sys_role_menu rm
+LEFT JOIN sys_menu m ON m.id = rm.menu_id
+WHERE m.id IS NULL;
+
+-- 首页
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 0, 1, '首页', NULL, 10, NULL, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE pid = 0 AND menu_type = 1 AND menu_name = '首页' AND del_flag = 0
+);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 2, '首页', 'dashboard:page', 10, '/dashboard', 0
+FROM sys_menu p
+WHERE p.pid = 0 AND p.menu_type = 1 AND p.menu_name = '首页' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/dashboard' AND del_flag = 0);
+
+-- 系统
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 0, 1, '系统', NULL, 20, NULL, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE pid = 0 AND menu_type = 1 AND menu_name = '系统' AND del_flag = 0
+);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 2, '审核配置', 'config:approval-settings:page', 10, '/system/approval-settings', 0
+FROM sys_menu p
+WHERE p.pid = 0 AND p.menu_type = 1 AND p.menu_name = '系统' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/system/approval-settings' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '保存配置', 'config:approval-settings:update', 10, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/system/approval-settings' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'config:approval-settings:update' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 2, '预约配置', 'config:booking-settings:page', 20, '/system/booking-settings', 0
+FROM sys_menu p
+WHERE p.pid = 0 AND p.menu_type = 1 AND p.menu_name = '系统' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/system/booking-settings' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '保存配置', 'config:booking-settings:update', 10, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/system/booking-settings' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'config:booking-settings:update' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 2, '来访事由配置', 'config:visit-reasons:page', 30, '/system/visit-reasons', 0
+FROM sys_menu p
+WHERE p.pid = 0 AND p.menu_type = 1 AND p.menu_name = '系统' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/system/visit-reasons' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '新增', 'config:visit-reasons:create', 10, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/system/visit-reasons' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'config:visit-reasons:create' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '编辑', 'config:visit-reasons:update', 20, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/system/visit-reasons' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'config:visit-reasons:update' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '删除', 'config:visit-reasons:delete', 30, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/system/visit-reasons' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'config:visit-reasons:delete' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 2, '内容设置', 'config:content-settings:page', 40, '/system/content-settings', 0
+FROM sys_menu p
+WHERE p.pid = 0 AND p.menu_type = 1 AND p.menu_name = '系统' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/system/content-settings' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '保存配置', 'config:content-settings:update', 10, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/system/content-settings' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'config:content-settings:update' AND del_flag = 0);
+
+-- 员工管理
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 0, 1, '员工管理', NULL, 30, NULL, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE pid = 0 AND menu_type = 1 AND menu_name = '员工管理' AND del_flag = 0
+);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 2, '部门管理', 'sys:depts:page', 10, '/staff/depts', 0
+FROM sys_menu p
+WHERE p.pid = 0 AND p.menu_type = 1 AND p.menu_name = '员工管理' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/staff/depts' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '新增', 'sys:depts:create', 10, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/staff/depts' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:depts:create' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '编辑', 'sys:depts:update', 20, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/staff/depts' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:depts:update' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '删除', 'sys:depts:delete', 30, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/staff/depts' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:depts:delete' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 2, '员工管理', 'sys:staff:page', 20, '/staff/employees', 0
+FROM sys_menu p
+WHERE p.pid = 0 AND p.menu_type = 1 AND p.menu_name = '员工管理' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/staff/employees' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '查看详情', 'sys:staff:detail', 10, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/staff/employees' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:staff:detail' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '新增', 'sys:staff:create', 20, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/staff/employees' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:staff:create' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '编辑', 'sys:staff:update', 30, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/staff/employees' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:staff:update' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '启停账号', 'sys:staff:status', 40, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/staff/employees' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:staff:status' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '删除', 'sys:staff:delete', 50, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/staff/employees' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:staff:delete' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '导入', 'sys:staff:import', 60, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/staff/employees' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:staff:import' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 2, '人员分组', 'sys:staff-groups:page', 30, '/staff/groups', 0
+FROM sys_menu p
+WHERE p.pid = 0 AND p.menu_type = 1 AND p.menu_name = '员工管理' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/staff/groups' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '查看详情', 'sys:staff-groups:detail', 10, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/staff/groups' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:staff-groups:detail' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '新增', 'sys:staff-groups:create', 20, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/staff/groups' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:staff-groups:create' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '编辑', 'sys:staff-groups:update', 30, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/staff/groups' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:staff-groups:update' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '成员维护', 'sys:staff-groups:members', 40, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/staff/groups' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:staff-groups:members' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '删除', 'sys:staff-groups:delete', 50, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/staff/groups' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:staff-groups:delete' AND del_flag = 0);
+
+-- 区域
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 0, 1, '区域', NULL, 40, NULL, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE pid = 0 AND menu_type = 1 AND menu_name = '区域' AND del_flag = 0
+);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 2, '通行区域管理', 'sys:pass-areas:page', 10, '/region/pass-areas', 0
+FROM sys_menu p
+WHERE p.pid = 0 AND p.menu_type = 1 AND p.menu_name = '区域' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/region/pass-areas' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '查看详情', 'sys:pass-areas:detail', 10, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/region/pass-areas' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:pass-areas:detail' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '新增', 'sys:pass-areas:create', 20, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/region/pass-areas' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:pass-areas:create' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '编辑', 'sys:pass-areas:update', 30, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/region/pass-areas' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:pass-areas:update' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '删除', 'sys:pass-areas:delete', 40, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/region/pass-areas' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:pass-areas:delete' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 2, '属地单位管理', 'sys:local-units:page', 20, '/region/local-units', 0
+FROM sys_menu p
+WHERE p.pid = 0 AND p.menu_type = 1 AND p.menu_name = '区域' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/region/local-units' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '查看详情', 'sys:local-units:detail', 10, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/region/local-units' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:local-units:detail' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '新增', 'sys:local-units:create', 20, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/region/local-units' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:local-units:create' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '编辑', 'sys:local-units:update', 30, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/region/local-units' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:local-units:update' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '删除', 'sys:local-units:delete', 40, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/region/local-units' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:local-units:delete' AND del_flag = 0);
+
+-- 访客管理
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 0, 1, '访客管理', NULL, 50, NULL, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE pid = 0 AND menu_type = 1 AND menu_name = '访客管理' AND del_flag = 0
+);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 2, '访客信息', 'sys:visitor-users:page', 10, '/visitor-info', 0
+FROM sys_menu p
+WHERE p.pid = 0 AND p.menu_type = 1 AND p.menu_name = '访客管理' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/visitor-info' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '查看详情', 'sys:visitor-users:detail', 10, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/visitor-info' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:visitor-users:detail' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '编辑', 'sys:visitor-users:update', 20, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/visitor-info' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:visitor-users:update' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '启停账号', 'sys:visitor-users:status', 30, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/visitor-info' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:visitor-users:status' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '删除', 'sys:visitor-users:delete', 40, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/visitor-info' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:visitor-users:delete' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '导出', 'sys:visitor-users:export', 50, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/visitor-info' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'sys:visitor-users:export' AND del_flag = 0);
+
+-- 记录与日志
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 0, 1, '记录与日志', NULL, 60, NULL, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE pid = 0 AND menu_type = 1 AND menu_name = '记录与日志' AND del_flag = 0
+);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 2, '预约记录', 'appointments:page', 10, '/appointments', 0
+FROM sys_menu p
+WHERE p.pid = 0 AND p.menu_type = 1 AND p.menu_name = '记录与日志' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/appointments' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '查看详情', 'appointments:detail', 10, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/appointments' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'appointments:detail' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '导出', 'appointments:export', 20, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/appointments' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'appointments:export' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 2, '待办日志', 'approvals:page', 20, '/approvals', 0
+FROM sys_menu p
+WHERE p.pid = 0 AND p.menu_type = 1 AND p.menu_name = '记录与日志' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/approvals' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '处理审批', 'approvals:action', 10, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/approvals' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'approvals:action' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 2, '导出任务', 'exports:page', 30, '/exports', 0
+FROM sys_menu p
+WHERE p.pid = 0 AND p.menu_type = 1 AND p.menu_name = '记录与日志' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/exports' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 3, '发起导出', 'exports:enqueue', 10, NULL, 0
+FROM sys_menu p
+WHERE p.route_path = '/exports' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE perm_code = 'exports:enqueue' AND del_flag = 0);
+
+-- 账号管理
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, 0, 1, '账号管理', NULL, 70, NULL, 0
+FROM DUAL
+WHERE NOT EXISTS (
+ SELECT 1 FROM sys_menu WHERE pid = 0 AND menu_type = 1 AND menu_name = '账号管理' AND del_flag = 0
+);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 2, '平台管理员', 'sys:users:page', 10, '/users', 0
+FROM sys_menu p
+WHERE p.pid = 0 AND p.menu_type = 1 AND p.menu_name = '账号管理' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/users' AND del_flag = 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, p.id, 2, '角色管理', 'sys:roles:page', 20, '/roles', 0
+FROM sys_menu p
+WHERE p.pid = 0 AND p.menu_type = 1 AND p.menu_name = '账号管理' AND p.del_flag = 0
+ AND NOT EXISTS (SELECT 1 FROM sys_menu WHERE route_path = '/roles' AND del_flag = 0);
+
+-- ADMIN 自动绑定所有菜单
+INSERT INTO sys_role_menu (uuid, version, role_id, menu_id, del_flag)
+SELECT LOWER(MD5(CONCAT(r.id, ':', m.id, ':', RAND()))), 1, r.id, m.id, 0
+FROM sys_role r
+JOIN sys_menu m ON m.del_flag = 0
+LEFT JOIN sys_role_menu rm ON rm.role_id = r.id AND rm.menu_id = m.id AND rm.del_flag = 0
+WHERE r.role_code = 'ADMIN'
+ AND r.del_flag = 0
+ AND rm.id IS NULL;
diff --git a/backend/src/main/resources/db/migration/V2__mp_user.sql b/backend/src/main/resources/db/migration/V2__mp_user.sql
new file mode 100644
index 0000000..1d6ee67
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V2__mp_user.sql
@@ -0,0 +1,21 @@
+-- 小程序用户;执行 V3 后:员工含 dept_id、dept_leader_flag,且不再与 sys_user 绑定
+CREATE TABLE IF NOT EXISTS mp_user (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ open_id VARCHAR(64) NOT NULL,
+ union_id VARCHAR(64) NULL,
+ user_type VARCHAR(32) NOT NULL COMMENT 'VISITOR / EMPLOYEE',
+ sys_user_id BIGINT NULL COMMENT '历史字段,V3 迁移后删除',
+ nick_name VARCHAR(128) NULL,
+ avatar_url VARCHAR(512) NULL,
+ mobile VARCHAR(32) NULL,
+ UNIQUE KEY uk_mp_user_uuid (uuid),
+ UNIQUE KEY uk_mp_user_open_id (open_id),
+ KEY idx_mp_user_sys (sys_user_id)
+);
diff --git a/backend/src/main/resources/db/migration/V3__user_mp_dept.sql b/backend/src/main/resources/db/migration/V3__user_mp_dept.sql
new file mode 100644
index 0000000..8beee56
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V3__user_mp_dept.sql
@@ -0,0 +1,27 @@
+-- 管理端 sys_user 去掉部门;小程序 mp_user 保留部门并增加「是否部门负责人」
+-- 不创建 sys_user 与部门的关联表
+
+-- 预约单:被访人所属部门(列表筛选等;二级审批在管理端按 DEPT_LEADER 角色全员派单,不按部门过滤)
+ALTER TABLE appointment_order
+ ADD COLUMN visited_dept_id BIGINT NULL COMMENT '被访人所属部门' AFTER visited_user_name;
+
+UPDATE appointment_order o
+JOIN sys_user u ON o.visited_user_id = u.id AND u.del_flag = 0
+SET o.visited_dept_id = u.dept_id
+WHERE o.visited_dept_id IS NULL AND u.dept_id IS NOT NULL;
+
+-- mp_user:员工部门 + 是否部门负责人;解除与 sys_user 绑定
+ALTER TABLE mp_user ADD COLUMN dept_id BIGINT NULL COMMENT '员工所属部门,访客为空' AFTER user_type;
+ALTER TABLE mp_user
+ ADD COLUMN dept_leader_flag TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否部门负责人' AFTER dept_id;
+
+UPDATE mp_user m
+JOIN sys_user s ON m.sys_user_id = s.id AND s.del_flag = 0
+SET m.dept_id = s.dept_id
+WHERE m.user_type = 'EMPLOYEE' AND m.sys_user_id IS NOT NULL;
+
+ALTER TABLE sys_user ADD COLUMN avatar_url VARCHAR(512) NULL AFTER mobile;
+
+ALTER TABLE mp_user DROP COLUMN sys_user_id;
+
+ALTER TABLE sys_user DROP COLUMN dept_id;
diff --git a/backend/src/main/resources/db/migration/V4__menu_role_menu.sql b/backend/src/main/resources/db/migration/V4__menu_role_menu.sql
new file mode 100644
index 0000000..5a7574b
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V4__menu_role_menu.sql
@@ -0,0 +1,73 @@
+-- 角色备注、菜单表、角色-菜单关联;示例「账号管理」下菜单树;ADMIN 绑定全部菜单
+
+ALTER TABLE sys_role
+ ADD COLUMN remark VARCHAR(512) NULL COMMENT '备注' AFTER role_name;
+
+CREATE TABLE IF NOT EXISTS sys_menu (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ pid BIGINT NOT NULL DEFAULT 0 COMMENT '父级 id,板块根为 0',
+ menu_type TINYINT NOT NULL COMMENT '1板块 2页面 3权限',
+ menu_name VARCHAR(128) NOT NULL,
+ perm_code VARCHAR(128) NULL COMMENT '权限编码(扩展用)',
+ sort_order INT NOT NULL DEFAULT 0,
+ route_path VARCHAR(255) NULL COMMENT '前端路由,页面级可填',
+ UNIQUE KEY uk_sys_menu_uuid (uuid),
+ KEY idx_menu_pid (pid)
+);
+
+CREATE TABLE IF NOT EXISTS sys_role_menu (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 1,
+ create_id BIGINT NULL,
+ create_time DATETIME NULL,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL,
+ del_flag INT NOT NULL DEFAULT 0,
+ role_id BIGINT NOT NULL,
+ menu_id BIGINT NOT NULL,
+ UNIQUE KEY uk_sys_role_menu_uuid (uuid),
+ KEY idx_rm_role (role_id),
+ KEY idx_rm_menu (menu_id)
+);
+
+-- ========== 示例:账号管理(板块)→ 平台管理员 / 角色管理(页面)→ 操作权限(叶子)==========
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+VALUES (REPLACE(UUID(), '-', ''), 1, 0, 1, '账号管理', NULL, 100, NULL, 0);
+SET @menu_acct = LAST_INSERT_ID();
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+VALUES (REPLACE(UUID(), '-', ''), 1, @menu_acct, 2, '平台管理员', 'sys:users:page', 10, '/users', 0);
+SET @menu_users = LAST_INSERT_ID();
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag) VALUES
+(REPLACE(UUID(), '-', ''), 1, @menu_users, 3, '查看详情', 'sys:users:detail', 10, NULL, 0),
+(REPLACE(UUID(), '-', ''), 1, @menu_users, 3, '新增用户', 'sys:users:create', 20, NULL, 0),
+(REPLACE(UUID(), '-', ''), 1, @menu_users, 3, '编辑用户', 'sys:users:update', 30, NULL, 0),
+(REPLACE(UUID(), '-', ''), 1, @menu_users, 3, '删除用户', 'sys:users:delete', 40, NULL, 0),
+(REPLACE(UUID(), '-', ''), 1, @menu_users, 3, '启停账号', 'sys:users:status', 50, NULL, 0),
+(REPLACE(UUID(), '-', ''), 1, @menu_users, 3, '重置密码', 'sys:users:password', 60, NULL, 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+VALUES (REPLACE(UUID(), '-', ''), 1, @menu_acct, 2, '角色管理', 'sys:roles:page', 20, '/roles', 0);
+SET @menu_roles = LAST_INSERT_ID();
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag) VALUES
+(REPLACE(UUID(), '-', ''), 1, @menu_roles, 3, '查看详情', 'sys:roles:detail', 10, NULL, 0),
+(REPLACE(UUID(), '-', ''), 1, @menu_roles, 3, '新增角色', 'sys:roles:create', 20, NULL, 0),
+(REPLACE(UUID(), '-', ''), 1, @menu_roles, 3, '编辑角色', 'sys:roles:update', 30, NULL, 0),
+(REPLACE(UUID(), '-', ''), 1, @menu_roles, 3, '删除角色', 'sys:roles:delete', 40, NULL, 0);
+
+-- 系统管理员角色绑定当前库中全部菜单(含上列示例)
+INSERT INTO sys_role_menu (uuid, version, role_id, menu_id, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, r.id, m.id, 0
+FROM sys_role r
+JOIN sys_menu m ON m.del_flag = 0
+WHERE r.role_code = 'ADMIN' AND r.del_flag = 0;
diff --git a/backend/src/main/resources/db/migration/V5__dept_status.sql b/backend/src/main/resources/db/migration/V5__dept_status.sql
new file mode 100644
index 0000000..272a367
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V5__dept_status.sql
@@ -0,0 +1,4 @@
+-- 部门启用状态:1 启用 0 禁用(预约等下拉仅展示启用部门)
+
+ALTER TABLE sys_dept
+ ADD COLUMN status INT NOT NULL DEFAULT 1 COMMENT '1启用 0禁用' AFTER sort_order;
diff --git a/backend/src/main/resources/db/migration/V6__mp_staff.sql b/backend/src/main/resources/db/migration/V6__mp_staff.sql
new file mode 100644
index 0000000..54c4f60
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V6__mp_staff.sql
@@ -0,0 +1,7 @@
+-- 员工:编号、启用状态;管理端预建档 open_id 使用 PLHD_ 前缀,首次微信员工登录按手机号绑定
+
+ALTER TABLE mp_user
+ ADD COLUMN staff_no VARCHAR(64) NULL COMMENT '员工编号' AFTER mobile,
+ ADD COLUMN status INT NOT NULL DEFAULT 1 COMMENT '1启用 0禁用' AFTER staff_no;
+
+ALTER TABLE mp_user ADD UNIQUE KEY uk_mp_user_staff_no (staff_no);
diff --git a/backend/src/main/resources/db/migration/V7__mp_staff_group.sql b/backend/src/main/resources/db/migration/V7__mp_staff_group.sql
new file mode 100644
index 0000000..81f876f
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V7__mp_staff_group.sql
@@ -0,0 +1,37 @@
+-- 人员分组:分组主表 + 成员关联表(mp_user 员工)
+
+CREATE TABLE IF NOT EXISTS mp_staff_group (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 0,
+ create_id BIGINT NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+ del_flag INT NOT NULL DEFAULT 0,
+
+ group_name VARCHAR(128) NOT NULL COMMENT '分组名称',
+ leader_mp_user_id BIGINT NULL COMMENT '负责人(mp_user.id,可为空)',
+
+ KEY idx_mpsg_name (group_name),
+ KEY idx_mpsg_leader (leader_mp_user_id)
+);
+
+CREATE TABLE IF NOT EXISTS mp_staff_group_member (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 0,
+ create_id BIGINT NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+ del_flag INT NOT NULL DEFAULT 0,
+
+ group_id BIGINT NOT NULL COMMENT 'mp_staff_group.id',
+ mp_user_id BIGINT NOT NULL COMMENT 'mp_user.id(员工)',
+
+ UNIQUE KEY uk_group_user (group_id, mp_user_id),
+ KEY idx_msgm_group (group_id),
+ KEY idx_msgm_user (mp_user_id)
+);
+
diff --git a/backend/src/main/resources/db/migration/V8__pass_access_area.sql b/backend/src/main/resources/db/migration/V8__pass_access_area.sql
new file mode 100644
index 0000000..c77e032
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V8__pass_access_area.sql
@@ -0,0 +1,41 @@
+-- 通行区域(两层:pid=0 为一级;pid=一级 id 为子区域),备注字段与海康地点管理命名一致
+
+CREATE TABLE IF NOT EXISTS pass_access_area (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 0,
+ create_id BIGINT NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+ del_flag INT NOT NULL DEFAULT 0,
+
+ pid BIGINT NOT NULL DEFAULT 0 COMMENT '0 一级区域;否则为父区域 id(父须为一级)',
+ area_name VARCHAR(128) NOT NULL COMMENT '区域名称',
+ remark VARCHAR(512) NULL COMMENT '备注(与海康地点管理一致)',
+ area_desc TEXT NULL COMMENT '区域说明',
+
+ UNIQUE KEY uk_pass_access_area_uuid (uuid),
+ KEY idx_pass_access_area_pid (pid),
+ KEY idx_pass_access_area_name (area_name)
+);
+
+-- 区域管理(板块)→ 通行区域管理 / 属地单位管理(页面);ADMIN 绑定
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+VALUES (REPLACE(UUID(), '-', ''), 1, 0, 1, '区域管理', NULL, 85, NULL, 0);
+SET @menu_region_root = LAST_INSERT_ID();
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+VALUES (REPLACE(UUID(), '-', ''), 1, @menu_region_root, 2, '通行区域管理', 'sys:pass-areas:page', 10, '/region/pass-areas', 0);
+
+INSERT INTO sys_menu (uuid, version, pid, menu_type, menu_name, perm_code, sort_order, route_path, del_flag)
+VALUES (REPLACE(UUID(), '-', ''), 1, @menu_region_root, 2, '属地单位管理', 'sys:local-units:page', 20, '/region/local-units', 0);
+
+INSERT INTO sys_role_menu (uuid, version, role_id, menu_id, del_flag)
+SELECT REPLACE(UUID(), '-', ''), 1, r.id, m.id, 0
+FROM sys_role r
+JOIN sys_menu m
+ ON m.del_flag = 0
+ AND (m.id = @menu_region_root OR m.pid = @menu_region_root)
+WHERE r.role_code = 'ADMIN'
+ AND r.del_flag = 0;
diff --git a/backend/src/main/resources/db/migration/V9__pass_local_unit.sql b/backend/src/main/resources/db/migration/V9__pass_local_unit.sql
new file mode 100644
index 0000000..0dcbeed
--- /dev/null
+++ b/backend/src/main/resources/db/migration/V9__pass_local_unit.sql
@@ -0,0 +1,38 @@
+-- 属地单位:审批方式(部门 / 人员分组 / 人员)+ 人员多选关联表
+
+CREATE TABLE IF NOT EXISTS pass_local_unit (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 0,
+ create_id BIGINT NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+ del_flag INT NOT NULL DEFAULT 0,
+
+ unit_name VARCHAR(128) NOT NULL COMMENT '单位名称',
+ remark VARCHAR(512) NULL COMMENT '备注',
+ approver_type TINYINT NOT NULL COMMENT '1部门 2人员分组 3人员',
+ approver_group_id BIGINT NULL COMMENT '人员分组 mp_staff_group.id',
+ approver_dept_ids TEXT NULL COMMENT '部门 id 列表 JSON',
+
+ UNIQUE KEY uk_pass_local_unit_uuid (uuid),
+ KEY idx_plu_name (unit_name)
+);
+
+CREATE TABLE IF NOT EXISTS pass_local_unit_member (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT,
+ uuid VARCHAR(64) NOT NULL,
+ version BIGINT NOT NULL DEFAULT 0,
+ create_id BIGINT NULL,
+ create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ update_id BIGINT NULL,
+ update_time DATETIME NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
+ del_flag INT NOT NULL DEFAULT 0,
+
+ unit_id BIGINT NOT NULL COMMENT 'pass_local_unit.id',
+ mp_user_id BIGINT NOT NULL COMMENT 'mp_user.id',
+
+ UNIQUE KEY uk_plum_unit_user (unit_id, mp_user_id),
+ KEY idx_plum_unit (unit_id)
+);
diff --git a/backend/src/main/resources/mapper/BizHolidayDateMapper.xml b/backend/src/main/resources/mapper/BizHolidayDateMapper.xml
new file mode 100644
index 0000000..25d9a09
--- /dev/null
+++ b/backend/src/main/resources/mapper/BizHolidayDateMapper.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+ "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.sinata.appointment.mapper.BizHolidayDateMapper">
+
+ <delete id="deleteByHolidayDates">
+ DELETE FROM biz_holiday_date
+ WHERE holiday_date IN
+ <foreach collection="dates" item="d" open="(" separator="," close=")">
+ #{d}
+ </foreach>
+ </delete>
+
+</mapper>
+
diff --git a/backend/src/main/resources/mapper/MpPassLocalUnitMemberMapper.xml b/backend/src/main/resources/mapper/MpPassLocalUnitMemberMapper.xml
new file mode 100644
index 0000000..c53d321
--- /dev/null
+++ b/backend/src/main/resources/mapper/MpPassLocalUnitMemberMapper.xml
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+ "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.sinata.appointment.mapper.MpPassLocalUnitMemberMapper">
+
+ <delete id="deletePhysicalByUnitId">
+ DELETE FROM pass_local_unit_member WHERE unit_id = #{unitId}
+ </delete>
+
+</mapper>
+
diff --git a/backend/src/main/resources/mapper/MpStaffGroupMemberMapper.xml b/backend/src/main/resources/mapper/MpStaffGroupMemberMapper.xml
new file mode 100644
index 0000000..759e56e
--- /dev/null
+++ b/backend/src/main/resources/mapper/MpStaffGroupMemberMapper.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+ "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.sinata.appointment.mapper.MpStaffGroupMemberMapper">
+
+ <delete id="deletePhysicalByGroupId">
+ DELETE FROM mp_staff_group_member WHERE group_id = #{groupId}
+ </delete>
+
+ <delete id="deletePhysicalByGroupAndUser">
+ DELETE FROM mp_staff_group_member
+ WHERE group_id = #{groupId} AND mp_user_id = #{mpUserId}
+ </delete>
+
+</mapper>
+
diff --git a/backend/src/main/resources/mapper/SysUserMapper.xml b/backend/src/main/resources/mapper/SysUserMapper.xml
new file mode 100644
index 0000000..7de10df
--- /dev/null
+++ b/backend/src/main/resources/mapper/SysUserMapper.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+ "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.sinata.appointment.mapper.SysUserMapper">
+
+ <select id="listUserIdsByRoleCode" resultType="java.lang.Long">
+ SELECT DISTINCT u.id FROM sys_user u
+ JOIN sys_user_role ur ON ur.user_id = u.id AND ur.del_flag = 0
+ JOIN sys_role r ON r.id = ur.role_id AND r.del_flag = 0
+ WHERE u.del_flag = 0 AND u.status = 1 AND r.role_code = #{roleCode}
+ </select>
+
+ <select id="listRoleCodesByUserId" resultType="java.lang.String">
+ SELECT r.role_code FROM sys_user_role ur
+ JOIN sys_role r ON r.id = ur.role_id AND r.del_flag = 0
+ WHERE ur.user_id = #{userId} AND ur.del_flag = 0
+ </select>
+
+ <select id="listPermissionCodesByUserId" resultType="java.lang.String">
+ SELECT DISTINCT m.perm_code
+ FROM sys_user_role ur
+ JOIN sys_role r ON r.id = ur.role_id AND r.del_flag = 0
+ JOIN sys_role_menu rm ON rm.role_id = r.id AND rm.del_flag = 0
+ JOIN sys_menu m ON m.id = rm.menu_id AND m.del_flag = 0
+ WHERE ur.user_id = #{userId}
+ AND ur.del_flag = 0
+ AND m.perm_code IS NOT NULL
+ AND m.perm_code != ''
+ </select>
+
+ <select id="listRoleLabelsByUserIds" resultType="com.sinata.appointment.dto.UserRoleLabelRow">
+ SELECT ur.user_id AS userId, r.role_name AS roleName
+ FROM sys_user_role ur
+ INNER JOIN sys_role r ON r.id = ur.role_id AND r.del_flag = 0
+ WHERE ur.del_flag = 0 AND ur.user_id IN
+ <foreach collection="userIds" item="id" open="(" separator="," close=")">
+ #{id}
+ </foreach>
+ </select>
+
+</mapper>
+
diff --git a/backend/src/main/resources/mapper/SysUserRoleMapper.xml b/backend/src/main/resources/mapper/SysUserRoleMapper.xml
new file mode 100644
index 0000000..7e52ce9
--- /dev/null
+++ b/backend/src/main/resources/mapper/SysUserRoleMapper.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+ "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.sinata.appointment.mapper.SysUserRoleMapper">
+
+ <select id="countUsersGroupedByRoleId" resultType="com.sinata.appointment.dto.RoleUserCountRow">
+ SELECT role_id AS roleId, COUNT(*) AS userCount
+ FROM sys_user_role
+ WHERE del_flag = 0
+ <if test="ids != null and ids.size() > 0">
+ AND role_id IN
+ <foreach collection="ids" item="id" open="(" separator="," close=")">
+ #{id}
+ </foreach>
+ </if>
+ GROUP BY role_id
+ </select>
+
+</mapper>
+
--
Gitblit v1.7.1