From cec5498060357fcb2cab8b0dceaeff4d1e98e4ee Mon Sep 17 00:00:00 2001
From: duhao
Date: Fri, 18 Sep 2026 16:45:38 +0800
Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E4=BB=A3=E7=A0=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.dockerignore | 27 +
.gitignore | 26 +
.mvn/wrapper/maven-wrapper.properties | 3 +
.run/LaopAdminApplication.run.xml | 11 +
Dockerfile | 63 +
PRD-无人机信息开放平台小程序.md | 997 +++++
mvn17 | 30 +
mvnw | 295 ++
mvnw.cmd | 189 +
pom.xml | 125 +
.../com/haisen/laop/LaopAdminApplication.java | 15 +
.../laop/client/wx/AbstractWxApiClient.java | 204 +
.../com/haisen/laop/client/wx/WxApiPaths.java | 36 +
.../haisen/laop/client/wx/WxMiniClient.java | 173 +
.../com/haisen/laop/client/wx/WxMpClient.java | 165 +
.../haisen/laop/common/BusinessException.java | 43 +
.../laop/common/GlobalExceptionHandler.java | 71 +
.../haisen/laop/common/RequestContext.java | 50 +
.../java/com/haisen/laop/common/Result.java | 85 +
.../constants/AnnouncementConstants.java | 36 +
.../common/constants/AppBannerConstants.java | 36 +
.../laop/common/constants/CertConstants.java | 27 +
.../common/constants/CourseConstants.java | 51 +
.../laop/common/constants/FileConstants.java | 33 +
.../laop/common/constants/HomeConstants.java | 24 +
.../common/constants/PointsConstants.java | 85 +
.../laop/common/constants/TaskConstants.java | 67 +
.../laop/common/constants/UserConstants.java | 24 +
.../common/constants/WxMessageConstants.java | 88 +
.../common/enums/AnnouncementStatusEnum.java | 48 +
.../common/enums/AppointmentStatusEnum.java | 50 +
.../laop/common/enums/BannerStatusEnum.java | 48 +
.../laop/common/enums/CertStatusEnum.java | 66 +
.../laop/common/enums/CourseCategoryEnum.java | 50 +
.../laop/common/enums/CourseStatusEnum.java | 53 +
.../laop/common/enums/ExamBankStatusEnum.java | 48 +
.../laop/common/enums/ExamDifficultyEnum.java | 53 +
.../laop/common/enums/FileStatusEnum.java | 48 +
.../laop/common/enums/FileTypeEnum.java | 114 +
.../haisen/laop/common/enums/GenderEnum.java | 53 +
.../laop/common/enums/InsuranceTypeEnum.java | 57 +
.../laop/common/enums/LicenseTypeEnum.java | 65 +
.../common/enums/PointsChangeTypeEnum.java | 48 +
.../laop/common/enums/PointsEventEnum.java | 84 +
.../laop/common/enums/QuestionTypeEnum.java | 60 +
.../laop/common/enums/RegionLevelEnum.java | 53 +
.../laop/common/enums/RoleTypeEnum.java | 58 +
.../laop/common/enums/ScheduleTypeEnum.java | 55 +
.../common/enums/TaskRecordStatusEnum.java | 58 +
.../laop/common/enums/TaskStatusEnum.java | 65 +
.../laop/common/enums/TaskTypeEnum.java | 79 +
.../common/enums/TrainingStoreStatusEnum.java | 48 +
.../laop/common/enums/UserStatusEnum.java | 48 +
.../common/enums/WxNotifyBizTypeEnum.java | 58 +
.../common/enums/WxNotifyTaskStatusEnum.java | 58 +
.../laop/common/enums/WxSendStatusEnum.java | 58 +
.../haisen/laop/common/enums/YesNoEnum.java | 52 +
.../com/haisen/laop/config/JacksonConfig.java | 84 +
.../laop/config/UserContextInterceptor.java | 61 +
.../com/haisen/laop/config/WebConfig.java | 44 +
.../controller/AnnouncementController.java | 121 +
.../laop/controller/AppBannerController.java | 134 +
.../laop/controller/CourseController.java | 152 +
.../laop/controller/DashboardController.java | 39 +
.../laop/controller/ExamController.java | 124 +
.../laop/controller/FileController.java | 59 +
.../laop/controller/MiniConfigController.java | 66 +
.../laop/controller/TaskController.java | 108 +
.../laop/controller/UserController.java | 155 +
.../controller/WxMessageLogController.java | 52 +
.../controller/WxMpCallbackController.java | 89 +
.../mini/MiniAnnouncementController.java | 55 +
.../controller/mini/MiniAuthController.java | 86 +
.../controller/mini/MiniBannerController.java | 35 +
.../controller/mini/MiniCertController.java | 87 +
.../controller/mini/MiniCourseController.java | 168 +
.../controller/mini/MiniExamController.java | 123 +
.../controller/mini/MiniHomeController.java | 84 +
.../controller/mini/MiniPointsController.java | 124 +
.../mini/MiniPromoteController.java | 43 +
.../controller/mini/MiniTaskController.java | 697 ++++
.../controller/mini/MiniWxController.java | 48 +
.../haisen/laop/dto/AnnouncementQueryDTO.java | 40 +
.../haisen/laop/dto/AnnouncementSaveDTO.java | 42 +
.../haisen/laop/dto/AppBannerQueryDTO.java | 27 +
.../com/haisen/laop/dto/AppBannerSaveDTO.java | 52 +
.../laop/dto/AppConfigChangedEvent.java | 18 +
.../haisen/laop/dto/CourseAppointmentDTO.java | 51 +
.../com/haisen/laop/dto/CourseQueryDTO.java | 40 +
.../com/haisen/laop/dto/CourseSaveDTO.java | 103 +
.../haisen/laop/dto/ExamQuestionSaveDTO.java | 59 +
.../com/haisen/laop/dto/ExamSubmitDTO.java | 35 +
.../haisen/laop/dto/MerchantCertifyDTO.java | 68 +
.../haisen/laop/dto/MessageLogQueryDTO.java | 46 +
.../com/haisen/laop/dto/PilotCertifyDTO.java | 97 +
.../com/haisen/laop/dto/PointsAdjustDTO.java | 49 +
.../com/haisen/laop/dto/TaskHallQueryDTO.java | 56 +
.../haisen/laop/dto/TaskManageQueryDTO.java | 53 +
.../com/haisen/laop/dto/TaskPublishDTO.java | 141 +
.../com/haisen/laop/dto/TaskReviewDTO.java | 33 +
.../com/haisen/laop/dto/TrainingStoreDTO.java | 34 +
.../haisen/laop/dto/UserProfileUpdateDTO.java | 43 +
.../com/haisen/laop/dto/UserQueryDTO.java | 35 +
.../java/com/haisen/laop/dto/WxLoginDTO.java | 34 +
.../com/haisen/laop/dto/WxNotifyTaskDTO.java | 29 +
.../com/haisen/laop/entity/Announcement.java | 58 +
.../com/haisen/laop/entity/AppBanner.java | 57 +
.../com/haisen/laop/entity/AppConfig.java | 78 +
.../java/com/haisen/laop/entity/Course.java | 151 +
.../haisen/laop/entity/CourseAppointment.java | 91 +
.../java/com/haisen/laop/entity/ExamBank.java | 55 +
.../com/haisen/laop/entity/ExamQuestion.java | 92 +
.../java/com/haisen/laop/entity/Merchant.java | 112 +
.../java/com/haisen/laop/entity/Pilot.java | 102 +
.../com/haisen/laop/entity/PilotLicense.java | 67 +
.../com/haisen/laop/entity/PointsDetail.java | 69 +
.../java/com/haisen/laop/entity/Region.java | 62 +
.../java/com/haisen/laop/entity/SysFile.java | 78 +
.../java/com/haisen/laop/entity/Task.java | 176 +
.../com/haisen/laop/entity/TaskRecord.java | 136 +
.../com/haisen/laop/entity/TrainingStore.java | 54 +
.../java/com/haisen/laop/entity/User.java | 133 +
.../com/haisen/laop/entity/WxMessageLog.java | 127 +
.../com/haisen/laop/entity/WxNotifyTask.java | 100 +
.../entity/base/AbstractAuditableEntity.java | 42 +
.../laop/entity/base/AbstractBaseEntity.java | 61 +
.../repository/AnnouncementRepository.java | 19 +
.../laop/repository/AppBannerRepository.java | 29 +
.../laop/repository/AppConfigRepository.java | 17 +
.../CourseAppointmentRepository.java | 53 +
.../laop/repository/CourseRepository.java | 18 +
.../laop/repository/ExamBankRepository.java | 19 +
.../repository/ExamQuestionRepository.java | 77 +
.../laop/repository/MerchantRepository.java | 89 +
.../repository/PilotLicenseRepository.java | 25 +
.../laop/repository/PilotRepository.java | 101 +
.../repository/PointsDetailRepository.java | 36 +
.../laop/repository/RegionRepository.java | 36 +
.../laop/repository/SysFileRepository.java | 25 +
.../laop/repository/TaskRecordRepository.java | 169 +
.../laop/repository/TaskRepository.java | 172 +
.../repository/TrainingStoreRepository.java | 19 +
.../laop/repository/UserRepository.java | 62 +
.../repository/WxMessageLogRepository.java | 28 +
.../repository/WxNotifyTaskRepository.java | 133 +
.../laop/service/AnnouncementService.java | 88 +
.../haisen/laop/service/AppBannerService.java | 83 +
.../haisen/laop/service/AppConfigService.java | 55 +
.../com/haisen/laop/service/CertService.java | 51 +
.../haisen/laop/service/CourseService.java | 157 +
.../haisen/laop/service/DashboardService.java | 19 +
.../com/haisen/laop/service/ExamService.java | 113 +
.../com/haisen/laop/service/FileService.java | 22 +
.../laop/service/FileStorageService.java | 27 +
.../com/haisen/laop/service/HomeService.java | 58 +
.../com/haisen/laop/service/LoginService.java | 21 +
.../haisen/laop/service/PointsService.java | 42 +
.../haisen/laop/service/PromoteService.java | 19 +
.../com/haisen/laop/service/TaskService.java | 184 +
.../com/haisen/laop/service/UserService.java | 104 +
.../laop/service/WxMessageLogService.java | 24 +
.../laop/service/WxMpCallbackService.java | 47 +
.../laop/service/WxMpSubscribeService.java | 20 +
.../haisen/laop/service/WxNotifyService.java | 30 +
.../service/impl/AnnouncementServiceImpl.java | 200 +
.../service/impl/AppBannerServiceImpl.java | 180 +
.../service/impl/AppConfigServiceImpl.java | 133 +
.../laop/service/impl/CertServiceImpl.java | 249 ++
.../laop/service/impl/CourseServiceImpl.java | 633 +++
.../service/impl/DashboardServiceImpl.java | 184 +
.../laop/service/impl/ExamServiceImpl.java | 469 +++
.../laop/service/impl/FileServiceImpl.java | 116 +
.../laop/service/impl/HomeServiceImpl.java | 392 ++
.../impl/LocalFileStorageServiceImpl.java | 59 +
.../laop/service/impl/LoginServiceImpl.java | 128 +
.../laop/service/impl/PointsServiceImpl.java | 106 +
.../laop/service/impl/PromoteServiceImpl.java | 50 +
.../laop/service/impl/TaskServiceImpl.java | 1152 ++++++
.../laop/service/impl/UserServiceImpl.java | 431 ++
.../service/impl/WxMessageLogServiceImpl.java | 122 +
.../service/impl/WxMpCallbackServiceImpl.java | 249 ++
.../impl/WxMpSubscribeServiceImpl.java | 47 +
.../service/impl/WxNotifyServiceImpl.java | 561 +++
.../laop/service/support/FileUrlResolver.java | 60 +
.../laop/vo/AdminCourseAppointmentVO.java | 68 +
.../com/haisen/laop/vo/AnnouncementVO.java | 49 +
.../java/com/haisen/laop/vo/AppBannerVO.java | 50 +
.../haisen/laop/vo/CourseAppointmentVO.java | 61 +
.../java/com/haisen/laop/vo/CourseVO.java | 96 +
.../java/com/haisen/laop/vo/DashboardVO.java | 156 +
.../java/com/haisen/laop/vo/ExamBankVO.java | 47 +
.../com/haisen/laop/vo/ExamQuestionVO.java | 59 +
.../java/com/haisen/laop/vo/ExamResultVO.java | 67 +
.../java/com/haisen/laop/vo/FileUploadVO.java | 47 +
.../com/haisen/laop/vo/HomeMerchantVO.java | 33 +
.../java/com/haisen/laop/vo/HomePilotVO.java | 46 +
src/main/java/com/haisen/laop/vo/LoginVO.java | 33 +
.../com/haisen/laop/vo/MerchantCertVO.java | 77 +
.../com/haisen/laop/vo/MerchantDetailVO.java | 48 +
.../java/com/haisen/laop/vo/MiniCourseVO.java | 96 +
.../java/com/haisen/laop/vo/PilotCertVO.java | 87 +
.../com/haisen/laop/vo/PilotDetailVO.java | 69 +
.../com/haisen/laop/vo/PointsDetailVO.java | 52 +
.../java/com/haisen/laop/vo/PromoteQrVO.java | 24 +
.../com/haisen/laop/vo/TaskApplicantVO.java | 55 +
.../java/com/haisen/laop/vo/TaskBriefVO.java | 72 +
.../java/com/haisen/laop/vo/TaskManageVO.java | 154 +
.../com/haisen/laop/vo/TaskPublishVO.java | 35 +
.../com/haisen/laop/vo/TrainingStoreVO.java | 41 +
.../java/com/haisen/laop/vo/UserManageVO.java | 69 +
.../haisen/laop/vo/UserMerchantDetailVO.java | 31 +
.../com/haisen/laop/vo/UserPilotDetailVO.java | 31 +
.../com/haisen/laop/vo/WxMessageLogVO.java | 79 +
.../haisen/laop/vo/WxSubscribeStatusVO.java | 21 +
src/main/resources/application-dev.yml | 88 +
src/main/resources/application-prod.yml | 83 +
src/main/resources/application.yml | 43 +
src/main/resources/db/data.sql | 3513 +++++++++++++++++
.../db/migration/V10__app_config.sql | 88 +
...V11__course_appointment_partial_unique.sql | 21 +
.../V12__training_store_and_exam_seed.sql | 53 +
.../db/migration/V13__exam_questions_seed.sql | 2 +
.../V15__exam_questions_multi_type.sql | 1 +
.../V16__task_apply_and_wx_message.sql | 83 +
.../db/migration/V17__wx_notify_task.sql | 70 +
.../db/migration/V18__course_featured.sql | 9 +
.../V19__appointment_status_two_states.sql | 28 +
src/main/resources/db/migration/V1__init.sql | 552 +++
.../db/migration/V2__points_detail.sql | 33 +
.../db/migration/V2__seed_region.sql | 3372 ++++++++++++++++
.../db/migration/V3__pilot_license_multi.sql | 23 +
.../db/migration/V4__pilot_service_type.sql | 8 +
.../db/migration/V5__pilot_license_date.sql | 11 +
.../db/migration/V6__pilot_license_table.sql | 48 +
.../db/migration/V7__drop_order_count.sql | 8 +
.../db/migration/V8__course_schedule.sql | 34 +
.../resources/db/migration/V9__app_banner.sql | 46 +
src/main/resources/db/schema.sql | 848 ++++
238 files changed, 29368 insertions(+)
create mode 100644 .dockerignore
create mode 100644 .gitignore
create mode 100644 .mvn/wrapper/maven-wrapper.properties
create mode 100644 .run/LaopAdminApplication.run.xml
create mode 100644 Dockerfile
create mode 100644 PRD-无人机信息开放平台小程序.md
create mode 100755 mvn17
create mode 100755 mvnw
create mode 100644 mvnw.cmd
create mode 100644 pom.xml
create mode 100644 src/main/java/com/haisen/laop/LaopAdminApplication.java
create mode 100644 src/main/java/com/haisen/laop/client/wx/AbstractWxApiClient.java
create mode 100644 src/main/java/com/haisen/laop/client/wx/WxApiPaths.java
create mode 100644 src/main/java/com/haisen/laop/client/wx/WxMiniClient.java
create mode 100644 src/main/java/com/haisen/laop/client/wx/WxMpClient.java
create mode 100644 src/main/java/com/haisen/laop/common/BusinessException.java
create mode 100644 src/main/java/com/haisen/laop/common/GlobalExceptionHandler.java
create mode 100644 src/main/java/com/haisen/laop/common/RequestContext.java
create mode 100644 src/main/java/com/haisen/laop/common/Result.java
create mode 100644 src/main/java/com/haisen/laop/common/constants/AnnouncementConstants.java
create mode 100644 src/main/java/com/haisen/laop/common/constants/AppBannerConstants.java
create mode 100644 src/main/java/com/haisen/laop/common/constants/CertConstants.java
create mode 100644 src/main/java/com/haisen/laop/common/constants/CourseConstants.java
create mode 100644 src/main/java/com/haisen/laop/common/constants/FileConstants.java
create mode 100644 src/main/java/com/haisen/laop/common/constants/HomeConstants.java
create mode 100644 src/main/java/com/haisen/laop/common/constants/PointsConstants.java
create mode 100644 src/main/java/com/haisen/laop/common/constants/TaskConstants.java
create mode 100644 src/main/java/com/haisen/laop/common/constants/UserConstants.java
create mode 100644 src/main/java/com/haisen/laop/common/constants/WxMessageConstants.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/AnnouncementStatusEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/AppointmentStatusEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/BannerStatusEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/CertStatusEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/CourseCategoryEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/CourseStatusEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/ExamBankStatusEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/ExamDifficultyEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/FileStatusEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/FileTypeEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/GenderEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/InsuranceTypeEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/LicenseTypeEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/PointsChangeTypeEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/PointsEventEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/QuestionTypeEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/RegionLevelEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/RoleTypeEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/ScheduleTypeEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/TaskRecordStatusEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/TaskStatusEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/TaskTypeEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/TrainingStoreStatusEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/UserStatusEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/WxNotifyBizTypeEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/WxNotifyTaskStatusEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/WxSendStatusEnum.java
create mode 100644 src/main/java/com/haisen/laop/common/enums/YesNoEnum.java
create mode 100644 src/main/java/com/haisen/laop/config/JacksonConfig.java
create mode 100644 src/main/java/com/haisen/laop/config/UserContextInterceptor.java
create mode 100644 src/main/java/com/haisen/laop/config/WebConfig.java
create mode 100644 src/main/java/com/haisen/laop/controller/AnnouncementController.java
create mode 100644 src/main/java/com/haisen/laop/controller/AppBannerController.java
create mode 100644 src/main/java/com/haisen/laop/controller/CourseController.java
create mode 100644 src/main/java/com/haisen/laop/controller/DashboardController.java
create mode 100644 src/main/java/com/haisen/laop/controller/ExamController.java
create mode 100644 src/main/java/com/haisen/laop/controller/FileController.java
create mode 100644 src/main/java/com/haisen/laop/controller/MiniConfigController.java
create mode 100644 src/main/java/com/haisen/laop/controller/TaskController.java
create mode 100644 src/main/java/com/haisen/laop/controller/UserController.java
create mode 100644 src/main/java/com/haisen/laop/controller/WxMessageLogController.java
create mode 100644 src/main/java/com/haisen/laop/controller/WxMpCallbackController.java
create mode 100644 src/main/java/com/haisen/laop/controller/mini/MiniAnnouncementController.java
create mode 100644 src/main/java/com/haisen/laop/controller/mini/MiniAuthController.java
create mode 100644 src/main/java/com/haisen/laop/controller/mini/MiniBannerController.java
create mode 100644 src/main/java/com/haisen/laop/controller/mini/MiniCertController.java
create mode 100644 src/main/java/com/haisen/laop/controller/mini/MiniCourseController.java
create mode 100644 src/main/java/com/haisen/laop/controller/mini/MiniExamController.java
create mode 100644 src/main/java/com/haisen/laop/controller/mini/MiniHomeController.java
create mode 100644 src/main/java/com/haisen/laop/controller/mini/MiniPointsController.java
create mode 100644 src/main/java/com/haisen/laop/controller/mini/MiniPromoteController.java
create mode 100644 src/main/java/com/haisen/laop/controller/mini/MiniTaskController.java
create mode 100644 src/main/java/com/haisen/laop/controller/mini/MiniWxController.java
create mode 100644 src/main/java/com/haisen/laop/dto/AnnouncementQueryDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/AnnouncementSaveDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/AppBannerQueryDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/AppBannerSaveDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/AppConfigChangedEvent.java
create mode 100644 src/main/java/com/haisen/laop/dto/CourseAppointmentDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/CourseQueryDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/CourseSaveDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/ExamQuestionSaveDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/ExamSubmitDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/MerchantCertifyDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/MessageLogQueryDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/PilotCertifyDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/PointsAdjustDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/TaskHallQueryDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/TaskManageQueryDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/TaskPublishDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/TaskReviewDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/TrainingStoreDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/UserProfileUpdateDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/UserQueryDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/WxLoginDTO.java
create mode 100644 src/main/java/com/haisen/laop/dto/WxNotifyTaskDTO.java
create mode 100644 src/main/java/com/haisen/laop/entity/Announcement.java
create mode 100644 src/main/java/com/haisen/laop/entity/AppBanner.java
create mode 100644 src/main/java/com/haisen/laop/entity/AppConfig.java
create mode 100644 src/main/java/com/haisen/laop/entity/Course.java
create mode 100644 src/main/java/com/haisen/laop/entity/CourseAppointment.java
create mode 100644 src/main/java/com/haisen/laop/entity/ExamBank.java
create mode 100644 src/main/java/com/haisen/laop/entity/ExamQuestion.java
create mode 100644 src/main/java/com/haisen/laop/entity/Merchant.java
create mode 100644 src/main/java/com/haisen/laop/entity/Pilot.java
create mode 100644 src/main/java/com/haisen/laop/entity/PilotLicense.java
create mode 100644 src/main/java/com/haisen/laop/entity/PointsDetail.java
create mode 100644 src/main/java/com/haisen/laop/entity/Region.java
create mode 100644 src/main/java/com/haisen/laop/entity/SysFile.java
create mode 100644 src/main/java/com/haisen/laop/entity/Task.java
create mode 100644 src/main/java/com/haisen/laop/entity/TaskRecord.java
create mode 100644 src/main/java/com/haisen/laop/entity/TrainingStore.java
create mode 100644 src/main/java/com/haisen/laop/entity/User.java
create mode 100644 src/main/java/com/haisen/laop/entity/WxMessageLog.java
create mode 100644 src/main/java/com/haisen/laop/entity/WxNotifyTask.java
create mode 100644 src/main/java/com/haisen/laop/entity/base/AbstractAuditableEntity.java
create mode 100644 src/main/java/com/haisen/laop/entity/base/AbstractBaseEntity.java
create mode 100644 src/main/java/com/haisen/laop/repository/AnnouncementRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/AppBannerRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/AppConfigRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/CourseAppointmentRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/CourseRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/ExamBankRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/ExamQuestionRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/MerchantRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/PilotLicenseRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/PilotRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/PointsDetailRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/RegionRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/SysFileRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/TaskRecordRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/TaskRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/TrainingStoreRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/UserRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/WxMessageLogRepository.java
create mode 100644 src/main/java/com/haisen/laop/repository/WxNotifyTaskRepository.java
create mode 100644 src/main/java/com/haisen/laop/service/AnnouncementService.java
create mode 100644 src/main/java/com/haisen/laop/service/AppBannerService.java
create mode 100644 src/main/java/com/haisen/laop/service/AppConfigService.java
create mode 100644 src/main/java/com/haisen/laop/service/CertService.java
create mode 100644 src/main/java/com/haisen/laop/service/CourseService.java
create mode 100644 src/main/java/com/haisen/laop/service/DashboardService.java
create mode 100644 src/main/java/com/haisen/laop/service/ExamService.java
create mode 100644 src/main/java/com/haisen/laop/service/FileService.java
create mode 100644 src/main/java/com/haisen/laop/service/FileStorageService.java
create mode 100644 src/main/java/com/haisen/laop/service/HomeService.java
create mode 100644 src/main/java/com/haisen/laop/service/LoginService.java
create mode 100644 src/main/java/com/haisen/laop/service/PointsService.java
create mode 100644 src/main/java/com/haisen/laop/service/PromoteService.java
create mode 100644 src/main/java/com/haisen/laop/service/TaskService.java
create mode 100644 src/main/java/com/haisen/laop/service/UserService.java
create mode 100644 src/main/java/com/haisen/laop/service/WxMessageLogService.java
create mode 100644 src/main/java/com/haisen/laop/service/WxMpCallbackService.java
create mode 100644 src/main/java/com/haisen/laop/service/WxMpSubscribeService.java
create mode 100644 src/main/java/com/haisen/laop/service/WxNotifyService.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/AnnouncementServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/AppBannerServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/AppConfigServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/CertServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/CourseServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/DashboardServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/ExamServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/FileServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/HomeServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/LocalFileStorageServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/LoginServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/PointsServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/PromoteServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/TaskServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/UserServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/WxMessageLogServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/WxMpCallbackServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/WxMpSubscribeServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/impl/WxNotifyServiceImpl.java
create mode 100644 src/main/java/com/haisen/laop/service/support/FileUrlResolver.java
create mode 100644 src/main/java/com/haisen/laop/vo/AdminCourseAppointmentVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/AnnouncementVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/AppBannerVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/CourseAppointmentVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/CourseVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/DashboardVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/ExamBankVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/ExamQuestionVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/ExamResultVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/FileUploadVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/HomeMerchantVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/HomePilotVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/LoginVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/MerchantCertVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/MerchantDetailVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/MiniCourseVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/PilotCertVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/PilotDetailVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/PointsDetailVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/PromoteQrVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/TaskApplicantVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/TaskBriefVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/TaskManageVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/TaskPublishVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/TrainingStoreVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/UserManageVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/UserMerchantDetailVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/UserPilotDetailVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/WxMessageLogVO.java
create mode 100644 src/main/java/com/haisen/laop/vo/WxSubscribeStatusVO.java
create mode 100644 src/main/resources/application-dev.yml
create mode 100644 src/main/resources/application-prod.yml
create mode 100644 src/main/resources/application.yml
create mode 100644 src/main/resources/db/data.sql
create mode 100644 src/main/resources/db/migration/V10__app_config.sql
create mode 100644 src/main/resources/db/migration/V11__course_appointment_partial_unique.sql
create mode 100644 src/main/resources/db/migration/V12__training_store_and_exam_seed.sql
create mode 100644 src/main/resources/db/migration/V13__exam_questions_seed.sql
create mode 100644 src/main/resources/db/migration/V15__exam_questions_multi_type.sql
create mode 100644 src/main/resources/db/migration/V16__task_apply_and_wx_message.sql
create mode 100644 src/main/resources/db/migration/V17__wx_notify_task.sql
create mode 100644 src/main/resources/db/migration/V18__course_featured.sql
create mode 100644 src/main/resources/db/migration/V19__appointment_status_two_states.sql
create mode 100644 src/main/resources/db/migration/V1__init.sql
create mode 100644 src/main/resources/db/migration/V2__points_detail.sql
create mode 100644 src/main/resources/db/migration/V2__seed_region.sql
create mode 100644 src/main/resources/db/migration/V3__pilot_license_multi.sql
create mode 100644 src/main/resources/db/migration/V4__pilot_service_type.sql
create mode 100644 src/main/resources/db/migration/V5__pilot_license_date.sql
create mode 100644 src/main/resources/db/migration/V6__pilot_license_table.sql
create mode 100644 src/main/resources/db/migration/V7__drop_order_count.sql
create mode 100644 src/main/resources/db/migration/V8__course_schedule.sql
create mode 100644 src/main/resources/db/migration/V9__app_banner.sql
create mode 100644 src/main/resources/db/schema.sql
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..d6e6dcb
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,27 @@
+# 构建上下文排除项,加速构建并避免敏感文件泄漏
+target/
+!.mvn/wrapper/maven-wrapper.jar
+.mvn/wrapper/maven-wrapper.properties
+
+# IDE & 系统
+.idea/
+*.iml
+*.iws
+*.ipr
+.vscode/
+.DS_Store
+
+# 日志
+logs/
+*.log
+
+# Git
+.git/
+.gitignore
+
+# 文档(不需要进入镜像)
+*.md
+docs/
+
+# 测试
+src/test/
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..9b558f3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,26 @@
+# Maven
+target/
+!.mvn/wrapper/maven-wrapper.jar
+*.original
+
+# IDE
+.idea/
+*.iml
+*.iws
+*.ipr
+.vscode/
+.settings/
+.project
+.classpath
+.factorypath
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Logs
+logs/
+*.log
+
+# Local config
+application-local.yml
diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 0000000..ffcab66
--- /dev/null
+++ b/.mvn/wrapper/maven-wrapper.properties
@@ -0,0 +1,3 @@
+wrapperVersion=3.3.4
+distributionType=only-script
+distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
diff --git a/.run/LaopAdminApplication.run.xml b/.run/LaopAdminApplication.run.xml
new file mode 100644
index 0000000..37769b4
--- /dev/null
+++ b/.run/LaopAdminApplication.run.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..49490cd
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,63 @@
+# =====================================================================
+# 翼云Hub · LAOP-admin 后端镜像 Dockerfile
+# --------------------------------------------------------------------
+# 说明:
+# - 项目 pom.xml 指定 Java 17(source/target),编译期和运行期版本严格一致,
+# - jar 包最终名为 laop-admin.jar(由 pom.xml finalName 决定)。
+# =====================================================================
+
+# -------------------------------------------------------------------
+# 阶段1:构建期(maven + jdk17,与 pom.xml 严格对齐)
+# -------------------------------------------------------------------
+FROM maven:3.9-eclipse-temurin-17 AS builder
+
+WORKDIR /build
+
+# 先单独 COPY pom.xml,利用 Docker 缓存层加速依赖下载
+COPY pom.xml ./
+# 预下载依赖(仅当 pom 变化时重新执行,提升后续构建速度)
+RUN mvn dependency:go-offline -B
+
+# 复制源码
+COPY src ./src
+
+# 打包:激活 prod profile,让 application.yml 中的 @spring.profiles.active@ 替换为 prod
+# -DskipTests:构建镜像时跳过测试,测试应在 CI 流水线单独执行
+RUN mvn clean package -DskipTests -Pprod
+
+
+# -------------------------------------------------------------------
+# 阶段2:运行期(JRE 17 LTS)
+# -------------------------------------------------------------------
+FROM eclipse-temurin:17-jre-jammy
+
+LABEL maintainer="laop-admin"
+LABEL description="翼云Hub · 无人机信息开放平台 后端服务"
+
+WORKDIR /app
+
+# 时区设置为中国时区(与 application.yml 中 jackson.time-zone 一致)
+ENV TZ=Asia/Shanghai
+RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
+
+# 预创建静态文件挂载目录 /data/static
+# - compose 通过 volumes 挂载宿主机 /home/laop/admin 到此处,持久化上传文件
+# - 这里显式创建是为了:即使未挂载(如直接 docker run 调试),代码写文件也不会因目录缺失失败
+# - 同时通过 ENV LAOP_STATIC_DIR=/data/static 给镜像内置默认值,与 compose 挂载点对齐
+RUN mkdir -p /data/static
+ENV LAOP_STATIC_DIR=/data/static
+
+# 拷贝构建产物
+COPY --from=builder /build/target/laop-admin.jar app.jar
+
+# 容器内暴露端口(与 application.yml server.port=8081 一致)
+EXPOSE 8081
+
+# JVM 参数:容器内内存限制感知 + GC 优化(2核8G 服务器)
+# -XX:+UseContainerSupport:让 JVM 感知 cgroup 限制(JDK10+ 默认开启,显式声明)
+# -XX:MaxRAMPercentage=75.0:限制堆内存占容器内存 75%,给非堆留空间
+# -Djava.security.egd=file:/dev/./urandom:加速 SecureRandom 启动
+ENV JAVA_OPTS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -Djava.security.egd=file:/dev/./urandom"
+
+# 运行:显式指定 prod profile,覆盖任何默认配置
+ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar --spring.profiles.active=prod"]
diff --git a/PRD-无人机信息开放平台小程序.md b/PRD-无人机信息开放平台小程序.md
new file mode 100644
index 0000000..eaa0c93
--- /dev/null
+++ b/PRD-无人机信息开放平台小程序.md
@@ -0,0 +1,997 @@
+# 翼云Hub · 产品文档(PRD)
+
+> **Slogan**:全链路低空体系聚合,打破供需两端信息壁垒。
+
+> **版本 v1.0** · 待开发
+> **主色**:`#5E46F6`(深紫主色)/ `#7C6AF8`(辅)/ `#F2EFFE`(底)/ `#FFFFFF`(卡)/ `#F59E0B`(金色点缀)
+> **导航**:4 个底部一级 Tab(**首页 / 任务中心 / 研学与课程 / 个人中心**)
+> **参考原型**:图2(首页结构)· 图3(个人中心结构)· 图1(功能菜单来源)
+
+---
+
+## 0. 修订记录
+
+| 版本 | 日期 | 主要变更 |
+|------|------|----------|
+| v1.0 | 待开发 | 初版产品需求文档,以图 1 功能菜单为蓝本,4 Tab 结构(首页 / 任务中心 / 研学与课程 / 个人中心) |
+
+
+
+---
+
+## 1. 背景与机会
+
+### 1.1 背景
+国内民用无人机产业进入"持证合规 + 商业落地"双驱动的爆发期。CAAC(民用无人驾驶航空器综合管理平台)2024 年起强制要求超 250g 机型实名注册、操控员持证;植保、巡检、航拍、表演四大场景的商业订单快速放量,但供需两端存在明显错配。
+
+- **需求侧(商家/客户)**:临时用机、航拍交付、植保作业、应急巡检,缺可信飞手,缺合同/合规保障
+- **供给侧(飞手)**:持证后无稳定接单渠道,资质难被看见
+- **服务端(培训机构、维修点、保险公司、租赁商)**:缺精准流量
+
+行业缺少一个"**开放聚合 + 资质可信 + 交易闭环**"的信息平台。
+
+### 1.2 目标(北极星指标)
+
+| 阶段 | 目标 | 北极星指标 |
+|------|------|------------|
+| M3 | 上线跑通 | 认证飞手数 ≥ 3,000;认证商家数 ≥ 500 |
+| M6 | 双边活跃 | 月成交任务数 ≥ 2,000;月活飞手 ≥ 5,000 |
+| M12 | 行业心智 | CAAC 报名转化率行业第一;"找飞手"心智占位 |
+
+### 1.3 用户角色
+
+| 角色 | 占比 | 核心诉求 | 关键场景 |
+|------|------|----------|----------|
+| **C1 飞手** | 50% | 接单变现、资质展示、考证指引 | 任务中心接单、飞手中心、刷题 |
+| **C2 商家/客户** | 30% | 发布需求、找可信飞手、合规交付 | 任务中心发单、查附近、CAAC 报名 |
+| **C3 培训机构** | 10% | 招生、课程展示、品牌曝光 | 商家认证、题库合作 |
+| **C4 服务商** | 10% | 流量、订单分发 | 商家认证 |
+
+---
+
+## 2. 导航与功能架构
+
+### 2.1 底部 Tab(4 个)
+
+| 序号 | Tab | 定位 | 核心目的 |
+|------|-----|------|----------|
+| ① | **首页** | 流量分发中枢 | 黄金位 + 9 宫格 + 供需双广场 + 优质任务 1 条 |
+| ② | **任务中心** | 交易主战场 | 任务列表 + 多维度筛选 + 抢单 |
+| ③ | **研学与课程** | 内容/服务留存 | 课程展示 + 预约 |
+| ④ | **个人中心** | 身份与认证 | 我的任务 + 飞手/商家双身份认证 + 推广 |
+
+### 2.2 全量功能清单(按图 1 + 业务扩展)
+
+| 一级 Tab | 二级入口 | 三级 / 页面 | 价值 | 优先级 |
+|----------|----------|-------------|------|--------|
+| **首页** | 城市定位 | 定位选择 | LBS 入口 | P0 |
+| | 顶部轮播 | CAAC 报名 / 招商 / 表演 | 运营位 | P0 |
+| | 公告条 | 最新政策 / 新闻 | 内容置顶 | P0 |
+| | 9 宫格金刚区(详见 2.3) | 9 个入口 | 流量分发 | P0 |
+| | **供需双广场**(独立菜单) | 商家发单 · 飞手接单 | **核心交易入口** | **P0** |
+| | **优质任务**(仅 1 张) | 首页露出 1 张顶部任务 | 转化错点 | P0 |
+| **任务中心** | 筛选按钮 | 推荐 / 附近 / 高价 / 长期 | 排序入口 | P0 |
+| | **⚙ 筛选按钮** | 7 组 / 55 个筛选项(右侧抽屉) | 精准筛选 | P0 |
+| | 任务列表(横向大卡) | 1 优质 + 多普通任务 | 抢单入口 | P0 |
+| | 任务详情 | 详情页 + IM + 接单 | 成交 | P0 |
+| **研学与课程** | 分类 Tab | 全部 / 认知启蒙 / 技能进阶 / 创新应用 / 竞赛辅导 | 内容导航 | P0 |
+| | 课程列表 | 卡片列表(6门真实案例课程) | 内容/服务留存 | **P0** |
+| | 课程详情 | 课程信息 + 预约入口 | 深度了解 | P0 |
+| | 研学预约 | 填写信息提交预约 | 核心转化 | **P0** |
+| **个人中心** | 我的任务(3 态) | 待接单 / 进行中 / 已完成 | 任务管理 | **P0** |
+| | 飞手推广 | 老带新得积分 | 拉新 | P0 |
+| | 认证与工具 | 飞手认证 / 商家认证 / 我的发布 / 实名认证 | **身份门槛** | **P0** |
+| | | 飞手中心 / 商家中心 | 身份聚合 | P0 |
+
+
+### 2.3 首页 9 宫格金刚区(9 个入口)
+
+| # | 入口 | 跳转 | 备注 |
+|---|------|------|------|
+| 1 | CAAC 报名 | 报名页 | 强引流,潜在客转化 |
+| 2 | 无人机表演 | 表演列表 | 高客单 |
+| 3 | 实力商家 | 商家列表 | 信任锚点 |
+| 4 | 本地飞手 | 飞手列表 | 信任锚点 |
+| 5 | 培训考试 | 培训模块 | 留存 + 引流 |
+| 6 | 发布任务 | 发布任务页(需商家认证) | 商家侧入口 |
+| 7 | 我要接单 | 任务中心(需飞手认证) | 飞手侧入口 |
+| 8 | 飞手推广 | 推广页(带 HOT 角标) | 拉新 |
+| 9 | **滴滴打无人机** | 调度页(类打车模式) | **新功能·核心亮点** |
+
+---
+
+## 3. 功能关联分析(核心)
+
+### 3.1 三大 Tab 关联总览
+
+```
+┌──────────────────────────────────────────────────────────┐
+│ 首页(流量分发中枢) │
+│ 轮播位 ─→ 强运营:CAAC报名 / 表演 / 招商 │
+│ 公告 ─→ 政策更新、行业新闻(点击跳行业动态详情) │
+│ 金刚区(9 宫格) ─→ 功能分发 │
+│ ├─ CAAC报名 ──→ 飞手认证 ──→ 训练题库 │
+│ ├─ 无人机表演 ──→ 任务中心(高客单任务) │
+│ ├─ 实力商家 ──→ 商家认证入口(个人中心) │
+│ ├─ 本地飞手 ──→ 飞手认证入口(个人中心) │
+│ ├─ 培训考试 ──→ 附近商家 + 题库刷题 │
+│ ├─ 发布任务 ──→ 商家认证校验(个人中心) │
+│ └─ 我要接单 ──→ 飞手认证校验(个人中心) │
+└──────────────────────────────────────────────────────────┘
+ │ │
+ ▼ ▼
+ 研学与课程 个人中心 · 身份
+ (内容留存) 飞手 ←─→ 商家
+ │ │
+ └──────────┬────────────┘
+ ▼
+ ┌─────────────────┐
+ │ 任务中心 │
+ │ 商家发单/飞手接单│
+ │ (需对应身份认证)│
+ └─────────────────┘
+```
+
+### 3.2 关键功能关联(10 条)
+
+| # | 触发点 | 关联点 | 业务价值 |
+|---|--------|--------|----------|
+| 1 | CAAC 报名点击 | → 飞手认证引导页 | 流量 → 认证漏斗 |
+| 2 | 实力商家 / 本地飞手 | → 详情页 + IM + 收藏 | 信任 → 咨询 → 成交 |
+| 3 | 培训考试题库 | → 错题 → 课程推荐 | 学习 → 报名 → 机构 |
+| 4 | 商家发单 | → 校验商家认证状态 | 未认证阻断 + 引导 |
+| 5 | 飞手接单 | → 校验飞手认证 + 保险 | 风控前置 |
+| 6 | 任务中心"优质任务"标签 | → 付费订阅 / 会员 | GMV 提升 |
+| 7 | 飞手推广 | → 老带新积分 | 拉新 |
+| 8 | 附近商家 | → 定位授权 + 商家详情 | LBS 转化 |
+| 9 | 研学课程详情 | → 相关任务 / 商家挂载 | 内容 → 交易 |
+| 11 | 滴滴打无人机 | → 无人机飞手网络 → 配送任务派单 | 新增核心功能 |
+| 10 | 资质中心 | → 过期提醒 + 续期 | 长期留存 |
+
+### 3.3 首页信息流(自上而下)
+
+```
+1. 状态栏
+2. 城市定位(如:全国 / 上海)+ 消息/设置
+3. 轮播图(CAAC 报名 / 招商 / 表演)
+4. 公告条(最新政策 / 新闻)
+5. 9 宫格金刚区(9 个入口,详见 2.3)
+6. 供需双广场(独立菜单 · 仿图 1)
+ 左卡:📤 商家发单 │ 右卡:🛩 飞手接单
+7. 滴滴打无人机(同城即时配送)
+ 地图动画示意(无人机飞手轨迹)
+ 配送流程 4 步:骑手取货 → 无人机起飞 → 配送中 → 预计到达
+ 底部快捷下单栏:30 分钟必达 · 支持紧急件
+8. 研学体系(首页展示课程 · 2~3 张横向卡)
+ ① [两天一晚世园无人机研学课程] ¥1,388/期
+ ② [无人机技能进阶营(3-5天)] ¥2,680/期
+ ③ [鄂尔多斯雏鹰计划] ¥3,200/期
+```
+
+### 3.4 任务中心 Tab
+
+```
+顶部筛选按钮栏:
+[⚙ 筛选▾] [📍 附近] ← 推荐/高价/长期 已合并到筛选面板
+
+点击 [⚙ 筛选] → 右侧抽屉式筛选面板(7 组 / 55 个筛选项):
+ · 任务分类(7) 全部 / 植保 / 航拍 / 巡检 / 表演 / 吊运 / 测绘
+ · 任务状态(5) 全部 / 待接单 / 已接单 / 执行中 / 已完成
+ · 价格预算(5+输入)不限 / 0-3千 / 3-10千 / 1-5万 / 5万以上 + 自定义
+ · 所在地区(9) 全国 + 8 省市
+ · 人员数量(5) 不限 / 1人 / 2人 / 3-5人 / 5人以上
+ · 发布时间(5) 全部 / 今天 / 3天内 / 本周内 / 本月内
+ · 其他筛选(5) 优质任务 / 含飞手险 / 长期合作 / 急单 / 团队单
+
+主体任务卡(仿图 1 横向大卡):
+ · 头部:标签(分类 + 优质 + 状态)+ 地点
+ · 标题:粗体任务名
+ · 左图右信息卡:地点 / 人数 / 时间
+ · 底部:价格(左侧大字号)+ 操作按钮(查看附近飞手 / 去抢单)
+ · 描述:飞手险 + 任务详情(实景文字)
+
+优质任务强化(仅首卡):
+ · 金色边框 + 米黄渐变背景 + 金色 ★ 徽标
+ · 金色价格 + 金色 CTA 按钮
+ · 右上角金色光晕
+```
+
+### 3.5 滴滴打无人机 Tab(独立 Tab)
+
+> 核心定位:同城即时配送平台,配送端为无人机(类比美团/闪送,骑手换无人机)
+
+```
+核心业务流程:
+ 用户下单 → 系统派单给附近无人机 → 无人机取货起飞 → 配送中(实时轨迹)→ 送达
+
+功能清单:
+ · 一键下单:填写取货/收货地址 + 物品重量/类型 → 生成订单
+ · 配送地图:无人机轨迹实时展示(起飞点→配送中→到达)
+ · 配送流程:4 步追踪(骑手取货 / 无人机起飞 / 配送中 / 预计到达)
+ · 计价规则:按距离 + 重量 + 时效(普通件/加急件)
+ · 配送范围:同城 30 分钟必达,支持紧急件
+ · 飞手/商家入驻:认证后接入配送网络
+
+展示 4 步配送流程示例(首页版):
+ ① ✓ 骑手取货 09:14
+ ② ✓ 无人机起飞 09:16 [T100 起飞]
+ ③ 🚁 配送中 09:17 [飞行高度 120m · 时速 60km/h]
+ ④ ◎ 预计到达 09:25 [送货上门]
+
+底部下单栏:30 分钟内送达 · 支持紧急件 [立即下单]
+```
+
+### 3.6 研学与课程 Tab
+
+```
+分类 Tab:全部 / 认知启蒙 / 技能进阶 / 创新应用 / 竞赛辅导
+
+课程卡片字段:
+ · 标签(热门/推荐/新开)+ 分类标签 + 对象年龄
+ · 课程名 + 地点 + 时长
+ · 课程亮点(3项展开)
+ · 价格 + [立即预约] 按钮
+
+展示 6 门真实课程案例:
+ ① 两天一晚世园无人机研学课程(北京)¥1,388/期 [热门·青少年·认知启蒙]
+ ② 无人机技能进阶营(3-5天)¥2,680/期 [技能进阶·初中生]
+ ③ 鄂尔多斯科技馆「雏鹰计划」(8天16课)¥3,200 [创新应用·高中生]
+ ④ 「越州蒲公英计划」青少年无人机研学(绍兴)¥680 [竞赛辅导·青少年]
+ ⑤ 「智趣少年营」无人机兴趣课程(社区)¥380 [趣味科普·零基础]
+ ⑥ 「小小农发人」无人机农业研学之旅(简阳)¥280 [农业应用·亲子]
+
+预约交互:[立即预约] → 弹出预约表单(姓名/手机/人数/备注)→ 提交成功提示
+```
+
+### 3.6 个人中心信息流(自上而下)
+
+```
+1. 顶部渐变区(无右上角图标):
+ 👤 头像 + 登录/注册按钮
+2. 我的任务(3 态):
+ [📋 待接单 2] [🛩 进行中 1] [✓ 已完成]
+3. 飞手推广 Banner:
+ 📢 邀请飞手认证 · 认证得积分 · 积分可提现 [去推广 ›]
+4. 认证与工具:
+ 上排 4:[飞手认证] [商家认证] [我的发布] [实名认证]
+ 下排 2:[🏆飞手中心] [🏬商家中心]
+```
+
+> ❌ 已删除模块:我的服务(优惠券/商家发货/分销绑定/收货地址)、信息完善、飞手资质、商家资质、功能关联导览、个人中心右上角 3 图标(App/消息/设置)。
+
+---
+
+## 4. 核心流程
+
+### 4.1 飞手入驻
+注册 / 微信一键登录 → 飞手认证(上传 CAAC 执照 + 身份证 + 机型)→ 审核(1 工作日)→ 通过 → 进入任务中心接单
+
+### 4.2 商家发单
+注册 → 商家认证(营业执照 + 法人身份证 + 经营范围)→ 审核 → 通过 → 任务中心 → 发布任务(类型 / 地点 / 时间 / 预算 / 机型要求)→ 飞手接单 → 双方 IM 确认 → 线下执行 → 平台评价 → 抽佣结算
+
+### 4.3 任务状态机
+```
+待接单 → 已接单(待执行)→ 执行中 → 待验收 → 已完成
+ ↓
+ 异常:取消 / 申诉 / 退款
+```
+
+### 4.4 认证状态机
+```
+未认证 → 资料上传中 → 审核中(≤24h)→ 已认证(有效 1 年)→ 即将过期(30 天)→ 过期
+ ↓
+ 续期申请
+```
+
+### 4.5 筛选交互流程
+点击 [⚙ 筛选] → 右侧抽屉滑入 → 7 组筛选项可点击切换 → 自定义价格输入 → [重置全部] 或 [确认筛选 (12)] → 关闭抽屉 → 列表刷新 + 提示"筛选已应用"
+
+---
+
+## 5. 信息架构
+
+### 5.1 页面清单(28 个)
+
+| 模块 | 页面 |
+|------|------|
+| 通用 | 启动页、登录/注册、消息中心、定位选择 |
+| 首页 | 首页、新闻列表、新闻详情、CAAC 报名、无人机表演列表 / 详情 |
+| 商家 / 飞手 | 实力商家列表 / 详情、本地飞手列表 / 详情 |
+| 培训 | 培训考试首页、附近商家(培训)、题库刷题、错题本 |
+| 任务中心 | 任务中心首页、发布任务、我要接单、任务详情、任务管理、筛选面板 |
+| 研学与课程 | 列表(全部 / 认知启蒙 / 技能进阶 / 创新应用 / 竞赛辅导)、详情、预约 |
+| 我的 | 我的首页(任务 3 态)、飞手推广、飞手认证、商家认证、我的发布、实名认证、飞手中心、商家中心、关于我们 |
+
+### 5.2 底部 Tab(4 个 · 按图 1 一级菜单)
+
+- **首页**:流量分发
+- **任务中心**:核心交易(独立 Tab · 必到)
+- **行业动态**:资讯流
+- **个人中心**:身份与认证
+
+> 与图 1 一级菜单对应关系:图 1 的"首页" = 本文档的首页 + 任务中心(交易功能下沉到 Tab);图 1 的"行业动态" = 研学与课程 Tab;图 1 的"个人中心" = 同名 Tab。
+
+---
+
+## 6. UI 规范
+
+### 6.1 色彩系统
+
+| 用途 | 颜色 | 备注 |
+|------|------|------|
+| **主色** | **`#5E46F6`** = `rgb(94,70,246)` | 品牌色 · 按钮 / Tag / 强调 / Tab 选中 / 阴影 |
+| 辅助紫 | `#7C6AF8` | 次级按钮、渐变起点 |
+| 浅紫底 | `#F2EFFE` | 页面背景、Tab 浅态 |
+| 卡片白 | `#FFFFFF` | 内容卡 |
+| 文字主 | `#1A1730` | 标题 |
+| 文字次 | `#5E5870` | 描述 |
+| 文字弱 | `#9D97AE` | 提示 |
+| 成功 | `#22C55E` | 认证通过 |
+| 警告 | `#F59E0B` | 即将过期 |
+| 错误 | `#EF4444` | 失败 |
+| **金色点缀** | **`#F59E0B`** | **优质任务标签 / 价格 / CTA / 边框** |
+
+### 6.2 字体
+- iOS:PingFang SC
+- Android:系统默认
+- 字号阶梯:11 / 12 / 13 / 14 / 15 / 17 / 20 / 24 / 32
+
+### 6.3 圆角 / 阴影
+- 卡圆角:12-16 px
+- 按钮圆角:8-14 px
+- 头像圆角:50%
+- 卡阴影:`0 2px 12px rgba(94,70,246,0.10)` ——`--shadow`
+- 任务中心卡阴影:`0 4px 16px rgba(94,70,246,0.12)` ——`--shadow-md`
+- 大阴影(轮播 / Banner):`0 8px 24px rgba(94,70,246,0.18)` ——`--shadow-lg`
+- 优质任务金色阴影:`0 6px 20px rgba(245,158,11,0.22)`
+
+---
+
+## 7. 关键指标
+
+| 指标 | 目标(M3) | 监控位 |
+|------|------------|--------|
+| CAAC 报名点击 → 提交转化率 | ≥ 35% | 首页轮播点击 |
+| 飞手认证完成率 | ≥ 60% | 引导页漏斗 |
+| 任务中心点击率 | ≥ 50% | 首页"供需双广场" + "优质任务" |
+| 优质任务点击 → 接单 | ≥ 20% | 任务中心首卡 |
+| 任务平均成交时长 | ≤ 4h | 任务详情 |
+| 7 日留存 | ≥ 30% | 启动埋点 |
+| 研学与课程 Tab PV | 首页 PV 的 30% | Tab 切换埋点 |
+| 筛选面板使用率 | ≥ 15% | 任务中心 Tab |
+
+---
+
+## 8. 风险与对策
+
+| 风险 | 等级 | 对策 |
+|------|------|------|
+| 飞手资质造假 | 高 | 接入 CAAC 接口核验执照真伪 + 履约保证金 |
+| 商家拖欠 / 跑单 | 高 | 平台担保支付 + 评价 + 黑名单 |
+| 飞手人身安全 | 中 | 保险合作 + 紧急 SOS 按钮 + 行程共享 |
+| 监管政策变化 | 中 | 法务对接民航局,配置审核人力 |
+| 冷启动供需 | 中 | 地推:先与 3-5 家培训机构、2-3 个无人机协会合作 |
+| 优质任务供给不足 | 中 | 平台补贴 + 头部商家 BD |
+| 首页任务中心被弱化 | 中 | 顶部"优质任务"金色强化 + 供需双广场独立菜单导流 |
+| 低空配送合规(飞行审批/空域限制) | 高 | 对接 UOM 平台获取飞行许可;设置电子围栏自动拦截禁飞区订单 |
+| 配送延误/事故(天气/机械故障) | 中 | 无人机状态实时监控;天气预警自动暂停;备用机调度机制 |
+
+---
+
+## 9. 版本规划
+
+| 版本 | 时间 | 内容 |
+|------|------|------|
+| v1.0 | 待开发 | 首页 + 任务中心 + 研学与课程 + 个人中心,4 Tab 完整功能 |
+| v2.0 | M3 | 培训题库 + 保险合作 + 智能匹配 |
+| v3.0 | M6+ | 行业 SaaS + AR 展示 + 地图找飞手 |
+
+---
+
+## 10. 附录:与原图功能对照表
+
+### 10.1 一级菜单对照
+
+| 图 1 一级菜单 | 本文档 Tab | 关键二级 |
+|--------------|-----------|----------|
+| 首页 | **首页 + 任务中心** | 9 宫格 + 供需双广场 + 滴滴打无人机 + 研学体系 |
+| 行业动态 | **研学与课程** | 列表 + 详情 + 预约(6 门课程) |
+| 个人中心 | **个人中心** | 任务 3 态 + 飞手 / 商家双身份认证 + 推广 |
+
+### 10.2 二级菜单对照
+
+| 图 1 二级菜单 | 本文档对应 | 实现位置 |
+|--------------|------------|----------|
+| 新闻咨询 | ✓ | 首页轮播 + 公告(研学与课程内容) |
+| CAAC 报名 | ✓ | 首页 9 宫格 #1 + 独立页 |
+| 无人机表演 | ✓ | 首页 9 宫格 #2 + 独立板块 |
+| 实力商家 | ✓ | 首页 9 宫格 #3 + 商家列表 |
+| 本地飞手 | ✓ | 首页 9 宫格 #4 + 飞手列表 |
+| 培训考试 | ✓ | 首页 9 宫格 #5 + 培训模块 |
+| **供需双广场** | ✓(**独立菜单**) | 首页 6 区(左:商家发单 / 右:飞手接单) |
+| **优质任务** | ✓ | 首页 7 区(仅 1 张金色强化卡)+ 任务中心首卡 |
+| 信息完善 | ❌(已删除) | 个人中心(合并到飞手 / 商家认证流程中) |
+| 飞手认证 | ✓ | 个人中心「认证与工具」上排 #1 |
+| 飞手资质中心 | ❌(已删除) | 资质合并到飞手认证页面 |
+| 商家认证 | ✓ | 个人中心「认证与工具」上排 #2 |
+| 商家资质中心 | ❌(已删除) | 资质合并到商家认证页面 |
+| 关于我们 | ✓ | 个人中心底部 |
+
+
+---
+
+## 11. 数据库设计(DDL)
+
+> 数据库类型:MySQL 8.0
+> 字符集:`utf8mb4` / 排序规则:`utf8mb4_unicode_ci`
+> 所有表均含 `created_at` / `updated_at` 通用字段;主键均使用 `BIGINT` 自增;软删除用 `deleted_at`
+
+---
+
+### 11.1 公告表 `announcement`
+
+| 字段 | 类型 | 说明 | 约束 |
+|------|------|------|------|
+| id | BIGINT | 主键 | PK, AUTO_INCREMENT |
+| title | VARCHAR(100) | 公告标题 | NOT NULL |
+| content | TEXT | 公告正文 | |
+| valid_from | DATETIME | 生效开始时间 | |
+| valid_to | DATETIME | 生效结束时间 | |
+| status | TINYINT | 状态:0隐藏 1显示 | DEFAULT 1 |
+| sort_order | INT | 排序,越小越靠前 | DEFAULT 0 |
+| publisher_id | BIGINT | 发布人ID(关联user.id) | |
+| created_at | DATETIME | 创建时间 | DEFAULT CURRENT_TIMESTAMP |
+| updated_at | DATETIME | 更新时间 | |
+| deleted_at | DATETIME | 软删除时间 | |
+
+```sql
+CREATE TABLE announcement (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+ title VARCHAR(100) NOT NULL COMMENT '公告标题',
+ content TEXT COMMENT '公告正文',
+ valid_from DATETIME COMMENT '生效开始时间',
+ valid_to DATETIME COMMENT '生效结束时间',
+ status TINYINT DEFAULT 1 COMMENT '状态:0隐藏 1显示',
+ sort_order INT DEFAULT 0 COMMENT '排序',
+ publisher_id BIGINT COMMENT '发布人ID',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间',
+ INDEX idx_status_sort (status, sort_order),
+ INDEX idx_valid_period (valid_from, valid_to)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='公告表';
+```
+
+---
+
+### 11.2 任务表 `task`
+
+| 字段 | 类型 | 说明 | 约束 |
+|------|------|------|------|
+| id | BIGINT | 主键 | PK, AUTO_INCREMENT |
+| task_no | VARCHAR(32) | 任务编号(全局唯一) | UNIQUE |
+| name | VARCHAR(100) | 任务名称 | NOT NULL |
+| type | VARCHAR(20) | 任务类型:植保/吊运/航拍/巡检/清洗/表演/测绘 | NOT NULL |
+| province | VARCHAR(20) | 省份 | |
+| city | VARCHAR(20) | 城市 | |
+| district | VARCHAR(20) | 区县 | |
+| address | VARCHAR(200) | 详细地址 | |
+| latitude | DECIMAL(10,6) | 纬度坐标 | |
+| longitude | DECIMAL(10,6) | 经度坐标 | |
+| scene_images | JSON | 作业场景图(file_id数组) | |
+| required_count | INT | 需求人数 | DEFAULT 1 |
+| start_time | DATETIME | 开始时间 | NOT NULL |
+| end_time | DATETIME | 结束时间 | |
+| reward | DECIMAL(10,2) | 报酬(元) | NOT NULL |
+| insurance_require | JSON | 保险要求:三者险/飞手险/机身险 | |
+| contact_name | VARCHAR(50) | 联系人姓名 | |
+| contact_phone | VARCHAR(20) | 联系电话 | NOT NULL |
+| remark | TEXT | 备注 | |
+| status | TINYINT | 状态:0草稿 1待接单 2已接单 3执行中 4已完成 5已取消 | DEFAULT 0 |
+| is_premium | TINYINT | 是否优质任务:0否 1是 | DEFAULT 0 |
+| premium_cost | INT | 优质任务积分消耗 | DEFAULT 0 |
+| publisher_id | BIGINT | 发布人ID(关联user.id) | NOT NULL |
+| accepted_pilot_id | BIGINT | 接单飞手ID(关联pilot.id) | |
+| platform_fee | DECIMAL(10,2) | 平台服务费 | DEFAULT 0.00 |
+| created_at | DATETIME | 创建时间 | DEFAULT CURRENT_TIMESTAMP |
+| updated_at | DATETIME | 更新时间 | |
+| deleted_at | DATETIME | 软删除时间 | |
+
+```sql
+CREATE TABLE task (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+ task_no VARCHAR(32) NOT NULL COMMENT '任务编号(全局唯一)',
+ name VARCHAR(100) NOT NULL COMMENT '任务名称',
+ type VARCHAR(20) NOT NULL COMMENT '任务类型:植保/吊运/航拍/巡检/清洗/表演/测绘',
+ province VARCHAR(20) COMMENT '省份',
+ city VARCHAR(20) COMMENT '城市',
+ district VARCHAR(20) COMMENT '区县',
+ address VARCHAR(200) COMMENT '详细地址',
+ latitude DECIMAL(10,6) COMMENT '纬度坐标',
+ longitude DECIMAL(10,6) COMMENT '经度坐标',
+ scene_images JSON COMMENT '作业场景图(file_id数组)',
+ required_count INT DEFAULT 1 COMMENT '需求人数',
+ start_time DATETIME NOT NULL COMMENT '开始时间',
+ end_time DATETIME COMMENT '结束时间',
+ reward DECIMAL(10,2) NOT NULL COMMENT '报酬(元)',
+ insurance_require JSON COMMENT '保险要求:三者险/飞手险/机身险',
+ contact_name VARCHAR(50) COMMENT '联系人姓名',
+ contact_phone VARCHAR(20) NOT NULL COMMENT '联系电话',
+ remark TEXT COMMENT '备注',
+ status TINYINT DEFAULT 0 COMMENT '状态:0草稿 1待接单 2已接单 3执行中 4已完成 5已取消',
+ is_premium TINYINT DEFAULT 0 COMMENT '是否优质任务:0否 1是',
+ premium_cost INT DEFAULT 0 COMMENT '优质任务积分消耗',
+ publisher_id BIGINT NOT NULL COMMENT '发布人ID',
+ accepted_pilot_id BIGINT COMMENT '接单飞手ID',
+ platform_fee DECIMAL(10,2) DEFAULT 0.00 COMMENT '平台服务费',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间',
+ UNIQUE KEY uk_task_no (task_no),
+ INDEX idx_type_status (type, status),
+ INDEX idx_city_status (city, status),
+ INDEX idx_publisher (publisher_id),
+ INDEX idx_pilot (accepted_pilot_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务表';
+```
+
+---
+
+### 11.3 商家表 `merchant`
+
+| 字段 | 类型 | 说明 | 约束 |
+|------|------|------|------|
+| id | BIGINT | 主键 | PK, AUTO_INCREMENT |
+| user_id | BIGINT | 关联用户ID | UNIQUE, NOT NULL |
+| name | VARCHAR(100) | 商家名称 | NOT NULL |
+| address | VARCHAR(200) | 商家地址 | |
+| door_image | BIGINT | 门头照片file_id | |
+| license_file_id | BIGINT | 营业执照file_id | |
+| contact_name | VARCHAR(50) | 联系人 | |
+| contact_phone | VARCHAR(20) | 联系电话 | |
+| latitude | DECIMAL(10,6) | 纬度 | |
+| longitude | DECIMAL(10,6) | 经度 | |
+| service_types | JSON | 服务类型数组 | |
+| description | TEXT | 商家简介 | |
+| cert_status | TINYINT | 认证状态:0未认证 1认证中 2已认证 3认证失败 | DEFAULT 0 |
+| cert_time | DATETIME | 认证时间 | |
+| rating | DECIMAL(2,1) | 评分(1.0-5.0) | DEFAULT 5.0 |
+| order_count | INT | 成交订单数 | DEFAULT 0 |
+| created_at | DATETIME | 创建时间 | DEFAULT CURRENT_TIMESTAMP |
+| updated_at | DATETIME | 更新时间 | |
+| deleted_at | DATETIME | 软删除时间 | |
+
+```sql
+CREATE TABLE merchant (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+ user_id BIGINT NOT NULL COMMENT '关联用户ID',
+ name VARCHAR(100) NOT NULL COMMENT '商家名称',
+ address VARCHAR(200) COMMENT '商家地址',
+ door_image BIGINT COMMENT '门头照片file_id',
+ license_file_id BIGINT COMMENT '营业执照file_id',
+ contact_name VARCHAR(50) COMMENT '联系人',
+ contact_phone VARCHAR(20) COMMENT '联系电话',
+ latitude DECIMAL(10,6) COMMENT '纬度',
+ longitude DECIMAL(10,6) COMMENT '经度',
+ service_types JSON COMMENT '服务类型数组',
+ description TEXT COMMENT '商家简介',
+ cert_status TINYINT DEFAULT 0 COMMENT '认证状态:0未认证 1认证中 2已认证 3认证失败',
+ cert_time DATETIME COMMENT '认证时间',
+ rating DECIMAL(2,1) DEFAULT 5.0 COMMENT '评分(1.0-5.0)',
+ order_count INT DEFAULT 0 COMMENT '成交订单数',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间',
+ UNIQUE KEY uk_user (user_id),
+ INDEX idx_cert_status (cert_status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='商家表';
+```
+
+---
+
+### 11.4 飞手表 `pilot`
+
+| 字段 | 类型 | 说明 | 约束 |
+|------|------|------|------|
+| id | BIGINT | 主键 | PK, AUTO_INCREMENT |
+| user_id | BIGINT | 关联用户ID | UNIQUE, NOT NULL |
+| name | VARCHAR(50) | 姓名(实名) | NOT NULL |
+| phone | VARCHAR(20) | 手机号 | NOT NULL |
+| birthday | DATE | 出生年月 | |
+| age | TINYINT | 年龄(自动计算) | |
+| avatar_file_id | BIGINT | 头像file_id | |
+| cert_status | TINYINT | 认证状态:0未认证 1认证中 2已认证 3认证失败 | DEFAULT 0 |
+| cert_time | DATETIME | 认证时间 | |
+| license_type | VARCHAR(20) | CAAC执照类型:AOPA/UTC/ASFC | |
+| license_no | VARCHAR(50) | CAAC执照编号 | |
+| license_file_id | BIGINT | 执照图片file_id | |
+| drone_models | JSON | 可操控机型数组 | |
+| service_cities | JSON | 服务城市数组 | |
+| bio | VARCHAR(200) | 个人简介 | |
+| rating | DECIMAL(2,1) | 评分 | DEFAULT 5.0 |
+| order_count | INT | 完成任务数 | DEFAULT 0 |
+| insurance_expire | DATE | 飞手险到期日 | |
+| created_at | DATETIME | 创建时间 | DEFAULT CURRENT_TIMESTAMP |
+| updated_at | DATETIME | 更新时间 | |
+| deleted_at | DATETIME | 软删除时间 | |
+
+```sql
+CREATE TABLE pilot (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+ user_id BIGINT NOT NULL COMMENT '关联用户ID',
+ name VARCHAR(50) NOT NULL COMMENT '姓名(实名)',
+ phone VARCHAR(20) NOT NULL COMMENT '手机号',
+ birthday DATE COMMENT '出生年月',
+ age TINYINT COMMENT '年龄(自动计算)',
+ avatar_file_id BIGINT COMMENT '头像file_id',
+ cert_status TINYINT DEFAULT 0 COMMENT '认证状态:0未认证 1认证中 2已认证 3认证失败',
+ cert_time DATETIME COMMENT '认证时间',
+ license_type VARCHAR(20) COMMENT 'CAAC执照类型:AOPA/UTC/ASFC',
+ license_no VARCHAR(50) COMMENT 'CAAC执照编号',
+ license_file_id BIGINT COMMENT '执照图片file_id',
+ drone_models JSON COMMENT '可操控机型数组',
+ service_cities JSON COMMENT '服务城市数组',
+ bio VARCHAR(200) COMMENT '个人简介',
+ rating DECIMAL(2,1) DEFAULT 5.0 COMMENT '评分',
+ order_count INT DEFAULT 0 COMMENT '完成任务数',
+ insurance_expire DATE COMMENT '飞手险到期日',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间',
+ UNIQUE KEY uk_user (user_id),
+ INDEX idx_cert_status (cert_status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='飞手表';
+```
+
+---
+
+### 11.5 题库表 `exam_bank`
+
+| 字段 | 类型 | 说明 | 约束 |
+|------|------|------|------|
+| id | BIGINT | 主键 | PK, AUTO_INCREMENT |
+| name | VARCHAR(100) | 题库名称 | NOT NULL |
+| cover_image | BIGINT | 封面图file_id | |
+| description | TEXT | 题库描述 | |
+| question_count | INT | 题目数量 | DEFAULT 0 |
+| difficulty | TINYINT | 难度:1简单 2中等 3困难 | DEFAULT 1 |
+| status | TINYINT | 状态:0禁用 1启用 | DEFAULT 1 |
+| created_at | DATETIME | 创建时间 | DEFAULT CURRENT_TIMESTAMP |
+| updated_at | DATETIME | 更新时间 | |
+| deleted_at | DATETIME | 软删除时间 | |
+
+```sql
+CREATE TABLE exam_bank (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+ name VARCHAR(100) NOT NULL COMMENT '题库名称',
+ cover_image BIGINT COMMENT '封面图file_id',
+ description TEXT COMMENT '题库描述',
+ question_count INT DEFAULT 0 COMMENT '题目数量',
+ difficulty TINYINT DEFAULT 1 COMMENT '难度:1简单 2中等 3困难',
+ status TINYINT DEFAULT 1 COMMENT '状态:0禁用 1启用',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间'
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题库表';
+```
+
+---
+
+### 11.6 习题表 `exam_question`
+
+| 字段 | 类型 | 说明 | 约束 |
+|------|------|------|------|
+| id | BIGINT | 主键 | PK, AUTO_INCREMENT |
+| bank_id | BIGINT | 所属题库ID | NOT NULL |
+| content | TEXT | 题目内容 | NOT NULL |
+| options | JSON | 选项数组(含ABCD四选项) | |
+| question_type | TINYINT | 题型:1单选 2多选 3判断 | NOT NULL |
+| answer | VARCHAR(10) | 标准答案(如:A 或 AB) | NOT NULL |
+| explanation | TEXT | 答案解析 | |
+| difficulty | TINYINT | 难度:1简单 2中等 3困难 | DEFAULT 1 |
+| score | TINYINT | 分值(单选/多选) | DEFAULT 5 |
+| sort_order | INT | 排序 | DEFAULT 0 |
+| used_count | INT | 被使用次数 | DEFAULT 0 |
+| created_at | DATETIME | 创建时间 | DEFAULT CURRENT_TIMESTAMP |
+| updated_at | DATETIME | 更新时间 | |
+| deleted_at | DATETIME | 软删除时间 | |
+
+```sql
+CREATE TABLE exam_question (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+ bank_id BIGINT NOT NULL COMMENT '所属题库ID',
+ content TEXT NOT NULL COMMENT '题目内容',
+ options JSON COMMENT '选项数组([{"key":"A","val":"..."},...])',
+ question_type TINYINT NOT NULL COMMENT '题型:1单选 2多选 3判断',
+ answer VARCHAR(10) NOT NULL COMMENT '标准答案(如:A 或 AB)',
+ explanation TEXT COMMENT '答案解析',
+ difficulty TINYINT DEFAULT 1 COMMENT '难度:1简单 2中等 3困难',
+ score TINYINT DEFAULT 5 COMMENT '分值',
+ sort_order INT DEFAULT 0 COMMENT '排序',
+ used_count INT DEFAULT 0 COMMENT '被使用次数',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间',
+ INDEX idx_bank (bank_id),
+ INDEX idx_type (question_type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='习题表';
+```
+
+---
+
+### 11.7 课程表 `course`
+
+| 字段 | 类型 | 说明 | 约束 |
+|------|------|------|------|
+| id | BIGINT | 主键 | PK, AUTO_INCREMENT |
+| name | VARCHAR(100) | 课程名称 | NOT NULL |
+| tags | JSON | 标签数组(认知启蒙/技能进阶/创新应用/竞赛辅导) | |
+| category | VARCHAR(20) | 分类 | |
+| target_age | VARCHAR(20) | 适合年龄 | |
+| location | VARCHAR(200) | 上课地点 | |
+| latitude | DECIMAL(10,6) | 纬度 | |
+| longitude | DECIMAL(10,6) | 经度 | |
+| description | TEXT | 课程描述 | |
+| highlights | JSON | 课程亮点(3项) | |
+| duration_desc | VARCHAR(50) | 课时说明 | |
+| capacity_min | INT | 最小人数 | DEFAULT 1 |
+| capacity_max | INT | 最大人数 | DEFAULT 20 |
+| images | JSON | 课程图片file_id数组 | |
+| price | DECIMAL(10,2) | 课程价格 | NOT NULL |
+| merchant_id | BIGINT | 所属商家ID | |
+| status | TINYINT | 状态:0下架 1上架 2已满员 | DEFAULT 1 |
+| view_count | INT | 浏览次数 | DEFAULT 0 |
+| book_count | INT | 已预约人数 | DEFAULT 0 |
+| rating | DECIMAL(2,1) | 课程评分 | DEFAULT 5.0 |
+| created_at | DATETIME | 创建时间 | DEFAULT CURRENT_TIMESTAMP |
+| updated_at | DATETIME | 更新时间 | |
+| deleted_at | DATETIME | 软删除时间 | |
+
+```sql
+CREATE TABLE course (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+ name VARCHAR(100) NOT NULL COMMENT '课程名称',
+ tags JSON COMMENT '标签数组(认知启蒙/技能进阶/创新应用/竞赛辅导)',
+ category VARCHAR(20) COMMENT '分类',
+ target_age VARCHAR(20) COMMENT '适合年龄',
+ location VARCHAR(200) COMMENT '上课地点',
+ latitude DECIMAL(10,6) COMMENT '纬度',
+ longitude DECIMAL(10,6) COMMENT '经度',
+ description TEXT COMMENT '课程描述',
+ highlights JSON COMMENT '课程亮点(3项)',
+ duration_desc VARCHAR(50) COMMENT '课时说明',
+ capacity_min INT DEFAULT 1 COMMENT '最小人数',
+ capacity_max INT DEFAULT 20 COMMENT '最大人数',
+ images JSON COMMENT '课程图片file_id数组',
+ price DECIMAL(10,2) NOT NULL COMMENT '课程价格',
+ merchant_id BIGINT COMMENT '所属商家ID',
+ status TINYINT DEFAULT 1 COMMENT '状态:0下架 1上架 2已满员',
+ view_count INT DEFAULT 0 COMMENT '浏览次数',
+ book_count INT DEFAULT 0 COMMENT '已预约人数',
+ rating DECIMAL(2,1) DEFAULT 5.0 COMMENT '课程评分',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间',
+ INDEX idx_category (category),
+ INDEX idx_status (status),
+ INDEX idx_merchant (merchant_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程表';
+```
+
+---
+
+### 11.8 用户表 `user`
+
+| 字段 | 类型 | 说明 | 约束 |
+|------|------|------|------|
+| id | BIGINT | 主键 | PK, AUTO_INCREMENT |
+| openid | VARCHAR(64) | 微信openId | UNIQUE, NOT NULL |
+| nickname | VARCHAR(50) | 用户昵称 | |
+| avatar_url | VARCHAR(500) | 头像URL | |
+| phone | VARCHAR(20) | 手机号 | |
+| gender | TINYINT | 性别:0未知 1男 2女 | DEFAULT 0 |
+| bio | VARCHAR(200) | 个人简介 | |
+| role_type | TINYINT | 角色类型:1普通用户 2飞手 3商家 4机构 | DEFAULT 1 |
+| points | INT | 积分余额 | DEFAULT 0 |
+| is_pilot_bound | TINYINT | 是否已绑定飞手身份 | DEFAULT 0 |
+| is_merchant_bound | TINYINT | 是否已绑定商家身份 | DEFAULT 0 |
+| pilot_id | BIGINT | 关联飞手ID | |
+| merchant_id | BIGINT | 关联商家ID | |
+| status | TINYINT | 账号状态:0禁用 1正常 | DEFAULT 1 |
+| last_login_at | DATETIME | 最后登录时间 | |
+| created_at | DATETIME | 注册时间 | DEFAULT CURRENT_TIMESTAMP |
+| updated_at | DATETIME | 更新时间 | |
+| deleted_at | DATETIME | 软删除时间 | |
+
+```sql
+CREATE TABLE `user` (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+ openid VARCHAR(64) NOT NULL COMMENT '微信openId',
+ nickname VARCHAR(50) COMMENT '用户昵称',
+ avatar_url VARCHAR(500) COMMENT '头像URL',
+ phone VARCHAR(20) COMMENT '手机号',
+ gender TINYINT DEFAULT 0 COMMENT '性别:0未知 1男 2女',
+ bio VARCHAR(200) COMMENT '个人简介',
+ role_type TINYINT DEFAULT 1 COMMENT '角色类型:1普通用户 2飞手 3商家 4机构',
+ points INT DEFAULT 0 COMMENT '积分余额',
+ is_pilot_bound TINYINT DEFAULT 0 COMMENT '是否已绑定飞手身份',
+ is_merchant_bound TINYINT DEFAULT 0 COMMENT '是否已绑定商家身份',
+ pilot_id BIGINT COMMENT '关联飞手ID',
+ merchant_id BIGINT COMMENT '关联商家ID',
+ status TINYINT DEFAULT 1 COMMENT '账号状态:0禁用 1正常',
+ last_login_at DATETIME COMMENT '最后登录时间',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间',
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间',
+ UNIQUE KEY uk_openid (openid),
+ INDEX idx_phone (phone)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';
+```
+
+---
+
+### 11.9 任务执行记录表 `task_record`
+
+| 字段 | 类型 | 说明 | 约束 |
+|------|------|------|------|
+| id | BIGINT | 主键 | PK, AUTO_INCREMENT |
+| task_id | BIGINT | 任务ID | NOT NULL |
+| user_id | BIGINT | 关联用户ID | NOT NULL |
+| pilot_id | BIGINT | 接单飞手ID | |
+| merchant_id | BIGINT | 发单商家ID | |
+| status | TINYINT | 状态:1待接单 2已接单 3执行中 4已完成 5已取消 | DEFAULT 1 |
+| apply_time | DATETIME | 申请/接单时间 | |
+| accept_time | DATETIME | 飞手接单时间 | |
+| start_time | DATETIME | 实际开始时间 | |
+| finish_time | DATETIME | 完成时间 | |
+| cancel_time | DATETIME | 取消时间 | |
+| cancel_reason | VARCHAR(200) | 取消原因 | |
+| insurance_verified | TINYINT | 保险是否校验:0否 1是 | DEFAULT 0 |
+| actual_reward | DECIMAL(10,2) | 实际报酬 | |
+| platform_fee | DECIMAL(10,2) | 平台服务费 | |
+| rating | TINYINT | 用户评分(1-5) | |
+| rating_comment | VARCHAR(200) | 评价内容 | |
+| created_at | DATETIME | 创建时间 | DEFAULT CURRENT_TIMESTAMP |
+| updated_at | DATETIME | 更新时间 | |
+| deleted_at | DATETIME | 软删除时间 | |
+
+```sql
+CREATE TABLE task_record (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+ task_id BIGINT NOT NULL COMMENT '任务ID',
+ user_id BIGINT NOT NULL COMMENT '关联用户ID',
+ pilot_id BIGINT COMMENT '接单飞手ID',
+ merchant_id BIGINT COMMENT '发单商家ID',
+ status TINYINT DEFAULT 1 COMMENT '状态:1待接单 2已接单 3执行中 4已完成 5已取消',
+ apply_time DATETIME COMMENT '申请/接单时间',
+ accept_time DATETIME COMMENT '飞手接单时间',
+ start_time DATETIME COMMENT '实际开始时间',
+ finish_time DATETIME COMMENT '完成时间',
+ cancel_time DATETIME COMMENT '取消时间',
+ cancel_reason VARCHAR(200) COMMENT '取消原因',
+ insurance_verified TINYINT DEFAULT 0 COMMENT '保险是否校验:0否 1是',
+ actual_reward DECIMAL(10,2) COMMENT '实际报酬',
+ platform_fee DECIMAL(10,2) COMMENT '平台服务费',
+ rating TINYINT COMMENT '用户评分(1-5)',
+ rating_comment VARCHAR(200) COMMENT '评价内容',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间',
+ UNIQUE KEY uk_task_user (task_id, user_id),
+ INDEX idx_user_status (user_id, status),
+ INDEX idx_pilot_status (pilot_id, status)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务执行记录表';
+```
+
+---
+
+### 11.10 文件表 `file`
+
+| 字段 | 类型 | 说明 | 约束 |
+|------|------|------|------|
+| id | BIGINT | 主键 | PK, AUTO_INCREMENT |
+| file_name | VARCHAR(200) | 原始文件名 | NOT NULL |
+| file_key | VARCHAR(200) | 存储路径/OSS Key | NOT NULL |
+| file_suffix | VARCHAR(10) | 文件后缀 | |
+| file_url | VARCHAR(500) | 访问URL | |
+| file_size | BIGINT | 文件大小(字节) | |
+| file_type | VARCHAR(20) | 类型:营业执照/图片/飞手执照/其他 | |
+| uploader_id | BIGINT | 上传用户ID | |
+| status | TINYINT | 状态:0禁用 1正常 | DEFAULT 1 |
+| created_at | DATETIME | 上传时间 | DEFAULT CURRENT_TIMESTAMP |
+| deleted_at | DATETIME | 软删除时间 | |
+
+```sql
+CREATE TABLE `file` (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+ file_name VARCHAR(200) NOT NULL COMMENT '原始文件名',
+ file_key VARCHAR(200) NOT NULL COMMENT '存储路径/OSS Key',
+ file_suffix VARCHAR(10) COMMENT '文件后缀',
+ file_url VARCHAR(500) COMMENT '访问URL',
+ file_size BIGINT COMMENT '文件大小(字节)',
+ file_type VARCHAR(20) COMMENT '类型:营业执照/图片/飞手执照/其他',
+ uploader_id BIGINT COMMENT '上传用户ID',
+ status TINYINT DEFAULT 1 COMMENT '状态:0禁用 1正常',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '上传时间',
+ deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间',
+ INDEX idx_type (file_type),
+ INDEX idx_uploader (uploader_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='文件表';
+```
+
+---
+
+### 11.11 课程预约表 `course_appointment`
+
+| 字段 | 类型 | 说明 | 约束 |
+|------|------|------|------|
+| id | BIGINT | 主键 | PK, AUTO_INCREMENT |
+| user_id | BIGINT | 用户ID | NOT NULL |
+| course_id | BIGINT | 课程ID | NOT NULL |
+| merchant_id | BIGINT | 商家ID | |
+| appointment_date | DATETIME | 预约时间 | NOT NULL |
+| person_count | INT | 预约人数 | DEFAULT 1 |
+| contact_name | VARCHAR(50) | 联系人姓名 | |
+| contact_phone | VARCHAR(20) | 联系人电话 | |
+| remark | VARCHAR(200) | 备注 | |
+| status | TINYINT | 状态:1已预约 2已确认 3已完成 4已取消 | DEFAULT 1 |
+| confirm_time | DATETIME | 商家确认时间 | |
+| cancel_time | DATETIME | 取消时间 | |
+| cancel_reason | VARCHAR(200) | 取消原因 | |
+| amount | DECIMAL(10,2) | 支付金额 | DEFAULT 0.00 |
+| pay_status | TINYINT | 支付状态:0未支付 1已支付 2已退款 | DEFAULT 0 |
+| created_at | DATETIME | 预约时间 | DEFAULT CURRENT_TIMESTAMP |
+| updated_at | DATETIME | 更新时间 | |
+| deleted_at | DATETIME | 软删除时间 | |
+
+```sql
+CREATE TABLE course_appointment (
+ id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键',
+ user_id BIGINT NOT NULL COMMENT '用户ID',
+ course_id BIGINT NOT NULL COMMENT '课程ID',
+ merchant_id BIGINT COMMENT '商家ID',
+ appointment_date DATETIME NOT NULL COMMENT '预约时间',
+ person_count INT DEFAULT 1 COMMENT '预约人数',
+ contact_name VARCHAR(50) COMMENT '联系人姓名',
+ contact_phone VARCHAR(20) COMMENT '联系人电话',
+ remark VARCHAR(200) COMMENT '备注',
+ status TINYINT DEFAULT 1 COMMENT '状态:1已预约 2已确认 3已完成 4已取消',
+ confirm_time DATETIME COMMENT '商家确认时间',
+ cancel_time DATETIME COMMENT '取消时间',
+ cancel_reason VARCHAR(200) COMMENT '取消原因',
+ amount DECIMAL(10,2) DEFAULT 0.00 COMMENT '支付金额',
+ pay_status TINYINT DEFAULT 0 COMMENT '支付状态:0未支付 1已支付 2已退款',
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '预约时间',
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+ deleted_at DATETIME DEFAULT NULL COMMENT '软删除时间',
+ UNIQUE KEY uk_user_course_date (user_id, course_id, appointment_date),
+ INDEX idx_course_status (course_id, status),
+ INDEX idx_merchant (merchant_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程预约表';
+```
+
+---
+
+### 11.12 表间关系(ER)
+
+```
+user ─────┬── 1:1 pilot(飞手身份)
+ └── 1:1 merchant(商家身份)
+ └── 1:N task_record(我的任务)
+ └── 1:N course_appointment(课程预约)
+
+pilot ────── N:1 task_record(接单飞手)
+merchant ─── N:1 task_record(发单商家)
+ N:1 course(商家开课)
+
+task ──────── 1:1 task_record(任务→执行记录)
+course ────── 1:N course_appointment(课程→预约)
+
+file ──────── N:1 user(上传者)
+ N:1 pilot(执照图片)
+ N:1 merchant(营业执照)
+ N:1 task(作业场景图)
+ N:1 course(课程图片)
+
+exam_bank ── 1:N exam_question(题库→习题)
+```
diff --git a/mvn17 b/mvn17
new file mode 100755
index 0000000..22dda08
--- /dev/null
+++ b/mvn17
@@ -0,0 +1,30 @@
+#!/bin/bash
+# ============================================================
+# 翼云Hub 构建包装器
+# 使用 Homebrew OpenJDK 17 运行 Maven,不影响系统默认 Java 8 环境
+#
+# 常用命令:
+# ./mvn17 compile 编译
+# ./mvn17 clean package 打包
+# ./mvn17 spring-boot:run 启动服务(开发模式)
+# ./mvn17 test 运行测试
+# ./mvn17 run 运行已打包的 jar
+# ============================================================
+
+JAVA17_HOME="/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home"
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+
+export JAVA_HOME="$JAVA17_HOME"
+
+# run 子命令:直接用 Java 17 运行已打包的 jar
+if [[ "$1" == "run" ]]; then
+ JAR=$(find "$SCRIPT_DIR/target" -name "laop-admin-*.jar" ! -name "*.original" 2>/dev/null | head -1)
+ if [[ -z "$JAR" ]]; then
+ echo "未找到 jar,请先执行:./mvn17 clean package" >&2
+ exit 1
+ fi
+ exec "$JAVA17_HOME/bin/java" -jar "$JAR"
+fi
+
+# 其他命令透传给 Maven Wrapper
+exec "$SCRIPT_DIR/mvnw" "$@"
diff --git a/mvnw b/mvnw
new file mode 100755
index 0000000..bd8896b
--- /dev/null
+++ b/mvnw
@@ -0,0 +1,295 @@
+#!/bin/sh
+# ----------------------------------------------------------------------------
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+# ----------------------------------------------------------------------------
+
+# ----------------------------------------------------------------------------
+# Apache Maven Wrapper startup batch script, version 3.3.4
+#
+# Optional ENV vars
+# -----------------
+# JAVA_HOME - location of a JDK home dir, required when download maven via java source
+# MVNW_REPOURL - repo url base for downloading maven distribution
+# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
+# ----------------------------------------------------------------------------
+
+set -euf
+[ "${MVNW_VERBOSE-}" != debug ] || set -x
+
+# OS specific support.
+native_path() { printf %s\\n "$1"; }
+case "$(uname)" in
+CYGWIN* | MINGW*)
+ [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
+ native_path() { cygpath --path --windows "$1"; }
+ ;;
+esac
+
+# set JAVACMD and JAVACCMD
+set_java_home() {
+ # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
+ if [ -n "${JAVA_HOME-}" ]; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ]; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD="$JAVA_HOME/jre/sh/java"
+ JAVACCMD="$JAVA_HOME/jre/sh/javac"
+ else
+ JAVACMD="$JAVA_HOME/bin/java"
+ JAVACCMD="$JAVA_HOME/bin/javac"
+
+ if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
+ echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
+ echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
+ return 1
+ fi
+ fi
+ else
+ JAVACMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v java
+ )" || :
+ JAVACCMD="$(
+ 'set' +e
+ 'unset' -f command 2>/dev/null
+ 'command' -v javac
+ )" || :
+
+ if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
+ echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
+ return 1
+ fi
+ fi
+}
+
+# hash string like Java String::hashCode
+hash_string() {
+ str="${1:-}" h=0
+ while [ -n "$str" ]; do
+ char="${str%"${str#?}"}"
+ h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
+ str="${str#?}"
+ done
+ printf %x\\n $h
+}
+
+verbose() { :; }
+[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
+
+die() {
+ printf %s\\n "$1" >&2
+ exit 1
+}
+
+trim() {
+ # MWRAPPER-139:
+ # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
+ # Needed for removing poorly interpreted newline sequences when running in more
+ # exotic environments such as mingw bash on Windows.
+ printf "%s" "${1}" | tr -d '[:space:]'
+}
+
+scriptDir="$(dirname "$0")"
+scriptName="$(basename "$0")"
+
+# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
+while IFS="=" read -r key value; do
+ case "${key-}" in
+ distributionUrl) distributionUrl=$(trim "${value-}") ;;
+ distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
+ esac
+done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
+[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+
+case "${distributionUrl##*/}" in
+maven-mvnd-*bin.*)
+ MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
+ case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
+ *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
+ :Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
+ :Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
+ :Linux*x86_64*) distributionPlatform=linux-amd64 ;;
+ *)
+ echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
+ distributionPlatform=linux-amd64
+ ;;
+ esac
+ distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
+ ;;
+maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
+*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
+esac
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
+distributionUrlName="${distributionUrl##*/}"
+distributionUrlNameMain="${distributionUrlName%.*}"
+distributionUrlNameMain="${distributionUrlNameMain%-bin}"
+MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
+MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
+
+exec_maven() {
+ unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
+ exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
+}
+
+if [ -d "$MAVEN_HOME" ]; then
+ verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ exec_maven "$@"
+fi
+
+case "${distributionUrl-}" in
+*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
+*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
+esac
+
+# prepare tmp dir
+if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
+ clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
+ trap clean HUP INT TERM EXIT
+else
+ die "cannot create temp dir"
+fi
+
+mkdir -p -- "${MAVEN_HOME%/*}"
+
+# Download and Install Apache Maven
+verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+verbose "Downloading from: $distributionUrl"
+verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+# select .zip or .tar.gz
+if ! command -v unzip >/dev/null; then
+ distributionUrl="${distributionUrl%.zip}.tar.gz"
+ distributionUrlName="${distributionUrl##*/}"
+fi
+
+# verbose opt
+__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
+[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
+
+# normalize http auth
+case "${MVNW_PASSWORD:+has-password}" in
+'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
+esac
+
+if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
+ verbose "Found wget ... using wget"
+ wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
+elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
+ verbose "Found curl ... using curl"
+ curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
+elif set_java_home; then
+ verbose "Falling back to use Java to download"
+ javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
+ targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
+ cat >"$javaSource" <<-END
+ public class Downloader extends java.net.Authenticator
+ {
+ protected java.net.PasswordAuthentication getPasswordAuthentication()
+ {
+ return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
+ }
+ public static void main( String[] args ) throws Exception
+ {
+ setDefault( new Downloader() );
+ java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
+ }
+ }
+ END
+ # For Cygwin/MinGW, switch paths to Windows format before running javac and java
+ verbose " - Compiling Downloader.java ..."
+ "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
+ verbose " - Running Downloader.java ..."
+ "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
+fi
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+if [ -n "${distributionSha256Sum-}" ]; then
+ distributionSha256Result=false
+ if [ "$MVN_CMD" = mvnd.sh ]; then
+ echo "Checksum validation is not supported for maven-mvnd." >&2
+ echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ elif command -v sha256sum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ elif command -v shasum >/dev/null; then
+ if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
+ distributionSha256Result=true
+ fi
+ else
+ echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
+ echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
+ exit 1
+ fi
+ if [ $distributionSha256Result = false ]; then
+ echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
+ echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
+ exit 1
+ fi
+fi
+
+# unzip and move
+if command -v unzip >/dev/null; then
+ unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
+else
+ tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
+fi
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+actualDistributionDir=""
+
+# First try the expected directory name (for regular distributions)
+if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
+ if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$distributionUrlNameMain"
+ fi
+fi
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if [ -z "$actualDistributionDir" ]; then
+ # enable globbing to iterate over items
+ set +f
+ for dir in "$TMP_DOWNLOAD_DIR"/*; do
+ if [ -d "$dir" ]; then
+ if [ -f "$dir/bin/$MVN_CMD" ]; then
+ actualDistributionDir="$(basename "$dir")"
+ break
+ fi
+ fi
+ done
+ set -f
+fi
+
+if [ -z "$actualDistributionDir" ]; then
+ verbose "Contents of $TMP_DOWNLOAD_DIR:"
+ verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
+ die "Could not find Maven distribution directory in extracted archive"
+fi
+
+verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
+mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
+
+clean || :
+exec_maven "$@"
diff --git a/mvnw.cmd b/mvnw.cmd
new file mode 100644
index 0000000..92450f9
--- /dev/null
+++ b/mvnw.cmd
@@ -0,0 +1,189 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.4
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+
+$MAVEN_M2_PATH = "$HOME/.m2"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
+}
+
+if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
+ New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
+}
+
+$MAVEN_WRAPPER_DISTS = $null
+if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
+ $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
+} else {
+ $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
+}
+
+$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+
+# Find the actual extracted directory name (handles snapshots where filename != directory name)
+$actualDistributionDir = ""
+
+# First try the expected directory name (for regular distributions)
+$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
+$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
+if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
+ $actualDistributionDir = $distributionUrlNameMain
+}
+
+# If not found, search for any directory with the Maven executable (for snapshots)
+if (!$actualDistributionDir) {
+ Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
+ $testPath = Join-Path $_.FullName "bin/$MVN_CMD"
+ if (Test-Path -Path $testPath -PathType Leaf) {
+ $actualDistributionDir = $_.Name
+ }
+ }
+}
+
+if (!$actualDistributionDir) {
+ Write-Error "Could not find Maven distribution directory in extracted archive"
+}
+
+Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..e7d4685
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,125 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.12
+
+
+
+ com.haisen
+ laop-admin
+ 1.0.0-SNAPSHOT
+ jar
+ laop-admin
+ 翼云Hub · 无人机信息开放平台
+
+
+ 17
+ 17
+ 17
+ UTF-8
+ UTF-8
+ 42.7.3
+ 3.27.2
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+
+ org.postgresql
+ postgresql
+ ${postgresql.version}
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+ org.redisson
+ redisson-spring-boot-starter
+ ${redisson.version}
+
+
+
+
+ org.flywaydb
+ flyway-core
+
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+
+
+ org.apache.commons
+ commons-lang3
+
+
+
+
+
+ ${project.artifactId}
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
+
+
+
+ dev
+
+ dev
+
+
+ true
+
+
+
+
+ prod
+
+ prod
+
+
+
+
diff --git a/src/main/java/com/haisen/laop/LaopAdminApplication.java b/src/main/java/com/haisen/laop/LaopAdminApplication.java
new file mode 100644
index 0000000..99f6d84
--- /dev/null
+++ b/src/main/java/com/haisen/laop/LaopAdminApplication.java
@@ -0,0 +1,15 @@
+package com.haisen.laop;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * 后端服务启动入口。
+ */
+@SpringBootApplication
+public class LaopAdminApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(LaopAdminApplication.class, args);
+ }
+}
diff --git a/src/main/java/com/haisen/laop/client/wx/AbstractWxApiClient.java b/src/main/java/com/haisen/laop/client/wx/AbstractWxApiClient.java
new file mode 100644
index 0000000..edda51d
--- /dev/null
+++ b/src/main/java/com/haisen/laop/client/wx/AbstractWxApiClient.java
@@ -0,0 +1,204 @@
+package com.haisen.laop.client.wx;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.redisson.api.RBucket;
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Value;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+
+/**
+ * 微信三方 Client 抽象基类:统一承载 token 获取(Redis 缓存)与 HTTP 调用。
+ *
+ * token 缓存策略(cache-aside):access_token 非一次性凭据(有效期 7200 秒、
+ * 每日获取次数有限),必须缓存复用 —— 业务触发时先查 Redis,命中直接返回;
+ * 未命中(或强制刷新)才请求微信并回填 Redis,TTL = 微信返回的有效期(expires_in),
+ * 保证过期后自动失效重新获取。Redis 缓存使多实例部署共享同一 token,
+ * 避免各实例各自刷新导致微信侧 token 互相失效。
+ *
+ * 域名配置化:所有接口 URL = {@code wechat.base-url}(配置注入)+
+ * {@link WxApiPaths} 中的接口路径,Client 内不出现裸域名。
+ *
+ * @author duhao
+ */
+@Slf4j
+public abstract class AbstractWxApiClient {
+
+ /** token 失效类错误码:invalid credential,需强制刷新后重试 */
+ public static final int ERR_INVALID_TOKEN = 40001;
+
+ /** token 过期类错误码:access_token expired,需强制刷新后重试 */
+ public static final int ERR_TOKEN_EXPIRED = 42001;
+
+ /** 微信返回错误码字段 */
+ protected static final String WX_FIELD_ERRCODE = "errcode";
+ /** 微信返回错误信息字段 */
+ protected static final String WX_FIELD_ERRMSG = "errmsg";
+
+ /** 单次微信调用超时(秒) */
+ private static final int HTTP_TIMEOUT_SECONDS = 10;
+
+ /** Redis token 缓存 key 前缀与后缀,中段由子类提供(如 mp / mini)区分两端应用 */
+ private static final String TOKEN_KEY_PREFIX = "laop:wx:";
+ private static final String TOKEN_KEY_SUFFIX = ":access_token";
+
+ /** 微信开放平台 API 域名(配置化,默认官方域名) */
+ @Value("${wechat.base-url:https://api.weixin.qq.com}")
+ protected String baseUrl;
+
+ protected final ObjectMapper objectMapper;
+
+ private final HttpClient httpClient = HttpClient.newBuilder().build();
+ private final RedissonClient redissonClient;
+
+ protected AbstractWxApiClient(ObjectMapper objectMapper, RedissonClient redissonClient) {
+ this.objectMapper = objectMapper;
+ this.redissonClient = redissonClient;
+ }
+
+ /**
+ * Redis token 缓存 key 的业务段(如 {@code mp} / {@code mini}),区分不同微信应用。
+ */
+ protected abstract String tokenCacheSegment();
+
+ /**
+ * 所属微信应用的 appId(由子类从配置注入)。
+ */
+ protected abstract String appId();
+
+ /**
+ * 所属微信应用的 appSecret(由子类从配置注入)。
+ */
+ protected abstract String appSecret();
+
+ /**
+ * 日志标识(如 "微信服务号" / "微信小程序"),统一日志前缀便于排查。
+ */
+ protected abstract String logTag();
+
+ /**
+ * 判断该端微信能力是否已配置(appId/appSecret 齐全)。
+ *
+ * 未配置时调用方应降级处理(跳过发送/提示配置缺失),不阻断主业务。
+ */
+ public boolean isConfigured() {
+ return StringUtils.isNotBlank(appId()) && StringUtils.isNotBlank(appSecret());
+ }
+
+ /**
+ * 获取 access_token(cache-aside:先 Redis 后微信,结果回填 Redis)。
+ *
+ * @param forceRefresh true 强制刷新(token 失效重试场景),跳过缓存直取微信
+ * @return access_token;获取失败返回 null,调用方降级处理
+ */
+ public synchronized String getAccessToken(boolean forceRefresh) {
+ RBucket bucket = redissonClient.getBucket(tokenCacheKey());
+ if (!forceRefresh) {
+ String cached = bucket.get();
+ if (StringUtils.isNotBlank(cached)) {
+ return cached;
+ }
+ }
+ return fetchAndCacheToken(bucket);
+ }
+
+ /**
+ * 请求微信获取新 token 并回填 Redis(TTL = 微信返回的 expires_in 有效期)。
+ */
+ private String fetchAndCacheToken(RBucket bucket) {
+ String url = baseUrl + String.format(WxApiPaths.ACCESS_TOKEN,
+ URLEncoder.encode(appId(), StandardCharsets.UTF_8),
+ URLEncoder.encode(appSecret(), StandardCharsets.UTF_8));
+ try {
+ JsonNode json = execute(getRequest(URI.create(url)));
+ int errcode = json.path(WX_FIELD_ERRCODE).asInt(0);
+ if (errcode != 0) {
+ log.error("[{}] 获取 access_token 失败: code={}, msg={}",
+ logTag(), errcode, json.path(WX_FIELD_ERRMSG).asText());
+ return null;
+ }
+ String token = json.path("access_token").asText("");
+ if (token.isBlank()) {
+ log.error("[{}] 获取 access_token 返回为空", logTag());
+ return null;
+ }
+ // TTL 严格等于微信下发的有效期(秒),过期自动重新获取;
+ // 微信侧 token 过期后仍有短暂宽限期,无需额外预留余量
+ long expiresInSeconds = json.path("expires_in").asLong(7200);
+ bucket.set(token, Duration.ofSeconds(expiresInSeconds));
+ log.info("[{}] access_token 已刷新并写入 Redis, ttl={}s", logTag(), expiresInSeconds);
+ return token;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ log.error("[{}] 获取 access_token 被中断", logTag());
+ return null;
+ } catch (IOException e) {
+ log.error("[{}] 获取 access_token 网络异常", logTag(), e);
+ return null;
+ } catch (Exception e) {
+ log.error("[{}] 获取 access_token 异常", logTag(), e);
+ return null;
+ }
+ }
+
+ /**
+ * token 在 Redis 中的完整缓存 key。
+ */
+ private String tokenCacheKey() {
+ return TOKEN_KEY_PREFIX + tokenCacheSegment() + TOKEN_KEY_SUFFIX;
+ }
+
+ // ==================== 通用 HTTP 执行(子类按接口语义组合使用) ====================
+
+ /**
+ * 构建 GET 请求。
+ */
+ protected HttpRequest getRequest(URI uri) {
+ return HttpRequest.newBuilder()
+ .uri(uri)
+ .GET()
+ .timeout(Duration.ofSeconds(HTTP_TIMEOUT_SECONDS))
+ .build();
+ }
+
+ /**
+ * 构建 POST JSON 请求。
+ */
+ protected HttpRequest postJsonRequest(URI uri, Object body) throws IOException {
+ return HttpRequest.newBuilder()
+ .uri(uri)
+ .header("Content-Type", "application/json")
+ .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
+ .timeout(Duration.ofSeconds(HTTP_TIMEOUT_SECONDS))
+ .build();
+ }
+
+ /**
+ * 执行请求并解析响应为 JSON(微信 JSON 接口通用)。
+ */
+ protected JsonNode execute(HttpRequest request) throws IOException, InterruptedException {
+ HttpResponse response =
+ httpClient.send(request, HttpResponse.BodyHandlers.ofString());
+ return objectMapper.readTree(response.body());
+ }
+
+ /**
+ * 执行请求并返回原始字节数组(微信二进制接口通用,如小程序码图片)。
+ *
+ * 微信二进制接口失败时返回 JSON 报文,调用方需按响应 Content-Type 区分处理。
+ */
+ protected HttpResponse executeForBytes(HttpRequest request)
+ throws IOException, InterruptedException {
+ return httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
+ }
+}
diff --git a/src/main/java/com/haisen/laop/client/wx/WxApiPaths.java b/src/main/java/com/haisen/laop/client/wx/WxApiPaths.java
new file mode 100644
index 0000000..0bfddeb
--- /dev/null
+++ b/src/main/java/com/haisen/laop/client/wx/WxApiPaths.java
@@ -0,0 +1,36 @@
+package com.haisen.laop.client.wx;
+
+/**
+ * 微信开放平台接口路径常量(仅存接口名称/路径,域名统一由 {@code wechat.base-url} 配置注入)。
+ *
+ * 设计规则:与微信三方交互的 URL 收口此处,禁止在业务代码中裸写接口地址;
+ * 路径中的 {@code %s} 为请求参数占位符,由各 Client 按参数顺序格式化填充。
+ *
+ * @author duhao
+ */
+public final class WxApiPaths {
+
+ /** 获取全局接口调用凭据 access_token(服务号/小程序通用,参数:appId、appSecret)。 */
+ public static final String ACCESS_TOKEN =
+ "/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s";
+
+ /** 服务号:获取用户信息(参数:accessToken、openid),返回 subscribe/unionid 等字段。 */
+ public static final String MP_USER_INFO =
+ "/cgi-bin/user/info?access_token=%s&openid=%s&lang=zh_CN";
+
+ /** 服务号:发送模板消息(参数:accessToken)。 */
+ public static final String MP_TEMPLATE_SEND =
+ "/cgi-bin/message/template/send?access_token=%s";
+
+ /** 小程序:code 换登录会话(参数:appId、appSecret、jsCode)。 */
+ public static final String MINI_CODE2SESSION =
+ "/sns/jscode2session?appid=%s&secret=%s&js_code=%s&grant_type=authorization_code";
+
+ /** 小程序:获取不限制数量小程序码(参数:accessToken)。 */
+ public static final String MINI_WXACODE_UNLIMIT =
+ "/wxa/getwxacodeunlimit?access_token=%s";
+
+ private WxApiPaths() {
+ throw new UnsupportedOperationException("Utility class cannot be instantiated");
+ }
+}
diff --git a/src/main/java/com/haisen/laop/client/wx/WxMiniClient.java b/src/main/java/com/haisen/laop/client/wx/WxMiniClient.java
new file mode 100644
index 0000000..9eac9c7
--- /dev/null
+++ b/src/main/java/com/haisen/laop/client/wx/WxMiniClient.java
@@ -0,0 +1,173 @@
+package com.haisen.laop.client.wx;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.haisen.laop.common.BusinessException;
+import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 微信小程序三方 Client:code 换登录会话、小程序码生成。
+ *
+ * 仅封装微信接口协议(HTTP 组装/解析),不含业务规则;
+ * token 的 Redis 缓存逻辑统一在 {@link AbstractWxApiClient}。
+ *
+ * @author duhao
+ */
+@Slf4j
+@Component
+public class WxMiniClient extends AbstractWxApiClient {
+
+ /** 微信返回 openid 字段 */
+ private static final String WX_FIELD_OPENID = "openid";
+ /** 微信返回 unionid 字段 */
+ private static final String WX_FIELD_UNIONID = "unionid";
+
+ /** 小程序码落地页由业务方传入,check_path 固定关闭:小程序未发布版本跳过页面存在性校验 */
+ private static final boolean WXACODE_CHECK_PATH = false;
+
+ @Value("${wechat.mini.app-id}")
+ private String appId;
+
+ @Value("${wechat.mini.app-secret}")
+ private String appSecret;
+
+ public WxMiniClient(ObjectMapper objectMapper, RedissonClient redissonClient) {
+ super(objectMapper, redissonClient);
+ }
+
+ @Override
+ protected String tokenCacheSegment() {
+ return "mini";
+ }
+
+ @Override
+ protected String appId() {
+ return appId;
+ }
+
+ @Override
+ protected String appSecret() {
+ return appSecret;
+ }
+
+ @Override
+ protected String logTag() {
+ return "微信小程序";
+ }
+
+ /**
+ * code2session 会话凭证:openid 为登录主键,unionid 用于跨端(服务号)身份关联,可能为 null。
+ */
+ public record WxSession(String openid, String unionid) {
+ }
+
+ /**
+ * 用微信 code 换登录会话(openid + unionid)。
+ *
+ * 登录是同步强依赖链路,失败直接抛业务异常终止登录流程。
+ *
+ * @param code wx.login() 临时登录凭证
+ * @return 会话凭证
+ * @throws BusinessException 微信返回错误或 openid 为空时抛出
+ */
+ public WxSession jscode2session(String code) {
+ // 防呆:appId/appSecret 仍是脚手架占位值时给出明确配置指引
+ if (appId.contains("your-appid") || appSecret.contains("your-secret")) {
+ throw new BusinessException(500, "后端未配置微信小程序 appId/appSecret,"
+ + "请在 application-dev.yml 中填入或设置环境变量 WECHAT_MINI_APP_ID/SECRET");
+ }
+
+ String url = baseUrl + String.format(WxApiPaths.MINI_CODE2SESSION,
+ URLEncoder.encode(appId, StandardCharsets.UTF_8),
+ URLEncoder.encode(appSecret, StandardCharsets.UTF_8),
+ URLEncoder.encode(code, StandardCharsets.UTF_8));
+ try {
+ JsonNode json = execute(getRequest(URI.create(url)));
+ if (json.has(WX_FIELD_ERRCODE)) {
+ String errMsg = json.path(WX_FIELD_ERRMSG).asText("未知错误");
+ log.error("[微信小程序] code2session 失败: code={}, msg={}",
+ json.path(WX_FIELD_ERRCODE).asText(), errMsg);
+ throw new BusinessException(500, "微信登录失败:" + errMsg);
+ }
+ String openid = json.path(WX_FIELD_OPENID).asText("");
+ if (openid.isBlank()) {
+ throw new BusinessException(500, "微信返回 openid 为空");
+ }
+ // unionid 仅在开放平台绑定后返回,未绑定时为空,不影响登录
+ String unionid = json.path(WX_FIELD_UNIONID).asText("");
+ // TODO 临时调试日志:排查开放平台绑定后 unionid 未返回问题,验证后立即移除
+ log.info("[临时调试] code2session 原始响应: {}", json);
+ return new WxSession(openid, unionid.isBlank() ? null : unionid);
+ } catch (BusinessException e) {
+ throw e;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new BusinessException(500, "微信登录超时,请稍后重试");
+ } catch (IOException e) {
+ log.error("[微信小程序] code2session 调用异常", e);
+ throw new BusinessException(500, "微信登录网络异常");
+ }
+ }
+
+ /**
+ * 生成不限制数量小程序码(scene 携带业务参数,如推广人 ID)。
+ *
+ * 用户可见的同步动作,失败抛业务异常由前端提示。
+ *
+ * @param page 落地页路径(如 pages/home/index)
+ * @param scene scene 参数(如 i=123)
+ * @param envVersion 小程序码打开版本:release / trial / develop;空则微信默认 release
+ * @return 小程序码图片二进制
+ * @throws BusinessException 微信返回错误或图片为空时抛出
+ */
+ public byte[] getWxacodeUnlimit(String page, String scene, String envVersion) {
+ String token = getAccessToken(false);
+ if (token == null) {
+ throw new BusinessException(500, "获取微信接口凭据失败,请稍后重试");
+ }
+
+ Map body = new HashMap<>();
+ body.put("page", page);
+ body.put("scene", scene);
+ body.put("check_path", WXACODE_CHECK_PATH);
+ if (envVersion != null && !envVersion.isBlank()) {
+ body.put("env_version", envVersion);
+ }
+
+ String url = baseUrl + String.format(WxApiPaths.MINI_WXACODE_UNLIMIT, token);
+ try {
+ var response = executeForBytes(postJsonRequest(URI.create(url), body));
+ // 微信失败时返回 JSON(application/json),成功时直接返回图片二进制流
+ String contentType = response.headers().firstValue("Content-Type").orElse("");
+ if (contentType.contains("application/json")) {
+ JsonNode json = objectMapper.readTree(new String(response.body(), StandardCharsets.UTF_8));
+ log.error("[微信小程序] 生成小程序码失败: code={}, msg={}",
+ json.path(WX_FIELD_ERRCODE).asInt(), json.path(WX_FIELD_ERRMSG).asText());
+ throw new BusinessException(500, "生成小程序码失败,请稍后重试");
+ }
+ byte[] imageBytes = response.body();
+ if (imageBytes == null || imageBytes.length == 0) {
+ throw new BusinessException(500, "生成小程序码失败,请稍后重试");
+ }
+ return imageBytes;
+ } catch (BusinessException e) {
+ throw e;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new BusinessException(500, "生成小程序码超时,请稍后重试");
+ } catch (IOException e) {
+ log.error("[微信小程序] 生成小程序码调用异常", e);
+ throw new BusinessException(500, "生成小程序码网络异常,请稍后重试");
+ }
+ }
+}
diff --git a/src/main/java/com/haisen/laop/client/wx/WxMpClient.java b/src/main/java/com/haisen/laop/client/wx/WxMpClient.java
new file mode 100644
index 0000000..296825d
--- /dev/null
+++ b/src/main/java/com/haisen/laop/client/wx/WxMpClient.java
@@ -0,0 +1,165 @@
+package com.haisen.laop.client.wx;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.redisson.api.RedissonClient;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+import java.io.IOException;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * 微信服务号(公众号 MP)三方 Client:token 获取、用户信息查询、模板消息发送。
+ *
+ * 仅封装微信接口协议(HTTP 组装/解析),不含业务规则;
+ * token 的 Redis 缓存逻辑统一在 {@link AbstractWxApiClient}。
+ *
+ * @author duhao
+ */
+@Slf4j
+@Component
+public class WxMpClient extends AbstractWxApiClient {
+
+ /** 模板 data 结构中每个字段的包裹键({"xxx": {"value": "..."}},微信接口协议要求) */
+ private static final String TMPL_FIELD_VALUE_KEY = "value";
+
+ /** 微信返回 msg_id 字段 */
+ private static final String WX_FIELD_MSG_ID = "msg_id";
+
+ @Value("${wechat.mp.app-id}")
+ private String appId;
+
+ @Value("${wechat.mp.app-secret}")
+ private String appSecret;
+
+ public WxMpClient(ObjectMapper objectMapper, RedissonClient redissonClient) {
+ super(objectMapper, redissonClient);
+ }
+
+ @Override
+ protected String tokenCacheSegment() {
+ return "mp";
+ }
+
+ @Override
+ protected String appId() {
+ return appId;
+ }
+
+ @Override
+ protected String appSecret() {
+ return appSecret;
+ }
+
+ @Override
+ protected String logTag() {
+ return "微信服务号";
+ }
+
+ /**
+ * 服务号用户信息快照(/cgi-bin/user/info 返回的本平台关注字段)。
+ *
+ * @param unionid 用户 unionid(服务号已绑定微信开放平台时返回)
+ * @param subscribe 是否关注服务号:1 已关注,0 未关注
+ */
+ public record WxMpUserInfo(String unionid, Integer subscribe) {
+ }
+
+ /**
+ * 查询服务号用户信息(关注状态 + unionid)。
+ *
+ * 查询失败返回 empty,调用方据此降级(如放弃自动绑定/视为已关注避免误打扰)。
+ *
+ * @param openid 服务号用户 openid
+ * @return 用户信息快照
+ */
+ public Optional getUserInfo(String openid) {
+ String token = getAccessToken(false);
+ if (token == null) {
+ return Optional.empty();
+ }
+ String url = baseUrl + String.format(WxApiPaths.MP_USER_INFO, token,
+ URLEncoder.encode(openid, StandardCharsets.UTF_8));
+ try {
+ JsonNode json = execute(getRequest(URI.create(url)));
+ int errcode = json.path(WX_FIELD_ERRCODE).asInt(0);
+ if (errcode != 0) {
+ log.warn("[微信服务号] 查询用户信息失败: openid={}, code={}, msg={}",
+ openid, errcode, json.path(WX_FIELD_ERRMSG).asText());
+ return Optional.empty();
+ }
+ String unionid = json.path("unionid").asText("");
+ return Optional.of(new WxMpUserInfo(
+ unionid.isBlank() ? null : unionid,
+ json.path("subscribe").asInt(0)));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return Optional.empty();
+ } catch (Exception e) {
+ log.error("[微信服务号] 查询用户信息网络异常: openid={}", openid, e);
+ return Optional.empty();
+ }
+ }
+
+ /**
+ * 发送模板消息(提交微信,送达结果由微信异步回调确认)。
+ *
+ * @param accessToken 调用方先取好的 access_token(token 失效重试策略由调用方掌控)
+ * @param openId 接收人服务号 openid
+ * @param templateId 模板 ID(服务号后台申请获得)
+ * @param data 模板参数(字段 key → 值)
+ * @return 提交结果(errcode=0 表示微信受理成功,含 msgId)
+ */
+ public WxTemplateSendResult sendTemplate(String accessToken, String openId,
+ String templateId, Map data) {
+ Map body = new LinkedHashMap<>();
+ body.put("touser", openId);
+ body.put("template_id", templateId);
+ body.put("data", wrapTemplateData(data));
+
+ String url = baseUrl + String.format(WxApiPaths.MP_TEMPLATE_SEND, accessToken);
+ try {
+ JsonNode json = execute(postJsonRequest(URI.create(url), body));
+ int errcode = json.path(WX_FIELD_ERRCODE).asInt(0);
+ return new WxTemplateSendResult(
+ errcode == 0 ? json.path(WX_FIELD_MSG_ID).asLong(0L) : 0L,
+ errcode,
+ json.path(WX_FIELD_ERRMSG).asText("未知错误"));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return new WxTemplateSendResult(0L, -1, "发送被中断");
+ } catch (IOException e) {
+ log.error("[微信服务号] 发送模板消息网络异常: openId={}", openId, e);
+ return new WxTemplateSendResult(0L, -1, "微信接口网络异常:" + e.getMessage());
+ } catch (Exception e) {
+ log.error("[微信服务号] 发送模板消息异常: openId={}", openId, e);
+ return new WxTemplateSendResult(0L, -1, "微信接口调用异常:" + e.getMessage());
+ }
+ }
+
+ /**
+ * 模板消息提交结果。
+ *
+ * @param msgId 微信消息 ID(受理成功时返回,送达回调按此关联)
+ * @param errcode 错误码:0 成功,-1 本地异常(网络/中断)
+ * @param errmsg 错误信息
+ */
+ public record WxTemplateSendResult(long msgId, int errcode, String errmsg) {
+ }
+
+ /**
+ * 模板参数包裹为微信要求的 {"xxx": {"value": "..."}} 结构。
+ */
+ private Map wrapTemplateData(Map data) {
+ Map wrapped = new LinkedHashMap<>();
+ data.forEach((key, value) -> wrapped.put(key, Map.of(TMPL_FIELD_VALUE_KEY, value)));
+ return wrapped;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/BusinessException.java b/src/main/java/com/haisen/laop/common/BusinessException.java
new file mode 100644
index 0000000..9ba003f
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/BusinessException.java
@@ -0,0 +1,43 @@
+package com.haisen.laop.common;
+
+import lombok.Getter;
+
+/**
+ * 业务异常,用于在 Service 层主动抛出以终止流程并返回错误信息。
+ *
+ * 由 {@link GlobalExceptionHandler} 统一捕获并转换为 {@link Result} 响应,
+ * 前端根据 {@code message} 展示用户提示。
+ *
+ * @author duhao
+ */
+@Getter
+public class BusinessException extends RuntimeException {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 业务错误码,默认 500;可自定义以支持前端按码分支处理(如 1001=参数校验失败)。
+ */
+ private final Integer code;
+
+ /**
+ * 构造业务异常(默认错误码 500)。
+ *
+ * @param message 用户可见的错误提示
+ */
+ public BusinessException(String message) {
+ super(message);
+ this.code = 500;
+ }
+
+ /**
+ * 构造指定错误码的业务异常。
+ *
+ * @param code 业务错误码
+ * @param message 用户可见的错误提示
+ */
+ public BusinessException(Integer code, String message) {
+ super(message);
+ this.code = code;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/GlobalExceptionHandler.java b/src/main/java/com/haisen/laop/common/GlobalExceptionHandler.java
new file mode 100644
index 0000000..a185ae1
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/GlobalExceptionHandler.java
@@ -0,0 +1,71 @@
+package com.haisen.laop.common;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.validation.BindException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+
+/**
+ * 全局异常处理器,统一拦截 Controller 层抛出的异常并转换为 {@link Result} 响应。
+ *
+ * 职责:
+ *
+ * - 捕获 {@link BusinessException} — 业务主动抛出的异常,直接返回其错误码和消息
+ * - 捕获参数校验异常 — 返回首个校验失败的错误消息
+ * - 兜底捕获 {@link Exception} — 返回 500 通用错误,同时记录完整堆栈便于排查
+ *
+ *
+ * @author duhao
+ */
+@Slf4j
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ /**
+ * 业务异常,直接透传 code 和 message。
+ */
+ @ExceptionHandler(BusinessException.class)
+ public Result handleBusinessException(BusinessException ex) {
+ log.warn("[业务异常] code={}, msg={}", ex.getCode(), ex.getMessage());
+ return Result.fail(ex.getCode(), ex.getMessage());
+ }
+
+ /**
+ * 参数校验异常(@Valid @Validated),返回首个校验失败的消息。
+ */
+ @ExceptionHandler({MethodArgumentNotValidException.class, BindException.class})
+ public Result handleValidationException(Exception ex) {
+ String message = extractFirstErrorMessage(ex);
+ log.warn("[参数校验失败] {}", message);
+ return Result.fail(1001, message);
+ }
+
+ /**
+ * 兜底异常处理器,捕获所有未被上层处理的异常。
+ */
+ @ExceptionHandler(Exception.class)
+ public Result handleException(Exception ex) {
+ log.error("[系统异常] ", ex);
+ return Result.fail("系统繁忙,请稍后再试");
+ }
+
+ /**
+ * 从校验异常中提取首个错误消息。
+ */
+ private String extractFirstErrorMessage(Exception ex) {
+ if (ex instanceof MethodArgumentNotValidException methodEx) {
+ return methodEx.getBindingResult().getFieldErrors().stream()
+ .findFirst()
+ .map(error -> error.getField() + ": " + error.getDefaultMessage())
+ .orElse("参数校验失败");
+ }
+ if (ex instanceof BindException bindEx) {
+ return bindEx.getBindingResult().getFieldErrors().stream()
+ .findFirst()
+ .map(error -> error.getField() + ": " + error.getDefaultMessage())
+ .orElse("参数校验失败");
+ }
+ return ex.getMessage();
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/RequestContext.java b/src/main/java/com/haisen/laop/common/RequestContext.java
new file mode 100644
index 0000000..848a611
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/RequestContext.java
@@ -0,0 +1,50 @@
+package com.haisen.laop.common;
+
+/**
+ * 当前请求上下文,基于 {@link ThreadLocal} 持有当前登录用户 ID。
+ *
+ * 由鉴权拦截器在请求进入时写入,请求结束时清理;业务代码通过 {@link #getUserId()} 获取,
+ * 避免在 Controller 层层层传递 userId。
+ *
+ * 设计说明:当前阶段 token 为前端直接传入的 userId(简化实现),后续升级为 JWT 时
+ * 拦截器内部替换为 JWT 解析逻辑即可,业务层调用点无需改动。
+ *
+ * @author duhao
+ */
+public final class RequestContext {
+
+ /**
+ * ThreadLocal 存储当前请求的用户 ID。
+ */
+ private static final ThreadLocal USER_ID_HOLDER = new ThreadLocal<>();
+
+ private RequestContext() {
+ throw new UnsupportedOperationException("Utility class cannot be instantiated");
+ }
+
+ /**
+ * 设置当前请求的用户 ID。
+ *
+ * @param userId 用户 ID
+ */
+ public static void setUserId(Long userId) {
+ USER_ID_HOLDER.set(userId);
+ }
+
+ /**
+ * 获取当前请求的用户 ID。
+ *
+ * @return 当前用户 ID,未设置时返回 null
+ */
+ public static Long getUserId() {
+ return USER_ID_HOLDER.get();
+ }
+
+ /**
+ * 清除当前请求的上下文,防止 Thread 复用时污染后续请求。
+ * 必须在请求结束时调用(如拦截器 afterCompletion)。
+ */
+ public static void clear() {
+ USER_ID_HOLDER.remove();
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/Result.java b/src/main/java/com/haisen/laop/common/Result.java
new file mode 100644
index 0000000..a3768c8
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/Result.java
@@ -0,0 +1,85 @@
+package com.haisen.laop.common;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 统一响应封装,前后端契约对齐。
+ *
+ * 约定:{@code code = 200} 表示成功;{@code 401} 表示未授权(token 过期/无效);
+ * 其他非 200 表示业务异常,前端根据 {@code message} 展示提示。
+ *
+ * @param 业务数据类型
+ * @author duhao
+ */
+@Data
+public class Result implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * 响应码,200 成功,401 未授权,其他非 200 为业务错误。
+ */
+ private Integer code;
+
+ /**
+ * 响应消息,失败时展示给用户。
+ */
+ private String message;
+
+ /**
+ * 业务数据,成功时携带;失败时通常为 null。
+ */
+ private T data;
+
+ /**
+ * 成功响应(无数据)。
+ */
+ public static Result success() {
+ return build(200, "success", null);
+ }
+
+ /**
+ * 成功响应(携带数据)。
+ */
+ public static Result success(T data) {
+ return build(200, "success", data);
+ }
+
+ /**
+ * 成功响应(携带数据和自定义消息)。
+ */
+ public static Result success(T data, String message) {
+ return build(200, message, data);
+ }
+
+ /**
+ * 业务失败响应。
+ */
+ public static Result fail(String message) {
+ return build(500, message, null);
+ }
+
+ /**
+ * 指定错误码的失败响应。
+ */
+ public static Result fail(Integer code, String message) {
+ return build(code, message, null);
+ }
+
+ /**
+ * 未授权响应(token 过期或无效)。
+ */
+ public static Result unauthorized(String message) {
+ return build(401, message, null);
+ }
+
+ private static Result build(Integer code, String message, T data) {
+ Result result = new Result<>();
+ result.setCode(code);
+ result.setMessage(message);
+ result.setData(data);
+ return result;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/constants/AnnouncementConstants.java b/src/main/java/com/haisen/laop/common/constants/AnnouncementConstants.java
new file mode 100644
index 0000000..39f76ec
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/constants/AnnouncementConstants.java
@@ -0,0 +1,36 @@
+package com.haisen.laop.common.constants;
+
+/**
+ * 公告领域常量类,承载 {@code announcement} 表相关的分页上限、长度限制等参数。
+ *
+ * 设计规则:通用参数统一放此类;公告显示状态由
+ * {@code com.haisen.laop.common.enums.AnnouncementStatusEnum} 维护,
+ * 禁止在 Service/Controller 中出现裸数字(如 {@code .status(1)})。
+ * 类私有无参构造器,防止被实例化。
+ *
+ * @author duhao
+ */
+public final class AnnouncementConstants {
+
+ /** 列表默认页大小。 */
+ public static final int DEFAULT_PAGE_SIZE = 10;
+
+ /** 列表最大页大小,防止一次拉取全量数据。 */
+ public static final int MAX_PAGE_SIZE = 50;
+
+ /** 公告标题最大长度(与 DDL varchar(100) 对齐)。 */
+ public static final int MAX_TITLE_LENGTH = 100;
+
+ /** 公告正文最大字符数,覆盖长文本的合理上限。 */
+ public static final int MAX_CONTENT_LENGTH = 5000;
+
+ /** 小程序端公告列表默认拉取条数(首页公告条取最新一条)。 */
+ public static final int MINI_LIST_DEFAULT_LIMIT = 5;
+
+ /** 小程序端公告列表单次最大拉取条数,防止拉取全量。 */
+ public static final int MINI_LIST_MAX_LIMIT = 20;
+
+ private AnnouncementConstants() {
+ throw new UnsupportedOperationException("Utility class cannot be instantiated");
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/constants/AppBannerConstants.java b/src/main/java/com/haisen/laop/common/constants/AppBannerConstants.java
new file mode 100644
index 0000000..e17c5cc
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/constants/AppBannerConstants.java
@@ -0,0 +1,36 @@
+package com.haisen.laop.common.constants;
+
+/**
+ * 轮播图领域常量类,承载 {@code app_banner} 表相关的跳转参数、排序、长度限制等。
+ *
+ * 设计规则:通用参数统一放此类;轮播图显示状态由
+ * {@code com.haisen.laop.common.enums.BannerStatusEnum} 维护,
+ * 禁止在 Service/Controller 中出现裸数字(如 {@code .status(1)})。
+ * 类私有无参构造器,防止被实例化。
+ *
+ * @author duhao
+ */
+public final class AppBannerConstants {
+
+ /** 列表默认页大小。 */
+ public static final int DEFAULT_PAGE_SIZE = 10;
+
+ /** 列表最大页大小,防止一次拉取全量数据。 */
+ public static final int MAX_PAGE_SIZE = 50;
+
+ /** 标题最大长度(与 DDL varchar(100) 对齐)。 */
+ public static final int MAX_TITLE_LENGTH = 100;
+
+ /** 副标题最大长度。 */
+ public static final int MAX_SUBTITLE_LENGTH = 200;
+
+ /** 自定义跳转 URL 最大长度。 */
+ public static final int MAX_LINK_URL_LENGTH = 500;
+
+ /** 排序值起始值,新增轮播图默认排在末尾。 */
+ public static final int DEFAULT_SORT_ORDER = 0;
+
+ private AppBannerConstants() {
+ throw new UnsupportedOperationException("Utility class cannot be instantiated");
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/constants/CertConstants.java b/src/main/java/com/haisen/laop/common/constants/CertConstants.java
new file mode 100644
index 0000000..6f8eae6
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/constants/CertConstants.java
@@ -0,0 +1,27 @@
+package com.haisen.laop.common.constants;
+
+/**
+ * 认证领域常量类,承载飞手/商家认证领域的业务错误码与数量上限等参数。
+ *
+ * 设计规则:认证状态统一由 {@code com.haisen.laop.common.enums.CertStatusEnum}
+ * 维护,执照类型统一由 {@code com.haisen.laop.common.enums.LicenseTypeEnum} 维护,
+ * 本类不再定义任何类型/状态编码,禁止在 Service/Controller 中出现裸数字判断
+ * (如 {@code certStatus == 2})。类私有无参构造器,防止被实例化。
+ *
+ * @author duhao
+ */
+public final class CertConstants {
+
+ /**
+ * 业务错误码:未认证飞手申请接单(前端据此弹"完成飞手认证"引导弹窗,而非普通错误提示)。
+ * 与通用 500 区分:40301 属产品引导场景,前端有专属交互。
+ */
+ public static final int CODE_NOT_PILOT_CERTIFIED = 40301;
+
+ /** 单个飞手最多可提交的执照数量上限。 */
+ public static final int MAX_LICENSE_COUNT = 3;
+
+ private CertConstants() {
+ throw new UnsupportedOperationException("Utility class cannot be instantiated");
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/constants/CourseConstants.java b/src/main/java/com/haisen/laop/common/constants/CourseConstants.java
new file mode 100644
index 0000000..2e28e4a
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/constants/CourseConstants.java
@@ -0,0 +1,51 @@
+package com.haisen.laop.common.constants;
+
+import java.time.format.DateTimeFormatter;
+
+/**
+ * 课程领域常量类,承载 {@code course} 表相关的排课规则、分页上限、长度限制等参数。
+ *
+ * 设计规则:通用参数统一放此类;课程上下架状态由 {@code CourseStatusEnum}、
+ * 预约状态由 {@code AppointmentStatusEnum}、排课方式由 {@code ScheduleTypeEnum}、
+ * 精选标记由 {@code YesNoEnum} 维护,禁止在 Service/Controller 中出现裸数字
+ * (如 {@code .setStatus(1)}、{@code scheduleType == 2})。类私有无参构造器,防止被实例化。
+ *
+ * @author duhao
+ */
+public final class CourseConstants {
+
+ /** 每周星期编码下限(1=周一,与 {@link java.time.DayOfWeek#MONDAY} 编码一致)。 */
+ public static final int WEEK_DAY_MIN = 1;
+
+ /** 每周星期编码上限(7=周日,与 {@link java.time.DayOfWeek#SUNDAY} 编码一致)。 */
+ public static final int WEEK_DAY_MAX = 7;
+
+ /** 日期排课最大日期数量,防止恶意/误操作提交超长排课串撑爆字段。 */
+ public static final int MAX_SCHEDULE_DATE_COUNT = 60;
+
+ /** 排课日期格式(与 schedule_dates 分号串内的单个日期格式一致)。 */
+ public static final DateTimeFormatter SCHEDULE_DATE_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE;
+
+ // ==================== 分页参数 ====================
+
+ /** 列表默认页大小。 */
+ public static final int DEFAULT_PAGE_SIZE = 10;
+
+ /** 列表最大页大小,防止一次拉取全量数据。 */
+ public static final int MAX_PAGE_SIZE = 50;
+
+ // ==================== 其他 ====================
+
+ /** 多选字段(星期串/日期串)统一分隔符:英文分号。 */
+ public static final String SEPARATOR = ";";
+
+ /** 课程简介(富文本 HTML)最大字符数,覆盖图文混排的合理上限。 */
+ public static final int MAX_DESCRIPTION_LENGTH = 20000;
+
+ /** 小程序首页精选课程列表默认上限(避免一次拉取过多数据)。 */
+ public static final int FEATURED_LIST_LIMIT = 6;
+
+ private CourseConstants() {
+ throw new UnsupportedOperationException("Utility class cannot be instantiated");
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/constants/FileConstants.java b/src/main/java/com/haisen/laop/common/constants/FileConstants.java
new file mode 100644
index 0000000..eaff003
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/constants/FileConstants.java
@@ -0,0 +1,33 @@
+package com.haisen.laop.common.constants;
+
+import java.util.Set;
+
+/**
+ * 文件领域常量类,承载 {@code sys_file} 相关的大小、后缀等校验参数。
+ *
+ * 设计规则:通用参数统一放此类;文件状态由
+ * {@code com.haisen.laop.common.enums.FileStatusEnum} 维护,
+ * 禁止在 Service 中出现裸数字(如 {@code .status(1)})。
+ * 类私有无参构造器,防止被实例化。
+ *
+ * @author duhao
+ */
+public final class FileConstants {
+
+ /** 单文件大小上限:5MB。 */
+ public static final long MAX_FILE_SIZE = 5L * 1024 * 1024;
+
+ /** 允许上传的图片后缀集合(小写、不含点)。 */
+ public static final Set ALLOWED_IMAGE_SUFFIXES = Set.of(
+ "jpg", "jpeg", "png", "gif", "webp", "bmp"
+ );
+
+ /** 允许上传的文件后缀集合(预留非图片类型时可扩展)。 */
+ public static final Set ALLOWED_FILE_SUFFIXES = Set.of(
+ "jpg", "jpeg", "png", "gif", "webp", "bmp", "pdf", "doc", "docx"
+ );
+
+ private FileConstants() {
+ throw new UnsupportedOperationException("Utility class cannot be instantiated");
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/constants/HomeConstants.java b/src/main/java/com/haisen/laop/common/constants/HomeConstants.java
new file mode 100644
index 0000000..004f516
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/constants/HomeConstants.java
@@ -0,0 +1,24 @@
+package com.haisen.laop.common.constants;
+
+/**
+ * 首页展示领域常量类,承载"实力商家 / 本地飞手"版块的数量限制等参数。
+ *
+ * 类私有无参构造器,防止被实例化。
+ *
+ * @author duhao
+ */
+public final class HomeConstants {
+
+ /** 首页"实力商家"展示数量上限。 */
+ public static final int HOME_MERCHANT_LIMIT = 10;
+
+ /** 首页"本地飞手"展示数量上限。 */
+ public static final int HOME_PILOT_LIMIT = 10;
+
+ /** 详情页任务列表展示数量上限(商家发布的任务 / 飞手参与的任务)。 */
+ public static final int DETAIL_TASK_LIMIT = 20;
+
+ private HomeConstants() {
+ throw new UnsupportedOperationException("Utility class cannot be instantiated");
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/constants/PointsConstants.java b/src/main/java/com/haisen/laop/common/constants/PointsConstants.java
new file mode 100644
index 0000000..26bbed7
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/constants/PointsConstants.java
@@ -0,0 +1,85 @@
+package com.haisen.laop.common.constants;
+
+import java.util.Set;
+
+/**
+ * 积分领域常量类,承载积分业务的错误码、固定积分规则、功能配置键与数值边界。
+ *
+ * 积分值来源约定:
+ *
+ * - 运营可调参数(发布任务奖励、优质任务扣减)放 {@code app_config},配置键与默认值收口在本类;
+ * - 协议/业务固定的积分规则(邀请注册、完成任务奖励)以常量形式保留在本类;
+ * - 事件类型见 {@link com.haisen.laop.common.enums.PointsEventEnum},
+ * 变动方向见 {@code com.haisen.laop.common.enums.PointsChangeTypeEnum},
+ * 禁止在业务代码中出现裸积分数字。
+ *
+ *
+ * 类私有无参构造器,防止被实例化。
+ *
+ * @author duhao
+ */
+public final class PointsConstants {
+
+ /** 业务错误码:积分不足,前端识别此 code 后弹引导弹窗(如发布优质任务引导推广拉新)。 */
+ public static final int ERROR_CODE_POINTS_INSUFFICIENT = 10001;
+
+ /** 积分不足时的默认提示文案(与前端"推广拉新得积分"引导弹窗呼应)。 */
+ public static final String MSG_POINTS_INSUFFICIENT =
+ "积分不足,推广小程序引导好友成功注册可得积分,快去推广吧!";
+
+ // ==================== 固定积分规则(不随运营配置变化) ====================
+
+ /** 邀请好友注册成功奖励积分。 */
+ public static final int INVITE_REGISTER_POINTS = 4;
+
+ /** 飞手完成任务奖励积分。 */
+ public static final int TASK_COMPLETE_POINTS = 1;
+
+ // ==================== 功能配置键(app_config.config_key) ====================
+
+ /**
+ * 功能配置键:发布任务获取积分数量。
+ * 用户每发布一个普通任务奖励的积分,非负整数;{@code 0} 表示不奖励。默认 {@value #DEFAULT_PUBLISH_AWARD_POINTS}。
+ */
+ public static final String CONFIG_KEY_POINTS_PUBLISH_AWARD = "points_publish_award";
+
+ /**
+ * 功能配置键:发布任务扣减积分数量。
+ * 发布优质任务消耗的积分,非负整数;{@code 0} 表示不扣减。默认 {@value #DEFAULT_PREMIUM_DEDUCT_POINTS}。
+ */
+ public static final String CONFIG_KEY_POINTS_PREMIUM_DEDUCT = "points_premium_deduct";
+
+ /** 发布任务获取积分数量的兜底默认值(配置缺失或非法时使用)。 */
+ public static final int DEFAULT_PUBLISH_AWARD_POINTS = 1;
+
+ /** 发布任务扣减积分数量的兜底默认值(配置缺失或非法时使用)。 */
+ public static final int DEFAULT_PREMIUM_DEDUCT_POINTS = 4;
+
+ // ==================== 数值边界 ====================
+
+ /** 积分规则配置项允许的最小值(0 表示不奖励/不扣减)。 */
+ public static final int MIN_CONFIG_POINTS = 0;
+
+ /** 积分规则配置项允许的最大值。 */
+ public static final int MAX_CONFIG_POINTS = 99999;
+
+ /** Web 后台单次手动增减积分允许的最小值(手动调整必须为正整数,方向另传)。 */
+ public static final int MIN_MANUAL_ADJUST_POINTS = 1;
+
+ /** Web 后台单次手动增减积分允许的最大值。 */
+ public static final int MAX_MANUAL_ADJUST_POINTS = 99999;
+
+ /** 手动调整原因最大长度(points_detail.remark 列长 200,人工原因从严限制 100)。 */
+ public static final int MAX_MANUAL_REMARK_LENGTH = 100;
+
+ /**
+ * 取值为"非负整数"的配置键集合,AppConfigService 更新时据此做数值校验。
+ */
+ public static final Set NUMERIC_CONFIG_KEYS = Set.of(
+ CONFIG_KEY_POINTS_PUBLISH_AWARD,
+ CONFIG_KEY_POINTS_PREMIUM_DEDUCT);
+
+ private PointsConstants() {
+ throw new UnsupportedOperationException("Utility class cannot be instantiated");
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/constants/TaskConstants.java b/src/main/java/com/haisen/laop/common/constants/TaskConstants.java
new file mode 100644
index 0000000..69f75ae
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/constants/TaskConstants.java
@@ -0,0 +1,67 @@
+package com.haisen.laop.common.constants;
+
+/**
+ * 任务领域常量类,承载 {@code task} 相关的业务默认值、上限下限等参数。
+ *
+ * 设计规则:通用参数统一放此类;任务状态由 {@code com.haisen.laop.common.enums.TaskStatusEnum}
+ * 维护、申请记录状态由 {@code TaskRecordStatusEnum} 维护、0/1 标记位由 {@code YesNoEnum} 维护,
+ * 禁止在 Service/Controller 中出现裸数字(如 {@code .premiumCost(0)})。
+ * 类私有无参构造器,防止被实例化。
+ *
+ * @author duhao
+ */
+public final class TaskConstants {
+
+ /** 场景图最多上传数量(同时也限制 {@code scene_image_ids} 分号串长度)。 */
+ public static final int MAX_SCENE_IMAGE_COUNT = 5;
+
+ /** 需求人数上下限。 */
+ public static final int REQUIRED_COUNT_MIN = 1;
+ public static final int REQUIRED_COUNT_MAX = 99;
+
+ /** 任务编号前缀。 */
+ public static final String TASK_NO_PREFIX = "LAOP";
+
+ /**
+ * 任务编号尾部(毫秒后4位)的补零长度:确保始终为 4 位(如 0089、5678)。
+ *
+ * 编号整体规则:{@code LAOP + yyyyMMdd + 毫秒后4位},共 13 位,如 {@code LAOP202608285678}。
+ */
+ public static final int TASK_NO_TAIL_LENGTH = 4;
+
+ // ==================== 申请记录(task_record)状态 ====================
+ // 申请记录状态编码统一由枚举 com.haisen.laop.common.enums.TaskRecordStatusEnum
+ // 维护(APPLIED=1 / APPROVED=2 / REJECTED=3 / WITHDRAWN=4),
+ // 业务代码通过 TaskRecordStatusEnum.XXX.getCode() 取编码写入数据库(status 仍为 SMALLINT),
+ // 禁止在此处重新定义 int 常量,避免编码与含义两处维护产生漂移。
+
+ /**
+ * 同意某位飞手后,其余"已申请"记录自动关闭的拒绝原因文案。
+ */
+ public static final String AUTO_REJECT_REASON = "商家已选择其他飞手";
+
+ /**
+ * 飞手撤销申请时写入 cancel_reason 的统一文案(申诉与风控审计依据)。
+ */
+ public static final String WITHDRAW_REASON_PILOT = "飞手主动撤销申请";
+
+ /**
+ * 商家撤回任务时写入申请记录 reject_reason 的统一文案。
+ * 已接单的飞手会收到任务撤回通知,申请记录状态同步关闭。
+ */
+ public static final String RECALL_REASON_MERCHANT = "商家撤回任务";
+
+ /**
+ * 商家确认开始任务时,自动关闭待确认申请的 reject_reason 统一文案。
+ */
+ public static final String AUTO_REJECT_REASON_TASK_STARTED = "任务已开始";
+
+ /**
+ * 单个任务的申请人数上限(防刷防御值,达到后不再接受新申请)。
+ */
+ public static final int MAX_APPLICANT_COUNT = 50;
+
+ private TaskConstants() {
+ throw new UnsupportedOperationException("Utility class cannot be instantiated");
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/constants/UserConstants.java b/src/main/java/com/haisen/laop/common/constants/UserConstants.java
new file mode 100644
index 0000000..6143477
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/constants/UserConstants.java
@@ -0,0 +1,24 @@
+package com.haisen.laop.common.constants;
+
+/**
+ * 用户领域常量类,承载 {@code "user"} 表相关的分页上限等参数。
+ *
+ * 设计规则:通用参数统一放此类;角色类型由 {@code RoleTypeEnum}、
+ * 性别由 {@code GenderEnum}、账号状态由 {@code UserStatusEnum} 维护,
+ * 禁止在 Service/Controller 中出现裸数字(如 {@code roleType == 2})。
+ * 类私有无参构造器,防止被实例化。
+ *
+ * @author duhao
+ */
+public final class UserConstants {
+
+ /** 列表默认页大小。 */
+ public static final int DEFAULT_PAGE_SIZE = 10;
+
+ /** 列表最大页大小,防止一次拉取全量数据。 */
+ public static final int MAX_PAGE_SIZE = 50;
+
+ private UserConstants() {
+ throw new UnsupportedOperationException("Utility class cannot be instantiated");
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/constants/WxMessageConstants.java b/src/main/java/com/haisen/laop/common/constants/WxMessageConstants.java
new file mode 100644
index 0000000..17c282e
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/constants/WxMessageConstants.java
@@ -0,0 +1,88 @@
+package com.haisen.laop.common.constants;
+
+import java.util.Set;
+
+/**
+ * 微信服务号模板消息领域常量类,承载模板字段 key、回调事件名、功能配置键等魔法字符串。
+ *
+ * 模板消息链路:业务事件 → DB 持久化队列 → 消费者调微信
+ * {@code /cgi-bin/message/template/send} 提交 → 微信异步回调
+ * {@code TEMPLATESENDJOBFINISH} 送达结果 → 回写发送日志。
+ *
+ * 设计规则:模板字段 key(如 {@code character_string14})、事件名等魔法字符串
+ * 统一收口此类,禁止在业务代码中裸写;业务类型、发送状态、任务状态等数字编码统一由
+ * {@code WxNotifyBizTypeEnum}、{@code WxSendStatusEnum}、{@code WxNotifyTaskStatusEnum}
+ * 维护。类私有无参构造器,防止被实例化。
+ *
+ * @author duhao
+ */
+public final class WxMessageConstants {
+
+ // ==================== 任务申请通知模板字段 key ====================
+
+ /** 模板字段:任务编号(character_string14)。 */
+ public static final String TMPL_TASK_NO = "character_string14";
+
+ /** 模板字段:任务名称(thing5,微信 thing 类型限 20 字符)。 */
+ public static final String TMPL_TASK_NAME = "thing5";
+
+ /** 模板字段:服务类型(thing12,如 植保、吊运)。 */
+ public static final String TMPL_SERVICE_TYPE = "thing12";
+
+ /** 模板字段:处理人(thing17,飞手认证姓名 + 电话)。 */
+ public static final String TMPL_HANDLER = "thing17";
+
+ /** 模板字段:接收时间(time18,yyyy-MM-dd HH:mm)。 */
+ public static final String TMPL_APPLY_TIME = "time18";
+
+ // ==================== 服务号用户字段 ====================
+
+ /** 服务号用户 subscribe 字段值:1 = 已关注(微信 API 返回值,非平台业务状态)。 */
+ public static final int MP_SUBSCRIBED = 1;
+
+ // ==================== 服务号回调事件 ====================
+
+ /** 事件类型字段名(XML)。 */
+ public static final String XML_FIELD_EVENT = "Event";
+
+ /** 模板消息送达结果事件:TEMPLATESENDJOBFINISH。 */
+ public static final String EVENT_TEMPLATE_SEND_JOB_FINISH = "TEMPLATESENDJOBFINISH";
+
+ /** 关注事件:subscribe(用于自动绑定服务号 openid)。 */
+ public static final String EVENT_SUBSCRIBE = "subscribe";
+
+ /** 取关事件:unsubscribe(用于自动解绑服务号 openid)。 */
+ public static final String EVENT_UNSUBSCRIBE = "unsubscribe";
+
+ /** 送达结果字段名(XML)。 */
+ public static final String XML_FIELD_STATUS = "Status";
+
+ /** 送达成功 Status 值。 */
+ public static final String STATUS_SUCCESS = "success";
+
+ /** 送达失败 Status 前缀(如 failed:user block、failed: system failed)。 */
+ public static final String STATUS_FAILED_PREFIX = "failed";
+
+ /** 用户拒收事件(软性失败,微信可能延迟重试后推送最终结果)。 */
+ public static final String STATUS_USER_BLOCK = "failed:user block";
+
+ /**
+ * XML 事件消息中允许处理的合法事件名集合(防御性校验,避免处理无关事件)。
+ */
+ public static final Set HANDLED_EVENTS = Set.of(
+ EVENT_TEMPLATE_SEND_JOB_FINISH, EVENT_SUBSCRIBE, EVENT_UNSUBSCRIBE);
+
+ // ==================== 功能配置键(app_config.config_key) ====================
+
+ /**
+ * 功能配置键:微信公众号模板消息推送总开关({@code app_config.mp_push_enabled})。
+ * 取值规则:{@code "1"}=开启(消费者正常轮询 wx_notify_task),
+ * {@code "0"}=关闭(消费者跳过 DB 操作,仅按轮询间隔空转监听开关状态)。
+ * 由 Web 端功能配置入口维护,运行时可切换。
+ */
+ public static final String CONFIG_KEY_MP_PUSH_ENABLED = "mp_push_enabled";
+
+ private WxMessageConstants() {
+ throw new UnsupportedOperationException("Utility class cannot be instantiated");
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/AnnouncementStatusEnum.java b/src/main/java/com/haisen/laop/common/enums/AnnouncementStatusEnum.java
new file mode 100644
index 0000000..5530491
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/AnnouncementStatusEnum.java
@@ -0,0 +1,48 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 公告状态枚举,对应 {@code announcement.status} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum AnnouncementStatusEnum {
+
+ /**
+ * 隐藏,小程序端不展示。
+ */
+ HIDDEN(0, "隐藏"),
+
+ /**
+ * 显示,小程序端首页公告条可见。
+ */
+ VISIBLE(1, "显示");
+
+ /** 数字编码,落库到 status 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ AnnouncementStatusEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static AnnouncementStatusEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (AnnouncementStatusEnum status : values()) {
+ if (status.code.equals(code)) {
+ return status;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/AppointmentStatusEnum.java b/src/main/java/com/haisen/laop/common/enums/AppointmentStatusEnum.java
new file mode 100644
index 0000000..d234819
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/AppointmentStatusEnum.java
@@ -0,0 +1,50 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 课程预约状态枚举,对应 {@code course_appointment.status} 字段。
+ *
+ * 二态模型:用户提交预约即生效(无需商家确认),取消为终态。
+ *
+ * @author duhao
+ */
+@Getter
+public enum AppointmentStatusEnum {
+
+ /**
+ * 已预约,用户提交即生效,无需商家确认。
+ */
+ ACTIVE(1, "已预约"),
+
+ /**
+ * 已取消,终态,用户可自助取消。
+ */
+ CANCELLED(2, "已取消");
+
+ /** 数字编码,落库到 course_appointment.status 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ AppointmentStatusEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static AppointmentStatusEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (AppointmentStatusEnum status : values()) {
+ if (status.code.equals(code)) {
+ return status;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/BannerStatusEnum.java b/src/main/java/com/haisen/laop/common/enums/BannerStatusEnum.java
new file mode 100644
index 0000000..102c740
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/BannerStatusEnum.java
@@ -0,0 +1,48 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 轮播图状态枚举,对应 {@code app_banner.status} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum BannerStatusEnum {
+
+ /**
+ * 隐藏,小程序端不展示。
+ */
+ HIDDEN(0, "隐藏"),
+
+ /**
+ * 显示,小程序端首页轮播可见。
+ */
+ VISIBLE(1, "显示");
+
+ /** 数字编码,落库到 status 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ BannerStatusEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static BannerStatusEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (BannerStatusEnum status : values()) {
+ if (status.code.equals(code)) {
+ return status;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/CertStatusEnum.java b/src/main/java/com/haisen/laop/common/enums/CertStatusEnum.java
new file mode 100644
index 0000000..9d980ef
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/CertStatusEnum.java
@@ -0,0 +1,66 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 认证状态枚举,对应 {@code pilot.cert_status} / {@code merchant.cert_status} 字段。
+ *
+ * 认证状态机:未认证(0)→ 认证中(1)→ 已认证(2)/ 认证失败(3);
+ * 已认证可被运营吊销为(4),吊销后如需恢复由管理员改回已认证。
+ *
+ * @author duhao
+ */
+@Getter
+public enum CertStatusEnum {
+
+ /**
+ * 未认证,从未提交过认证资料。
+ */
+ NONE(0, "未认证"),
+
+ /**
+ * 认证中,资料已提交等待审核。
+ */
+ PENDING(1, "认证中"),
+
+ /**
+ * 已认证,审核通过,业务权限放行。
+ */
+ CERTIFIED(2, "已认证"),
+
+ /**
+ * 认证失败,审核驳回,可修改后重新提交。
+ */
+ REJECTED(3, "认证失败"),
+
+ /**
+ * 已吊销,运营后台强制取消资格,认证记录保留不软删。
+ */
+ REVOKED(4, "已吊销");
+
+ /** 数字编码,落库到 cert_status 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ CertStatusEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static CertStatusEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (CertStatusEnum status : values()) {
+ if (status.code.equals(code)) {
+ return status;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/CourseCategoryEnum.java b/src/main/java/com/haisen/laop/common/enums/CourseCategoryEnum.java
new file mode 100644
index 0000000..aad331f
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/CourseCategoryEnum.java
@@ -0,0 +1,50 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 研学课程分类枚举,对应 {@code course.category} 字段。
+ *
+ * 编码与 {@code Course} 实体注释一致,用于小程序课程卡片/详情展示分类标签。
+ *
+ * @author duhao
+ */
+@Getter
+public enum CourseCategoryEnum {
+
+ COGNITION(1, "认知启蒙"),
+ SKILL(2, "技能进阶"),
+ INNOVATION(3, "创新应用"),
+ COMPETITION(4, "竞赛辅导");
+
+ private final Integer code;
+ private final String desc;
+
+ CourseCategoryEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,非法编码返回 null。
+ */
+ public static CourseCategoryEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (CourseCategoryEnum category : values()) {
+ if (category.code.equals(code)) {
+ return category;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 根据编码获取分类中文名,非法/空编码返回 null。
+ */
+ public static String nameOf(Integer code) {
+ CourseCategoryEnum category = of(code);
+ return category == null ? null : category.desc;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/CourseStatusEnum.java b/src/main/java/com/haisen/laop/common/enums/CourseStatusEnum.java
new file mode 100644
index 0000000..6617464
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/CourseStatusEnum.java
@@ -0,0 +1,53 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 课程状态枚举,对应 {@code course.status} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum CourseStatusEnum {
+
+ /**
+ * 下架,管理端可切换,小程序端不可见。
+ */
+ OFF_SHELF(0, "下架"),
+
+ /**
+ * 上架,小程序端可见可预约。
+ */
+ ON_SHELF(1, "上架"),
+
+ /**
+ * 已满员,预约名额已满,仅展示不允许预约。
+ */
+ FULL(2, "已满员");
+
+ /** 数字编码,落库到 course.status 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ CourseStatusEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static CourseStatusEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (CourseStatusEnum status : values()) {
+ if (status.code.equals(code)) {
+ return status;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/ExamBankStatusEnum.java b/src/main/java/com/haisen/laop/common/enums/ExamBankStatusEnum.java
new file mode 100644
index 0000000..bc63f0c
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/ExamBankStatusEnum.java
@@ -0,0 +1,48 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 题库状态枚举,对应 {@code exam_bank.status} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum ExamBankStatusEnum {
+
+ /**
+ * 禁用,不对小程序展示。
+ */
+ DISABLED(0, "禁用"),
+
+ /**
+ * 启用。
+ */
+ ENABLED(1, "启用");
+
+ /** 数字编码,落库到 status 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ ExamBankStatusEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static ExamBankStatusEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (ExamBankStatusEnum status : values()) {
+ if (status.code.equals(code)) {
+ return status;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/ExamDifficultyEnum.java b/src/main/java/com/haisen/laop/common/enums/ExamDifficultyEnum.java
new file mode 100644
index 0000000..9b40e8c
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/ExamDifficultyEnum.java
@@ -0,0 +1,53 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 题目/题库难度枚举,对应 {@code exam_question.difficulty} 与 {@code exam_bank.difficulty} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum ExamDifficultyEnum {
+
+ /**
+ * 简单。
+ */
+ SIMPLE(1, "简单"),
+
+ /**
+ * 中等。
+ */
+ MEDIUM(2, "中等"),
+
+ /**
+ * 困难。
+ */
+ HARD(3, "困难");
+
+ /** 数字编码,落库到 difficulty 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ ExamDifficultyEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static ExamDifficultyEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (ExamDifficultyEnum difficulty : values()) {
+ if (difficulty.code.equals(code)) {
+ return difficulty;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/FileStatusEnum.java b/src/main/java/com/haisen/laop/common/enums/FileStatusEnum.java
new file mode 100644
index 0000000..ed00d37
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/FileStatusEnum.java
@@ -0,0 +1,48 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 文件状态枚举,对应 {@code sys_file.status} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum FileStatusEnum {
+
+ /**
+ * 禁用,违规文件不可访问。
+ */
+ DISABLED(0, "禁用"),
+
+ /**
+ * 正常。
+ */
+ NORMAL(1, "正常");
+
+ /** 数字编码,落库到 status 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ FileStatusEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static FileStatusEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (FileStatusEnum status : values()) {
+ if (status.code.equals(code)) {
+ return status;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/FileTypeEnum.java b/src/main/java/com/haisen/laop/common/enums/FileTypeEnum.java
new file mode 100644
index 0000000..61ce031
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/FileTypeEnum.java
@@ -0,0 +1,114 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 文件类型枚举,对应 {@code sys_file.file_type} 字段。
+ *
+ * 设计说明:前端请求时传字符串标识(如 {@code SCENE_IMAGE}),后端由此枚举
+ * 统一转为数字编码存储,避免两端硬编码数字含义不一致。
+ *
+ * 存储目录规划:每种类型映射到磁盘上的大类子目录({@code storageDir}),
+ * 按"媒体形态 + 业务用途"归组,避免所有文件堆在同一个目录:
+ *
+ * - {@code image/} —— 通用图片(任务场景图、课程图、头像等)
+ * - {@code video/} —— 视频(预留,当前上传白名单仅放行图片)
+ * - {@code license/} —— 执照照片(营业执照、飞手执照)
+ * - {@code store/} —— 门店照片(商家门头照)
+ *
+ *
+ * @author duhao
+ */
+@Getter
+public enum FileTypeEnum {
+
+ /**
+ * 营业执照图片,商家认证上传。
+ */
+ BUSINESS_LICENSE(1, "business_license", "license"),
+
+ /**
+ * 飞手执照图片,飞手认证上传。
+ */
+ PILOT_LICENSE(2, "pilot_license", "license"),
+
+ /**
+ * 任务作业场景图,发布任务时上传。
+ */
+ SCENE_IMAGE(3, "scene_image", "image"),
+
+ /**
+ * 课程封面/详情图片。
+ */
+ COURSE_IMAGE(4, "course_image", "image"),
+
+ /**
+ * 用户头像。
+ */
+ AVATAR(5, "avatar", "image"),
+
+ /**
+ * 商家门头照片,商家认证上传。
+ */
+ DOOR_IMAGE(6, "door_image", "store"),
+
+ /**
+ * 视频(预留类型:目录规划已就绪,上传白名单按需放行视频格式后启用)。
+ */
+ VIDEO(7, "video", "video"),
+
+ /**
+ * 通用文件(未分类兜底)。
+ */
+ OTHER(9, "other", "other"),
+
+ /**
+ * 首页轮播图,运营后台上传。
+ */
+ BANNER_IMAGE(8, "banner_image", "image"),
+
+ /**
+ * 培训门店封面图,培训考试模块上传。
+ */
+ TRAINING_COVER(10, "training_cover", "image");
+
+ /** 数字编码,落库到 sys_file.file_type */
+ private final Integer code;
+
+ /** 字符串标识,前后端约定的类型 key */
+ private final String key;
+
+ /** 磁盘存储子目录名(staticDir/storageDir/uuid.suffix) */
+ private final String storageDir;
+
+ FileTypeEnum(Integer code, String key, String storageDir) {
+ this.code = code;
+ this.key = key;
+ this.storageDir = storageDir;
+ }
+
+ /**
+ * 按字符串标识查找枚举实例。
+ *
+ * @param key 前端传入的字符串标识,如 {@code SCENE_IMAGE}
+ * @return 枚举实例,找不到返回 null
+ */
+ public static FileTypeEnum of(String key) {
+ if (key == null || key.isBlank()) {
+ return null;
+ }
+ for (FileTypeEnum type : values()) {
+ if (type.name().equals(key) || type.key.equalsIgnoreCase(key)) {
+ return type;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 校验字符串标识是否合法。
+ */
+ public static boolean isValid(String key) {
+ return of(key) != null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/GenderEnum.java b/src/main/java/com/haisen/laop/common/enums/GenderEnum.java
new file mode 100644
index 0000000..900ba02
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/GenderEnum.java
@@ -0,0 +1,53 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 用户性别枚举,对应 {@code "user".gender} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum GenderEnum {
+
+ /**
+ * 未知,微信未授权性别时的默认值。
+ */
+ UNKNOWN(0, "未知"),
+
+ /**
+ * 男。
+ */
+ MALE(1, "男"),
+
+ /**
+ * 女。
+ */
+ FEMALE(2, "女");
+
+ /** 数字编码,落库到 gender 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ GenderEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static GenderEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (GenderEnum gender : values()) {
+ if (gender.code.equals(code)) {
+ return gender;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/InsuranceTypeEnum.java b/src/main/java/com/haisen/laop/common/enums/InsuranceTypeEnum.java
new file mode 100644
index 0000000..5e3ec2b
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/InsuranceTypeEnum.java
@@ -0,0 +1,57 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 保险类型枚举,对应 {@code task.insurance_require} 分号拼接串中的数字编码。
+ *
+ * @author duhao
+ */
+@Getter
+public enum InsuranceTypeEnum {
+
+ /**
+ * 第三者责任险,覆盖无人机对第三方造成的人身/财产损害。
+ */
+ THIRD_PARTY(1, "三者险"),
+
+ /**
+ * 飞手责任险,覆盖飞手自身操作失误。
+ */
+ PILOT(2, "飞手险"),
+
+ /**
+ * 机身险,覆盖无人机自身损坏/丢失。
+ */
+ AIRFRAME(3, "机身险");
+
+ private final Integer code;
+ private final String desc;
+
+ InsuranceTypeEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例。
+ */
+ public static InsuranceTypeEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (InsuranceTypeEnum type : values()) {
+ if (type.code.equals(code)) {
+ return type;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 校验编码是否属于合法的保险类型。
+ */
+ public static boolean isValid(Integer code) {
+ return of(code) != null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/LicenseTypeEnum.java b/src/main/java/com/haisen/laop/common/enums/LicenseTypeEnum.java
new file mode 100644
index 0000000..2c34dad
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/LicenseTypeEnum.java
@@ -0,0 +1,65 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 飞行执照类型枚举,对应 {@code pilot_license.license_type} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum LicenseTypeEnum {
+
+ /**
+ * AOPA 合格证(中国航空器拥有者及驾驶员协会)。
+ */
+ AOPA(1, "AOPA"),
+
+ /**
+ * UTC 合格证(无人驾驶航空器系统操作手合格证)。
+ */
+ UTC(2, "UTC"),
+
+ /**
+ * ASFC 执照(航空运动协会遥控航空模型飞行员执照)。
+ */
+ ASFC(3, "ASFC"),
+
+ /**
+ * CAAC 执照(民航局颁发的民用无人机操控员执照)。
+ */
+ CAAC(4, "CAAC");
+
+ /** 数字编码,落库到 license_type 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ LicenseTypeEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static LicenseTypeEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (LicenseTypeEnum type : values()) {
+ if (type.code.equals(code)) {
+ return type;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 校验编码是否属于合法的执照类型。
+ */
+ public static boolean isValid(Integer code) {
+ return of(code) != null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/PointsChangeTypeEnum.java b/src/main/java/com/haisen/laop/common/enums/PointsChangeTypeEnum.java
new file mode 100644
index 0000000..ca05696
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/PointsChangeTypeEnum.java
@@ -0,0 +1,48 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 积分变动方向枚举,对应 {@code points_detail.change_type} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum PointsChangeTypeEnum {
+
+ /**
+ * 收入,积分增加。
+ */
+ GAIN(1, "收入"),
+
+ /**
+ * 支出,积分扣减。
+ */
+ SPEND(2, "支出");
+
+ /** 数字编码,落库到 change_type 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ PointsChangeTypeEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static PointsChangeTypeEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (PointsChangeTypeEnum type : values()) {
+ if (type.code.equals(code)) {
+ return type;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/PointsEventEnum.java b/src/main/java/com/haisen/laop/common/enums/PointsEventEnum.java
new file mode 100644
index 0000000..fb37961
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/PointsEventEnum.java
@@ -0,0 +1,84 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 积分事件枚举,对应 {@code points_detail.event_type},定义事件编码、变动方向与展示名称。
+ *
+ * 设计说明:本枚举只描述"是什么事件",不再承载固定积分变动值——
+ * 发布任务奖励/优质任务扣减已转为 {@code app_config} 运营可配置项,
+ * 手动增减的数值由管理员逐笔填写。各事件的积分数量由调用方
+ * ({@link com.haisen.laop.service.PointsService} 的调用方)显式传入:
+ *
+ * - 固定规则(邀请注册 +4、完成任务 +1)取值 {@code PointsConstants};
+ * - 可配置规则(发布任务奖励、优质任务扣减)运行时读取 {@code app_config}。
+ *
+ *
+ * @author duhao
+ */
+@Getter
+public enum PointsEventEnum {
+
+ /**
+ * 商家发布任务,发放配置项 points_publish_award 设定的奖励积分。
+ */
+ PUBLISH_TASK(1, PointsChangeTypeEnum.GAIN, "发布任务"),
+
+ /**
+ * 飞手完成任务,发放固定奖励积分。
+ */
+ TASK_COMPLETE(2, PointsChangeTypeEnum.GAIN, "完成任务"),
+
+ /**
+ * 邀请好友注册成功,发放固定奖励积分。
+ */
+ INVITE_REGISTER(3, PointsChangeTypeEnum.GAIN, "邀请好友注册"),
+
+ /**
+ * 发布优质任务,扣减配置项 points_premium_deduct 设定的积分。
+ */
+ PREMIUM_TASK(4, PointsChangeTypeEnum.SPEND, "发布优质任务"),
+
+ /**
+ * Web 后台手动新增积分,数量由管理员逐笔填写。
+ */
+ MANUAL_ADD(5, PointsChangeTypeEnum.GAIN, "手动新增"),
+
+ /**
+ * Web 后台手动扣减积分,数量由管理员逐笔填写,余额不足时拒绝扣减。
+ */
+ MANUAL_DEDUCT(6, PointsChangeTypeEnum.SPEND, "手动扣减");
+
+ /** 事件类型编码,存入 points_detail.event_type。 */
+ private final Integer code;
+
+ /** 变动方向,存入 points_detail.change_type(落库取 {@link PointsChangeTypeEnum#getCode()})。 */
+ private final PointsChangeTypeEnum changeType;
+
+ /** 事件名称,积分明细列表直接展示。 */
+ private final String desc;
+
+ PointsEventEnum(Integer code, PointsChangeTypeEnum changeType, String desc) {
+ this.code = code;
+ this.changeType = changeType;
+ this.desc = desc;
+ }
+
+ /**
+ * 按编码查找枚举实例。
+ *
+ * @param code 事件类型编码
+ * @return 枚举实例,找不到返回 null
+ */
+ public static PointsEventEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (PointsEventEnum event : values()) {
+ if (event.code.equals(code)) {
+ return event;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/QuestionTypeEnum.java b/src/main/java/com/haisen/laop/common/enums/QuestionTypeEnum.java
new file mode 100644
index 0000000..bd94c7a
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/QuestionTypeEnum.java
@@ -0,0 +1,60 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 考试题型枚举,对应 {@code exam_question.question_type} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum QuestionTypeEnum {
+
+ /**
+ * 单选题。
+ */
+ SINGLE(1, "单选题"),
+
+ /**
+ * 多选题。
+ */
+ MULTI(2, "多选题"),
+
+ /**
+ * 判断题。
+ */
+ JUDGE(3, "判断题");
+
+ /** 数字编码,落库到 question_type 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ QuestionTypeEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static QuestionTypeEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (QuestionTypeEnum type : values()) {
+ if (type.code.equals(code)) {
+ return type;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 校验编码是否属于合法的题型。
+ */
+ public static boolean isValid(Integer code) {
+ return of(code) != null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/RegionLevelEnum.java b/src/main/java/com/haisen/laop/common/enums/RegionLevelEnum.java
new file mode 100644
index 0000000..93ddb0b
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/RegionLevelEnum.java
@@ -0,0 +1,53 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 行政区划级别枚举,对应 {@code region.level} 字段(省/市/区县三级)。
+ *
+ * @author duhao
+ */
+@Getter
+public enum RegionLevelEnum {
+
+ /**
+ * 省级行政区(省、自治区、直辖市)。
+ */
+ PROVINCE(1, "省"),
+
+ /**
+ * 市级行政区(地级市、自治州等)。
+ */
+ CITY(2, "市"),
+
+ /**
+ * 区县级行政区(市辖区、县级市、县等)。
+ */
+ DISTRICT(3, "区县");
+
+ /** 数字编码,落库到 level 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ RegionLevelEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static RegionLevelEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (RegionLevelEnum level : values()) {
+ if (level.code.equals(code)) {
+ return level;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/RoleTypeEnum.java b/src/main/java/com/haisen/laop/common/enums/RoleTypeEnum.java
new file mode 100644
index 0000000..2165978
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/RoleTypeEnum.java
@@ -0,0 +1,58 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 用户主角色类型枚举,对应 {@code "user".role_type} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum RoleTypeEnum {
+
+ /**
+ * 普通用户。
+ */
+ NORMAL(1, "普通用户"),
+
+ /**
+ * 飞手。
+ */
+ PILOT(2, "飞手"),
+
+ /**
+ * 商家。
+ */
+ MERCHANT(3, "商家"),
+
+ /**
+ * 机构。
+ */
+ ORG(4, "机构");
+
+ /** 数字编码,落库到 role_type 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ RoleTypeEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static RoleTypeEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (RoleTypeEnum role : values()) {
+ if (role.code.equals(code)) {
+ return role;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/ScheduleTypeEnum.java b/src/main/java/com/haisen/laop/common/enums/ScheduleTypeEnum.java
new file mode 100644
index 0000000..e2cf81a
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/ScheduleTypeEnum.java
@@ -0,0 +1,55 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 课程排课方式枚举,对应课程排课 DTO 中的 {@code scheduleType}。
+ *
+ * @author duhao
+ */
+@Getter
+public enum ScheduleTypeEnum {
+
+ /**
+ * 每周重复,每周固定星期几上课,取值存 schedule_week_days。
+ */
+ WEEKLY(1, "每周重复"),
+
+ /**
+ * 日期排课,指定具体日期上课,取值存 schedule_dates。
+ */
+ DATE(2, "日期排课");
+
+ /** 数字编码。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ ScheduleTypeEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static ScheduleTypeEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (ScheduleTypeEnum type : values()) {
+ if (type.code.equals(code)) {
+ return type;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 校验编码是否属于合法的排课方式。
+ */
+ public static boolean isValid(Integer code) {
+ return of(code) != null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/TaskRecordStatusEnum.java b/src/main/java/com/haisen/laop/common/enums/TaskRecordStatusEnum.java
new file mode 100644
index 0000000..b7becd6
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/TaskRecordStatusEnum.java
@@ -0,0 +1,58 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 任务申请记录状态枚举,对应 {@code task_record.status} 字段。
+ *
+ * 申请-审批流状态机:飞手申请(1)→ 商家同意(2)/ 拒绝(3)→ 飞手可主动撤销(4)。
+ * 用于小程序"已接单"列表展示申请进度,与任务状态({@link TaskStatusEnum})相互独立。
+ *
+ * @author duhao
+ */
+@Getter
+public enum TaskRecordStatusEnum {
+
+ /**
+ * 已申请,等待商家确认。
+ */
+ APPLIED(1, "待商家确认"),
+
+ /**
+ * 商家已同意,任务进入已接单。
+ */
+ APPROVED(2, "已接单"),
+
+ /**
+ * 商家已拒绝。
+ */
+ REJECTED(3, "已拒绝"),
+
+ /**
+ * 飞手已撤销申请。
+ */
+ WITHDRAWN(4, "已撤销");
+
+ private final Integer code;
+ private final String desc;
+
+ TaskRecordStatusEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例。
+ */
+ public static TaskRecordStatusEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (TaskRecordStatusEnum status : values()) {
+ if (status.code.equals(code)) {
+ return status;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/TaskStatusEnum.java b/src/main/java/com/haisen/laop/common/enums/TaskStatusEnum.java
new file mode 100644
index 0000000..eaecc88
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/TaskStatusEnum.java
@@ -0,0 +1,65 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 任务状态枚举,对应 {@code task.status} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum TaskStatusEnum {
+
+ /**
+ * 草稿,发布前的暂存状态。
+ */
+ DRAFT(0, "草稿"),
+
+ /**
+ * 待接单,发布成功后默认状态,等待飞手抢单。
+ */
+ PENDING(1, "待接单"),
+
+ /**
+ * 已接单,飞手抢单成功但尚未开始执行。
+ */
+ ACCEPTED(2, "已接单"),
+
+ /**
+ * 执行中,飞手已到场并开始作业。
+ */
+ EXECUTING(3, "执行中"),
+
+ /**
+ * 已完成,作业结束且双方确认无异议。
+ */
+ COMPLETED(4, "已完成"),
+
+ /**
+ * 已取消,商家或系统主动终止任务。
+ */
+ CANCELLED(5, "已取消");
+
+ private final Integer code;
+ private final String desc;
+
+ TaskStatusEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例。
+ */
+ public static TaskStatusEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (TaskStatusEnum status : values()) {
+ if (status.code.equals(code)) {
+ return status;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/TaskTypeEnum.java b/src/main/java/com/haisen/laop/common/enums/TaskTypeEnum.java
new file mode 100644
index 0000000..f11cc9c
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/TaskTypeEnum.java
@@ -0,0 +1,79 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 任务类型枚举,对应 {@code task.type} 字段。
+ *
+ * 编码取值与 DDL 注释一致:1植保 2吊运 3航拍 4巡检 5清洗 6表演 7测绘。
+ *
+ * @author duhao
+ */
+@Getter
+public enum TaskTypeEnum {
+
+ /**
+ * 植保作业,农用无人机喷洒农药/化肥。
+ */
+ PLANT_PROTECTION(1, "植保"),
+
+ /**
+ * 吊运物资,短距离物资投送/吊装。
+ */
+ LIFTING(2, "吊运"),
+
+ /**
+ * 航拍,商业宣传片/活动/婚礼航拍。
+ */
+ AERIAL_PHOTOGRAPHY(3, "航拍"),
+
+ /**
+ * 巡检,电力/铁路/管道/安防等设施巡检。
+ */
+ INSPECTION(4, "巡检"),
+
+ /**
+ * 清洗,高楼/光伏板/风力发电机清洗。
+ */
+ CLEANING(5, "清洗"),
+
+ /**
+ * 表演,商业活动/婚礼/庆典无人机编队表演。
+ */
+ PERFORMANCE(6, "表演"),
+
+ /**
+ * 测绘,地形/建筑/电力线路三维建模。
+ */
+ SURVEYING(7, "测绘");
+
+ private final Integer code;
+ private final String desc;
+
+ TaskTypeEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static TaskTypeEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (TaskTypeEnum type : values()) {
+ if (type.code.equals(code)) {
+ return type;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * 校验编码是否属于合法的任务类型。
+ */
+ public static boolean isValid(Integer code) {
+ return of(code) != null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/TrainingStoreStatusEnum.java b/src/main/java/com/haisen/laop/common/enums/TrainingStoreStatusEnum.java
new file mode 100644
index 0000000..f18f543
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/TrainingStoreStatusEnum.java
@@ -0,0 +1,48 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 培训门店状态枚举,对应 {@code training_store.status} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum TrainingStoreStatusEnum {
+
+ /**
+ * 禁用。
+ */
+ DISABLED(0, "禁用"),
+
+ /**
+ * 启用,小程序培训页展示当前唯一启用门店。
+ */
+ ENABLED(1, "启用");
+
+ /** 数字编码,落库到 status 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ TrainingStoreStatusEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static TrainingStoreStatusEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (TrainingStoreStatusEnum status : values()) {
+ if (status.code.equals(code)) {
+ return status;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/UserStatusEnum.java b/src/main/java/com/haisen/laop/common/enums/UserStatusEnum.java
new file mode 100644
index 0000000..c23dbaf
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/UserStatusEnum.java
@@ -0,0 +1,48 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 用户账号状态枚举,对应 {@code "user".status} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum UserStatusEnum {
+
+ /**
+ * 禁用,禁止登录与业务操作。
+ */
+ DISABLED(0, "禁用"),
+
+ /**
+ * 正常。
+ */
+ NORMAL(1, "正常");
+
+ /** 数字编码,落库到 status 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ UserStatusEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static UserStatusEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (UserStatusEnum status : values()) {
+ if (status.code.equals(code)) {
+ return status;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/WxNotifyBizTypeEnum.java b/src/main/java/com/haisen/laop/common/enums/WxNotifyBizTypeEnum.java
new file mode 100644
index 0000000..c17edd8
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/WxNotifyBizTypeEnum.java
@@ -0,0 +1,58 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 微信模板消息业务类型枚举,对应 {@code wx_message_log.biz_type} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum WxNotifyBizTypeEnum {
+
+ /**
+ * 任务申请通知,飞手申请接单 → 通知商家。
+ */
+ TASK_APPLY(1, "任务申请通知"),
+
+ /**
+ * 商家审核结果通知,商家同意/拒绝 → 通知飞手。
+ */
+ REVIEW_NOTIFY(2, "审核结果通知"),
+
+ /**
+ * 飞手撤销申请通知,飞手撤回接单申请 → 通知商家。
+ */
+ CANCEL_NOTIFY(3, "撤销申请通知"),
+
+ /**
+ * 商家撤回任务通知,商家撤回已发布任务 → 通知已接单飞手。
+ */
+ TASK_RECALL(4, "任务撤回通知");
+
+ /** 数字编码,落库到 biz_type 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ WxNotifyBizTypeEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static WxNotifyBizTypeEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (WxNotifyBizTypeEnum type : values()) {
+ if (type.code.equals(code)) {
+ return type;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/WxNotifyTaskStatusEnum.java b/src/main/java/com/haisen/laop/common/enums/WxNotifyTaskStatusEnum.java
new file mode 100644
index 0000000..8e85906
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/WxNotifyTaskStatusEnum.java
@@ -0,0 +1,58 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 微信通知发送任务状态枚举,对应 {@code wx_notify_task.status} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum WxNotifyTaskStatusEnum {
+
+ /**
+ * 待处理,入队后等待消费者取走。
+ */
+ PENDING(0, "待处理"),
+
+ /**
+ * 处理中,消费者已 CAS 占位,正在发送。
+ */
+ PROCESSING(1, "处理中"),
+
+ /**
+ * 成功,发送成功或跳过,消费完成后软删除。
+ */
+ SUCCESS(2, "成功"),
+
+ /**
+ * 失败,重试次数用尽,消费完成后软删除。
+ */
+ FAILED(3, "失败");
+
+ /** 数字编码,落库到 status 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ WxNotifyTaskStatusEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static WxNotifyTaskStatusEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (WxNotifyTaskStatusEnum status : values()) {
+ if (status.code.equals(code)) {
+ return status;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/WxSendStatusEnum.java b/src/main/java/com/haisen/laop/common/enums/WxSendStatusEnum.java
new file mode 100644
index 0000000..b8a8839
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/WxSendStatusEnum.java
@@ -0,0 +1,58 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 微信模板消息发送状态枚举,对应 {@code wx_message_log.send_status} 字段。
+ *
+ * @author duhao
+ */
+@Getter
+public enum WxSendStatusEnum {
+
+ /**
+ * 已跳过,接收人未关注服务号、模板配置缺失等,未实际调微信。
+ */
+ SKIPPED(0, "已跳过"),
+
+ /**
+ * 已提交,微信受理成功,等待送达回调。
+ */
+ SUBMITTED(1, "已提交"),
+
+ /**
+ * 送达成功,微信回调 success。
+ */
+ DELIVERED(2, "送达成功"),
+
+ /**
+ * 送达失败,提交被拒或回调返回 failed:*。
+ */
+ FAILED(3, "送达失败");
+
+ /** 数字编码,落库到 send_status 列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ WxSendStatusEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static WxSendStatusEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (WxSendStatusEnum status : values()) {
+ if (status.code.equals(code)) {
+ return status;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/common/enums/YesNoEnum.java b/src/main/java/com/haisen/laop/common/enums/YesNoEnum.java
new file mode 100644
index 0000000..7c9db64
--- /dev/null
+++ b/src/main/java/com/haisen/laop/common/enums/YesNoEnum.java
@@ -0,0 +1,52 @@
+package com.haisen.laop.common.enums;
+
+import lombok.Getter;
+
+/**
+ * 通用"是否"标记枚举,对应数据库中 0/1 语义的 SMALLINT 标记列。
+ *
+ * 适用于纯布尔语义的标记字段(如 {@code task.is_premium}、{@code course.featured}、
+ * {@code task_record.insurance_verified}、{@code user.is_pilot_bound} 等)。
+ * 各业务表状态机字段(如任务状态、认证状态)禁止复用本枚举,必须定义领域专属状态枚举。
+ *
+ * @author duhao
+ */
+@Getter
+public enum YesNoEnum {
+
+ /**
+ * 否。
+ */
+ NO(0, "否"),
+
+ /**
+ * 是。
+ */
+ YES(1, "是");
+
+ /** 数字编码,落库到 0/1 标记列。 */
+ private final Integer code;
+
+ /** 展示名称。 */
+ private final String desc;
+
+ YesNoEnum(Integer code, String desc) {
+ this.code = code;
+ this.desc = desc;
+ }
+
+ /**
+ * 根据编码获取枚举实例,找不到返回 null。
+ */
+ public static YesNoEnum of(Integer code) {
+ if (code == null) {
+ return null;
+ }
+ for (YesNoEnum value : values()) {
+ if (value.code.equals(code)) {
+ return value;
+ }
+ }
+ return null;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/config/JacksonConfig.java b/src/main/java/com/haisen/laop/config/JacksonConfig.java
new file mode 100644
index 0000000..6bedba3
--- /dev/null
+++ b/src/main/java/com/haisen/laop/config/JacksonConfig.java
@@ -0,0 +1,84 @@
+package com.haisen.laop.config;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.SerializationFeature;
+import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
+import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
+import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
+import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
+import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
+import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
+import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * Jackson 全局序列化/反序列化配置。
+ *
+ * 为什么需要此配置:Spring Boot 3 默认 Jackson 对 {@link LocalDateTime} 使用
+ * ISO-8601 格式({@code 2026-08-27T10:00:00}),但前端(小程序/H5)通常以可读的
+ * {@code yyyy-MM-dd HH:mm} 格式提交和展示时间。此配置统一覆盖,前后端契约一致。
+ *
+ * 影响范围:所有 Controller 的 {@code @RequestBody LocalDateTime} 反序列化
+ * 和 {@code @ResponseBody LocalDateTime} 序列化均走此格式。DB 层 JPA/Hibernate
+ * 不受影响(JDBC 直接处理 {@link LocalDateTime} 对象)。
+ *
+ * @author duhao
+ */
+@Slf4j
+@Configuration
+public class JacksonConfig {
+
+ /** 日期时间格式:前端提交和后端响应统一走此格式。 */
+ public static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
+
+ /** 日期格式。 */
+ public static final String DATE_PATTERN = "yyyy-MM-dd";
+
+ /** 时间格式。 */
+ public static final String TIME_PATTERN = "HH:mm:ss";
+
+ /** 秒级精度的日期时间(用于发布任务等场景)。 */
+ public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN);
+
+ /** 日期格式化器。 */
+ public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(DATE_PATTERN);
+
+ /** 时间格式化器。 */
+ public static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern(TIME_PATTERN);
+
+ /**
+ * 自定义 {@link ObjectMapper},覆盖 Spring Boot 自动配置。
+ *
+ * 注册 {@link JavaTimeModule} 并替换 Java 8 时间类型的序列化/反序列化格式。
+ */
+ @Bean
+ @Primary
+ public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
+ JavaTimeModule javaTimeModule = new JavaTimeModule();
+
+ // 反序列化:前端 → Java 对象
+ javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer(DATE_TIME_FORMATTER));
+ javaTimeModule.addDeserializer(LocalDate.class, new LocalDateDeserializer(DATE_FORMATTER));
+ javaTimeModule.addDeserializer(LocalTime.class, new LocalTimeDeserializer(TIME_FORMATTER));
+
+ // 序列化:Java 对象 → 前端
+ javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DATE_TIME_FORMATTER));
+ javaTimeModule.addSerializer(LocalDate.class, new LocalDateSerializer(DATE_FORMATTER));
+ javaTimeModule.addSerializer(LocalTime.class, new LocalTimeSerializer(TIME_FORMATTER));
+
+ ObjectMapper mapper = builder.modules(javaTimeModule).build();
+ // 禁止序列化时把 Java 8 时间类型写成数组([2026,8,27,10,0,0])
+ mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ log.info("[JacksonConfig] LocalDateTime 格式统一为 {}", DATE_TIME_PATTERN);
+ return mapper;
+ }
+}
diff --git a/src/main/java/com/haisen/laop/config/UserContextInterceptor.java b/src/main/java/com/haisen/laop/config/UserContextInterceptor.java
new file mode 100644
index 0000000..729c5f1
--- /dev/null
+++ b/src/main/java/com/haisen/laop/config/UserContextInterceptor.java
@@ -0,0 +1,61 @@
+package com.haisen.laop.config;
+
+import com.haisen.laop.common.RequestContext;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+/**
+ * 用户上下文拦截器:从 {@code Authorization} 请求头解析当前用户 ID 并写入 {@link RequestContext},
+ * 供业务代码通过 {@code RequestContext.getUserId()} 获取"当前登录用户"。
+ *
+ * 这不是权限校验:本拦截器不做任何登录态/角色校验,也从不拦截请求(始终放行)。
+ * 项目当前的登录标识约定为"token 即 userId",管理端与小程序端均无权限控制需求;
+ * 后续若升级为 JWT,仅需在本类内部替换解析逻辑,调用点无需改动。
+ *
+ * @author duhao
+ */
+@Slf4j
+@Component
+public class UserContextInterceptor implements HandlerInterceptor {
+
+ /**
+ * Authorization header 名称。
+ */
+ private static final String AUTH_HEADER = "Authorization";
+
+ /**
+ * Bearer token 前缀。
+ */
+ private static final String BEARER_PREFIX = "Bearer ";
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
+ String auth = request.getHeader(AUTH_HEADER);
+ if (StringUtils.isBlank(auth)) {
+ // 未携带登录标识,放行;需要当前用户的接口自行通过 RequestContext.getUserId() 判断
+ return true;
+ }
+
+ try {
+ String token = auth.startsWith(BEARER_PREFIX) ? auth.substring(BEARER_PREFIX.length()).trim() : auth.trim();
+ if (StringUtils.isNotBlank(token)) {
+ RequestContext.setUserId(Long.parseLong(token));
+ }
+ } catch (NumberFormatException ex) {
+ // 登录标识不是合法数字,视为未登录,放行由业务层处理
+ log.warn("[用户上下文] 登录标识解析失败: {}", auth);
+ }
+ return true;
+ }
+
+ @Override
+ public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
+ Object handler, Exception ex) {
+ // 请求结束清理 ThreadLocal,防止线程复用时用户信息串线
+ RequestContext.clear();
+ }
+}
diff --git a/src/main/java/com/haisen/laop/config/WebConfig.java b/src/main/java/com/haisen/laop/config/WebConfig.java
new file mode 100644
index 0000000..592f5e7
--- /dev/null
+++ b/src/main/java/com/haisen/laop/config/WebConfig.java
@@ -0,0 +1,44 @@
+package com.haisen.laop.config;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * Web MVC 统一配置:静态资源映射 + 全局拦截器注册。
+ *
+ * @author duhao
+ */
+@Configuration
+@RequiredArgsConstructor
+public class WebConfig implements WebMvcConfigurer {
+
+ private final UserContextInterceptor userContextInterceptor;
+
+ /** 静态资源根目录绝对路径,末尾不带斜杠(dev/prod 各自配置 laop.static-dir) */
+ @Value("${laop.static-dir}")
+ private String staticDir;
+
+ @Override
+ public void addResourceHandlers(ResourceHandlerRegistry registry) {
+ registry.addResourceHandler("/static/**")
+ .addResourceLocations("file:" + staticDir + "/")
+ .setCachePeriod(0);
+ }
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ // 用户上下文拦截器:解析 Authorization 头中的当前用户 ID 供业务层使用,
+ // 不做任何权限校验,始终放行
+ registry.addInterceptor(userContextInterceptor)
+ .addPathPatterns("/**")
+ .excludePathPatterns(
+ "/static/**",
+ "/error",
+ "/favicon.ico"
+ );
+ }
+}
diff --git a/src/main/java/com/haisen/laop/controller/AnnouncementController.java b/src/main/java/com/haisen/laop/controller/AnnouncementController.java
new file mode 100644
index 0000000..36263ff
--- /dev/null
+++ b/src/main/java/com/haisen/laop/controller/AnnouncementController.java
@@ -0,0 +1,121 @@
+package com.haisen.laop.controller;
+
+import com.haisen.laop.common.Result;
+import com.haisen.laop.dto.AnnouncementQueryDTO;
+import com.haisen.laop.dto.AnnouncementSaveDTO;
+import com.haisen.laop.service.AnnouncementService;
+import com.haisen.laop.vo.AnnouncementVO;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * 公告模块 —— Web 后台管理端 Controller。
+ *
+ * 消费端:Web 管理后台(管理员电脑端操作)。
+ *
路径前缀:{@code /announcement/*},与小程序端公告查询(待扩展)严格隔离。
+ *
职责:承载通知公告的维护操作(分页查询、详情、新增、编辑、显示/隐藏、删除),
+ * 业务逻辑全部委托 {@link AnnouncementService},本层只做参数接收与 Result 包装。
+ *
+ * @author duhao
+ */
+@RestController
+@RequestMapping("/announcement")
+@RequiredArgsConstructor
+public class AnnouncementController {
+
+ private final AnnouncementService announcementService;
+
+ /**
+ * 公告分页列表。
+ *
+ * 返回结构与小程序端分页约定保持一致:{list, totalElements, totalPages, pageNo, pageSize},
+ * 其中 pageNo 为 0 基页码。
+ *
+ * @param query 筛选条件(GET 参数自动绑定,全部可选)
+ * @return 公告 VO 分页数据
+ */
+ @GetMapping("/list")
+ public Result