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() &gt; 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