From b3179fc83647ea0eb13181dc5a9f23cf043f6b31 Mon Sep 17 00:00:00 2001 From: Vicky <10212746+haifeng-jiang@user.noreply.gitee.com> Date: Thu, 15 Oct 2026 11:44:26 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=90=8E=E7=AB=AF=E6=A0=87?= =?UTF-8?q?=E8=AF=86=E7=BB=9F=E4=B8=80=E4=B8=BA=20FruitShorts=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E8=90=BD=E5=9C=B0=2005=20=E7=A6=BB=E7=BA=BF=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么: 1) 产品已定名 FruitShorts,但后端仍是 reelshort 系列标识 —— Java 包 com.reelshort、 模块名与 jar 名 reelshort-*、spring.application.name、配置前缀 reelshort.*, 这些会出现在启动日志、命令行和 IDE 里,与产品名不一致。本次整体替换为 fruitshorts, 数据库连接名上一轮已改名,保持不变。 2) 第一阶段下一批第 1 步(05 离线下载)要求把下载授权(设备绑定/有效期/吊销)、 下载任务与断点续传、配额与过期清理、仅 Wi-Fi 规则做成完整接口,本提交一并落地。 3) 顺带修复:schema-phase1.sql 里 playback_ticket / order_event 仍按普通表定义唯一索引, 表分区化之后重跑脚本必定报错(分区表的唯一索引必须包含分区键)。改为在本脚本里就 按分区表定义,并验证脚本可重复执行。 --- README.md | 181 +++- docs/api-phase1.md | 264 +++++ docs/architecture-design.md | 46 +- docs/backend-design-phase1.md | 292 ++++++ docs/prd-phase1.md | 272 ++++++ .../pom.xml | 28 +- .../fruitshorts/FruitShortsApplication.java | 8 +- .../admin/controller/AdminUserController.java | 18 +- .../controller/AppUserAdminController.java | 118 +++ .../admin/controller/AuthController.java | 22 +- .../admin/controller/BannerController.java | 16 +- .../admin/controller/ConfigController.java | 10 +- .../admin/controller/DictController.java | 10 +- .../controller/DownloadAdminController.java | 56 ++ .../controller/DramaCategoryController.java | 14 +- .../admin/controller/DramaController.java | 22 +- .../admin/controller/EpisodeController.java | 14 +- .../admin/controller/MediaController.java | 16 +- .../admin/controller/MenuController.java | 12 +- .../controller/OperationLogController.java | 12 +- .../controller/PermissionController.java | 8 +- .../controller/RoleCategoryController.java | 12 +- .../admin/controller/RoleController.java | 16 +- .../admin/vo/AppUserWatchHistoryVO.java | 30 + .../src/main/resources/application.yml | 14 +- .../pom.xml | 24 +- .../fruitshorts}/app/AppApiApplication.java | 10 +- .../app/controller/AppAuthController.java | 61 ++ .../app/controller/AppCatalogController.java | 10 +- .../app/controller/AppDownloadController.java | 96 ++ .../app/controller/AppDramaController.java | 43 + .../app/controller/AppFavoriteController.java | 48 + .../app/controller/AppMeController.java | 63 ++ .../app/controller/AppWatchController.java | 57 ++ .../app/service/AppCatalogService.java | 90 +- .../com/fruitshorts}/app/vo/AppBannerVO.java | 2 +- .../fruitshorts}/app/vo/AppCategoryVO.java | 2 +- .../fruitshorts}/app/vo/AppDramaCardVO.java | 2 +- .../fruitshorts}/app/vo/AppDramaDetailVO.java | 2 +- .../com/fruitshorts}/app/vo/AppEpisodeVO.java | 2 +- .../com/fruitshorts}/app/vo/AppFeedVO.java | 2 +- .../com/fruitshorts}/app/vo/AppSectionVO.java | 2 +- .../src/main/resources/application.yml | 20 +- .../pom.xml | 8 +- .../common/exception/ErrorCode.java | 2 +- .../common/exception/ServiceException.java | 2 +- .../fruitshorts}/common/pojo/PageParam.java | 2 +- .../fruitshorts}/common/pojo/PageResult.java | 2 +- .../com/fruitshorts}/common/pojo/Result.java | 4 +- .../pom.xml | 12 +- .../framework/config/MybatisPlusConfig.java | 2 +- .../framework/config/SchedulingConfig.java | 10 + .../framework/log/AccessDeniedRecorder.java | 8 +- .../framework/log/OperationLog.java | 2 +- .../framework/log/OperationLogAspect.java | 8 +- .../framework/log/OperationLogEntry.java | 2 +- .../framework/log/OperationLogSink.java | 2 +- .../partition/PartitionMaintainer.java | 81 ++ .../partition/PartitionProperties.java | 27 + .../security/AppUserStatusChecker.java | 17 + .../security/JwtAuthenticationFilter.java | 21 +- .../framework/security/JwtProperties.java | 30 + .../framework/security/JwtTokenProvider.java | 168 ++++ .../framework/security/LoginUser.java | 2 +- .../framework/security/PermissionChecker.java | 2 +- .../security/PermissionProvider.java | 2 +- .../security/RestAuthEntryPoint.java | 8 +- .../framework/security/SecurityConfig.java | 21 +- .../framework/security/SecurityUtils.java | 59 ++ .../framework/web/GlobalExceptionHandler.java | 10 +- .../framework/web/SlowRequestLogger.java | 2 +- .../fruitshorts}/framework/web/WebUtils.java | 2 +- .../pom.xml | 16 +- .../content/dal/entity/ContentBanner.java | 2 +- .../content/dal/entity/Drama.java | 23 +- .../content/dal/entity/DramaCategory.java | 2 +- .../content/dal/entity/DramaEpisode.java | 12 +- .../content/dal/entity/DramaReviewRecord.java | 2 +- .../dal/mapper/ContentBannerMapper.java | 4 +- .../dal/mapper/DramaCategoryMapper.java | 4 +- .../dal/mapper/DramaEpisodeMapper.java | 4 +- .../content/dal/mapper/DramaMapper.java | 4 +- .../dal/mapper/DramaReviewRecordMapper.java | 4 +- .../content/enums/BannerLinkType.java | 2 +- .../content/enums/BannerPosition.java | 2 +- .../content/enums/ContentStatus.java | 2 +- .../content/service/BannerService.java | 24 +- .../content/service/DramaCategoryService.java | 18 +- .../content/service/DramaService.java | 46 +- .../content/service/EpisodeService.java | 25 +- .../fruitshorts}/content/vo/BannerRespVO.java | 2 +- .../content/vo/BannerSaveReqVO.java | 2 +- .../content/vo/DramaCategoryRespVO.java | 2 +- .../content/vo/DramaCategorySaveReqVO.java | 2 +- .../content/vo/DramaPageReqVO.java | 4 +- .../fruitshorts}/content/vo/DramaRespVO.java | 2 +- .../content/vo/DramaSaveReqVO.java | 2 +- .../content/vo/DramaStatusReqVO.java | 2 +- .../content/vo/EpisodeRespVO.java | 2 +- .../content/vo/EpisodeSaveReqVO.java | 2 +- .../content/vo/EpisodeSortReqVO.java | 2 +- .../pom.xml | 12 +- .../media/config/MediaProperties.java | 38 + .../media/config/MediaWebConfig.java | 2 +- .../media/dal/entity/MediaAsset.java | 7 +- .../media/dal/entity/MediaRendition.java | 43 + .../media/dal/entity/MediaUploadTask.java | 2 +- .../media/dal/entity/TranscodeTask.java | 40 + .../media/dal/mapper/MediaAssetMapper.java | 4 +- .../dal/mapper/MediaRenditionMapper.java | 7 + .../dal/mapper/MediaUploadTaskMapper.java | 4 +- .../media/dal/mapper/TranscodeTaskMapper.java | 7 + .../media/service/MediaService.java | 24 +- .../media/service/MediaStorageService.java | 8 +- .../media/service/PlaybackSigner.java | 77 ++ .../media/service/TranscodeService.java | 122 +++ .../media/vo/MediaAssetRespVO.java | 2 +- .../media/vo/UploadCompleteReqVO.java | 2 +- .../media/vo/UploadInitReqVO.java | 2 +- .../media/vo/UploadInitRespVO.java | 2 +- .../media/web/MediaSignatureFilter.java | 66 ++ fruitshorts-module-playback/pom.xml | 35 + .../playback/config/DownloadProperties.java | 33 + .../playback/dal/entity/DownloadLicense.java | 38 + .../playback/dal/entity/DownloadTask.java | 44 + .../playback/dal/entity/PlaybackTicket.java | 38 + .../playback/dal/entity/UserFavorite.java | 30 + .../playback/dal/entity/WatchHistory.java | 29 + .../playback/dal/entity/WatchProgress.java | 36 + .../dal/mapper/DownloadLicenseMapper.java | 7 + .../dal/mapper/DownloadTaskMapper.java | 7 + .../dal/mapper/PlaybackTicketMapper.java | 7 + .../dal/mapper/UserFavoriteMapper.java | 7 + .../dal/mapper/WatchHistoryMapper.java | 7 + .../dal/mapper/WatchProgressMapper.java | 7 + .../service/DefaultEntitlementPort.java | 17 + .../playback/service/DownloadService.java | 483 ++++++++++ .../playback/service/EntitlementPort.java | 15 + .../playback/service/FavoriteService.java | 102 ++ .../playback/service/PlaybackService.java | 128 +++ .../service/WatchProgressService.java | 181 ++++ .../playback/vo/DownloadGrantReqVO.java | 18 + .../playback/vo/DownloadLicenseRespVO.java | 33 + .../playback/vo/DownloadPolicyRespVO.java | 23 + .../playback/vo/DownloadProgressReqVO.java | 16 + .../playback/vo/DownloadQueryReqVO.java | 15 + .../playback/vo/DownloadSettingReqVO.java | 11 + .../playback/vo/FavoriteToggleReqVO.java | 12 + .../fruitshorts/playback/vo/PlayRespVO.java | 28 + .../playback/vo/ProgressRespVO.java | 18 + .../playback/vo/ProgressSaveReqVO.java | 19 + .../pom.xml | 12 +- .../system/dal/entity/AdminUser.java | 2 +- .../system/dal/entity/AdminUserRole.java | 2 +- .../fruitshorts}/system/dal/entity/Menu.java | 2 +- .../system/dal/entity/OperationLog.java | 2 +- .../system/dal/entity/Permission.java | 2 +- .../fruitshorts}/system/dal/entity/Role.java | 2 +- .../system/dal/entity/RoleCategory.java | 2 +- .../system/dal/entity/RoleMenu.java | 2 +- .../system/dal/entity/RolePermission.java | 2 +- .../system/dal/entity/SysConfig.java | 2 +- .../system/dal/mapper/AdminUserMapper.java | 4 +- .../dal/mapper/AdminUserRoleMapper.java | 4 +- .../system/dal/mapper/MenuMapper.java | 4 +- .../system/dal/mapper/OperationLogMapper.java | 4 +- .../system/dal/mapper/PermissionMapper.java | 4 +- .../system/dal/mapper/RoleCategoryMapper.java | 4 +- .../system/dal/mapper/RoleMapper.java | 4 +- .../system/dal/mapper/RoleMenuMapper.java | 4 +- .../dal/mapper/RolePermissionMapper.java | 4 +- .../system/dal/mapper/SysConfigMapper.java | 4 +- .../system/service/AdminUserService.java | 22 +- .../system/service/AuthService.java | 22 +- .../system/service/ConfigService.java | 8 +- .../system/service/MenuService.java | 16 +- .../system/service/OperationLogService.java | 20 +- .../system/service/PermissionService.java | 12 +- .../system/service/RoleCategoryService.java | 18 +- .../system/service/RoleService.java | 38 +- .../system/vo/AdminUserPageReqVO.java | 4 +- .../system/vo/AdminUserRespVO.java | 2 +- .../system/vo/AdminUserSaveReqVO.java | 2 +- .../fruitshorts}/system/vo/ConfigItemVO.java | 2 +- .../fruitshorts}/system/vo/LoginReqVO.java | 2 +- .../fruitshorts}/system/vo/LoginRespVO.java | 2 +- .../fruitshorts}/system/vo/MenuSaveReqVO.java | 2 +- .../com/fruitshorts}/system/vo/MenuVO.java | 2 +- .../system/vo/OperationLogPageReqVO.java | 4 +- .../system/vo/OperationLogRespVO.java | 2 +- .../system/vo/PasswordChangeReqVO.java | 2 +- .../system/vo/PermissionGroupVO.java | 2 +- .../fruitshorts}/system/vo/ProfileRespVO.java | 2 +- .../system/vo/RoleCategoryRespVO.java | 2 +- .../system/vo/RoleCategorySaveReqVO.java | 2 +- .../system/vo/RolePermissionSaveReqVO.java | 2 +- .../fruitshorts}/system/vo/RoleRespVO.java | 2 +- .../fruitshorts}/system/vo/RoleSaveReqVO.java | 2 +- fruitshorts-module-user/pom.xml | 23 + .../user/dal/entity/AppAccountDeletion.java | 36 + .../fruitshorts/user/dal/entity/AppUser.java | 62 ++ .../user/dal/entity/AppUserBanRecord.java | 44 + .../user/dal/entity/AppUserConsent.java | 33 + .../user/dal/entity/AppUserCredential.java | 37 + .../user/dal/entity/AppUserDevice.java | 34 + .../user/dal/entity/AppUserOtp.java | 37 + .../user/dal/entity/AppUserSession.java | 39 + .../dal/mapper/AppAccountDeletionMapper.java | 7 + .../dal/mapper/AppUserBanRecordMapper.java | 7 + .../user/dal/mapper/AppUserConsentMapper.java | 7 + .../dal/mapper/AppUserCredentialMapper.java | 7 + .../user/dal/mapper/AppUserDeviceMapper.java | 7 + .../user/dal/mapper/AppUserMapper.java | 7 + .../user/dal/mapper/AppUserOtpMapper.java | 7 + .../user/dal/mapper/AppUserSessionMapper.java | 7 + .../fruitshorts/user/enums/AppUserStatus.java | 25 + .../user/enums/CredentialType.java | 13 + .../user/enums/DeletionStatus.java | 12 + .../com/fruitshorts/user/enums/OtpScene.java | 18 + .../user/service/AppAuthService.java | 343 +++++++ .../user/service/AppIdentifier.java | 66 ++ .../user/service/AppOtpService.java | 117 +++ .../user/service/AppUserAdminService.java | 155 +++ .../user/service/AppUserService.java | 147 +++ .../service/AppUserStatusCheckerImpl.java | 29 + .../user/service/CredentialHasher.java | 69 ++ .../user/service/LogOtpSender.java | 27 + .../fruitshorts/user/service/OtpSender.java | 16 + .../user/vo/AppUserAdminRespVO.java | 17 + .../user/vo/AppUserQueryReqVO.java | 16 + .../fruitshorts/user/vo/AppUserRespVO.java | 28 + .../fruitshorts/user/vo/DeactivateReqVO.java | 11 + .../com/fruitshorts/user/vo/LoginReqVO.java | 27 + .../com/fruitshorts/user/vo/LogoutReqVO.java | 11 + .../com/fruitshorts/user/vo/OtpSendReqVO.java | 26 + .../user/vo/ProfileUpdateReqVO.java | 18 + .../com/fruitshorts/user/vo/RefreshReqVO.java | 12 + .../fruitshorts/user/vo/RegisterReqVO.java | 35 + .../com/fruitshorts/user/vo/TokenRespVO.java | 18 + pom.xml | 54 +- .../app/controller/AppDramaController.java | 31 - .../com/reelshort/app/vo/AppPlayTicketVO.java | 20 - .../framework/security/JwtProperties.java | 17 - .../framework/security/JwtTokenProvider.java | 67 -- .../framework/security/SecurityUtils.java | 28 - .../media/config/MediaProperties.java | 23 - sql/data-phase1.sql | 83 ++ sql/data.sql | 4 +- sql/partition-maintenance.sql | 167 ++++ sql/schema-phase1.sql | 905 ++++++++++++++++++ sql/schema.sql | 4 +- 251 files changed, 7261 insertions(+), 767 deletions(-) create mode 100644 docs/api-phase1.md create mode 100644 docs/backend-design-phase1.md create mode 100644 docs/prd-phase1.md rename {reelshort-admin-api => fruitshorts-admin-api}/pom.xml (60%) rename reelshort-admin-api/src/main/java/com/reelshort/ReelShortApplication.java => fruitshorts-admin-api/src/main/java/com/fruitshorts/FruitShortsApplication.java (58%) rename {reelshort-admin-api/src/main/java/com/reelshort => fruitshorts-admin-api/src/main/java/com/fruitshorts}/admin/controller/AdminUserController.java (86%) create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AppUserAdminController.java rename {reelshort-admin-api/src/main/java/com/reelshort => fruitshorts-admin-api/src/main/java/com/fruitshorts}/admin/controller/AuthController.java (77%) rename {reelshort-admin-api/src/main/java/com/reelshort => fruitshorts-admin-api/src/main/java/com/fruitshorts}/admin/controller/BannerController.java (88%) rename {reelshort-admin-api/src/main/java/com/reelshort => fruitshorts-admin-api/src/main/java/com/fruitshorts}/admin/controller/ConfigController.java (82%) rename {reelshort-admin-api/src/main/java/com/reelshort => fruitshorts-admin-api/src/main/java/com/fruitshorts}/admin/controller/DictController.java (81%) create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DownloadAdminController.java rename {reelshort-admin-api/src/main/java/com/reelshort => fruitshorts-admin-api/src/main/java/com/fruitshorts}/admin/controller/DramaCategoryController.java (84%) rename {reelshort-admin-api/src/main/java/com/reelshort => fruitshorts-admin-api/src/main/java/com/fruitshorts}/admin/controller/DramaController.java (88%) rename {reelshort-admin-api/src/main/java/com/reelshort => fruitshorts-admin-api/src/main/java/com/fruitshorts}/admin/controller/EpisodeController.java (87%) rename {reelshort-admin-api/src/main/java/com/reelshort => fruitshorts-admin-api/src/main/java/com/fruitshorts}/admin/controller/MediaController.java (83%) rename {reelshort-admin-api/src/main/java/com/reelshort => fruitshorts-admin-api/src/main/java/com/fruitshorts}/admin/controller/MenuController.java (86%) rename {reelshort-admin-api/src/main/java/com/reelshort => fruitshorts-admin-api/src/main/java/com/fruitshorts}/admin/controller/OperationLogController.java (76%) rename {reelshort-admin-api/src/main/java/com/reelshort => fruitshorts-admin-api/src/main/java/com/fruitshorts}/admin/controller/PermissionController.java (79%) rename {reelshort-admin-api/src/main/java/com/reelshort => fruitshorts-admin-api/src/main/java/com/fruitshorts}/admin/controller/RoleCategoryController.java (86%) rename {reelshort-admin-api/src/main/java/com/reelshort => fruitshorts-admin-api/src/main/java/com/fruitshorts}/admin/controller/RoleController.java (89%) create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AppUserWatchHistoryVO.java rename {reelshort-admin-api => fruitshorts-admin-api}/src/main/resources/application.yml (77%) rename {reelshort-app-api => fruitshorts-app-api}/pom.xml (53%) rename {reelshort-app-api/src/main/java/com/reelshort => fruitshorts-app-api/src/main/java/com/fruitshorts}/app/AppApiApplication.java (51%) create mode 100644 fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java rename {reelshort-app-api/src/main/java/com/reelshort => fruitshorts-app-api/src/main/java/com/fruitshorts}/app/controller/AppCatalogController.java (80%) create mode 100644 fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppDownloadController.java create mode 100644 fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppDramaController.java create mode 100644 fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppFavoriteController.java create mode 100644 fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppMeController.java create mode 100644 fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppWatchController.java rename {reelshort-app-api/src/main/java/com/reelshort => fruitshorts-app-api/src/main/java/com/fruitshorts}/app/service/AppCatalogService.java (75%) rename {reelshort-app-api/src/main/java/com/reelshort => fruitshorts-app-api/src/main/java/com/fruitshorts}/app/vo/AppBannerVO.java (87%) rename {reelshort-app-api/src/main/java/com/reelshort => fruitshorts-app-api/src/main/java/com/fruitshorts}/app/vo/AppCategoryVO.java (83%) rename {reelshort-app-api/src/main/java/com/reelshort => fruitshorts-app-api/src/main/java/com/fruitshorts}/app/vo/AppDramaCardVO.java (94%) rename {reelshort-app-api/src/main/java/com/reelshort => fruitshorts-app-api/src/main/java/com/fruitshorts}/app/vo/AppDramaDetailVO.java (93%) rename {reelshort-app-api/src/main/java/com/reelshort => fruitshorts-app-api/src/main/java/com/fruitshorts}/app/vo/AppEpisodeVO.java (94%) rename {reelshort-app-api/src/main/java/com/reelshort => fruitshorts-app-api/src/main/java/com/fruitshorts}/app/vo/AppFeedVO.java (90%) rename {reelshort-app-api/src/main/java/com/reelshort => fruitshorts-app-api/src/main/java/com/fruitshorts}/app/vo/AppSectionVO.java (91%) rename {reelshort-app-api => fruitshorts-app-api}/src/main/resources/application.yml (60%) rename {reelshort-common => fruitshorts-common}/pom.xml (73%) rename {reelshort-common/src/main/java/com/reelshort => fruitshorts-common/src/main/java/com/fruitshorts}/common/exception/ErrorCode.java (96%) rename {reelshort-common/src/main/java/com/reelshort => fruitshorts-common/src/main/java/com/fruitshorts}/common/exception/ServiceException.java (94%) rename {reelshort-common/src/main/java/com/reelshort => fruitshorts-common/src/main/java/com/fruitshorts}/common/pojo/PageParam.java (94%) rename {reelshort-common/src/main/java/com/reelshort => fruitshorts-common/src/main/java/com/fruitshorts}/common/pojo/PageResult.java (91%) rename {reelshort-common/src/main/java/com/reelshort => fruitshorts-common/src/main/java/com/fruitshorts}/common/pojo/Result.java (91%) rename {reelshort-framework => fruitshorts-framework}/pom.xml (88%) rename {reelshort-framework/src/main/java/com/reelshort => fruitshorts-framework/src/main/java/com/fruitshorts}/framework/config/MybatisPlusConfig.java (93%) create mode 100644 fruitshorts-framework/src/main/java/com/fruitshorts/framework/config/SchedulingConfig.java rename {reelshort-framework/src/main/java/com/reelshort => fruitshorts-framework/src/main/java/com/fruitshorts}/framework/log/AccessDeniedRecorder.java (91%) rename {reelshort-framework/src/main/java/com/reelshort => fruitshorts-framework/src/main/java/com/fruitshorts}/framework/log/OperationLog.java (93%) rename {reelshort-framework/src/main/java/com/reelshort => fruitshorts-framework/src/main/java/com/fruitshorts}/framework/log/OperationLogAspect.java (96%) rename {reelshort-framework/src/main/java/com/reelshort => fruitshorts-framework/src/main/java/com/fruitshorts}/framework/log/OperationLogEntry.java (93%) rename {reelshort-framework/src/main/java/com/reelshort => fruitshorts-framework/src/main/java/com/fruitshorts}/framework/log/OperationLogSink.java (82%) create mode 100644 fruitshorts-framework/src/main/java/com/fruitshorts/framework/partition/PartitionMaintainer.java create mode 100644 fruitshorts-framework/src/main/java/com/fruitshorts/framework/partition/PartitionProperties.java create mode 100644 fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/AppUserStatusChecker.java rename {reelshort-framework/src/main/java/com/reelshort => fruitshorts-framework/src/main/java/com/fruitshorts}/framework/security/JwtAuthenticationFilter.java (68%) create mode 100644 fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/JwtProperties.java create mode 100644 fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/JwtTokenProvider.java rename {reelshort-framework/src/main/java/com/reelshort => fruitshorts-framework/src/main/java/com/fruitshorts}/framework/security/LoginUser.java (88%) rename {reelshort-framework/src/main/java/com/reelshort => fruitshorts-framework/src/main/java/com/fruitshorts}/framework/security/PermissionChecker.java (97%) rename {reelshort-framework/src/main/java/com/reelshort => fruitshorts-framework/src/main/java/com/fruitshorts}/framework/security/PermissionProvider.java (87%) rename {reelshort-framework/src/main/java/com/reelshort => fruitshorts-framework/src/main/java/com/fruitshorts}/framework/security/RestAuthEntryPoint.java (90%) rename {reelshort-framework/src/main/java/com/reelshort => fruitshorts-framework/src/main/java/com/fruitshorts}/framework/security/SecurityConfig.java (81%) create mode 100644 fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityUtils.java rename {reelshort-framework/src/main/java/com/reelshort => fruitshorts-framework/src/main/java/com/fruitshorts}/framework/web/GlobalExceptionHandler.java (93%) rename {reelshort-framework/src/main/java/com/reelshort => fruitshorts-framework/src/main/java/com/fruitshorts}/framework/web/SlowRequestLogger.java (97%) rename {reelshort-framework/src/main/java/com/reelshort => fruitshorts-framework/src/main/java/com/fruitshorts}/framework/web/WebUtils.java (95%) rename {reelshort-module-content => fruitshorts-module-content}/pom.xml (59%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/dal/entity/ContentBanner.java (96%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/dal/entity/Drama.java (61%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/dal/entity/DramaCategory.java (89%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/dal/entity/DramaEpisode.java (70%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/dal/entity/DramaReviewRecord.java (92%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/dal/mapper/ContentBannerMapper.java (56%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/dal/mapper/DramaCategoryMapper.java (56%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/dal/mapper/DramaEpisodeMapper.java (56%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/dal/mapper/DramaMapper.java (55%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/dal/mapper/DramaReviewRecordMapper.java (57%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/enums/BannerLinkType.java (94%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/enums/BannerPosition.java (94%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/enums/ContentStatus.java (94%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/service/BannerService.java (92%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/service/DramaCategoryService.java (90%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/service/DramaService.java (87%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/service/EpisodeService.java (90%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/vo/BannerRespVO.java (94%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/vo/BannerSaveReqVO.java (92%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/vo/DramaCategoryRespVO.java (86%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/vo/DramaCategorySaveReqVO.java (87%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/vo/DramaPageReqVO.java (79%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/vo/DramaRespVO.java (96%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/vo/DramaSaveReqVO.java (93%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/vo/DramaStatusReqVO.java (78%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/vo/EpisodeRespVO.java (93%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/vo/EpisodeSaveReqVO.java (93%) rename {reelshort-module-content/src/main/java/com/reelshort => fruitshorts-module-content/src/main/java/com/fruitshorts}/content/vo/EpisodeSortReqVO.java (86%) rename {reelshort-module-media => fruitshorts-module-media}/pom.xml (65%) create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/config/MediaProperties.java rename {reelshort-module-media/src/main/java/com/reelshort => fruitshorts-module-media/src/main/java/com/fruitshorts}/media/config/MediaWebConfig.java (97%) rename {reelshort-module-media/src/main/java/com/reelshort => fruitshorts-module-media/src/main/java/com/fruitshorts}/media/dal/entity/MediaAsset.java (82%) create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/dal/entity/MediaRendition.java rename {reelshort-module-media/src/main/java/com/reelshort => fruitshorts-module-media/src/main/java/com/fruitshorts}/media/dal/entity/MediaUploadTask.java (95%) create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/dal/entity/TranscodeTask.java rename {reelshort-module-media/src/main/java/com/reelshort => fruitshorts-module-media/src/main/java/com/fruitshorts}/media/dal/mapper/MediaAssetMapper.java (57%) create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/dal/mapper/MediaRenditionMapper.java rename {reelshort-module-media/src/main/java/com/reelshort => fruitshorts-module-media/src/main/java/com/fruitshorts}/media/dal/mapper/MediaUploadTaskMapper.java (58%) create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/dal/mapper/TranscodeTaskMapper.java rename {reelshort-module-media/src/main/java/com/reelshort => fruitshorts-module-media/src/main/java/com/fruitshorts}/media/service/MediaService.java (91%) rename {reelshort-module-media/src/main/java/com/reelshort => fruitshorts-module-media/src/main/java/com/fruitshorts}/media/service/MediaStorageService.java (96%) create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/PlaybackSigner.java create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java rename {reelshort-module-media/src/main/java/com/reelshort => fruitshorts-module-media/src/main/java/com/fruitshorts}/media/vo/MediaAssetRespVO.java (93%) rename {reelshort-module-media/src/main/java/com/reelshort => fruitshorts-module-media/src/main/java/com/fruitshorts}/media/vo/UploadCompleteReqVO.java (88%) rename {reelshort-module-media/src/main/java/com/reelshort => fruitshorts-module-media/src/main/java/com/fruitshorts}/media/vo/UploadInitReqVO.java (93%) rename {reelshort-module-media/src/main/java/com/reelshort => fruitshorts-module-media/src/main/java/com/fruitshorts}/media/vo/UploadInitRespVO.java (87%) create mode 100644 fruitshorts-module-media/src/main/java/com/fruitshorts/media/web/MediaSignatureFilter.java create mode 100644 fruitshorts-module-playback/pom.xml create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/config/DownloadProperties.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/DownloadLicense.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/DownloadTask.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/PlaybackTicket.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/UserFavorite.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/WatchHistory.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/WatchProgress.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/DownloadLicenseMapper.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/DownloadTaskMapper.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/PlaybackTicketMapper.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/UserFavoriteMapper.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/WatchHistoryMapper.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/WatchProgressMapper.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/DefaultEntitlementPort.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/DownloadService.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/EntitlementPort.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/PlaybackService.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/WatchProgressService.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadGrantReqVO.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadLicenseRespVO.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadPolicyRespVO.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadProgressReqVO.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadQueryReqVO.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadSettingReqVO.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/FavoriteToggleReqVO.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/PlayRespVO.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressRespVO.java create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressSaveReqVO.java rename {reelshort-module-system => fruitshorts-module-system}/pom.xml (65%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/entity/AdminUser.java (96%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/entity/AdminUserRole.java (93%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/entity/Menu.java (96%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/entity/OperationLog.java (94%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/entity/Permission.java (90%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/entity/Role.java (95%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/entity/RoleCategory.java (95%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/entity/RoleMenu.java (92%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/entity/RolePermission.java (93%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/entity/SysConfig.java (89%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/mapper/AdminUserMapper.java (56%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/mapper/AdminUserRoleMapper.java (57%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/mapper/MenuMapper.java (56%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/mapper/OperationLogMapper.java (57%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/mapper/PermissionMapper.java (56%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/mapper/RoleCategoryMapper.java (57%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/mapper/RoleMapper.java (56%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/mapper/RoleMenuMapper.java (56%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/mapper/RolePermissionMapper.java (57%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/dal/mapper/SysConfigMapper.java (56%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/service/AdminUserService.java (93%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/service/AuthService.java (84%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/service/ConfigService.java (89%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/service/MenuService.java (93%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/service/OperationLogService.java (88%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/service/PermissionService.java (86%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/service/RoleCategoryService.java (87%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/service/RoleService.java (90%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/AdminUserPageReqVO.java (74%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/AdminUserRespVO.java (94%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/AdminUserSaveReqVO.java (93%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/ConfigItemVO.java (84%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/LoginReqVO.java (87%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/LoginRespVO.java (92%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/MenuSaveReqVO.java (93%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/MenuVO.java (96%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/OperationLogPageReqVO.java (89%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/OperationLogRespVO.java (95%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/PasswordChangeReqVO.java (91%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/PermissionGroupVO.java (90%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/ProfileRespVO.java (93%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/RoleCategoryRespVO.java (83%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/RoleCategorySaveReqVO.java (86%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/RolePermissionSaveReqVO.java (86%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/RoleRespVO.java (92%) rename {reelshort-module-system/src/main/java/com/reelshort => fruitshorts-module-system/src/main/java/com/fruitshorts}/system/vo/RoleSaveReqVO.java (93%) create mode 100644 fruitshorts-module-user/pom.xml create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppAccountDeletion.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUser.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserBanRecord.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserConsent.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserCredential.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserDevice.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserOtp.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserSession.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppAccountDeletionMapper.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserBanRecordMapper.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserConsentMapper.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserCredentialMapper.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserDeviceMapper.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserMapper.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserOtpMapper.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserSessionMapper.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/enums/AppUserStatus.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/enums/CredentialType.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/enums/DeletionStatus.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/enums/OtpScene.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppIdentifier.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppOtpService.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppUserAdminService.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppUserService.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppUserStatusCheckerImpl.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/CredentialHasher.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/LogOtpSender.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/OtpSender.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/AppUserAdminRespVO.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/AppUserQueryReqVO.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/AppUserRespVO.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/DeactivateReqVO.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/LoginReqVO.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/LogoutReqVO.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/OtpSendReqVO.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/ProfileUpdateReqVO.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/RefreshReqVO.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/RegisterReqVO.java create mode 100644 fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/TokenRespVO.java delete mode 100644 reelshort-app-api/src/main/java/com/reelshort/app/controller/AppDramaController.java delete mode 100644 reelshort-app-api/src/main/java/com/reelshort/app/vo/AppPlayTicketVO.java delete mode 100644 reelshort-framework/src/main/java/com/reelshort/framework/security/JwtProperties.java delete mode 100644 reelshort-framework/src/main/java/com/reelshort/framework/security/JwtTokenProvider.java delete mode 100644 reelshort-framework/src/main/java/com/reelshort/framework/security/SecurityUtils.java delete mode 100644 reelshort-module-media/src/main/java/com/reelshort/media/config/MediaProperties.java create mode 100644 sql/data-phase1.sql create mode 100644 sql/partition-maintenance.sql create mode 100644 sql/schema-phase1.sql diff --git a/README.md b/README.md index aca883d..82f6801 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,14 @@ -# ReelShort 短剧服务(后端) +# FruitShorts 短剧服务(后端) -短剧服务的服务端,Spring Boot + PostgreSQL,目前实现管理后台接口(`/admin/**`)。 -完整架构设计与迭代计划见 [docs/architecture-design.md](docs/architecture-design.md)。 +短剧服务的服务端,Spring Boot + PostgreSQL,对外两个入口: +管理后台 `/admin/**`(8080)与客户端 `/app/**`(8081)。 + +| 文档 | 内容 | +| --- | --- | +| [docs/prd-phase1.md](docs/prd-phase1.md) | 第一阶段 PRD:范围、验收口径、里程碑 | +| [docs/backend-design-phase1.md](docs/backend-design-phase1.md) | 后端设计:1 万并发观看的容量推导、成本、部署 | +| [docs/api-phase1.md](docs/api-phase1.md) | **接口清单:后台管理 + 客户端,标注已实现 / 下一批** | +| [docs/architecture-design.md](docs/architecture-design.md) | 早期架构设计文档 | | 项 | 版本 | | --- | --- | @@ -13,14 +20,18 @@ ## 目录结构 ``` -java/ -├── reelshort-common/ 统一响应、分页、错误码、业务异常 -├── reelshort-framework/ JWT 鉴权、权限校验、全局异常、MyBatis-Plus、CORS -├── reelshort-module-system/ 管理员、角色、权限、菜单、角色分类、配置、操作日志 -├── reelshort-module-content/ 短剧、剧集、题材、审核发布 -├── reelshort-admin-api/ 启动模块与 /admin/** 控制器 -├── sql/ schema.sql 建表、data.sql 初始化数据 -└── docs/ 架构设计文档 +FruitServer/ +├── fruitshorts-common/ 统一响应、分页、错误码、业务异常 +├── fruitshorts-framework/ JWT 鉴权(管理端/客户端双密钥)、权限校验、全局异常、分区维护、MyBatis-Plus、CORS +├── fruitshorts-module-system/ 管理员、角色、权限、菜单、角色分类、配置、操作日志 +├── fruitshorts-module-content/ 短剧、剧集、题材、轮播、审核发布 +├── fruitshorts-module-media/ 分片上传、媒资、播放地址签名、转码任务与多码率产物 +├── fruitshorts-module-user/ C 端账号:验证码、注册登录、会话、资料、注销、风控 +├── fruitshorts-module-playback/ 播放票据、观看进度与流水、收藏、下载授权 +├── fruitshorts-admin-api/ 启动模块与 /admin/** 控制器(8080) +├── fruitshorts-app-api/ 启动模块与 /app/** 控制器(8081) +├── sql/ 建表、初始化数据、分区维护(见下) +└── docs/ PRD、后端设计、接口清单 ``` ## 开发环境 @@ -56,28 +67,40 @@ mvn -v # Maven 3.10.0 / Java 21.0.12.1 | 服务 | 模块 | 端口 | 面向 | | --- | --- | --- | --- | -| 管理端 | `reelshort-admin-api` | 8080 | Vue 后台 | -| 客户端 | `reelshort-app-api` | 8081 | Flutter 客户端 | +| 管理端 | `fruitshorts-admin-api` | 8080 | Vue 后台 | +| 客户端 | `fruitshorts-app-api` | 8081 | Flutter 客户端 | ### 1. 初始化数据库 ```sh export PATH="/opt/homebrew/opt/postgresql@18/bin:$PATH" cd /Users/jianghaifeng/Desktop/ReelShort/FruitServer -psql -h 127.0.0.1 -U reelshort -d reelshort -f sql/schema.sql -psql -h 127.0.0.1 -U reelshort -d reelshort -f sql/data.sql +psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/schema.sql +psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/data.sql +psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/schema-phase1.sql +psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/partition-maintenance.sql +psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -f sql/data-phase1.sql ``` -数据库名 `reelshort`,角色 `reelshort`(密码 `reelshort`),两个脚本都可重复执行。 +| 脚本 | 作用 | +| --- | --- | +| `schema.sql` / `data.sql` | 系统域与内容域的基础表与初始化数据 | +| `schema-phase1.sql` | 第一阶段增量表:用户、播放记录、互动、交易、创作者、转码、运营 | +| `partition-maintenance.sql` | 分区函数 + 把高写入表转成分区 + 预建未来 3 个月分区 | +| `data-phase1.sql` | 新增权限点、超管授权、占位商品 | + +四个脚本都幂等可重复执行,**顺序不要颠倒**:分区转换要求目标表是空的。 + +数据库名 `fruitshorts`,角色 `fruitshorts`(密码 `fruitshorts`)。 首次搭建环境时先建角色与库: ```sh -psql -d postgres -c "CREATE ROLE reelshort LOGIN PASSWORD 'reelshort';" -psql -d postgres -c "CREATE DATABASE reelshort OWNER reelshort ENCODING 'UTF8';" +psql -d postgres -c "CREATE ROLE fruitshorts LOGIN PASSWORD 'fruitshorts';" +psql -d postgres -c "CREATE DATABASE fruitshorts OWNER fruitshorts ENCODING 'UTF8';" ``` 连接信息在 -`reelshort-admin-api/src/main/resources/application.yml`,可用环境变量 +`fruitshorts-admin-api/src/main/resources/application.yml`,可用环境变量 `DB_USER` / `DB_PASSWORD` / `JWT_ADMIN_SECRET` 覆盖。 ### 关于 PostgreSQL 的两点说明 @@ -89,6 +112,41 @@ psql -d postgres -c "CREATE DATABASE reelshort OWNER reelshort ENCODING 'UTF8';" - 轮播图的展示位置列名叫 `slot`(`position` 在 PostgreSQL 里是关键字), 实体用 `@TableField("slot")` 映射,对外接口字段仍是 `position`,前端无需改动。 +### 大表分区与保留策略 + +**为什么要一开始就分区**:`watch_history`(观看流水)、`playback_ticket`(播放票据)、 +`order_event`(订单流水)都是「每次行为写一行」的表。按 1 万并发观看估算,观看流水约 +300 万行/天、播放票据约 360 万行/天,一年就是十亿级。这类表后补分区的代价很高 +(要在维护窗口里整表重建),所以第一阶段就按 `watched_at` / `created_at` 做按月声明式分区。 + +**光建表没用**:声明式分区不会自动长分区,只建 default 分区的话数据会全堆进 default, +分区等于白做。所以有两层保障: + +1. `sql/partition-maintenance.sql` 里的 `ensure_monthly_partitions(table, from, months)` + 负责按月建分区,执行脚本时会建好当前月 + 未来 3 个月; +2. `fruitshorts-framework` 的 `PartitionMaintainer` 在**启动时**和**每天 03:17** 各调一次, + 跨月时即使服务停机一周也不会漏建。 + +验证分区是否真的生效(应落在 `watch_history_2026xx` 而不是 `_default`): + +```sh +psql -h 127.0.0.1 -U fruitshorts -d fruitshorts -c \ + "select tableoid::regclass as 分区, count(*) from watch_history group by 1;" +``` + +**保留策略默认只建不删**。删数据不可逆,第一阶段先解决"写不爆",等保留期与合规确认后 +再把 `fruitshorts.partition.retention-enabled` 打开;各表保留月数写在 `PartitionMaintainer` +的 `TABLES` 常量里(观看流水 24 个月、播放票据 6 个月、订单流水 36 个月)。手工清理: + +```sql +SELECT drop_partitions_before('watch_history', CURRENT_DATE - INTERVAL '24 months'); +``` + +分区表有两条 PostgreSQL 限制要注意:唯一索引必须包含分区键,所以 `playback_ticket` +的唯一索引是 `(ticket_no, created_at)`、主键是 `(id, created_at)`。 +`coin_ledger` 故意**不分区** —— 它靠 `idempotent_key` 全局唯一保证不重复发币, +这个唯一性比分区更重要。 + ### 2. 构建 ```sh @@ -100,12 +158,46 @@ mvn -DskipTests package ```sh cd /Users/jianghaifeng/Desktop/ReelShort/FruitServer -java -jar reelshort-admin-api/target/reelshort-admin-api-1.0.0-SNAPSHOT.jar -java -jar reelshort-app-api/target/reelshort-app-api-1.0.0-SNAPSHOT.jar +java -jar fruitshorts-admin-api/target/fruitshorts-admin-api-1.0.0-SNAPSHOT.jar +java -jar fruitshorts-app-api/target/fruitshorts-app-api-1.0.0-SNAPSHOT.jar ``` 启动后:服务地址 `http://localhost:8080`,接口文档 `http://localhost:8080/doc.html`。 +## 关键配置 + +| 配置项 | 默认值 | 说明 | +| --- | --- | --- | +| `fruitshorts.jwt.admin-secret` | 开发默认值 | 管理端签名密钥,**生产必须用 `JWT_ADMIN_SECRET` 注入** | +| `fruitshorts.jwt.app-secret` | 开发默认值 | 客户端签名密钥,与管理端物理隔离,两边 token 不能互换 | +| `fruitshorts.jwt.app-access-ttl-minutes` | 120 | 客户端 access token 有效期 | +| `fruitshorts.jwt.app-refresh-ttl-days` | 30 | refresh token 有效期,每次刷新轮换 | +| `fruitshorts.media.signature-secret` | 开发默认值 | 播放地址签名密钥,生产必须注入 | +| `fruitshorts.media.signature-ttl-minutes` | 15 | 播放地址有效期 | +| `fruitshorts.media.signature-required` | `false` | **生产必须打开**;打开后 `/files/episode/**` 无有效签名返回 403 | +| `fruitshorts.otp.sender` | `log` | 验证码通道,开发打日志;接入短信/邮件时实现 `OtpSender` 并改这里 | +| `fruitshorts.partition.enabled` | `true` | 是否自动维护分区 | +| `fruitshorts.partition.months-ahead` | 3 | 预建未来几个月分区 | +| `fruitshorts.partition.retention-enabled` | `false` | 是否自动清理过期分区(默认只建不删) | + +开发阶段签名校验默认关闭,是为了不打断正在联调的客户端。**上线前必须打开**, +否则片源地址可以被任意复用与盗链。 + +## 第一阶段实现进度 + +| 模块 | 状态 | 说明 | +| --- | --- | --- | +| 01 基础与交付准备 | 已实现 | 多模块骨架、接口契约、双密钥鉴权、分区维护 | +| 02 用户账号与登录 | 已实现 | 手机号/邮箱注册登录、验证码反刷、令牌轮换、资料、注销、封禁即时生效 | +| 03 内容发现与详情 | 已实现 | 首页/发现/分类/详情;补内容分级、可播区域、版权窗口、标签字段 | +| 04 播放器与播放控制 | 部分 | 播放票据 + 签名地址 + 进度已实现;多码率片源待转码 worker | +| 05 记录、收藏与下载 | 部分 | 进度、观看流水、收藏已实现;离线下载授权待做 | +| 06 评论、点赞与分享 | 待做 | 表已建,接口与实现待补 | +| 07 创作者中心 | 待做 | 表已建;转码任务与多码率产物记账已实现 | +| 08 平台运营后台 | 部分 | App 用户管理已实现;内容治理、订单、看板待做 | +| 09 分集收费与支付 | 待做 | 表已建;播放入口的权益判定端口已预留,接上即生效 | +| 10 质量、数据与上线 | 待做 | 压测、埋点、对账待做 | + ## 默认账号 密码均为 `admin123`。 @@ -168,9 +260,9 @@ java -jar reelshort-app-api/target/reelshort-app-api-1.0.0-SNAPSHOT.jar | 项 | 默认值 | 说明 | | --- | --- | --- | -| 存储目录 | `./data/uploads` | 配置项 `reelshort.media.root-dir`,按 `业务类型/年月/uuid.ext` 分目录 | -| 访问前缀 | `/files/**` | 配置项 `reelshort.media.public-prefix`,映射为静态资源 | -| 分片大小 | 5MB | `reelshort.media.chunk-size`,单文件上限 2GB | +| 存储目录 | `./data/uploads` | 配置项 `fruitshorts.media.root-dir`,按 `业务类型/年月/uuid.ext` 分目录 | +| 访问前缀 | `/files/**` | 配置项 `fruitshorts.media.public-prefix`,映射为静态资源 | +| 分片大小 | 5MB | `fruitshorts.media.chunk-size`,单文件上限 2GB | | 请求上限 | 20MB | `spring.servlet.multipart`,只需容纳一个分片 | 要点: @@ -188,27 +280,45 @@ java -jar reelshort-app-api/target/reelshort-app-api-1.0.0-SNAPSHOT.jar - 鉴权头 `Authorization: Bearer `;未登录 `code=401`,无权限 `code=403`。 - 错误码见架构文档附录 A;业务错误如 `100000` 账号密码错误、`200003` 状态不允许该操作。 -## 权限模型 - ## 客户端接口 `/app/**` 面向 Flutter 客户端,只暴露已上线内容,不含审核状态、操作日志等管理端字段。 +**完整接口清单(含请求/响应示例与错误码)见 [docs/api-phase1.md](docs/api-phase1.md)。** | 方法 | 路径 | 说明 | | --- | --- | --- | +| POST | `/app/auth/otp` | 发送验证码(REGISTER/LOGIN/RESET_PWD/BIND) | +| POST | `/app/auth/register` | 手机号/邮箱注册,返回令牌 | +| POST | `/app/auth/login` | 验证码或密码登录 | +| POST | `/app/auth/refresh` | 刷新令牌(refresh token 每次轮换) | +| POST | `/app/auth/logout` | 登出,`allDevices=true` 吊销全部会话 | +| GET/PUT | `/app/me` | 我的资料 | +| GET | `/app/me/devices` | 登录设备 | +| GET | `/app/me/export` | 数据导出(隐私合规) | +| POST | `/app/me/deactivate` `/cancel` | 申请/撤销注销(15 天冷静期) | | GET | `/app/home/feed` | 首页聚合:轮播(HOME_TOP)+ 分区(热门 / 新剧 / 各分类) | | GET | `/app/discover/feed` | 发现页聚合:轮播(DISCOVER)+ 分类分区 | | GET | `/app/categories` | 分类列表 | | GET | `/app/dramas/{id}` | 详情,含剧集列表,一次请求即可渲染详情页 | -| GET | `/app/episodes/{id}/play` | 播放票据 | +| GET | `/app/episodes/{id}/play` | 播放票据:服务端判定权益后签发**短时效签名地址** | +| POST | `/app/watch/progress` | 上报观看进度(建议每 10s 一次) | +| GET | `/app/watch/progress?dramaId=` | 某剧续看位置 | +| GET | `/app/watch/recent` | 最近观看 | +| DELETE | `/app/watch/progress[/{dramaId}]` | 删除 / 清空记录 | +| POST | `/app/favorites/toggle` | 收藏切换 | +| GET | `/app/favorites` | 收藏列表(卡片结构与首页一致) | 约定: - 只返回 `status = published` 的短剧与剧集,未上线按不存在处理。 -- 图片与视频返回相对路径(`/files/...`),客户端拼上服务地址即可。 -- 播放票据三种结果:`FREE`(返回 `playUrl`)、`COIN_REQUIRED`(返回 `unlockCoin`)、 - `NO_MEDIA`(已上线但还没传视频)。 -- `/app/**` 目前在安全配置里放行(浏览无需登录);手机号/邮箱登录与权益体系接入后再收紧。 +- 图片与视频返回相对路径(`/files/...`),客户端拼上服务地址即可; + 播放地址会带 `?e=&s=` 签名参数,客户端**原样使用、不要自己拼接**。 +- 播放票据四种结果:`FREE`、`ENTITLED`(已购权益)、`COIN_REQUIRED`(返回 `unlockCoin`)、 + `NO_MEDIA`(已上线但还没传视频或未转码完成)。 +- 鉴权边界:只有浏览类接口(`/app/auth/**`、`/app/home|discover|categories|dramas`、 + `/app/episodes/*/play`)允许匿名,其余 `/app/**` 一律需要客户端 token; + 管理端 token 打到客户端接口会被拒(两边密钥不同)。 +- 需要登录的接口建议带 `X-Device-Id`,用于下载授权、并发播放限制与风控。 性能(本机实测,预热后 30 次采样,目标 50ms): @@ -285,8 +395,13 @@ public Result create(@Valid @RequestBody DramaCategorySaveReqVO req) { ... ## 尚未实现 -视频转码(HLS 多码率)、C 端接口(`/app/**`)、金币与会员、单集解锁、支付与对账、 -数据看板统计、Redis 缓存与限流。详见架构设计文档第 11 章。 +评论点赞分享、订单与钱包权益(IAP / Google Play Billing 票据校验、对账、退款回收)、 +创作者中心、内容治理与经营看板、转码 worker(消费 `transcode_task`,产出多码率 HLS)、 +Redis 缓存与限流、离线下载授权、埋点与数据看板。 + +这些模块的表结构、接口契约、状态机与权限点都已经冻结,见 +[docs/api-phase1.md](docs/api-phase1.md) 里标注「下一批」的部分 —— 接下来是照契约填代码, +不会再有结构性改动。 ## 常见问题 diff --git a/docs/api-phase1.md b/docs/api-phase1.md new file mode 100644 index 0000000..c9aa578 --- /dev/null +++ b/docs/api-phase1.md @@ -0,0 +1,264 @@ +# 第一阶段接口清单(后台管理 + 客户端) + +> 版本 v1.0 · 配套 [PRD](./prd-phase1.md) 与 [后端设计](./backend-design-phase1.md) +> 状态说明:**已实现** = 代码已落地可联调;**下一批** = 表结构与契约已冻结,代码待补。 + +## 0. 通用约定 + +**服务入口**:后台管理 `fruitshorts-admin-api` 只暴露 `/admin/**`(8080); +客户端 `fruitshorts-app-api` 只暴露 `/app/**` 与媒资 `/files/**`(8081)。 + +**统一响应体** + +```json +{ "code": 0, "message": "ok", "data": {} } +``` + +`code = 0` 成功;非 0 时 `message` 可直接展示给用户。分页统一为 `{ total, list }`。 + +**鉴权**:`Authorization: Bearer `。 +管理端与客户端用**两套独立密钥**签发,管理端 token 打到客户端接口会直接被拒。 +客户端 `access token` 默认 2 小时,`refresh token` 30 天且每次刷新轮换。 + +**设备标识**:客户端建议所有请求都带 `X-Device-Id`,用于下载授权、并发播放限制与风控。 + +**主要错误码** + +| code | 含义 | 客户端处理 | +| --- | --- | --- | +| 0 | 成功 | — | +| 400 | 参数校验失败 | 提示 message | +| 401 | 未登录或登录过期 | 用 refresh token 换新 token,失败则跳登录 | +| 403 | 无权限 | 提示无权限 | +| 429 | 请求过于频繁 | 提示稍后再试(验证码频控走这里) | +| 100000 | 账号或密码错误 | 提示 | +| 100001 | 账号已停用 | 展示封禁申诉入口 | +| 100002 | 金币余额不足 | 拉起充值 | +| 100003 | 剧集未发布或不存在 | 返回上一页 | +| 100005 | 订单已支付或已关闭 | 刷新订单状态 | +| 200002 | 数据不存在 | 提示 | +| 200003 | 当前状态不允许该操作 | 提示 | + +## 1. 客户端接口(/app/**) + +### 1.1 账号(已实现) + +| 方法 | 路径 | 鉴权 | 说明 | +| --- | --- | --- | --- | +| POST | `/app/auth/otp` | 匿名 | 发送验证码。`scene` = REGISTER/LOGIN/RESET_PWD/BIND | +| POST | `/app/auth/register` | 匿名 | 手机号/邮箱注册,返回令牌 | +| POST | `/app/auth/login` | 匿名 | 验证码或密码登录 | +| POST | `/app/auth/refresh` | 匿名 | 用 refresh token 换新令牌并轮换 | +| POST | `/app/auth/logout` | 可选 | 登出;`allDevices=true` 吊销全部会话 | + +发送验证码: + +```json +POST /app/auth/otp +{ "scene": "REGISTER", "targetType": "PHONE", "target": "08123456789", "deviceId": "d-1" } +``` + +手机号会自动规范化成 E.164(印尼本地 `08xx` → `+62xxx`)。频控:同号 60 秒一次、每日 10 次, +同 IP 每小时 20 次;连续输错 5 次该验证码作废。 + +注册: + +```json +POST /app/auth/register +{ + "targetType": "PHONE", "target": "08123456789", "code": "123456", + "password": "可选,设置后可用密码登录", + "nickname": "可选", + "deviceId": "d-1", "platform": "IOS", "appVersion": "1.0.0", + "agreementVersion": "2026-10-01", "marketingConsent": false +} +``` + +响应(登录、刷新同结构): + +```json +{ + "code": 0, "message": "ok", + "data": { + "accessToken": "...", "refreshToken": "...", "expiresIn": 7200, "tokenType": "Bearer", + "newUser": true, + "user": { "uid": "U8KQ2ZP1", "nickname": "User8KQ2ZP", "status": "normal", + "countryCode": "ID", "language": "id-ID", "currency": "IDR", + "maskedTarget": "+628****789", "vipLevel": 0, "vipExpireAt": null } + } +} +``` + +### 1.2 个人中心(已实现) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/app/me` | 当前用户资料 | +| PUT | `/app/me` | 修改昵称/头像/性别/生日/语言/时区/区域 | +| GET | `/app/me/devices` | 登录设备列表 | +| GET | `/app/me/export` | 数据导出(商店与隐私法规要求) | +| POST | `/app/me/deactivate` | 申请注销,进入 15 天冷静期并立即登出全部设备 | +| POST | `/app/me/deactivate/cancel` | 冷静期内撤销注销 | + +### 1.3 内容浏览(已实现) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/app/home/feed` | 首页:轮播 + 分区,卡片带 `preview` 可播预览集 | +| GET | `/app/discover/feed` | 发现页分区 | +| GET | `/app/categories` | 分类列表 | +| GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) | + +### 1.4 播放(已实现,链路已重做) + +| 方法 | 路径 | 鉴权 | 说明 | +| --- | --- | --- | --- | +| GET | `/app/episodes/{id}/play` | 可选 | 签发播放票据与签名地址 | + +```json +{ + "code": 0, "message": "ok", + "data": { + "episodeId": 12, "dramaId": 5, "number": 1, "title": "第 1 集", "durationSec": 180, + "accessible": true, + "accessType": "FREE", + "playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...", + "qualities": ["AUTO"], + "unlockCoin": 0, + "previewSeconds": 0 + } +} +``` + +`accessType` 取值:`FREE`(免费集)、`ENTITLED`(已购权益)、`COIN_REQUIRED`(需解锁, +此时 `playUrl` 为空、`unlockCoin` 有值)、`NO_MEDIA`(还没上传片源或未转码完成)。 + +**判定顺序由服务端决定,客户端不得自行推断可否播放。** 签名默认 15 分钟有效; +开启 `fruitshorts.media.signature-required` 后,`/files/episode/**` 没带有效签名会返回 403。 + +### 1.5 观看记录与收藏(已实现) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/app/watch/progress` | 上报进度(建议每 10s 一次,切集时补一次) | +| GET | `/app/watch/progress?dramaId=` | 某剧续看位置 | +| GET | `/app/watch/recent?limit=20` | 最近观看 | +| DELETE | `/app/watch/progress/{dramaId}` | 删除某剧记录 | +| DELETE | `/app/watch/progress` | 清空记录 | +| POST | `/app/favorites/toggle` | 收藏/取消收藏,返回切换后状态 | +| GET | `/app/favorites` | 收藏列表(卡片结构与首页一致) | +| DELETE | `/app/favorites/{dramaId}` | 取消收藏 | + +进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。 +这样用户换设备不会看到进度倒退。 + +### 1.6 离线下载(已实现) + +服务端只发**授权**与**短时效地址**,分片由客户端直接向 CDN 拉取 —— +这样并发下载不会打到应用服务器,服务端只记账(进度、配额、有效期)。 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/app/downloads/policy` | 策略与配额:单设备上限、授权天数、仅 Wi-Fi、清晰度上限、已用/剩余 | +| PUT | `/app/downloads/settings` | 偏好设置:仅 Wi-Fi 下载、最高清晰度 | +| POST | `/app/downloads` | 申请下载授权(同剧集重复申请幂等,只刷新有效期) | +| GET | `/app/downloads` | 下载列表(授权 + 任务进度,可直接渲染) | +| GET | `/app/downloads/{episodeId}/url` | 断点续传前重新取地址(授权不变,地址是短时效的) | +| POST | `/app/downloads/{episodeId}/progress` | 上报下载进度,用于断点续传与跨端展示 | +| DELETE | `/app/downloads/{episodeId}` | 删除单条下载(吊销授权) | +| DELETE | `/app/downloads` | 清空当前设备全部下载,返回清除条数 | + +设备标识优先取请求体 `deviceId`,其次取请求头 `X-Device-Id`。 +授权默认 30 天有效,过期由每日定时任务置为 `EXPIRED`;被吊销或过期的授权客户端下次联网即失效。 +需要解锁的剧集走权益判定,未解锁不允许下载(当前内容均为免费集,付费链路见 09)。 +配额与有效期由 `fruitshorts.download.*` 配置,通过 `policy` 下发,客户端不要写死。 + +### 1.7 互动、交易、创作者(下一批) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| POST | `/app/comments` | 发表评论(先审后发) | +| GET | `/app/comments?dramaId=&cursor=` | 评论列表(游标分页) | +| DELETE | `/app/comments/{id}` | 删除自己的评论 | +| POST | `/app/likes/toggle` | 点赞(DRAMA/EPISODE/COMMENT) | +| POST | `/app/reports` | 举报 | +| POST | `/app/orders/verify` | 提交 IAP / Google Play 票据,服务端二次校验后发权益 | +| GET | `/app/orders` | 我的订单 | +| GET | `/app/wallet` | 金币余额与流水 | +| POST | `/app/episodes/{id}/unlock` | 金币解锁分集 | +| POST | `/app/creator/apply` | 创作者入驻 | +| GET | `/app/creator/works` | 我的作品与审核状态 | +| GET | `/app/creator/stats` | 创作者数据看板 | +| GET | `/app/config` | 启动配置(协议版本、开关、最低版本) | +| GET | `/app/notices` | 系统公告 | + +## 2. 后台管理接口(/admin/**) + +### 2.1 认证与系统(已实现) + +`POST /admin/auth/login`、`GET /admin/auth/menus`、`GET /admin/auth/profile`、 +`POST /admin/auth/logout`、`POST /admin/auth/password`; +管理员、角色、权限、菜单、角色分类、系统配置、操作日志各自一套增删改查。 + +### 2.2 内容与媒资(已实现) + +短剧(含是否热门、审核发布状态机)、剧集、分类、轮播图;媒资分片上传(init/chunk/complete)。 +新增字段:短剧 `rating`(内容分级)、`regionCodes`(可播区域)、版权窗口、 +`creatorId`、`tags`、`trailerUrl`;剧集 `transcodeStatus`、`hlsUrl`、`previewSeconds`、字幕。 + +### 2.3 App 用户管理(已实现) + +| 方法 | 路径 | 权限点 | 说明 | +| --- | --- | --- | --- | +| GET | `/admin/app-users/page` | `app-user:list` | 检索:keyword(uid/昵称/手机号/邮箱)、status、countryCode、registerSource | +| GET | `/admin/app-users/{id}` | `app-user:list` | 详情 | +| GET | `/admin/app-users/{id}/ban-records` | `app-user:list` | 封禁与申诉记录 | +| PUT | `/admin/app-users/{id}/status` | `app-user:update` | 改状态:normal/limited/banned | + +状态处置会**立即吊销该用户全部会话**并写入封禁记录,避免"封了还能用"。 + +### 2.4 下载管理(已实现) + +| 方法 | 路径 | 权限点 | 说明 | +| --- | --- | --- | --- | +| GET | `/admin/downloads/page` | `download:list` | 按用户 / 设备 / 状态检索下载授权,含剧名与集号 | +| GET | `/admin/downloads/stats` | `download:list` | 授权与任务的分布统计 | +| PUT | `/admin/downloads/{id}/revoke` | `download:revoke` | 吊销授权,用户侧下次联网即失效 | +| POST | `/admin/downloads/cleanup` | `download:revoke` | 手动清理过期授权(默认每天凌晨自动执行) | + +### 2.5 内容治理、订单、创作者审核、数据看板(下一批) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/admin/comments/page` | 评论审核队列 | +| PUT | `/admin/comments/{id}/audit` | 通过/驳回(带原因码) | +| GET | `/admin/reports/page` | 举报工单 | +| PUT | `/admin/reports/{id}/handle` | 处理举报(可联动封禁) | +| GET/POST/PUT | `/admin/sensitive-words` | 敏感词库 | +| GET | `/admin/orders/page` | 订单检索(可按用户、状态、平台) | +| POST | `/admin/orders/{orderNo}/compensate` | 票据丢失的补发 | +| PUT | `/admin/entitlements/{id}/revoke` | 权益回收 | +| POST | `/admin/reconcile/run` | 手动触发对账 | +| GET | `/admin/creators/page` | 创作者审核队列 | +| PUT | `/admin/creators/{id}/audit` | 入驻审核 | +| GET | `/admin/submissions/page` | 内容提交审核 | +| PUT | `/admin/submissions/{id}/audit` | 内容审核(通过/驳回/驳回原因码) | +| PUT | `/admin/prices` | 分集价格与免费集配置 | +| GET | `/admin/dashboard/overview` | 经营大盘(用户/播放/收入/成本) | +| GET | `/admin/dashboard/transcode` | 转码队列与失败任务 | +| POST | `/admin/notices` | 发布系统公告 | + +## 3. 契约与实现状态对照 + +| 模块 | 表结构 | 接口 | 代码 | +| --- | --- | --- | --- | +| 01 基础与交付准备 | 已就绪 | 约定已冻结 | 脚手架 / 契约 / 签名 / 转码骨架 已实现 | +| 02 用户账号与登录 | 已建 | 已冻结 | **已实现**(手机号/邮箱,第三方登录走同一凭证表) | +| 03 内容发现与详情 | 已建 | 已冻结 | 已实现(补了分级、区域、版权、标签字段) | +| 04 播放器与播放控制 | 已建 | 已冻结 | 票据 + 签名 + 进度 已实现;多码率依赖转码 worker | +| 05 记录、收藏与下载 | 已建 | 已冻结 | **已实现**:进度、观看流水、收藏、离线下载授权与任务 | +| 06 评论、点赞与分享 | 已建 | 已冻结 | 下一批 | +| 07 创作者中心 | 已建 | 已冻结 | 下一批(转码任务与产物记账已实现) | +| 08 平台运营后台 | 已建 | 用户管理已实现 | 治理/订单/看板 下一批 | +| 09 分集收费与支付 | 已建 | 已冻结 | 下一批(权益判定端口已预留,接上即生效) | +| 10 质量、数据与上线 | 已建 | 已冻结 | 压测与埋点 下一批 | diff --git a/docs/architecture-design.md b/docs/architecture-design.md index e402ca0..2aa419d 100644 --- a/docs/architecture-design.md +++ b/docs/architecture-design.md @@ -1,4 +1,4 @@ -# ReelShort 短剧服务 · 后端架构设计 +# FruitShorts 短剧服务 · 后端架构设计 > 技术栈:JDK 21(LTS,编译目标 Java 17)+ Spring Boot 3.2 + PostgreSQL 18 + Redis > @@ -94,26 +94,26 @@ ``` java/ ├── pom.xml # 聚合父 POM,统一依赖版本 -├── reelshort-common/ # 统一响应/异常/常量/枚举/工具 -├── reelshort-framework/ # 安全、MyBatis、Redis、日志、限流、OSS、MQ 客户端 -├── reelshort-module-system/ # 管理员、角色、权限、菜单、配置、操作日志 -├── reelshort-module-content/ # 短剧、剧集、分类标签、审核发布 -├── reelshort-module-media/ # 媒资上传、转码、播放地址签名 -├── reelshort-module-member/ # C 端用户、登录、设备、资料 -├── reelshort-module-interaction/ # 收藏、点赞、评论、观看历史 -├── reelshort-module-trade/ # 金币、流水、解锁、订单、VIP -├── reelshort-module-stat/ # 数据看板、日聚合 -├── reelshort-admin-api/ # B 端接口,/admin/** -├── reelshort-app-api/ # C 端接口,/app/** -└── reelshort-job/ # 定时任务与异步消费者 +├── fruitshorts-common/ # 统一响应/异常/常量/枚举/工具 +├── fruitshorts-framework/ # 安全、MyBatis、Redis、日志、限流、OSS、MQ 客户端 +├── fruitshorts-module-system/ # 管理员、角色、权限、菜单、配置、操作日志 +├── fruitshorts-module-content/ # 短剧、剧集、分类标签、审核发布 +├── fruitshorts-module-media/ # 媒资上传、转码、播放地址签名 +├── fruitshorts-module-member/ # C 端用户、登录、设备、资料 +├── fruitshorts-module-interaction/ # 收藏、点赞、评论、观看历史 +├── fruitshorts-module-trade/ # 金币、流水、解锁、订单、VIP +├── fruitshorts-module-stat/ # 数据看板、日聚合 +├── fruitshorts-admin-api/ # B 端接口,/admin/** +├── fruitshorts-app-api/ # C 端接口,/app/** +└── fruitshorts-job/ # 定时任务与异步消费者 ``` -起步阶段若不想铺这么开,可先合并为 `reelshort-common` + `reelshort-server`(含 system/content/media)+ `reelshort-app-api`,但包结构仍按域划分(`com.reelshort.content.*`),后续拆模块零成本。 +起步阶段若不想铺这么开,可先合并为 `fruitshorts-common` + `fruitshorts-server`(含 system/content/media)+ `fruitshorts-app-api`,但包结构仍按域划分(`com.fruitshorts.content.*`),后续拆模块零成本。 模块内部结构保持一致: ``` -com.reelshort.content +com.fruitshorts.content ├── controller/ # 仅 admin-api / app-api 暴露 ├── service/ # 接口 + impl ├── dal/ @@ -126,7 +126,7 @@ com.reelshort.content 命名约定: -- 包名 `com.reelshort..` +- 包名 `com.fruitshorts..` - 实体 `Drama`、`DramaEpisode`、`AppUser` - 请求对象 `XxxCreateReqVO` / `XxxUpdateReqVO` / `XxxPageReqVO`,响应对象 `XxxRespVO` - 表名 `<域前缀>_<业务>`,如 `drama`、`drama_episode`、`sys_role`、`app_user`、`stat_drama_daily` @@ -769,9 +769,9 @@ POST /app/pay/callback/{channel} ``` Nginx - ├── reelshort-admin-api (/admin) - ├── reelshort-app-api (/app) - └── reelshort-job (定时任务、异步消费) + ├── fruitshorts-admin-api (/admin) + ├── fruitshorts-app-api (/app) + └── fruitshorts-job (定时任务、异步消费) PostgreSQL(主从) / Redis / OSS(或 MinIO) / 转码 Worker(独立容器,可多实例水平扩容) ``` @@ -849,9 +849,9 @@ server: spring: datasource: - url: jdbc:postgresql://127.0.0.1:5432/reelshort - username: ${DB_USER:reelshort} - password: ${DB_PASSWORD:reelshort} + url: jdbc:postgresql://127.0.0.1:5432/fruitshorts + username: ${DB_USER:fruitshorts} + password: ${DB_PASSWORD:fruitshorts} data: redis: host: 127.0.0.1 @@ -861,7 +861,7 @@ spring: max-file-size: 2GB max-request-size: 2GB -reelshort: +fruitshorts: jwt: admin-secret: ${JWT_ADMIN_SECRET} app-secret: ${JWT_APP_SECRET} diff --git a/docs/backend-design-phase1.md b/docs/backend-design-phase1.md new file mode 100644 index 0000000..c091f8a --- /dev/null +++ b/docs/backend-design-phase1.md @@ -0,0 +1,292 @@ +# 后端服务设计(第一阶段 · 印尼市场 · 1 万并发观看) + +> 版本 v1.0 · 配套 [第一阶段 PRD](./prd-phase1.md) · 现有实现见 [架构设计](./architecture-design.md) +> 目标口径:**10,000 路并发播放不劣化**(对应 DAU 约 25–35 万),首发印尼。 + +## 1. 目标与约束 + +**业务目标**:日活规模下 1 万路并发观看流畅(首帧 P95 < 1.5s、卡顿率 < 2%),分集收费闭环准确无误,创作者上传到可播放全自动。 + +**工程约束**: + +1. 首发印尼,用户全部在东南亚,服务端与内容必须就近部署,不能用国内机房直连。 +2. 团队是 14–16 人的 MVP 团队,运维只有 1 人,所以不做微服务拆分,保持模块化单体。 +3. 数字商品必须走 Apple IAP / Google Play Billing,服务端只做校验与权益发放。 +4. 成本敏感:视频流量是最大成本项,码率与缓存策略从一开始就要按成本设计。 + +## 2. 现状盘点与差距 + +当前实现:7 个 Maven 模块的模块化单体,`fruitshorts-admin-api`(8080) + `fruitshorts-app-api`(8081), +MyBatis-Plus + PostgreSQL,媒资落本地磁盘 `data/uploads`,静态文件直出,无转码、无 CDN、无缓存层、无消息队列。 + +| 能力 | 现状 | 目标 | 优先级 | +| --- | --- | --- | --- | +| 媒资存储 | 本地磁盘,随机器走 | 对象存储(S3 兼容)+ 生命周期分层 | P0 | +| 视频分发 | 源站直出原文件 | 转码成 HLS 多码率 + CDN 分发 + 签名 URL | P0 | +| 缓存 | 无 | Redis + 本地 Caffeine 二级缓存 | P0 | +| 异步 | 无 | 消息队列(转码任务、计数、埋点、对账) | P0 | +| 数据库 | 单实例单库 | 主库 + 只读副本,热点与日志表分区 | P1 | +| 播放鉴权 | 免费集直接给地址 | 票据 + 签名 URL + 有效期 + 吊销 | P0 | +| 限流熔断 | 无 | 网关级限流 + 按接口降级 | P0 | +| 支付 | 未实现 | 订单状态机 + 票据校验 + 对账 | P0 | +| 可观测 | 慢请求日志 | 指标 + 链路追踪 + 告警 + 看板 | P1 | + +结论:现有代码是功能验证级,架构骨架(模块划分、鉴权、内容与媒资模型)可以直接复用, +但"视频从源站直出"这一点在 1 万并发下会立刻打爆,必须替换成 CDN + 签名 URL。 + +## 3. 容量规划 + +### 3.1 码率阶梯与带宽 + +印尼以 4G 为主、资费敏感,码率阶梯压到偏低档位: + +| 档位 | 分辨率 | 视频 | 音频 | 合计 | 预期占比 | +| --- | --- | --- | --- | --- | --- | +| 360p | 640x360 | 400 kbps | 64 kbps | ~0.47 Mbps | 40% | +| 480p | 854x480 | 800 kbps | 64 kbps | ~0.87 Mbps | 45% | +| 720p | 1280x720 | 1600 kbps | 96 kbps | ~1.70 Mbps | 12% | +| 1080p | 1920x1080 | 3200 kbps | 128 kbps | ~3.30 Mbps | 3% | + +按占比加权,平均码率约 **0.88 Mbps**。 + +- 10,000 路并发出口带宽约 **8.8 Gbps** +- 叠加首段突发、重连与预加载(+30%),峰值 **约 11–12 Gbps** +- CDN 命中率按 98% 计,回源带宽 **约 240 Mbps** + +源站只需 2 台带 1 Gbps 出口的机器即可承载回源,不需要为每个观众准备源站带宽。 +这条推导是整个设计的关键:**播放流量必须由 CDN 承担,源站只回源 2%**。 + +### 3.2 接口 QPS + +| 链路 | 估算方式 | 平均 | 峰值(x3,20:00–23:00 WIB) | +| --- | --- | --- | --- | +| 首页/发现/详情 | 每活跃用户每分钟 2 次 | ~333 QPS | ~1,000 QPS | +| 播放票据签发 | 每集一次,平均 4 分钟/集 | ~42 QPS | ~125 QPS | +| 播放进度上报 | 每 10 秒一次 | 1,000 QPS | 3,000 QPS | +| 埋点/播放计数 | 客户端按 30s 合批 | ~330 QPS | ~1,000 QPS | + +结论: + +- **读接口** 经 Redis + 本地缓存后,4–8 个 API 副本足够(单副本可撑 1,500+ QPS)。 +- **进度上报不直接打库**:写 Redis,后台任务按 `user+drama` 合并后批量 UPSERT,落库压力降到 100 QPS 量级。 +- **埋点与计数走消息队列**,消费者聚合后写回,不用事务强一致。 + +### 3.3 存储与转码 + +按第一阶段 300 部 x 60 集 x 单集 2 分钟估算: + +| 项 | 计算 | 容量 | +| --- | --- | --- | +| 原片(1080p) | 18,000 集 x ~50 MB | ~0.9 TB | +| 转码产物(4 档 HLS) | 18,000 集 x ~95 MB | ~1.7 TB | +| 封面/截图/字幕 | — | ~0.1 TB | +| **合计** | | **约 3 TB** | + +转码算力:单集 1080p 转 4 档约 8 分钟机时,首月入库 18,000 集约 2,400 小时; +100 核并行约 24 小时跑完。**按需弹性即可,不需要常驻转码集群**,但必须排队限流,避免抢在线服务的 CPU。 + +### 3.4 成本与单位经济性 + +假设 DAU 30 万、人均日观看 40 分钟、平均码率 0.88 Mbps: + +| 项 | 月度量/成本 | 说明 | +| --- | --- | --- | +| CDN 流量 | 约 79 TB/天,约 2,300 TB/月,**$50k–75k** | 按东南亚 $0.022–0.032/GB,**最大成本项** | +| 对象存储 | 3 TB,约 $60–100 | 冷热分层后更低 | +| 数据库 / Redis / MQ | $300–800 | 主从 + 只读副本 + 集群缓存 | +| API 与后台计算 | $400–1,200 | 4–8 副本 + 弹性转码 | +| 短信 OTP | $500–2,000 | 按 $0.02–0.08/条,必须频控 | +| 监控 / 日志 / APM | $100–300 | | +| **合计** | **约 $52k–79k/月** | CDN 占绝大部分可变成本 | + +单位经济性(决定商业模型是否成立): + +- 单集 2 分钟、0.88 Mbps,约 13 MB 流量,CDN 成本约 **$0.0004** +- 单集解锁(按 30 金币约 $0.03),视频成本约占解锁收入 **1.5%** + +推论: + +1. 金币解锁模式下视频成本可忽略;真正的压力来自第二阶段的广告模式(ARPU 低一个数量级时 CDN 会变成主要成本)。 +2. 因此第一阶段就要把平均码率、缓存命中率、CDN 单价做成**看板与告警**(单位播放成本 $/千次播放)。 +3. 把平均码率从 0.88 压到 0.6 Mbps(更多 360p、更激进的 ABR 起播策略)可直接省掉约 30% 的 CDN 账单,比任何架构优化都有效。 + +## 4. 部署与网络拓扑 + +``` +印尼用户 (iOS / Android) + | 1. 接口请求(JSON, HTTPS) + v +CDN / 全球加速 --- 2. 视频分片与静态资源(HLS,签名 URL,命中率 >=98%) + | ^ + | 回源(约 2%) | + v | +区域入口 (Jakarta) | + |- API 网关:TLS、限流、鉴权、WAF | + |- app-api x4-8(K8s HPA) | + |- admin-api x2(可放新加坡/内网,仅内部使用) | + |- worker(转码调度、计数聚合、对账、清理) | + | + |- PostgreSQL(主 + 只读副本) + |- Redis(集群:缓存 / 票据 / 进度缓冲 / 限流) + |- 消息队列(转码任务、埋点、计数、对账事件) + |- 对象存储 OSS(S3 兼容)--- 转码产物 -----------+ +``` + +**区域选择**:首选**雅加达**(阿里云 / 腾讯云 / AWS 均有区域),退路是新加坡 +(ap-southeast-1,到雅加达 RTT 约 20–30ms,仍可接受)。依据是到用户 RTT、CDN 覆盖与本地 PoP 价格、数据驻留要求。 + +**为什么要 API 网关**:限流、IP 黑名单、WAF、灰度都放在网关层,业务服务不必各自实现一遍。 + +**国内团队访问生产**:运营后台走独立入口 + 内网跳板,不要与 C 端出口混用。 + +## 5. 服务划分与演进 + +第一阶段**保持模块化单体**,按业务域划清边界,为后续拆分留缝: + +| 模块 | 职责 | 未来拆分优先级 | +| --- | --- | --- | +| user | 账号、凭证、会话、资料、风控 | 中 | +| content | 短剧、剧集、分类、标签、榜单 | 低 | +| media | 上传、转码、媒资、播放票据 | **高**(与 CDN/转码强绑定) | +| playback | 进度、记录、收藏、下载授权 | 中 | +| interaction | 评论、点赞、举报、分享 | 低 | +| commerce | 商品、订单、钱包、权益、对账 | **高**(支付需审计隔离) | +| creator | 创作者入驻、内容提交、数据看板 | 中 | +| admin/system | RBAC、菜单、审计、配置 | 低 | + +拆分只在这两个条件下发生:**独立扩缩容需求**或**合规隔离需求**。其余情况一律不加服务。 + +## 6. 数据模型与存储 + +核心表(现有 `sql/schema.sql` 已有基础,"新增"为需要补充的部分): + +- 内容域:`drama`、`drama_episode`、`drama_category`、`content_banner`,已具备。 +- 媒资域:`media_asset`、`media_upload_task` 已具备;**新增** `transcode_task`、`media_rendition`(多码率产物)。 +- 用户域:**新增** `app_user`、`user_credential`、`user_session`、`user_profile`(当前只有后台管理员表)。 +- 播放域:**新增** `watch_progress`(user+drama 唯一)、`watch_history`(按月分区)、`user_favorite`、`playback_ticket`。 +- 互动域:**新增** `comment`、`comment_reply`、`user_like`、`report`、`blocklist`。 +- 交易域:**新增** `product`、`order`、`order_event`、`wallet`、`coin_ledger`、`entitlement`、`receipt`。 +- 创作者域:**新增** `creator`、`creator_document`、`content_submission`、`audit_record`。 + +存储策略: + +- 大表分区:`watch_history`、`order_event`、`coin_ledger`、`playback_event` 按月分区,保留 12–24 个月。 +- 冷热分离:长期不播放的媒资转低频/归档存储,源站只保留热内容。 +- 读写分离:报表、导出、后台检索走只读副本,避免影响 C 端。 +- 连接池:Hikari 每副本 20–30 连接,按副本数 x 连接数留足 PG `max_connections` 余量。 + +## 7. 关键链路设计 + +### 7.1 播放与地址签发(第一阶段最重要的改造) + +1. 客户端请求 `/app/episodes/{id}/play`,服务端判定权益(已购 > 活动免费 > 免费集 > 试看 > 金币可扣 > 需充值)。 +2. 可播放时签发**短时效签名 URL**(建议 15 分钟),签名绑定 `episodeId + userId + 清晰度 + 过期时间`。 +3. 返回 HLS master playlist 地址(CDN 域名),CDN 校验签名后才回源。 +4. 签名校验失败、过期或用户被吊销,直接 403,客户端提示重新获取。 +5. 防盗链:Referer/UA 白名单 + 单账号同时在播设备数限制(建议 1–2 台)。 + +关键点:客户端拿不到可长期复用的裸地址,也不会出现"服务端扛视频"的情况。 + +### 7.2 上传与转码流水线 + +``` +创作者分片直传 OSS(STS 临时凭证) + -> 合并校验(MD5 / 时长 / 编码探测) + -> 写 transcode_task(排队) + -> worker 拉起 FFmpeg 或云转码:4 档 HLS + 封面 + 字幕封装 + -> 回调写 media_rendition + -> CDN 预热 + -> 状态回写创作者中心(可发布) +``` + +失败重试 3 次并记录原因;转码队列与在线服务隔离(独立节点池或命名空间),避免抢占资源。 + +### 7.3 支付与权益 + +1. 客户端拿到商店票据后提交 `/app/orders/verify`。 +2. 服务端**必须**再调 Apple / Google 校验接口二次验证,不信任客户端上报。 +3. 订单状态机幂等:以 `(platform, transactionId)` 唯一键去重,重复回调只返回既有结果。 +4. 权益发放与订单在同一事务;发放失败进补偿队列,T+1 内自动修复并留痕。 +5. 退款与撤销事件(App Store Server Notifications / Google RTDN)触发权益回收或欠款标记,写 `coin_ledger`。 +6. 每日对账:拉取商店账单与本地订单比对,差异进入人工处理队列并告警。 + +### 7.4 观看进度与记录 + +- 客户端每 10s 上报一次;服务端写 Redis(`progress:{user}:{drama}`),不直接写库。 +- 后台任务每 30s 把 Redis 中的变更按剧合并,批量 UPSERT 到 `watch_progress`(每用户每剧一行)。 +- `watch_history` 按事件追加、按月分区,用于"最近观看"与后续推荐。 +- 冲突规则:以 `updatedAt` 较新者为准;同秒冲突取进度较大者,避免进度倒退。 + +### 7.5 计数与埋点 + +播放数、完播数、点赞数一律走消息队列,消费者按窗口聚合后定时写回计数表。 +读取计数走缓存,允许秒级最终一致,不允许为了强一致把写压力压到主库。 + +## 8. 高并发与稳定性手段 + +| 手段 | 落点 | +| --- | --- | +| 多级缓存 | 客户端本地缓存 > CDN > Redis > Caffeine 本地缓存 > PG | +| 读缓存 | 首页/详情/分类结果缓存 30–300s,运营改动通过版本号主动失效 | +| 异步化 | 进度、计数、埋点、转码、对账全部异步 | +| 合批 | 进度上报合并写、计数聚合写、日志批量落盘 | +| 限流 | 网关按 IP/设备/用户三维限流,重点保护 OTP 与票据签发 | +| 熔断降级 | 短信通道、商店校验、审核供应商失败时降级并告警;播放链路的降级是"降码率"而不是"不可播" | +| 幂等 | 订单、回调、注册、上传合并全部幂等键化 | +| 游标分页 | 列表接口用 cursor 而非深 offset,避免大偏移查询 | +| 连接管理 | 视频分片走 CDN,API 走 HTTP/2 keep-alive,PG 连接池按副本配额 | +| 压测门禁 | 上线前通过 1 万并发播放 + 1,000 QPS 接口压测,错误率 < 0.1% | + +## 9. 可观测性与 SLO + +| 指标 | 目标 | 告警阈值 | +| --- | --- | --- | +| 接口可用性 | 99.9% | 5 分钟错误率 > 1% | +| 接口延迟 P95(客户端热点接口) | < 50ms | > 100ms 持续 5 分钟 | +| 首帧 P95 | < 1.5s | > 2.5s | +| 卡顿率 | < 2% | > 4% | +| CDN 命中率 | >= 98% | < 95% | +| 单位播放成本 | 有基线并有月度预算 | 超预算 20% | +| 支付成功率 | > 97% | < 95% | + +埋点与看板分四层:接口层(QPS/延迟/错误码)、播放层(起播/卡顿/完播/ERROR_CODE)、 +业务层(注册/首播/解锁/支付)、成本层(CDN 流量/存储/短信条数)。 +链路追踪覆盖"支付"与"播放票据签发"两条关键链路。 + +## 10. 安全与合规(印尼) + +- **合规**:Kominfo **PSE 注册**(未注册可能被整体封锁);UU 27/2022 个人数据保护(明示同意、删除权、导出权、跨境传输依据);内容分级(R13/R17/R21)与色情、宗教、政治敏感内容零容忍。 +- **应用安全**:网关 WAF;接口越权(水平 + 垂直)全量校验;密钥集中托管并支持轮换;后台敏感操作二次确认 + 审计留痕。 +- **支付安全**:服务端票据二次校验;防重放(nonce + 唯一键);金额与商品以服务端配置为准。 +- **内容保护**:签名 URL + 短时效 + 并发设备限制;第一阶段不上 DRM,但 URL 结构与接口要为第四阶段 DRM 预留。 +- **数据分级**:手机号、邮箱、设备标识、支付标识分级存储与脱敏;日志与埋点禁止写入 PII 明文。 + +## 11. 上线与扩容路径 + +1. **环境**:dev / test / staging / prod 四套,配置外置,密钥不入库不入 Git。 +2. **压测**:阶梯加压到 1 万并发播放(走真实 CDN 链路)+ 1,000 QPS 接口,记录水位与瓶颈。 +3. **灰度**:先 5% 流量,观察起播、卡顿、错误率与支付成功率,再逐步放量。 +4. **回滚**:镜像与配置双版本共存,一键回滚;数据库变更必须向后兼容(先加字段、再切读写、最后清理)。 +5. **扩容触发线**:API 副本 CPU > 60% 或 QPS 超过设计值 70%;转码队列积压 > 30 分钟;CDN 命中率 < 95%。 +6. **灾难恢复**:数据库 PITR + 每日全量备份(跨可用区),目标 RPO <= 15 分钟、RTO <= 2 小时,季度演练一次。 + +## 12. 与第一阶段工作包的对应关系 + +| 评估表模块 | 后端承担的部分 | 优先级 | +| --- | --- | --- | +| 01 基础与交付准备 | 服务骨架、OpenAPI 契约、ERD、CI/CD、环境与监控 | P0 | +| 02 用户账号与登录 | 用户域、OTP 与风控、会话与令牌、注销与导出 | P0 | +| 03 内容发现与详情 | 内容目录、搜索、分类榜单、可见性规则、缓存 | P0 | +| 04 播放器与播放控制 | **票据与签名 URL、转码与 ABR 片源、进度与续播接口、播放事件** | P0 | +| 05 记录、收藏与下载 | 进度存储与合并、收藏服务、下载授权与设备绑定 | P0 | +| 06 评论、点赞与分享 | 点赞与评论服务、内容治理联动、分享落地页接口 | P1 | +| 07 创作者中心 | 入驻与审核流、分片上传、转码流水线、媒资与状态服务 | P0 | +| 08 平台运营后台 | RBAC 与审计、内容/用户/订单管理、大盘数据接口 | P0 | +| 09 分集收费与支付 | 钱包与订单、票据校验、权益决策、退款与对账 | P0 | +| 10 质量、数据与上线 | 压测与容量报告、安全测试、埋点验收、发布与 DR Runbook | P0 | + +建议的开发顺序(后端视角): +基础骨架与契约 → 账号与鉴权 → 内容目录与缓存 → 媒资上传与转码 → 票据与签名播放 → +进度与收藏 → 支付与权益 → 运营后台与治理 → 压测与上线准备。 + +其中"票据 + 签名 URL + 转码"是第一阶段风险最高、最不该压缩的链路。 diff --git a/docs/prd-phase1.md b/docs/prd-phase1.md new file mode 100644 index 0000000..17b556a --- /dev/null +++ b/docs/prd-phase1.md @@ -0,0 +1,272 @@ +# 短剧 App 第一阶段 PRD(印尼首发) + +> 版本 v1.0 · 依据《四阶段功能规划与工作量评估》第一阶段明细(10 个模块 / 614 净人天) +> 配套文档:[后端服务设计](./backend-design-phase1.md) · [架构设计](./architecture-design.md) + +## 0. 文档说明 + +本 PRD 覆盖第一阶段(M0–M5)的产品范围,即"可上线、可收费、可运营、可供给内容"的 MVP 闭环。 +第二至第四阶段(会员订阅、广告、个性化推荐、多区域部署、DRM)不在本文范围内,仅在衔接处标注预留。 + +阅读约定:`P0` 为上线阻塞项,`P1` 为可延后但不能砍项;"人天"沿用评估表的合并口径,不重复计入通用后台与基础服务。 + +## 1. 背景与目标 + +### 1.1 背景 + +短剧(竖屏、单集 1–3 分钟、强钩子、连续解锁)在国内已被验证。本项目把它做到海外,首发印尼: +人口 2.8 亿、移动端占比极高、年轻用户对短视频形态接受度高,但支付能力分散、网络质量参差、 +内容与宗教文化敏感度高,因此不能直接照搬国内产品与运营方式。 + +### 1.2 第一阶段目标 + +做出一个能真实跑起来的业务闭环,而不是功能演示: + +1. **能看**:iOS / Android 双端可安装,播放稳定,弱网可用。 +2. **能收**:分集收费闭环走通,且完全符合 Apple / Google 的数字商品结算规则。 +3. **能供给**:创作者能自助入驻、上传、提交审核、看到数据。 +4. **能运营**:运营能编排内容、处理审核与举报、查订单与用户、看经营大盘。 +5. **能被度量**:埋点口径统一,核心指标可对账、可归因。 + +### 1.3 阶段退出标准 + +- 通过 UAT 与安全测试,高危漏洞清零; +- 支付、播放、内容上下架、账号注销四条全链路端到端通过; +- 商店审核材料齐备且首次提审材料可完整复现核心流程。 + +## 2. 市场与假设(M0 决策门禁) + +以下四项未冻结前不启动研发,否则登录、支付、版权、短信、UI 与商店资料会反复返工。 + +| 决策项 | 当前假设 | 影响面 | +| --- | --- | --- | +| 首发市场 | 印度尼西亚 | 支付税制、短信到达率、隐私法、内容分级 | +| 首发语言 | 印尼语(Bahasa Indonesia)为主,界面预留英文 | 文案长度、字幕、客服、ASO | +| 内容形态 | 竖屏短剧,单集 1–3 分钟,单剧 40–80 集 | 转码阶梯、CDN 成本、存储、审核成本 | +| 收费方式 | 金币解锁分集,走 Apple IAP / Google Play Billing | 财务模型(15%–30% 抽成 + 税费)、审核 | + +### 2.1 印尼本地化的硬性要求 + +- **PSE 注册**:印尼通信与信息部(Kominfo)要求电子系统运营商完成 PSE 注册,未注册可能被封锁。立项即启动。 +- **数据保护**:印尼《个人数据保护法》(UU 27/2022)要求明示同意、可删除、可导出,跨境传输需合法依据。 +- **内容分级与合规**:需遵守印尼内容分级(R13/R17/R21 等)与色情、宗教、政治敏感内容的严格限制;上线前需完成内容审核供应商与规则配置。 +- **商店支付**:App 内数字内容必须走商店结算,任何引导外部支付的文案都会被拒审或下架。 +- **网络与资费**:4G 为主、数据资费敏感、部分区域带宽受限 → 默认低码率起播,清晰度手工可上调。 +- **时区与货币**:WIB(UTC+7),IDR 展示按千位分隔(`Rp 12.000`);所有时间字段按用户本地时区展示。 + +## 3. 用户与角色 + +| 角色 | 主要场景 | 关键诉求 | +| --- | --- | --- | +| 观众(C 端) | 找剧、刷剧、追更、解锁分集 | 起播快、不卡、剧情钩子密、价格透明 | +| 创作者 | 入驻、上传、提交审核、看数据 | 上传稳定、审核反馈明确、数据可信 | +| 内容审核 | 审核创作者内容与片源 | 批量高效、驳回原因可复用、留痕 | +| 运营 | 编排内容、配置定价与活动、看大盘 | 改动即时生效、可回滚 | +| 客服 | 处理退款、账号、播放问题 | 按用户/订单快速检索,有处理入口 | +| 财务 | 对账、退款、异常订单 | 订单与商店账单可对账,差异可追溯 | +| 管理员 | 账号与权限管理 | RBAC 清晰、敏感操作留痕 | + +## 4. 范围 + +### 4.1 纳入(第一阶段) + +| 模块 | 人天 | 优先级 | +| --- | --- | --- | +| 01 基础与交付准备 | 8 | P0 | +| 02 用户账号与登录 | 35 | P0 | +| 03 内容发现与详情 | 37 | P0 | +| 04 播放器与播放控制 | 44 | P0 | +| 05 记录、收藏与下载 | 71 | P0 | +| 06 评论、点赞与分享 | 64 | P1 | +| 07 创作者中心 | 119 | P0 | +| 08 平台运营后台 | 100 | P0 | +| 09 分集收费与支付 | 98 | P0 | +| 10 质量、数据与上线 | 38 | P0 | + +### 4.2 明确不做(留到后续阶段) + +个性化推荐模型、会员订阅与自动续费、广告插入、创作者分成结算、多语言配音、DRM、多区域容灾、TV/Web 观看端。 + +## 5. 功能需求 + +每个模块给出:需求点 → 验收要点。验收要点直接作为 QA 的测试设计输入。 + +### 5.1 基础与交付准备(8 人天) + +需求点:需求澄清与范围冻结;技术架构、API 契约与数据模型;双端与 Web 工程骨架;后端服务骨架、鉴权与基础中间件; +对象存储与转码链路;短信/邮件/推送通道;崩溃与性能监控;埋点规范与数据字典;CI/CD、多环境、域名与 CDN;商店账号与签名;隐私与支付前置。 + +验收要点:契约兼容与越权校验;构建产物可复现;上传转码回调闭环;多国家短信到达与限流;事件去重与口径一致。 + +### 5.2 用户账号与登录(35 人天) + +需求点:手机号 + 邮箱 + Apple / Google 三种登录;多国家区号与 OTP 风控;隐私同意与年龄门槛;Token 与多设备会话; +资料(昵称/头像/地区);账号注销与数据导出;封禁、限制与申诉;登录注册限流反刷。 + +验收要点: + +- 并发注册幂等,重复提交不产生重复账号; +- 验证码刷取被拦截(同号/同 IP/同设备多维频控 + 图形验证码 + 熔断); +- 跨端登录状态一致,登出后旧 Token 立即失效; +- Sign in with Apple 提供"隐藏邮箱"路径下的账号可正常续用; +- 注销后 15 天内数据不可恢复,导出内容完整且不含他人数据。 + +### 5.3 内容发现与详情(37 人天) + +需求点:首页浏览流;关键词搜索(含热词与历史);分类与榜单;详情与选集;规则推荐与热度排序;地区/年龄/状态/版权过滤;加载、空态、离线与错误重试。 + +验收要点:上下架与地区过滤实时生效;分页刷新不错位不重复;深链进入返回栈正确;空结果有兜底;弱网超时不空白。 + +### 5.4 播放器与播放控制(44 人天) + +需求点:HLS 播放与生命周期管理;播放地址签发与试看校验;快进快退与手势;0.75x–2.0x 倍速与记忆;全屏/旋转/锁屏; +断点续播与异常恢复;自适应码率与清晰度切换;基础字幕;缓冲/断网/错误码提示;自动下一集;音频焦点与系统媒体控制;播放事件埋点。 + +验收要点(本模块是全阶段最影响口碑的一环): + +- 首帧 P95 < 1.5s,卡顿率 < 2%,弱网(3G / 丢包 5%)可降级播放不黑屏; +- 播放地址不可复用:签名过期后直接播放失败,越权拼接地址拿不到片源; +- 切集不重复计数,进度不跳变; +- 全程 2 小时连续播放无内存增长与音画不同步。 + +### 5.5 记录、收藏与下载(71 人天) + +需求点:观看记录(列表、续播、清理);收藏(状态同步、排序、批量删除);离线下载(队列、断点续传、加密存储与离线播放); +下载授权与设备绑定、过期与吊销;存储占用管理与仅 Wi-Fi 下载;跨端同步策略;与账号注销联动。 + +验收要点:授权、过期、吊销、换机四条路径可验证;下载文件无法被外部播放器直接提取;跨端进度冲突有确定的合并规则。 + +> 产品决策待定:第一阶段是否启用"真实离线下载"。若法务与版权未就绪,先做受控缓存(App 内缓存 + 授权有效期), +> 该决策会影响 05 模块约 20 人天的用量。 + +### 5.6 评论、点赞与分享(64 人天,P1) + +需求点:点赞(动画、计数、防刷);评论(发布、回复、删除、分页、审核状态);敏感词、举报、拉黑与审核联动; +系统分享、复制链接与海报;深链与落地页;分享素材模板。 + +验收要点:计数与实际关系一致(异步最终一致但可对账);审核中的评论对他人不可见;深链在未安装场景可唤起商店。 + +### 5.7 创作者中心(119 人天,最大单模块) + +需求点:创作者入驻(身份资料、协议、审核状态);工作台与内容列表;分片上传与断点续传;上传任务校验、合并与回调; +剧集/分集/封面/标签管理;多码率转码与媒资管理;人工审核与驳回原因;定时发布、下架、撤回与版权到期; +免费/收费分集与试看规则;数据看板;审核结果与系统公告;角色隔离与敏感操作审计;多语言与币种展示预留。 + +验收要点:上传中断可续传且不产生重复媒资;转码失败可重试且失败原因可见;审核状态流转无"卡死"态; +创作者只能看到自己的数据(水平越权测试覆盖);版权到期自动下架。 + +### 5.8 平台运营后台(100 人天) + +需求点:管理员登录、RBAC、审批与操作审计;内容/剧集/分类/标签/榜单/推荐位管理;创作者资质与违规处理; +用户检索与客服视图;评论举报与封禁申诉;订单、退款、补发与权益处理;定价与活动配置;经营大盘;批量操作与受限导出。 + +验收要点:权限变更后刷新即生效;关键操作 100% 留痕(操作人、时间、请求与响应);导出受限且可审计。 + +### 5.9 分集收费与支付(98 人天) + +需求点:钱包与金币;订单与权益幂等状态机;商品配置与价格分层;Apple IAP 与 Google Play Billing 集成;服务端票据校验与重放防护; +恢复购买与跨设备权益;退款、撤销、拒付与权益回收;商店账单对账;付费弹窗与解锁联动;免费集/试看/已购判断;定价实验预留。 + +验收要点: + +- 同一票据重复回调只发放一次权益(幂等); +- 票据丢失、异步通知延迟时,补偿任务能在 T+1 内自动修复并留痕; +- 恢复购买在换设备、重装、切区三种场景下权益一致; +- 每日与商店账单自动对账,差异进入人工处理队列。 + +### 5.10 质量、数据与上线(38 人天) + +需求点:核心用例自动化与冒烟;接口契约与集成测试;登录/内容/播放/支付/后台压测;安全测试; +IAP/Billing 沙盒到权益的端到端验证;埋点与指标一致性验证;UAT;商店素材与提审;灰度与回滚演练;值班与事故响应;备份与灾难恢复。 + +## 6. 核心流程与状态机 + +**内容状态**:草稿 → 待审核 → 审核中 →(驳回 → 草稿)→ 已通过 → 定时发布 → 已发布 → 已下架 / 版权到期。 +任何状态下都必须能被运营强制下架,且下架对已缓存列表在 60s 内生效。 + +**订单状态**:创建 → 已支付 → 已发放权益;异常分支:支付超时关闭、票据校验失败待补偿、退款中、已退款(回收权益)。 +所有状态迁移必须幂等,重复回调不改变已终态订单。 + +**权益判定优先级**:已购 > 活动免费 > 免费集 > 试看剩余 > 金币可扣 > 需充值。 +判定结果必须由服务端返回,客户端不得自行推断可播放性。 + +**账号状态**:未注册 → 已注册 → (受限 → 申诉中 → 恢复)/ 已注销。封禁与注销必须同步清理会话与缓存。 + +## 7. 非功能需求 + +### 7.1 性能与容量 + +业务方给的目标是"日 1 万加用户同时观看视频流畅"。这里按更严的口径落地: + +| 指标 | 目标 | 说明 | +| --- | --- | --- | +| 并发播放路数 | ≥ 10,000 路不劣化 | 对应 DAU 约 25–35 万;详见后端设计容量推导 | +| 首帧时间 P95 | < 1.5s | 4G 网络、360p 起播 | +| 卡顿率 | < 2% | 播放时长加权 | +| 接口延迟 P95 | < 100ms(客户端热点接口 < 50ms) | 服务端耗时 | +| 可用性 | 99.9%(第一阶段) | 月度 | + +若实际口径是"DAU 1 万",资源需求下降约一个数量级,可先按小规格部署、保留水平扩容能力。 + +### 7.2 安全 + +播放地址签名与防盗链;接口越权校验(水平 + 垂直);支付服务端校验与重放防护;后台敏感操作二次确认与留痕; +密钥集中托管与轮换;上线前渗透测试并将高危漏洞清零。 + +### 7.3 数据与埋点 + +统一指标字典,核心指标单一口径:播放(起播/卡顿/完播)、留存、收入、支付成功率。 +播放与收入类指标需与财务、商店账单对账,日报可追溯。 + +### 7.4 合规 + +隐私政策与用户协议随目标市场本地化;同意管理记录可审计;SDK 清单与商店 Data Safety / Privacy Labels 与实际数据流一致; +账号注销与数据导出在承诺时限内完成。 + +## 8. 核心指标 + +- **北极星**:日均有效观看时长(有效定义:单次播放 ≥ 10s 且进度 ≥ 5%)。 +- **激活漏斗**:安装 → 首次启动 → 首页首播 → 完成 1 集 → 次日留存。 +- **商业化**:付费转化率、ARPPU、解锁集数、退款率、支付成功率。 +- **供给**:创作者上传量、审核通过率、平均审核时长、上架内容数。 +- **质量**:崩溃率、起播 P95、卡顿率、错误码分布。 + +## 9. 依赖与前置 + +以下前置项的关键路径在外部,研发无法加速,必须在 M0 并行启动(完整清单见评估表"外部前置与采购"): + +1. 海外上架主体、DUNS / 企业验证、Apple 与 Google 组织账号; +2. 公司域名、企业邮箱与官网(隐私政策与客服页依赖它); +3. 隐私政策、用户协议、社区规范与版权政策; +4. 海外短信通道(目标市场到达率与成本必须先压测); +5. 云账号、对象存储、数据库、缓存、日志与 CDN(区域选择与预算同步确认); +6. Apple / Google 支付账号、税务资料与商品配置; +7. 内容版权台账(地区、期限、渠道、转授权范围必须结构化)。 + +## 10. 主要风险 + +| 风险 | 影响 | 应对 | +| --- | --- | --- | +| 市场/语言/内容形态未冻结 | 全链路返工 | 设为 M0 门禁,书面签署后进入变更控制 | +| 商店账号与主体审核超期 | 无法提审 | 立项首周并行申请,准备备用主体 | +| 支付一致性与跨账号权益 | 资损与投诉 | 幂等状态机 + 补偿任务 + 每日对账 + 客服入口 | +| 视频与 CDN 成本失控 | 毛利被侵蚀 | 多码率策略、冷热分层、命中率与单位成本看板 | +| 短信成本与到达率 | 无法登录 | 多供应商 + 频控 + 图形验证码 + 熔断 | +| 内容安全与版权 | 下架或处罚 | 机审 + 人审 + 举报 + 申诉 + 结构化版权台账 | +| 范围蔓延 | 无法形成可上线产品 | 冻结 MVP,变更需影响评估与批准 | + +## 11. 里程碑 + +| 里程碑 | 内容 | 产出 | +| --- | --- | --- | +| M0 | 决策与前置 | 四项决策冻结、账号与主体、云与短信采购 | +| M1 | 骨架与通道 | 双端与后端骨架、鉴权、存储转码链路、通道接入 | +| M2 | 核心链路 | 账号、内容、播放器、记录收藏、创作者上传 | +| M3 | 商业化 | 支付与权益、运营后台、内容治理 | +| M4 | 提审准备 | 压测与安全测试、埋点验收、商店素材与提审 | +| M5 | 上线与运营 | 灰度发布、值班与回滚演练、上线回归 | + +## 12. 交付物清单 + +双端 App(iOS / Android)、创作者中心、运营后台、后端服务与 OpenAPI 契约、ERD、 +转码与媒资流水线、监控与埋点看板、测试与压测报告、安全测试报告、商店提审素材、发布与回滚 Runbook。 diff --git a/reelshort-admin-api/pom.xml b/fruitshorts-admin-api/pom.xml similarity index 60% rename from reelshort-admin-api/pom.xml rename to fruitshorts-admin-api/pom.xml index 36e0663..4f2b405 100644 --- a/reelshort-admin-api/pom.xml +++ b/fruitshorts-admin-api/pom.xml @@ -5,27 +5,35 @@ 4.0.0 - com.reelshort - reelshort + com.fruitshorts + fruitshorts 1.0.0-SNAPSHOT - reelshort-admin-api - reelshort-admin-api + fruitshorts-admin-api + fruitshorts-admin-api 管理后台接入层:/admin/** - com.reelshort - reelshort-module-system + com.fruitshorts + fruitshorts-module-system - com.reelshort - reelshort-module-content + com.fruitshorts + fruitshorts-module-content - com.reelshort - reelshort-module-media + com.fruitshorts + fruitshorts-module-media + + + com.fruitshorts + fruitshorts-module-user + + + com.fruitshorts + fruitshorts-module-playback org.springframework.boot diff --git a/reelshort-admin-api/src/main/java/com/reelshort/ReelShortApplication.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/FruitShortsApplication.java similarity index 58% rename from reelshort-admin-api/src/main/java/com/reelshort/ReelShortApplication.java rename to fruitshorts-admin-api/src/main/java/com/fruitshorts/FruitShortsApplication.java index e2930f1..d5758d3 100644 --- a/reelshort-admin-api/src/main/java/com/reelshort/ReelShortApplication.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/FruitShortsApplication.java @@ -1,14 +1,14 @@ -package com.reelshort; +package com.fruitshorts; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -@MapperScan("com.reelshort.**.dal.mapper") -public class ReelShortApplication { +@MapperScan("com.fruitshorts.**.dal.mapper") +public class FruitShortsApplication { public static void main(String[] args) { - SpringApplication.run(ReelShortApplication.class, args); + SpringApplication.run(FruitShortsApplication.class, args); } } diff --git a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/AdminUserController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AdminUserController.java similarity index 86% rename from reelshort-admin-api/src/main/java/com/reelshort/admin/controller/AdminUserController.java rename to fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AdminUserController.java index 08bbab2..571848c 100644 --- a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/AdminUserController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AdminUserController.java @@ -1,13 +1,13 @@ -package com.reelshort.admin.controller; +package com.fruitshorts.admin.controller; -import com.reelshort.common.pojo.PageResult; -import com.reelshort.common.pojo.Result; -import com.reelshort.framework.security.SecurityUtils; -import com.reelshort.system.service.AdminUserService; -import com.reelshort.framework.log.OperationLog; -import com.reelshort.system.vo.AdminUserPageReqVO; -import com.reelshort.system.vo.AdminUserRespVO; -import com.reelshort.system.vo.AdminUserSaveReqVO; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.system.service.AdminUserService; +import com.fruitshorts.framework.log.OperationLog; +import com.fruitshorts.system.vo.AdminUserPageReqVO; +import com.fruitshorts.system.vo.AdminUserRespVO; +import com.fruitshorts.system.vo.AdminUserSaveReqVO; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AppUserAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AppUserAdminController.java new file mode 100644 index 0000000..fcd38df --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AppUserAdminController.java @@ -0,0 +1,118 @@ +package com.fruitshorts.admin.controller; + +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.admin.vo.AppUserWatchHistoryVO; +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.enums.ContentStatus; +import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.content.service.EpisodeService; +import com.fruitshorts.framework.log.OperationLog; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.playback.dal.entity.WatchHistory; +import com.fruitshorts.playback.service.WatchProgressService; +import com.fruitshorts.user.dal.entity.AppUserBanRecord; +import com.fruitshorts.user.service.AppUserAdminService; +import com.fruitshorts.user.vo.AppUserQueryReqVO; +import com.fruitshorts.user.vo.AppUserAdminRespVO; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** 后台用户管理。 */ +@RestController +@RequestMapping("/admin/app-users") +@RequiredArgsConstructor +public class AppUserAdminController { + + private final AppUserAdminService appUserAdminService; + private final WatchProgressService watchProgressService; + private final DramaService dramaService; + private final EpisodeService episodeService; + + @GetMapping("/page") + @PreAuthorize("@ss.hasPermission('app-user:list')") + public Result> page(AppUserQueryReqVO req) { + return Result.ok(appUserAdminService.page(req)); + } + + @GetMapping("/{id}") + @PreAuthorize("@ss.hasPermission('app-user:list')") + public Result detail(@PathVariable Long id) { + return Result.ok(appUserAdminService.detail(id)); + } + + @GetMapping("/{id}/ban-records") + @PreAuthorize("@ss.hasPermission('app-user:list')") + public Result> banRecords(@PathVariable Long id) { + return Result.ok(appUserAdminService.banRecords(id)); + } + + /** 观看流水明细:客服核对"用户说看了但没记录"这类问题时用,带上剧名与集号。 */ + @GetMapping("/{id}/watch-history") + @PreAuthorize("@ss.hasPermission('app-user:list')") + public Result> watchHistory(@PathVariable Long id, + @RequestParam(defaultValue = "50") int limit) { + List rows = watchProgressService.history(id, limit); + if (rows.isEmpty()) { + return Result.ok(List.of()); + } + // 批量取剧名与集号:一屏 50 行逐条查库就是 100 次往返 + Map dramas = dramaService + .listByIds(rows.stream().map(WatchHistory::getDramaId).distinct().toList()) + .stream().collect(Collectors.toMap(Drama::getId, item -> item, (a, b) -> a)); + Map episodes = episodeService + .listByIds(rows.stream().map(WatchHistory::getEpisodeId).distinct().toList()) + .stream().collect(Collectors.toMap(DramaEpisode::getId, item -> item, (a, b) -> a)); + return Result.ok(rows.stream().map(row -> toVo(row, dramas, episodes)).toList()); + } + + private AppUserWatchHistoryVO toVo(WatchHistory row, Map dramas, + Map episodes) { + AppUserWatchHistoryVO vo = new AppUserWatchHistoryVO(); + vo.setId(row.getId()); + vo.setDramaId(row.getDramaId()); + vo.setEpisodeId(row.getEpisodeId()); + vo.setPositionMs(row.getPositionMs()); + vo.setDurationMs(row.getDurationMs()); + vo.setSource(row.getSource()); + vo.setDeviceId(row.getDeviceId()); + vo.setWatchedAt(row.getWatchedAt()); + long position = row.getPositionMs() == null ? 0L : row.getPositionMs(); + long duration = row.getDurationMs() == null ? 0L : row.getDurationMs(); + vo.setFinished(duration > 0 && position + 1000L >= duration); + Drama drama = dramas.get(row.getDramaId()); + if (drama != null) { + vo.setDramaTitle(drama.getTitle()); + vo.setDramaPublished(ContentStatus.PUBLISHED.getCode().equals(drama.getStatus())); + } else { + vo.setDramaPublished(false); + } + DramaEpisode episode = episodes.get(row.getEpisodeId()); + if (episode != null) { + vo.setEpisodeNo(episode.getEpNo()); + } + return vo; + } + + /** 状态处置:normal/limited/banned。封禁会立即吊销该用户全部会话。 */ + @PutMapping("/{id}/status") + @PreAuthorize("@ss.hasPermission('app-user:update')") + @OperationLog(module = "用户管理", action = "修改用户状态") + public Result updateStatus(@PathVariable Long id, + @RequestParam String status, + @RequestParam(required = false) String reason) { + appUserAdminService.updateStatus(id, status, reason, SecurityUtils.getUserId()); + return Result.ok(); + } +} diff --git a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/AuthController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AuthController.java similarity index 77% rename from reelshort-admin-api/src/main/java/com/reelshort/admin/controller/AuthController.java rename to fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AuthController.java index 3e2862a..1cd72f0 100644 --- a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/AuthController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/AuthController.java @@ -1,15 +1,15 @@ -package com.reelshort.admin.controller; +package com.fruitshorts.admin.controller; -import com.reelshort.common.pojo.Result; -import com.reelshort.framework.security.SecurityUtils; -import com.reelshort.framework.log.OperationLog; -import com.reelshort.framework.web.WebUtils; -import com.reelshort.system.service.AuthService; -import com.reelshort.system.vo.LoginReqVO; -import com.reelshort.system.vo.LoginRespVO; -import com.reelshort.system.vo.MenuVO; -import com.reelshort.system.vo.PasswordChangeReqVO; -import com.reelshort.system.vo.ProfileRespVO; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.framework.log.OperationLog; +import com.fruitshorts.framework.web.WebUtils; +import com.fruitshorts.system.service.AuthService; +import com.fruitshorts.system.vo.LoginReqVO; +import com.fruitshorts.system.vo.LoginRespVO; +import com.fruitshorts.system.vo.MenuVO; +import com.fruitshorts.system.vo.PasswordChangeReqVO; +import com.fruitshorts.system.vo.ProfileRespVO; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; diff --git a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/BannerController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/BannerController.java similarity index 88% rename from reelshort-admin-api/src/main/java/com/reelshort/admin/controller/BannerController.java rename to fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/BannerController.java index 292b3b4..aaee7f6 100644 --- a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/BannerController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/BannerController.java @@ -1,12 +1,12 @@ -package com.reelshort.admin.controller; +package com.fruitshorts.admin.controller; -import com.reelshort.common.pojo.PageResult; -import com.reelshort.common.pojo.Result; -import com.reelshort.content.service.BannerService; -import com.reelshort.content.vo.BannerRespVO; -import com.reelshort.content.vo.BannerSaveReqVO; -import com.reelshort.framework.security.SecurityUtils; -import com.reelshort.framework.log.OperationLog; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.content.service.BannerService; +import com.fruitshorts.content.vo.BannerRespVO; +import com.fruitshorts.content.vo.BannerSaveReqVO; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.framework.log.OperationLog; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/ConfigController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ConfigController.java similarity index 82% rename from reelshort-admin-api/src/main/java/com/reelshort/admin/controller/ConfigController.java rename to fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ConfigController.java index b0bddcb..4393d1a 100644 --- a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/ConfigController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/ConfigController.java @@ -1,9 +1,9 @@ -package com.reelshort.admin.controller; +package com.fruitshorts.admin.controller; -import com.reelshort.common.pojo.Result; -import com.reelshort.system.service.ConfigService; -import com.reelshort.framework.log.OperationLog; -import com.reelshort.system.vo.ConfigItemVO; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.system.service.ConfigService; +import com.fruitshorts.framework.log.OperationLog; +import com.fruitshorts.system.vo.ConfigItemVO; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; diff --git a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/DictController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DictController.java similarity index 81% rename from reelshort-admin-api/src/main/java/com/reelshort/admin/controller/DictController.java rename to fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DictController.java index 1149235..903bafd 100644 --- a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/DictController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DictController.java @@ -1,9 +1,9 @@ -package com.reelshort.admin.controller; +package com.fruitshorts.admin.controller; -import com.reelshort.common.pojo.Result; -import com.reelshort.content.enums.ContentStatus; -import com.reelshort.content.service.DramaCategoryService; -import com.reelshort.content.vo.DramaCategoryRespVO; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.content.enums.ContentStatus; +import com.fruitshorts.content.service.DramaCategoryService; +import com.fruitshorts.content.vo.DramaCategoryRespVO; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DownloadAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DownloadAdminController.java new file mode 100644 index 0000000..682ca41 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DownloadAdminController.java @@ -0,0 +1,56 @@ +package com.fruitshorts.admin.controller; + +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.log.OperationLog; +import com.fruitshorts.playback.service.DownloadService; +import com.fruitshorts.playback.vo.DownloadLicenseRespVO; +import com.fruitshorts.playback.vo.DownloadQueryReqVO; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +/** 后台下载授权管理。 */ +@RestController +@RequestMapping("/admin/downloads") +@RequiredArgsConstructor +public class DownloadAdminController { + + private final DownloadService downloadService; + + @GetMapping("/page") + @PreAuthorize("@ss.hasPermission('download:list')") + public Result> page(DownloadQueryReqVO req) { + return Result.ok(downloadService.page(req)); + } + + @GetMapping("/stats") + @PreAuthorize("@ss.hasPermission('download:list')") + public Result> stats() { + return Result.ok(downloadService.stats()); + } + + /** 吊销单条授权:用户侧下次联网即失效。 */ + @PutMapping("/{id}/revoke") + @PreAuthorize("@ss.hasPermission('download:revoke')") + @OperationLog(module = "下载管理", action = "吊销下载授权") + public Result revoke(@PathVariable Long id) { + downloadService.adminRevoke(id); + return Result.ok(); + } + + /** 手动触发过期清理,正常情况下由定时任务每天执行。 */ + @PostMapping("/cleanup") + @PreAuthorize("@ss.hasPermission('download:revoke')") + @OperationLog(module = "下载管理", action = "清理过期下载授权") + public Result cleanup() { + return Result.ok(downloadService.expireLicenses()); + } +} diff --git a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/DramaCategoryController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DramaCategoryController.java similarity index 84% rename from reelshort-admin-api/src/main/java/com/reelshort/admin/controller/DramaCategoryController.java rename to fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DramaCategoryController.java index 1afd6e1..b77964e 100644 --- a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/DramaCategoryController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DramaCategoryController.java @@ -1,11 +1,11 @@ -package com.reelshort.admin.controller; +package com.fruitshorts.admin.controller; -import com.reelshort.common.pojo.Result; -import com.reelshort.content.service.DramaCategoryService; -import com.reelshort.content.vo.DramaCategoryRespVO; -import com.reelshort.content.vo.DramaCategorySaveReqVO; -import com.reelshort.framework.security.SecurityUtils; -import com.reelshort.framework.log.OperationLog; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.content.service.DramaCategoryService; +import com.fruitshorts.content.vo.DramaCategoryRespVO; +import com.fruitshorts.content.vo.DramaCategorySaveReqVO; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.framework.log.OperationLog; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/DramaController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DramaController.java similarity index 88% rename from reelshort-admin-api/src/main/java/com/reelshort/admin/controller/DramaController.java rename to fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DramaController.java index b32bc05..d469383 100644 --- a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/DramaController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/DramaController.java @@ -1,15 +1,15 @@ -package com.reelshort.admin.controller; +package com.fruitshorts.admin.controller; -import com.reelshort.common.pojo.PageResult; -import com.reelshort.common.pojo.Result; -import com.reelshort.content.dal.entity.DramaReviewRecord; -import com.reelshort.content.service.DramaService; -import com.reelshort.content.vo.DramaPageReqVO; -import com.reelshort.content.vo.DramaRespVO; -import com.reelshort.content.vo.DramaSaveReqVO; -import com.reelshort.content.vo.DramaStatusReqVO; -import com.reelshort.framework.security.SecurityUtils; -import com.reelshort.framework.log.OperationLog; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.content.dal.entity.DramaReviewRecord; +import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.content.vo.DramaPageReqVO; +import com.fruitshorts.content.vo.DramaRespVO; +import com.fruitshorts.content.vo.DramaSaveReqVO; +import com.fruitshorts.content.vo.DramaStatusReqVO; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.framework.log.OperationLog; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/EpisodeController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EpisodeController.java similarity index 87% rename from reelshort-admin-api/src/main/java/com/reelshort/admin/controller/EpisodeController.java rename to fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EpisodeController.java index cf2b569..21d3ff6 100644 --- a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/EpisodeController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/EpisodeController.java @@ -1,11 +1,11 @@ -package com.reelshort.admin.controller; +package com.fruitshorts.admin.controller; -import com.reelshort.common.pojo.Result; -import com.reelshort.content.service.EpisodeService; -import com.reelshort.content.vo.EpisodeRespVO; -import com.reelshort.content.vo.EpisodeSaveReqVO; -import com.reelshort.content.vo.EpisodeSortReqVO; -import com.reelshort.framework.log.OperationLog; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.content.service.EpisodeService; +import com.fruitshorts.content.vo.EpisodeRespVO; +import com.fruitshorts.content.vo.EpisodeSaveReqVO; +import com.fruitshorts.content.vo.EpisodeSortReqVO; +import com.fruitshorts.framework.log.OperationLog; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/MediaController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java similarity index 83% rename from reelshort-admin-api/src/main/java/com/reelshort/admin/controller/MediaController.java rename to fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java index 5347942..1ddc1f6 100644 --- a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/MediaController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MediaController.java @@ -1,12 +1,12 @@ -package com.reelshort.admin.controller; +package com.fruitshorts.admin.controller; -import com.reelshort.common.pojo.Result; -import com.reelshort.framework.security.SecurityUtils; -import com.reelshort.media.service.MediaService; -import com.reelshort.media.vo.MediaAssetRespVO; -import com.reelshort.media.vo.UploadCompleteReqVO; -import com.reelshort.media.vo.UploadInitReqVO; -import com.reelshort.media.vo.UploadInitRespVO; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.media.service.MediaService; +import com.fruitshorts.media.vo.MediaAssetRespVO; +import com.fruitshorts.media.vo.UploadCompleteReqVO; +import com.fruitshorts.media.vo.UploadInitReqVO; +import com.fruitshorts.media.vo.UploadInitRespVO; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/MenuController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MenuController.java similarity index 86% rename from reelshort-admin-api/src/main/java/com/reelshort/admin/controller/MenuController.java rename to fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MenuController.java index 2b9eeb6..1c4ea03 100644 --- a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/MenuController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/MenuController.java @@ -1,10 +1,10 @@ -package com.reelshort.admin.controller; +package com.fruitshorts.admin.controller; -import com.reelshort.common.pojo.Result; -import com.reelshort.system.service.MenuService; -import com.reelshort.framework.log.OperationLog; -import com.reelshort.system.vo.MenuSaveReqVO; -import com.reelshort.system.vo.MenuVO; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.system.service.MenuService; +import com.fruitshorts.framework.log.OperationLog; +import com.fruitshorts.system.vo.MenuSaveReqVO; +import com.fruitshorts.system.vo.MenuVO; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/OperationLogController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/OperationLogController.java similarity index 76% rename from reelshort-admin-api/src/main/java/com/reelshort/admin/controller/OperationLogController.java rename to fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/OperationLogController.java index 22efb8d..f2094b2 100644 --- a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/OperationLogController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/OperationLogController.java @@ -1,10 +1,10 @@ -package com.reelshort.admin.controller; +package com.fruitshorts.admin.controller; -import com.reelshort.common.pojo.PageResult; -import com.reelshort.common.pojo.Result; -import com.reelshort.system.service.OperationLogService; -import com.reelshort.system.vo.OperationLogPageReqVO; -import com.reelshort.system.vo.OperationLogRespVO; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.system.service.OperationLogService; +import com.fruitshorts.system.vo.OperationLogPageReqVO; +import com.fruitshorts.system.vo.OperationLogRespVO; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; diff --git a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/PermissionController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/PermissionController.java similarity index 79% rename from reelshort-admin-api/src/main/java/com/reelshort/admin/controller/PermissionController.java rename to fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/PermissionController.java index 36ecde8..ab15a18 100644 --- a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/PermissionController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/PermissionController.java @@ -1,8 +1,8 @@ -package com.reelshort.admin.controller; +package com.fruitshorts.admin.controller; -import com.reelshort.common.pojo.Result; -import com.reelshort.system.service.PermissionService; -import com.reelshort.system.vo.PermissionGroupVO; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.system.service.PermissionService; +import com.fruitshorts.system.vo.PermissionGroupVO; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; diff --git a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/RoleCategoryController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/RoleCategoryController.java similarity index 86% rename from reelshort-admin-api/src/main/java/com/reelshort/admin/controller/RoleCategoryController.java rename to fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/RoleCategoryController.java index b240212..cfd6b68 100644 --- a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/RoleCategoryController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/RoleCategoryController.java @@ -1,10 +1,10 @@ -package com.reelshort.admin.controller; +package com.fruitshorts.admin.controller; -import com.reelshort.common.pojo.Result; -import com.reelshort.system.service.RoleCategoryService; -import com.reelshort.framework.log.OperationLog; -import com.reelshort.system.vo.RoleCategoryRespVO; -import com.reelshort.system.vo.RoleCategorySaveReqVO; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.system.service.RoleCategoryService; +import com.fruitshorts.framework.log.OperationLog; +import com.fruitshorts.system.vo.RoleCategoryRespVO; +import com.fruitshorts.system.vo.RoleCategorySaveReqVO; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/RoleController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/RoleController.java similarity index 89% rename from reelshort-admin-api/src/main/java/com/reelshort/admin/controller/RoleController.java rename to fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/RoleController.java index fde8ff6..118cf5d 100644 --- a/reelshort-admin-api/src/main/java/com/reelshort/admin/controller/RoleController.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/RoleController.java @@ -1,12 +1,12 @@ -package com.reelshort.admin.controller; +package com.fruitshorts.admin.controller; -import com.reelshort.common.pojo.PageResult; -import com.reelshort.common.pojo.Result; -import com.reelshort.system.service.RoleService; -import com.reelshort.framework.log.OperationLog; -import com.reelshort.system.vo.RolePermissionSaveReqVO; -import com.reelshort.system.vo.RoleRespVO; -import com.reelshort.system.vo.RoleSaveReqVO; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.system.service.RoleService; +import com.fruitshorts.framework.log.OperationLog; +import com.fruitshorts.system.vo.RolePermissionSaveReqVO; +import com.fruitshorts.system.vo.RoleRespVO; +import com.fruitshorts.system.vo.RoleSaveReqVO; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AppUserWatchHistoryVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AppUserWatchHistoryVO.java new file mode 100644 index 0000000..ce0b966 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AppUserWatchHistoryVO.java @@ -0,0 +1,30 @@ +package com.fruitshorts.admin.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 后台观看流水行。 + * + *

比原始流水多了剧名与集号:客服核对问题时看「闪婚后 · 第 3 集」比看 + * 「dramaId=5 / episodeId=9」有用得多。剧已下架时仍然显示剧名,只标注状态。 + */ +@Data +public class AppUserWatchHistoryVO { + + private Long id; + private Long dramaId; + private String dramaTitle; + /** 短剧当前是否已上线,用于识别"用户说看了但剧已下线"的情况 */ + private Boolean dramaPublished; + private Long episodeId; + private Integer episodeNo; + private Long positionMs; + private Long durationMs; + /** 是否已看完(进度到达片尾 1 秒内) */ + private Boolean finished; + private String source; + private String deviceId; + private LocalDateTime watchedAt; +} diff --git a/reelshort-admin-api/src/main/resources/application.yml b/fruitshorts-admin-api/src/main/resources/application.yml similarity index 77% rename from reelshort-admin-api/src/main/resources/application.yml rename to fruitshorts-admin-api/src/main/resources/application.yml index ca7491a..a0f55f5 100644 --- a/reelshort-admin-api/src/main/resources/application.yml +++ b/fruitshorts-admin-api/src/main/resources/application.yml @@ -3,14 +3,14 @@ server: spring: application: - name: reelshort-admin-api + name: fruitshorts-admin-api autoconfigure: exclude: org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration datasource: driver-class-name: org.postgresql.Driver - url: jdbc:postgresql://127.0.0.1:5432/reelshort - username: ${DB_USER:reelshort} - password: ${DB_PASSWORD:reelshort} + url: jdbc:postgresql://127.0.0.1:5432/fruitshorts + username: ${DB_USER:fruitshorts} + password: ${DB_PASSWORD:fruitshorts} jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai @@ -31,7 +31,7 @@ mybatis-plus: logic-delete-value: 1 logic-not-delete-value: 0 -reelshort: +fruitshorts: media: # 本地媒资目录(相对应用启动目录),通过 /files/** 访问 root-dir: ./data/uploads @@ -39,7 +39,7 @@ reelshort: chunk-size: 5242880 max-file-size: 2147483648 jwt: - admin-secret: ${JWT_ADMIN_SECRET:reelshort-admin-dev-secret-change-me-in-production-32b} + admin-secret: ${JWT_ADMIN_SECRET:fruitshorts-admin-dev-secret-change-me-in-production-32b} admin-ttl-hours: 12 cors: allowed-origins: http://localhost:3005,http://127.0.0.1:3005 @@ -50,4 +50,4 @@ springdoc: logging: level: - com.reelshort: debug + com.fruitshorts: debug diff --git a/reelshort-app-api/pom.xml b/fruitshorts-app-api/pom.xml similarity index 53% rename from reelshort-app-api/pom.xml rename to fruitshorts-app-api/pom.xml index 6960f9f..4ef4f00 100644 --- a/reelshort-app-api/pom.xml +++ b/fruitshorts-app-api/pom.xml @@ -5,23 +5,31 @@ 4.0.0 - com.reelshort - reelshort + com.fruitshorts + fruitshorts 1.0.0-SNAPSHOT - reelshort-app-api - reelshort-app-api + fruitshorts-app-api + fruitshorts-app-api 客户端接入层:/app/** - com.reelshort - reelshort-module-content + com.fruitshorts + fruitshorts-module-content - com.reelshort - reelshort-module-media + com.fruitshorts + fruitshorts-module-media + + + com.fruitshorts + fruitshorts-module-user + + + com.fruitshorts + fruitshorts-module-playback diff --git a/reelshort-app-api/src/main/java/com/reelshort/app/AppApiApplication.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/AppApiApplication.java similarity index 51% rename from reelshort-app-api/src/main/java/com/reelshort/app/AppApiApplication.java rename to fruitshorts-app-api/src/main/java/com/fruitshorts/app/AppApiApplication.java index 6028a7b..94ee1ad 100644 --- a/reelshort-app-api/src/main/java/com/reelshort/app/AppApiApplication.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/AppApiApplication.java @@ -1,4 +1,4 @@ -package com.reelshort.app; +package com.fruitshorts.app; import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; @@ -6,11 +6,11 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; /** * 客户端接口服务,只暴露 /app/** 与静态媒资 /files/**。 - * 与 reelshort-admin-api 共用同一套领域模块和数据库,但入口、鉴权和限流策略独立。 + * 与 fruitshorts-admin-api 共用同一套领域模块和数据库,但入口、鉴权和限流策略独立。 */ -/* 显式指定扫描根包:本类在 com.reelshort.app 下,默认扫不到 content/system/framework 模块的 Bean */ -@SpringBootApplication(scanBasePackages = "com.reelshort") -@MapperScan("com.reelshort.**.dal.mapper") +/* 显式指定扫描根包:本类在 com.fruitshorts.app 下,默认扫不到 content/system/framework 模块的 Bean */ +@SpringBootApplication(scanBasePackages = "com.fruitshorts") +@MapperScan("com.fruitshorts.**.dal.mapper") public class AppApiApplication { public static void main(String[] args) { diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java new file mode 100644 index 0000000..d904f9e --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppAuthController.java @@ -0,0 +1,61 @@ +package com.fruitshorts.app.controller; + +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.framework.web.WebUtils; +import com.fruitshorts.user.service.AppAuthService; +import com.fruitshorts.user.service.AppOtpService; +import com.fruitshorts.user.vo.LoginReqVO; +import com.fruitshorts.user.vo.LogoutReqVO; +import com.fruitshorts.user.vo.OtpSendReqVO; +import com.fruitshorts.user.vo.RefreshReqVO; +import com.fruitshorts.user.vo.RegisterReqVO; +import com.fruitshorts.user.vo.TokenRespVO; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** 客户端账号:验证码、注册、登录、刷新、登出。 */ +@RestController +@RequestMapping("/app/auth") +@RequiredArgsConstructor +public class AppAuthController { + + private final AppOtpService otpService; + private final AppAuthService authService; + + /** 发送验证码。频控在服务端,客户端不需要自己限流。 */ + @PostMapping("/otp") + public Result sendOtp(@Valid @RequestBody OtpSendReqVO req, HttpServletRequest request) { + otpService.send(req, WebUtils.getClientIp(request)); + return Result.ok(); + } + + @PostMapping("/register") + public Result register(@Valid @RequestBody RegisterReqVO req, + HttpServletRequest request) { + return Result.ok(authService.register(req, WebUtils.getClientIp(request))); + } + + @PostMapping("/login") + public Result login(@Valid @RequestBody LoginReqVO req, + HttpServletRequest request) { + return Result.ok(authService.login(req, WebUtils.getClientIp(request))); + } + + @PostMapping("/refresh") + public Result refresh(@Valid @RequestBody RefreshReqVO req) { + return Result.ok(authService.refresh(req.getRefreshToken())); + } + + /** 登出:默认只吊销当前设备的会话,allDevices=true 时吊销全部。 */ + @PostMapping("/logout") + public Result logout(@RequestBody(required = false) LogoutReqVO req) { + authService.logout(SecurityUtils.getUserIdOrNull(), req == null ? new LogoutReqVO() : req); + return Result.ok(); + } +} diff --git a/reelshort-app-api/src/main/java/com/reelshort/app/controller/AppCatalogController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java similarity index 80% rename from reelshort-app-api/src/main/java/com/reelshort/app/controller/AppCatalogController.java rename to fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java index 0ebb5a7..937581e 100644 --- a/reelshort-app-api/src/main/java/com/reelshort/app/controller/AppCatalogController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppCatalogController.java @@ -1,9 +1,9 @@ -package com.reelshort.app.controller; +package com.fruitshorts.app.controller; -import com.reelshort.app.service.AppCatalogService; -import com.reelshort.app.vo.AppCategoryVO; -import com.reelshort.app.vo.AppFeedVO; -import com.reelshort.common.pojo.Result; +import com.fruitshorts.app.service.AppCatalogService; +import com.fruitshorts.app.vo.AppCategoryVO; +import com.fruitshorts.app.vo.AppFeedVO; +import com.fruitshorts.common.pojo.Result; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppDownloadController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppDownloadController.java new file mode 100644 index 0000000..300932f --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppDownloadController.java @@ -0,0 +1,96 @@ +package com.fruitshorts.app.controller; + +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.playback.service.DownloadService; +import com.fruitshorts.playback.vo.DownloadGrantReqVO; +import com.fruitshorts.playback.vo.DownloadLicenseRespVO; +import com.fruitshorts.playback.vo.DownloadPolicyRespVO; +import com.fruitshorts.playback.vo.DownloadProgressReqVO; +import com.fruitshorts.playback.vo.DownloadSettingReqVO; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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.List; + +/** + * 离线下载。 + * + *

服务端只发授权与地址,分片由客户端向 CDN 拉取;进度上报用于断点续传与跨端展示。 + * 设备标识优先取请求体,其次取请求头 X-Device-Id。 + */ +@RestController +@RequestMapping("/app/downloads") +@RequiredArgsConstructor +public class AppDownloadController { + + private final DownloadService downloadService; + + /** 申请下载授权(同一剧集重复申请幂等,只刷新有效期)。 */ + @PostMapping + public Result grant(@Valid @RequestBody DownloadGrantReqVO req, + HttpServletRequest request) { + return Result.ok(downloadService.grant(SecurityUtils.getAppUserId(), req, + request.getHeader("X-Device-Id"))); + } + + @GetMapping + public Result> list(@RequestParam(required = false) String deviceId) { + return Result.ok(downloadService.list(SecurityUtils.getAppUserId(), deviceId)); + } + + @GetMapping("/policy") + public Result policy(@RequestParam(required = false) String deviceId) { + return Result.ok(downloadService.policy(SecurityUtils.getAppUserId(), deviceId)); + } + + /** 仅 Wi-Fi 下载、清晰度上限等偏好。 */ + @PutMapping("/settings") + public Result updateSettings(@RequestBody DownloadSettingReqVO req) { + downloadService.updateSettings(SecurityUtils.getAppUserId(), req); + return Result.ok(); + } + + /** 断点续传前重新取一次地址:授权不变,只有地址是短时效的。 */ + @GetMapping("/{episodeId}/url") + public Result refreshUrl(@PathVariable Long episodeId, + HttpServletRequest request) { + return Result.ok(downloadService.refreshUrl(SecurityUtils.getAppUserId(), episodeId, + request.getHeader("X-Device-Id"))); + } + + @PostMapping("/{episodeId}/progress") + public Result reportProgress(@PathVariable Long episodeId, + @RequestBody DownloadProgressReqVO req, + HttpServletRequest request) { + return Result.ok(downloadService.reportProgress(SecurityUtils.getAppUserId(), episodeId, req, + request.getHeader("X-Device-Id"))); + } + + @DeleteMapping("/{episodeId}") + public Result revoke(@PathVariable Long episodeId, + @RequestParam(required = false) String deviceId, + HttpServletRequest request) { + downloadService.revoke(SecurityUtils.getAppUserId(), episodeId, + deviceId != null ? deviceId : request.getHeader("X-Device-Id")); + return Result.ok(); + } + + /** 清空当前设备的全部下载授权。 */ + @DeleteMapping + public Result clear(@RequestParam(required = false) String deviceId, + HttpServletRequest request) { + return Result.ok(downloadService.clear(SecurityUtils.getAppUserId(), + deviceId != null ? deviceId : request.getHeader("X-Device-Id"))); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppDramaController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppDramaController.java new file mode 100644 index 0000000..d5573ad --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppDramaController.java @@ -0,0 +1,43 @@ +package com.fruitshorts.app.controller; + +import com.fruitshorts.app.service.AppCatalogService; +import com.fruitshorts.app.vo.AppDramaDetailVO; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.framework.web.WebUtils; +import com.fruitshorts.playback.service.PlaybackService; +import com.fruitshorts.playback.vo.PlayRespVO; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/app") +@RequiredArgsConstructor +public class AppDramaController { + + private final AppCatalogService appCatalogService; + private final PlaybackService playbackService; + + /** 详情:包含剧集列表,客户端进详情页一次请求即可渲染 */ + @GetMapping("/dramas/{id}") + public Result detail(@PathVariable Long id) { + return Result.ok(appCatalogService.dramaDetail(id)); + } + + /** + * 播放票据:服务端判定权益后签发短时效播放地址。 + * 未登录也能播免费集;付费集未解锁时只返回需要多少金币。 + */ + @GetMapping("/episodes/{id}/play") + public Result play(@PathVariable Long id, HttpServletRequest request) { + return Result.ok(playbackService.play( + SecurityUtils.getUserIdOrNull(), + id, + request.getHeader("X-Device-Id"), + WebUtils.getClientIp(request))); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppFavoriteController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppFavoriteController.java new file mode 100644 index 0000000..6dc12c4 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppFavoriteController.java @@ -0,0 +1,48 @@ +package com.fruitshorts.app.controller; + +import com.fruitshorts.app.service.AppCatalogService; +import com.fruitshorts.app.vo.AppDramaCardVO; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.playback.service.FavoriteService; +import com.fruitshorts.playback.vo.FavoriteToggleReqVO; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** 收藏。 */ +@RestController +@RequestMapping("/app/favorites") +@RequiredArgsConstructor +public class AppFavoriteController { + + private final FavoriteService favoriteService; + private final AppCatalogService appCatalogService; + + /** 切换收藏,返回切换后的状态:true 为已收藏。 */ + @PostMapping("/toggle") + public Result toggle(@Valid @RequestBody FavoriteToggleReqVO req) { + return Result.ok(favoriteService.toggle(SecurityUtils.getAppUserId(), req.getDramaId())); + } + + /** 收藏列表,卡片结构与首页一致。 */ + @GetMapping + public Result> list() { + return Result.ok(appCatalogService.toCards( + favoriteService.listDramas(SecurityUtils.getAppUserId()))); + } + + @DeleteMapping("/{dramaId}") + public Result remove(@PathVariable Long dramaId) { + favoriteService.remove(SecurityUtils.getAppUserId(), dramaId); + return Result.ok(); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppMeController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppMeController.java new file mode 100644 index 0000000..b459202 --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppMeController.java @@ -0,0 +1,63 @@ +package com.fruitshorts.app.controller; + +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.user.dal.entity.AppUserDevice; +import com.fruitshorts.user.service.AppUserService; +import com.fruitshorts.user.vo.AppUserRespVO; +import com.fruitshorts.user.vo.DeactivateReqVO; +import com.fruitshorts.user.vo.ProfileUpdateReqVO; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** 客户端个人中心:资料、设备、注销、数据导出。 */ +@RestController +@RequestMapping("/app/me") +@RequiredArgsConstructor +public class AppMeController { + + private final AppUserService userService; + + @GetMapping + public Result me() { + return Result.ok(userService.me(SecurityUtils.getAppUserId())); + } + + @PutMapping + public Result update(@Valid @RequestBody ProfileUpdateReqVO req) { + return Result.ok(userService.updateProfile(SecurityUtils.getAppUserId(), req)); + } + + @GetMapping("/devices") + public Result> devices() { + return Result.ok(userService.devices(SecurityUtils.getAppUserId())); + } + + /** 商店与隐私法规要求的数据导出,返回账号侧数据;内容侧数据由客户端自行下载。 */ + @GetMapping("/export") + public Result> export() { + return Result.ok(userService.exportAccount(SecurityUtils.getAppUserId())); + } + + /** 申请注销:立即登出全部设备,进入冷静期,期间可撤销。 */ + @PostMapping("/deactivate") + public Result deactivate(@RequestBody(required = false) DeactivateReqVO req) { + return Result.ok(userService.requestDeactivation(SecurityUtils.getAppUserId(), req)); + } + + @PostMapping("/deactivate/cancel") + public Result cancelDeactivate() { + userService.cancelDeactivation(SecurityUtils.getAppUserId()); + return Result.ok(); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppWatchController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppWatchController.java new file mode 100644 index 0000000..524645a --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppWatchController.java @@ -0,0 +1,57 @@ +package com.fruitshorts.app.controller; + +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.playback.service.WatchProgressService; +import com.fruitshorts.playback.vo.ProgressRespVO; +import com.fruitshorts.playback.vo.ProgressSaveReqVO; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +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.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.List; + +/** 观看进度与观看记录。 */ +@RestController +@RequestMapping("/app/watch") +@RequiredArgsConstructor +public class AppWatchController { + + private final WatchProgressService progressService; + + /** 上报进度:客户端每 10 秒一次,切集时立即上报一次。 */ + @PostMapping("/progress") + public Result save(@Valid @RequestBody ProgressSaveReqVO req) { + return Result.ok(progressService.save(SecurityUtils.getAppUserId(), req)); + } + + /** 某部剧的续看位置,进详情页时用它决定"续看第 N 集"。 */ + @GetMapping("/progress") + public Result ofDrama(@RequestParam Long dramaId) { + return Result.ok(progressService.ofDrama(SecurityUtils.getAppUserId(), dramaId)); + } + + @GetMapping("/recent") + public Result> recent(@RequestParam(defaultValue = "20") int limit) { + return Result.ok(progressService.recent(SecurityUtils.getAppUserId(), limit)); + } + + @DeleteMapping("/progress/{dramaId}") + public Result remove(@PathVariable Long dramaId) { + progressService.remove(SecurityUtils.getAppUserId(), dramaId); + return Result.ok(); + } + + @DeleteMapping("/progress") + public Result clear() { + progressService.clear(SecurityUtils.getAppUserId()); + return Result.ok(); + } +} diff --git a/reelshort-app-api/src/main/java/com/reelshort/app/service/AppCatalogService.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java similarity index 75% rename from reelshort-app-api/src/main/java/com/reelshort/app/service/AppCatalogService.java rename to fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java index 5f46175..6aabb83 100644 --- a/reelshort-app-api/src/main/java/com/reelshort/app/service/AppCatalogService.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/service/AppCatalogService.java @@ -1,22 +1,22 @@ -package com.reelshort.app.service; +package com.fruitshorts.app.service; -import com.reelshort.app.vo.AppBannerVO; -import com.reelshort.app.vo.AppCategoryVO; -import com.reelshort.app.vo.AppDramaCardVO; -import com.reelshort.app.vo.AppDramaDetailVO; -import com.reelshort.app.vo.AppEpisodeVO; -import com.reelshort.app.vo.AppFeedVO; -import com.reelshort.app.vo.AppPlayTicketVO; -import com.reelshort.app.vo.AppSectionVO; -import com.reelshort.content.dal.entity.ContentBanner; -import com.reelshort.content.dal.entity.Drama; -import com.reelshort.content.dal.entity.DramaEpisode; -import com.reelshort.content.enums.BannerPosition; -import com.reelshort.content.service.BannerService; -import com.reelshort.content.service.DramaCategoryService; -import com.reelshort.content.service.DramaService; -import com.reelshort.content.service.EpisodeService; -import com.reelshort.content.vo.DramaCategoryRespVO; +import com.fruitshorts.app.vo.AppBannerVO; +import com.fruitshorts.app.vo.AppCategoryVO; +import com.fruitshorts.app.vo.AppDramaCardVO; +import com.fruitshorts.app.vo.AppDramaDetailVO; +import com.fruitshorts.app.vo.AppEpisodeVO; +import com.fruitshorts.app.vo.AppFeedVO; +import com.fruitshorts.app.vo.AppSectionVO; +import com.fruitshorts.content.dal.entity.ContentBanner; +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.enums.BannerPosition; +import com.fruitshorts.content.service.BannerService; +import com.fruitshorts.content.service.DramaCategoryService; +import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.content.service.EpisodeService; +import com.fruitshorts.media.service.PlaybackSigner; +import com.fruitshorts.content.vo.DramaCategoryRespVO; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -41,6 +41,7 @@ public class AppCatalogService { private final EpisodeService episodeService; private final BannerService bannerService; private final DramaCategoryService dramaCategoryService; + private final PlaybackSigner playbackSigner; public AppFeedVO homeFeed() { Map categoryNames = categoryNameMap(); @@ -65,6 +66,17 @@ public class AppCatalogService { .toList(); } + /** + * 供收藏、观看记录等列表复用卡片结构:字段与首页卡片完全一致, + * 客户端可以用同一套渲染逻辑,不用为每个列表各写一份。 + */ + public List toCards(List dramas) { + if (dramas == null || dramas.isEmpty()) { + return List.of(); + } + return cards(dramas, categoryNameMap()); + } + public AppDramaDetailVO dramaDetail(Long dramaId) { Drama drama = dramaService.getPublished(dramaId); List episodes = episodeService.listPublishedByDrama(dramaId); @@ -86,35 +98,6 @@ public class AppCatalogService { return detail; } - /** 播放票据:目前免费集直接给地址,未解锁只返回需要多少金币 */ - public AppPlayTicketVO playTicket(Long episodeId) { - DramaEpisode episode = episodeService.getPublished(episodeId); - dramaService.getPublished(episode.getDramaId()); - - boolean free = episode.getIsFree() != null && episode.getIsFree() == 1; - boolean hasMedia = episode.getVideoUrl() != null && !episode.getVideoUrl().isBlank(); - - AppPlayTicketVO ticket = new AppPlayTicketVO(); - ticket.setEpisodeId(episode.getId()); - ticket.setDramaId(episode.getDramaId()); - ticket.setEpNo(episode.getEpNo()); - ticket.setTitle(episode.getTitle()); - ticket.setDurationSec(episode.getDurationSec()); - ticket.setUnlockCoin(episode.getUnlockCoin()); - if (!free) { - ticket.setAccessible(false); - ticket.setAccessType("COIN_REQUIRED"); - } else if (!hasMedia) { - ticket.setAccessible(false); - ticket.setAccessType("NO_MEDIA"); - } else { - ticket.setAccessible(true); - ticket.setAccessType("FREE"); - ticket.setPlayUrl(episode.getVideoUrl()); - } - return ticket; - } - private List banners(String position) { return bannerService.listActive(position).stream().map(this::toBanner).toList(); } @@ -189,12 +172,21 @@ public class AppCatalogService { vo.setIsFree(free); vo.setLocked(!free); vo.setUnlockCoin(episode.getUnlockCoin()); - if (free && episode.getVideoUrl() != null && !episode.getVideoUrl().isBlank()) { - vo.setPlayUrl(episode.getVideoUrl()); + if (free) { + // 播放地址统一在这里签名:客户端拿不到可长期复用的裸地址 + vo.setPlayUrl(playbackSigner.sign(playableSource(episode))); } return vo; } + /** 有 HLS 多码率就用 HLS,否则退回原文件。 */ + private String playableSource(DramaEpisode episode) { + if (episode.getHlsUrl() != null && !episode.getHlsUrl().isBlank()) { + return episode.getHlsUrl(); + } + return episode.getVideoUrl() == null ? "" : episode.getVideoUrl(); + } + private Map categoryNameMap() { Map names = new HashMap<>(); dramaCategoryService.listBasic().forEach(category -> names.put(category.getId(), category.getName())); diff --git a/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppBannerVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppBannerVO.java similarity index 87% rename from reelshort-app-api/src/main/java/com/reelshort/app/vo/AppBannerVO.java rename to fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppBannerVO.java index 71af5a2..d0a8548 100644 --- a/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppBannerVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppBannerVO.java @@ -1,4 +1,4 @@ -package com.reelshort.app.vo; +package com.fruitshorts.app.vo; import lombok.Data; diff --git a/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppCategoryVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppCategoryVO.java similarity index 83% rename from reelshort-app-api/src/main/java/com/reelshort/app/vo/AppCategoryVO.java rename to fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppCategoryVO.java index 8a120d3..ac4c31d 100644 --- a/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppCategoryVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppCategoryVO.java @@ -1,4 +1,4 @@ -package com.reelshort.app.vo; +package com.fruitshorts.app.vo; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppDramaCardVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java similarity index 94% rename from reelshort-app-api/src/main/java/com/reelshort/app/vo/AppDramaCardVO.java rename to fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java index 387904e..23cd0c3 100644 --- a/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppDramaCardVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaCardVO.java @@ -1,4 +1,4 @@ -package com.reelshort.app.vo; +package com.fruitshorts.app.vo; import lombok.Data; diff --git a/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppDramaDetailVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaDetailVO.java similarity index 93% rename from reelshort-app-api/src/main/java/com/reelshort/app/vo/AppDramaDetailVO.java rename to fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaDetailVO.java index 86fe279..e150438 100644 --- a/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppDramaDetailVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppDramaDetailVO.java @@ -1,4 +1,4 @@ -package com.reelshort.app.vo; +package com.fruitshorts.app.vo; import lombok.Data; diff --git a/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppEpisodeVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEpisodeVO.java similarity index 94% rename from reelshort-app-api/src/main/java/com/reelshort/app/vo/AppEpisodeVO.java rename to fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEpisodeVO.java index d7b9d18..0705867 100644 --- a/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppEpisodeVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppEpisodeVO.java @@ -1,4 +1,4 @@ -package com.reelshort.app.vo; +package com.fruitshorts.app.vo; import lombok.Data; diff --git a/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppFeedVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppFeedVO.java similarity index 90% rename from reelshort-app-api/src/main/java/com/reelshort/app/vo/AppFeedVO.java rename to fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppFeedVO.java index c5e2e63..af6946e 100644 --- a/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppFeedVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppFeedVO.java @@ -1,4 +1,4 @@ -package com.reelshort.app.vo; +package com.fruitshorts.app.vo; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppSectionVO.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppSectionVO.java similarity index 91% rename from reelshort-app-api/src/main/java/com/reelshort/app/vo/AppSectionVO.java rename to fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppSectionVO.java index a9025eb..3976fbf 100644 --- a/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppSectionVO.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/vo/AppSectionVO.java @@ -1,4 +1,4 @@ -package com.reelshort.app.vo; +package com.fruitshorts.app.vo; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/reelshort-app-api/src/main/resources/application.yml b/fruitshorts-app-api/src/main/resources/application.yml similarity index 60% rename from reelshort-app-api/src/main/resources/application.yml rename to fruitshorts-app-api/src/main/resources/application.yml index f5a33fb..61488f9 100644 --- a/reelshort-app-api/src/main/resources/application.yml +++ b/fruitshorts-app-api/src/main/resources/application.yml @@ -3,14 +3,14 @@ server: spring: application: - name: reelshort-app-api + name: fruitshorts-app-api autoconfigure: exclude: org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration datasource: driver-class-name: org.postgresql.Driver - url: jdbc:postgresql://127.0.0.1:5432/reelshort - username: ${DB_USER:reelshort} - password: ${DB_PASSWORD:reelshort} + url: jdbc:postgresql://127.0.0.1:5432/fruitshorts + username: ${DB_USER:fruitshorts} + password: ${DB_PASSWORD:fruitshorts} jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai @@ -26,18 +26,24 @@ mybatis-plus: logic-delete-value: 1 logic-not-delete-value: 0 -reelshort: +fruitshorts: + download: + # 这些值会通过 /app/downloads/policy 下发给客户端,改这里不用发版 + max-per-device: 20 + license-days: 30 + wifi-only-default: true + max-quality: AUTO media: root-dir: ./data/uploads public-prefix: /files chunk-size: 5242880 max-file-size: 2147483648 jwt: - admin-secret: ${JWT_ADMIN_SECRET:reelshort-admin-dev-secret-change-me-in-production-32b} + admin-secret: ${JWT_ADMIN_SECRET:fruitshorts-admin-dev-secret-change-me-in-production-32b} admin-ttl-hours: 12 cors: allowed-origins: "*" logging: level: - com.reelshort: info + com.fruitshorts: info diff --git a/reelshort-common/pom.xml b/fruitshorts-common/pom.xml similarity index 73% rename from reelshort-common/pom.xml rename to fruitshorts-common/pom.xml index 5a2966c..36c4471 100644 --- a/reelshort-common/pom.xml +++ b/fruitshorts-common/pom.xml @@ -5,12 +5,12 @@ 4.0.0 - com.reelshort - reelshort + com.fruitshorts + fruitshorts 1.0.0-SNAPSHOT - reelshort-common - reelshort-common + fruitshorts-common + fruitshorts-common 通用模块:统一响应、分页、异常、工具 diff --git a/reelshort-common/src/main/java/com/reelshort/common/exception/ErrorCode.java b/fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ErrorCode.java similarity index 96% rename from reelshort-common/src/main/java/com/reelshort/common/exception/ErrorCode.java rename to fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ErrorCode.java index 8262e83..8420d13 100644 --- a/reelshort-common/src/main/java/com/reelshort/common/exception/ErrorCode.java +++ b/fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ErrorCode.java @@ -1,4 +1,4 @@ -package com.reelshort.common.exception; +package com.fruitshorts.common.exception; import lombok.Getter; diff --git a/reelshort-common/src/main/java/com/reelshort/common/exception/ServiceException.java b/fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ServiceException.java similarity index 94% rename from reelshort-common/src/main/java/com/reelshort/common/exception/ServiceException.java rename to fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ServiceException.java index 4c3e153..96675cd 100644 --- a/reelshort-common/src/main/java/com/reelshort/common/exception/ServiceException.java +++ b/fruitshorts-common/src/main/java/com/fruitshorts/common/exception/ServiceException.java @@ -1,4 +1,4 @@ -package com.reelshort.common.exception; +package com.fruitshorts.common.exception; import lombok.Getter; diff --git a/reelshort-common/src/main/java/com/reelshort/common/pojo/PageParam.java b/fruitshorts-common/src/main/java/com/fruitshorts/common/pojo/PageParam.java similarity index 94% rename from reelshort-common/src/main/java/com/reelshort/common/pojo/PageParam.java rename to fruitshorts-common/src/main/java/com/fruitshorts/common/pojo/PageParam.java index 59543bb..180e9b8 100644 --- a/reelshort-common/src/main/java/com/reelshort/common/pojo/PageParam.java +++ b/fruitshorts-common/src/main/java/com/fruitshorts/common/pojo/PageParam.java @@ -1,4 +1,4 @@ -package com.reelshort.common.pojo; +package com.fruitshorts.common.pojo; import lombok.Data; diff --git a/reelshort-common/src/main/java/com/reelshort/common/pojo/PageResult.java b/fruitshorts-common/src/main/java/com/fruitshorts/common/pojo/PageResult.java similarity index 91% rename from reelshort-common/src/main/java/com/reelshort/common/pojo/PageResult.java rename to fruitshorts-common/src/main/java/com/fruitshorts/common/pojo/PageResult.java index 12bb6b4..5d08ff0 100644 --- a/reelshort-common/src/main/java/com/reelshort/common/pojo/PageResult.java +++ b/fruitshorts-common/src/main/java/com/fruitshorts/common/pojo/PageResult.java @@ -1,4 +1,4 @@ -package com.reelshort.common.pojo; +package com.fruitshorts.common.pojo; import lombok.AllArgsConstructor; import lombok.Getter; diff --git a/reelshort-common/src/main/java/com/reelshort/common/pojo/Result.java b/fruitshorts-common/src/main/java/com/fruitshorts/common/pojo/Result.java similarity index 91% rename from reelshort-common/src/main/java/com/reelshort/common/pojo/Result.java rename to fruitshorts-common/src/main/java/com/fruitshorts/common/pojo/Result.java index 1e0e7f4..efb5b43 100644 --- a/reelshort-common/src/main/java/com/reelshort/common/pojo/Result.java +++ b/fruitshorts-common/src/main/java/com/fruitshorts/common/pojo/Result.java @@ -1,6 +1,6 @@ -package com.reelshort.common.pojo; +package com.fruitshorts.common.pojo; -import com.reelshort.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ErrorCode; import lombok.Getter; /** diff --git a/reelshort-framework/pom.xml b/fruitshorts-framework/pom.xml similarity index 88% rename from reelshort-framework/pom.xml rename to fruitshorts-framework/pom.xml index d236f8a..2b815d6 100644 --- a/reelshort-framework/pom.xml +++ b/fruitshorts-framework/pom.xml @@ -5,19 +5,19 @@ 4.0.0 - com.reelshort - reelshort + com.fruitshorts + fruitshorts 1.0.0-SNAPSHOT - reelshort-framework - reelshort-framework + fruitshorts-framework + fruitshorts-framework 技术框架:安全、鉴权、持久层、Web 配置 - com.reelshort - reelshort-common + com.fruitshorts + fruitshorts-common diff --git a/reelshort-framework/src/main/java/com/reelshort/framework/config/MybatisPlusConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/config/MybatisPlusConfig.java similarity index 93% rename from reelshort-framework/src/main/java/com/reelshort/framework/config/MybatisPlusConfig.java rename to fruitshorts-framework/src/main/java/com/fruitshorts/framework/config/MybatisPlusConfig.java index f8cfb90..d2eb568 100644 --- a/reelshort-framework/src/main/java/com/reelshort/framework/config/MybatisPlusConfig.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/config/MybatisPlusConfig.java @@ -1,4 +1,4 @@ -package com.reelshort.framework.config; +package com.fruitshorts.framework.config; import com.baomidou.mybatisplus.annotation.DbType; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/config/SchedulingConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/config/SchedulingConfig.java new file mode 100644 index 0000000..3b57d8b --- /dev/null +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/config/SchedulingConfig.java @@ -0,0 +1,10 @@ +package com.fruitshorts.framework.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +/** 打开定时任务:目前用于分区维护与后续的对账、计数聚合。 */ +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/reelshort-framework/src/main/java/com/reelshort/framework/log/AccessDeniedRecorder.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/log/AccessDeniedRecorder.java similarity index 91% rename from reelshort-framework/src/main/java/com/reelshort/framework/log/AccessDeniedRecorder.java rename to fruitshorts-framework/src/main/java/com/fruitshorts/framework/log/AccessDeniedRecorder.java index 12af8af..ffec7d7 100644 --- a/reelshort-framework/src/main/java/com/reelshort/framework/log/AccessDeniedRecorder.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/log/AccessDeniedRecorder.java @@ -1,8 +1,8 @@ -package com.reelshort.framework.log; +package com.fruitshorts.framework.log; -import com.reelshort.framework.security.LoginUser; -import com.reelshort.framework.security.SecurityUtils; -import com.reelshort.framework.web.WebUtils; +import com.fruitshorts.framework.security.LoginUser; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.framework.web.WebUtils; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.ObjectProvider; diff --git a/reelshort-framework/src/main/java/com/reelshort/framework/log/OperationLog.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/log/OperationLog.java similarity index 93% rename from reelshort-framework/src/main/java/com/reelshort/framework/log/OperationLog.java rename to fruitshorts-framework/src/main/java/com/fruitshorts/framework/log/OperationLog.java index c7e6777..e481c68 100644 --- a/reelshort-framework/src/main/java/com/reelshort/framework/log/OperationLog.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/log/OperationLog.java @@ -1,4 +1,4 @@ -package com.reelshort.framework.log; +package com.fruitshorts.framework.log; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/reelshort-framework/src/main/java/com/reelshort/framework/log/OperationLogAspect.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/log/OperationLogAspect.java similarity index 96% rename from reelshort-framework/src/main/java/com/reelshort/framework/log/OperationLogAspect.java rename to fruitshorts-framework/src/main/java/com/fruitshorts/framework/log/OperationLogAspect.java index 75434f1..9201026 100644 --- a/reelshort-framework/src/main/java/com/reelshort/framework/log/OperationLogAspect.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/log/OperationLogAspect.java @@ -1,9 +1,9 @@ -package com.reelshort.framework.log; +package com.fruitshorts.framework.log; import com.fasterxml.jackson.databind.ObjectMapper; -import com.reelshort.framework.security.LoginUser; -import com.reelshort.framework.security.SecurityUtils; -import com.reelshort.framework.web.WebUtils; +import com.fruitshorts.framework.security.LoginUser; +import com.fruitshorts.framework.security.SecurityUtils; +import com.fruitshorts.framework.web.WebUtils; import jakarta.servlet.ServletRequest; import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; diff --git a/reelshort-framework/src/main/java/com/reelshort/framework/log/OperationLogEntry.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/log/OperationLogEntry.java similarity index 93% rename from reelshort-framework/src/main/java/com/reelshort/framework/log/OperationLogEntry.java rename to fruitshorts-framework/src/main/java/com/fruitshorts/framework/log/OperationLogEntry.java index 7dfea77..47935ff 100644 --- a/reelshort-framework/src/main/java/com/reelshort/framework/log/OperationLogEntry.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/log/OperationLogEntry.java @@ -1,4 +1,4 @@ -package com.reelshort.framework.log; +package com.fruitshorts.framework.log; import lombok.Builder; import lombok.Data; diff --git a/reelshort-framework/src/main/java/com/reelshort/framework/log/OperationLogSink.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/log/OperationLogSink.java similarity index 82% rename from reelshort-framework/src/main/java/com/reelshort/framework/log/OperationLogSink.java rename to fruitshorts-framework/src/main/java/com/fruitshorts/framework/log/OperationLogSink.java index ea443c9..24f2b64 100644 --- a/reelshort-framework/src/main/java/com/reelshort/framework/log/OperationLogSink.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/log/OperationLogSink.java @@ -1,4 +1,4 @@ -package com.reelshort.framework.log; +package com.fruitshorts.framework.log; /** * 操作日志落库的扩展点,由业务模块实现(框架层不依赖具体存储)。 diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/partition/PartitionMaintainer.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/partition/PartitionMaintainer.java new file mode 100644 index 0000000..f9d6341 --- /dev/null +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/partition/PartitionMaintainer.java @@ -0,0 +1,81 @@ +package com.fruitshorts.framework.partition; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 分区自动维护。 + * + *

声明式分区不会自己长出来:只建 default 分区的话,数据全堆在 default 里, + * 分区就失去意义,而且往后拆分代价很高。所以启动时和每天凌晨各跑一次: + *

    + *
  1. 确保当前月与未来 N 个月的分区存在;
  2. + *
  3. 按配置清理过期分区(默认关闭,只建不删)。
  4. + *
+ * + *

维护函数在 sql/partition-maintenance.sql,这里只负责按时调用。 + * 函数不存在(例如环境还没执行脚本)时只告警不阻断启动。 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class PartitionMaintainer { + + /** 分区表与各自的保留月数;保留月数 <= 0 表示不清理。 */ + private static final List TABLES = List.of( + new PartitionedTable("watch_history", 24), + new PartitionedTable("playback_ticket", 6), + new PartitionedTable("order_event", 36)); + + private final JdbcTemplate jdbcTemplate; + private final PartitionProperties properties; + + @EventListener(ApplicationReadyEvent.class) + public void onStartup() { + if (properties.isEnabled()) { + maintain(); + } + } + + @Scheduled(cron = "${fruitshorts.partition.cron:0 17 3 * * *}") + public void scheduled() { + if (properties.isEnabled()) { + maintain(); + } + } + + public void maintain() { + for (PartitionedTable table : TABLES) { + try { + Integer created = jdbcTemplate.queryForObject( + "select ensure_monthly_partitions(?, current_date, ?)", + Integer.class, table.name(), properties.getMonthsAhead() + 1); + if (created != null && created > 0) { + log.info("分区维护:{} 新建 {} 个分区", table.name(), created); + } + if (properties.isRetentionEnabled() && table.retentionMonths() > 0) { + Integer dropped = jdbcTemplate.queryForObject( + "select drop_partitions_before(?, (current_date - make_interval(months => ?))::date)", + Integer.class, table.name(), table.retentionMonths()); + if (dropped != null && dropped > 0) { + log.warn("分区维护:{} 清理了 {} 个过期分区", table.name(), dropped); + } + } + } catch (Exception ex) { + // 表还没建或函数没装:不阻断启动,但要说清楚怎么办 + log.warn("分区维护跳过 {}:{}(确认已执行 sql/partition-maintenance.sql)", + table.name(), ex.getMessage()); + } + } + } + + private record PartitionedTable(String name, int retentionMonths) { + } +} diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/partition/PartitionProperties.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/partition/PartitionProperties.java new file mode 100644 index 0000000..397b291 --- /dev/null +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/partition/PartitionProperties.java @@ -0,0 +1,27 @@ +package com.fruitshorts.framework.partition; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** 分区维护配置。 */ +@Data +@Component +@ConfigurationProperties(prefix = "fruitshorts.partition") +public class PartitionProperties { + + /** 是否启用自动维护。关掉之后需要人工执行 sql/partition-maintenance.sql。 */ + private boolean enabled = true; + + /** 预建未来几个月的分区。默认 3 个月,跨月时即使服务停了一周也不会漏。 */ + private int monthsAhead = 3; + + /** + * 是否自动清理过期分区。 + * 默认关闭:删数据是不可逆操作,第一阶段先只建不删,等保留策略与合规确认后再打开。 + */ + private boolean retentionEnabled = false; + + /** 每日维护时间(cron)。默认凌晨 3:17,避开印尼晚高峰。 */ + private String cron = "0 17 3 * * *"; +} diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/AppUserStatusChecker.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/AppUserStatusChecker.java new file mode 100644 index 0000000..a108147 --- /dev/null +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/AppUserStatusChecker.java @@ -0,0 +1,17 @@ +package com.fruitshorts.framework.security; + +/** + * C 端账号状态校验端口。 + * + *

access token 是无状态的,签发出去以后无法收回。封禁、注销、改密这类操作 + * 如果只吊销 refresh token,用户最长还能继续用 2 小时,这不满足"处置立即生效"的要求。 + * 所以每个带 C 端身份的请求都要确认一次账号是否仍然可用。 + * + *

框架只依赖这个接口,实现放在用户模块;接入 Redis 后在这里加一层缓存, + * 把每次请求的库查询降为内存命中。 + */ +public interface AppUserStatusChecker { + + /** 返回 true 表示账号可继续访问。 */ + boolean isActive(Long userId); +} diff --git a/reelshort-framework/src/main/java/com/reelshort/framework/security/JwtAuthenticationFilter.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/JwtAuthenticationFilter.java similarity index 68% rename from reelshort-framework/src/main/java/com/reelshort/framework/security/JwtAuthenticationFilter.java rename to fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/JwtAuthenticationFilter.java index 00b4d9c..8b8acd2 100644 --- a/reelshort-framework/src/main/java/com/reelshort/framework/security/JwtAuthenticationFilter.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/JwtAuthenticationFilter.java @@ -1,10 +1,11 @@ -package com.reelshort.framework.security; +package com.fruitshorts.framework.security; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; @@ -26,6 +27,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final String PREFIX = "Bearer "; private final JwtTokenProvider tokenProvider; + private final ObjectProvider statusCheckerProvider; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, @@ -34,7 +36,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { if (StringUtils.hasText(header) && header.startsWith(PREFIX)) { String token = header.substring(PREFIX.length()).trim(); try { - LoginUser loginUser = tokenProvider.parseAdminToken(token); + // typ 决定用哪把密钥验签:管理端 token 无法冒充客户端身份,反之亦然 + LoginUser loginUser = tokenProvider.parse(token); + if (isInactiveAppUser(loginUser)) { + // 账号已被封禁/注销:当作未登录处理,后续 401 会引导客户端重新登录 + SecurityContextHolder.clearContext(); + filterChain.doFilter(request, response); + return; + } List authorities = loginUser.permissions() == null ? List.of() : loginUser.permissions().stream().map(SimpleGrantedAuthority::new).toList(); @@ -48,4 +57,12 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { } filterChain.doFilter(request, response); } + + private boolean isInactiveAppUser(LoginUser loginUser) { + if (!SecurityUtils.isAppUser(loginUser)) { + return false; + } + AppUserStatusChecker checker = statusCheckerProvider.getIfAvailable(); + return checker != null && !checker.isActive(loginUser.userId()); + } } diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/JwtProperties.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/JwtProperties.java new file mode 100644 index 0000000..cef053f --- /dev/null +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/JwtProperties.java @@ -0,0 +1,30 @@ +package com.fruitshorts.framework.security; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Data +@Component +@ConfigurationProperties(prefix = "fruitshorts.jwt") +public class JwtProperties { + + /** 管理端签名密钥,长度必须 >= 32 字节。生产环境通过环境变量注入。 */ + private String adminSecret = "fruitshorts-admin-dev-secret-change-me-in-production-32b"; + + /** 管理端 token 有效期(小时)。 */ + private int adminTtlHours = 12; + + /** + * 客户端签名密钥。与管理端物理隔离:管理端 token 拿到客户端接口上无法通过验签, + * 反之亦然。生产环境必须通过环境变量注入,不要沿用默认值。 + */ + private String appSecret = "fruitshorts-app-dev-secret-change-me-in-production-32bytes"; + + /** 客户端 access token 有效期(分钟)。 */ + private int appAccessTtlMinutes = 120; + + /** 客户端 refresh token 有效期(天)。 */ + private int appRefreshTtlDays = 30; + +} diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/JwtTokenProvider.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/JwtTokenProvider.java new file mode 100644 index 0000000..63dfc5b --- /dev/null +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/JwtTokenProvider.java @@ -0,0 +1,168 @@ +package com.fruitshorts.framework.security; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.util.Base64; +import java.util.Date; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private static final String CLAIM_USER_ID = "userId"; + private static final String CLAIM_ROLE_CODE = "roleCode"; + private static final String CLAIM_PERMISSIONS = "permissions"; + private static final String CLAIM_TOKEN_TYPE = "typ"; + private static final String CLAIM_DEVICE_ID = "did"; + + public static final String TYPE_ADMIN = "admin"; + public static final String TYPE_APP = "app"; + /** C 端登录态统一挂在登录载体上,权限为空,避免误入后台鉴权链路。 */ + public static final String APP_ROLE_CODE = "APP_USER"; + + private final JwtProperties properties; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public String createAdminToken(LoginUser loginUser) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + Duration.ofHours(properties.getAdminTtlHours()).toMillis()); + return Jwts.builder() + .subject(loginUser.username()) + .claim(CLAIM_USER_ID, loginUser.userId()) + .claim(CLAIM_ROLE_CODE, loginUser.roleCode()) + .claim(CLAIM_PERMISSIONS, loginUser.permissions()) + .claim(CLAIM_TOKEN_TYPE, TYPE_ADMIN) + .issuedAt(now) + .expiration(expiration) + .signWith(adminKey()) + .compact(); + } + + /** 客户端 access token:只带用户身份与设备,不带任何后台权限。 */ + public String createAppToken(Long userId, String uid, String deviceId) { + Date now = new Date(); + Date expiration = new Date(now.getTime() + + Duration.ofMinutes(properties.getAppAccessTtlMinutes()).toMillis()); + return Jwts.builder() + .subject(uid) + .claim(CLAIM_USER_ID, userId) + .claim(CLAIM_ROLE_CODE, APP_ROLE_CODE) + .claim(CLAIM_PERMISSIONS, List.of()) + .claim(CLAIM_TOKEN_TYPE, TYPE_APP) + .claim(CLAIM_DEVICE_ID, deviceId == null ? "" : deviceId) + .issuedAt(now) + .expiration(expiration) + .signWith(appKey()) + .compact(); + } + + @SuppressWarnings("unchecked") + public LoginUser parseAppToken(String token) { + try { + Claims claims = Jwts.parser() + .verifyWith(appKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + Number userId = claims.get(CLAIM_USER_ID, Number.class); + return new LoginUser( + userId == null ? null : userId.longValue(), + claims.getSubject(), + APP_ROLE_CODE, + (List) claims.get(CLAIM_PERMISSIONS, List.class)); + } catch (Exception ex) { + throw new ServiceException(ErrorCode.UNAUTHORIZED); + } + } + + /** 按 token 自带的 typ 选择验签密钥。读 typ 时不验签,只用于选路。 */ + public LoginUser parse(String token) { + return TYPE_APP.equals(peekType(token)) ? parseAppToken(token) : parseAdminToken(token); + } + + public String deviceIdOf(String token) { + try { + Claims claims = Jwts.parser() + .verifyWith(appKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + String deviceId = claims.get(CLAIM_DEVICE_ID, String.class); + return deviceId == null ? "" : deviceId; + } catch (Exception ex) { + return ""; + } + } + + private String peekType(String token) { + try { + String[] parts = token.split("\\."); + if (parts.length < 2) { + return TYPE_ADMIN; + } + byte[] payload = Base64.getUrlDecoder().decode(parts[1]); + JsonNode node = objectMapper.readTree(payload); + JsonNode type = node.get(CLAIM_TOKEN_TYPE); + return type == null ? TYPE_ADMIN : type.asText(); + } catch (Exception ex) { + return TYPE_ADMIN; + } + } + + /** refresh token 是不透明随机串,只在库里存哈希,这里不参与 JWT。 */ + public Duration appRefreshTtl() { + return Duration.ofDays(properties.getAppRefreshTtlDays()); + } + + public long accessTtlSeconds() { + return Duration.ofMinutes(properties.getAppAccessTtlMinutes()).toSeconds(); + } + + @SuppressWarnings("unchecked") + public LoginUser parseAdminToken(String token) { + try { + Claims claims = Jwts.parser() + .verifyWith(adminKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + Number userId = claims.get(CLAIM_USER_ID, Number.class); + return new LoginUser( + userId == null ? null : userId.longValue(), + claims.getSubject(), + claims.get(CLAIM_ROLE_CODE, String.class), + (List) claims.get(CLAIM_PERMISSIONS, List.class)); + } catch (Exception ex) { + throw new ServiceException(ErrorCode.UNAUTHORIZED); + } + } + + private SecretKey adminKey() { + return keyOf(properties.getAdminSecret(), "fruitshorts.jwt.admin-secret"); + } + + private SecretKey appKey() { + return keyOf(properties.getAppSecret(), "fruitshorts.jwt.app-secret"); + } + + private SecretKey keyOf(String secret, String property) { + byte[] bytes = secret.getBytes(StandardCharsets.UTF_8); + if (bytes.length < 32) { + throw new IllegalStateException(property + " 长度必须不少于 32 字节"); + } + return Keys.hmacShaKeyFor(bytes); + } +} diff --git a/reelshort-framework/src/main/java/com/reelshort/framework/security/LoginUser.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/LoginUser.java similarity index 88% rename from reelshort-framework/src/main/java/com/reelshort/framework/security/LoginUser.java rename to fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/LoginUser.java index 2a18102..7a03dc4 100644 --- a/reelshort-framework/src/main/java/com/reelshort/framework/security/LoginUser.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/LoginUser.java @@ -1,4 +1,4 @@ -package com.reelshort.framework.security; +package com.fruitshorts.framework.security; import java.util.List; diff --git a/reelshort-framework/src/main/java/com/reelshort/framework/security/PermissionChecker.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/PermissionChecker.java similarity index 97% rename from reelshort-framework/src/main/java/com/reelshort/framework/security/PermissionChecker.java rename to fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/PermissionChecker.java index dd4d79c..4fac6f1 100644 --- a/reelshort-framework/src/main/java/com/reelshort/framework/security/PermissionChecker.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/PermissionChecker.java @@ -1,4 +1,4 @@ -package com.reelshort.framework.security; +package com.fruitshorts.framework.security; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.ObjectProvider; diff --git a/reelshort-framework/src/main/java/com/reelshort/framework/security/PermissionProvider.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/PermissionProvider.java similarity index 87% rename from reelshort-framework/src/main/java/com/reelshort/framework/security/PermissionProvider.java rename to fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/PermissionProvider.java index 28aa3f0..8c0b6d6 100644 --- a/reelshort-framework/src/main/java/com/reelshort/framework/security/PermissionProvider.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/PermissionProvider.java @@ -1,4 +1,4 @@ -package com.reelshort.framework.security; +package com.fruitshorts.framework.security; import java.util.List; diff --git a/reelshort-framework/src/main/java/com/reelshort/framework/security/RestAuthEntryPoint.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/RestAuthEntryPoint.java similarity index 90% rename from reelshort-framework/src/main/java/com/reelshort/framework/security/RestAuthEntryPoint.java rename to fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/RestAuthEntryPoint.java index d7af7c4..dc48a65 100644 --- a/reelshort-framework/src/main/java/com/reelshort/framework/security/RestAuthEntryPoint.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/RestAuthEntryPoint.java @@ -1,9 +1,9 @@ -package com.reelshort.framework.security; +package com.fruitshorts.framework.security; import com.fasterxml.jackson.databind.ObjectMapper; -import com.reelshort.common.exception.ErrorCode; -import com.reelshort.common.pojo.Result; -import com.reelshort.framework.log.AccessDeniedRecorder; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.log.AccessDeniedRecorder; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; diff --git a/reelshort-framework/src/main/java/com/reelshort/framework/security/SecurityConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java similarity index 81% rename from reelshort-framework/src/main/java/com/reelshort/framework/security/SecurityConfig.java rename to fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java index cd32eff..2c2db23 100644 --- a/reelshort-framework/src/main/java/com/reelshort/framework/security/SecurityConfig.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java @@ -1,4 +1,4 @@ -package com.reelshort.framework.security; +package com.fruitshorts.framework.security; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -27,9 +27,20 @@ public class SecurityConfig { private static final String[] PUBLIC_PATHS = { "/admin/auth/login", - /* 客户端接口:浏览与播放暂不要求登录,登录体系(手机号/邮箱)在 C 端鉴权落地后收紧 */ - "/app/**", - /* 本地媒资:图片与视频用 /

设计要点:签名只覆盖路径与过期时间,不绑定用户,这样 CDN 可以做纯 URL 缓存; + * 需要按用户吊销时走票据表(playback_ticket)与并发设备限制,而不是把用户信息塞进 URL。 + */ +@Component +@RequiredArgsConstructor +public class PlaybackSigner { + + public static final String PARAM_EXPIRE = "e"; + public static final String PARAM_SIGNATURE = "s"; + + private final MediaProperties properties; + + /** 给相对路径补上 e / s 参数,返回可直接播放的地址。 */ + public String sign(String path) { + if (path == null || path.isBlank()) { + return ""; + } + long expireAt = Instant.now().plusSeconds(properties.getSignatureTtlMinutes() * 60L).getEpochSecond(); + String signature = hmac(path, expireAt); + String separator = path.contains("?") ? "&" : "?"; + return path + separator + PARAM_EXPIRE + "=" + expireAt + "&" + PARAM_SIGNATURE + "=" + signature; + } + + /** 校验签名。签名缺失、过期或被篡改都返回 false。 */ + public boolean verify(String path, String expire, String signature) { + if (path == null || path.isBlank() || expire == null || signature == null || signature.isBlank()) { + return false; + } + long expireAt; + try { + expireAt = Long.parseLong(expire); + } catch (NumberFormatException ex) { + return false; + } + if (expireAt < Instant.now().getEpochSecond()) { + return false; + } + byte[] expected = hmac(path, expireAt).getBytes(StandardCharsets.UTF_8); + byte[] actual = signature.getBytes(StandardCharsets.UTF_8); + return MessageDigest.isEqual(expected, actual); + } + + private String hmac(String path, long expireAt) { + try { + byte[] key = properties.getSignatureSecret().getBytes(StandardCharsets.UTF_8); + if (key.length < 32) { + throw new IllegalStateException("fruitshorts.media.signature-secret 长度必须不少于 32 字节"); + } + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + mac.update(path.getBytes(StandardCharsets.UTF_8)); + mac.update((byte) '|'); + mac.update(String.valueOf(expireAt).getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(mac.doFinal()); + } catch (IllegalStateException ex) { + throw ex; + } catch (Exception ex) { + throw new IllegalStateException("播放地址签名失败", ex); + } + } +} diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java new file mode 100644 index 0000000..a96553d --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/service/TranscodeService.java @@ -0,0 +1,122 @@ +package com.fruitshorts.media.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.media.dal.entity.MediaRendition; +import com.fruitshorts.media.dal.entity.TranscodeTask; +import com.fruitshorts.media.dal.mapper.MediaRenditionMapper; +import com.fruitshorts.media.dal.mapper.TranscodeTaskMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 转码任务编排。 + * + *

第一阶段只做任务与产物的记账:真正的 FFmpeg / 云转码执行放在独立的 worker 里, + * 通过 transcode_task 表认领任务(QUEUED → RUNNING → SUCCESS/FAILED), + * 与在线服务物理隔离,避免转码抢占播放链路的 CPU。 + */ +@Service +@RequiredArgsConstructor +public class TranscodeService { + + public static final String STATUS_QUEUED = "QUEUED"; + public static final String STATUS_RUNNING = "RUNNING"; + public static final String STATUS_SUCCESS = "SUCCESS"; + public static final String STATUS_FAILED = "FAILED"; + private static final int MAX_RETRY = 3; + + private final TranscodeTaskMapper transcodeTaskMapper; + private final MediaRenditionMapper mediaRenditionMapper; + + /** 上传完成后入队;同一剧集重复入队时复用未完成的任务,避免重复转码。 */ + @Transactional(rollbackFor = Exception.class) + public Long enqueue(Long mediaAssetId, Long dramaId, Long episodeId, String sourceUrl) { + TranscodeTask existing = transcodeTaskMapper.selectOne(Wrappers.lambdaQuery() + .eq(TranscodeTask::getEpisodeId, episodeId) + .in(TranscodeTask::getStatus, List.of(STATUS_QUEUED, STATUS_RUNNING)) + .last("limit 1")); + if (existing != null) { + return existing.getId(); + } + TranscodeTask task = new TranscodeTask(); + task.setMediaAssetId(mediaAssetId == null ? 0L : mediaAssetId); + task.setDramaId(dramaId == null ? 0L : dramaId); + task.setEpisodeId(episodeId == null ? 0L : episodeId); + task.setSourceUrl(sourceUrl == null ? "" : sourceUrl); + task.setProfile("default"); + task.setStatus(STATUS_QUEUED); + task.setProgress(0); + task.setRetryCount(0); + task.setErrorMsg(""); + transcodeTaskMapper.insert(task); + return task.getId(); + } + + /** worker 认领任务。并发下依赖状态条件更新,抢不到返回 null。 */ + public TranscodeTask claimNext() { + TranscodeTask candidate = transcodeTaskMapper.selectOne(Wrappers.lambdaQuery() + .eq(TranscodeTask::getStatus, STATUS_QUEUED) + .orderByAsc(TranscodeTask::getId) + .last("limit 1")); + if (candidate == null) { + return null; + } + int updated = transcodeTaskMapper.update(null, Wrappers.lambdaUpdate() + .eq(TranscodeTask::getId, candidate.getId()) + .eq(TranscodeTask::getStatus, STATUS_QUEUED) + .set(TranscodeTask::getStatus, STATUS_RUNNING) + .set(TranscodeTask::getStartedAt, LocalDateTime.now())); + return updated == 1 ? candidate : null; + } + + @Transactional(rollbackFor = Exception.class) + public void succeed(Long taskId, List renditions) { + TranscodeTask task = getExisting(taskId); + for (MediaRendition rendition : renditions) { + rendition.setMediaAssetId(task.getMediaAssetId()); + rendition.setDramaId(task.getDramaId()); + rendition.setEpisodeId(task.getEpisodeId()); + rendition.setStatus("ready"); + mediaRenditionMapper.insert(rendition); + } + task.setStatus(STATUS_SUCCESS); + task.setProgress(100); + task.setFinishedAt(LocalDateTime.now()); + transcodeTaskMapper.updateById(task); + } + + /** 失败重试最多 3 次,超过则停在 FAILED,由运营在后台看到并处理。 */ + public void fail(Long taskId, String errorMessage) { + TranscodeTask task = getExisting(taskId); + int retry = task.getRetryCount() == null ? 0 : task.getRetryCount(); + if (retry + 1 >= MAX_RETRY) { + task.setStatus(STATUS_FAILED); + } else { + task.setStatus(STATUS_QUEUED); + } + task.setRetryCount(retry + 1); + task.setErrorMsg(errorMessage == null ? "" : errorMessage); + task.setFinishedAt(LocalDateTime.now()); + transcodeTaskMapper.updateById(task); + } + + public List listRenditions(Long episodeId) { + return mediaRenditionMapper.selectList(Wrappers.lambdaQuery() + .eq(MediaRendition::getEpisodeId, episodeId) + .orderByAsc(MediaRendition::getVideoBitrate)); + } + + private TranscodeTask getExisting(Long id) { + TranscodeTask task = id == null ? null : transcodeTaskMapper.selectById(id); + if (task == null) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "转码任务不存在"); + } + return task; + } +} diff --git a/reelshort-module-media/src/main/java/com/reelshort/media/vo/MediaAssetRespVO.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaAssetRespVO.java similarity index 93% rename from reelshort-module-media/src/main/java/com/reelshort/media/vo/MediaAssetRespVO.java rename to fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaAssetRespVO.java index 6c32302..a4ce7e2 100644 --- a/reelshort-module-media/src/main/java/com/reelshort/media/vo/MediaAssetRespVO.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/MediaAssetRespVO.java @@ -1,4 +1,4 @@ -package com.reelshort.media.vo; +package com.fruitshorts.media.vo; import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; diff --git a/reelshort-module-media/src/main/java/com/reelshort/media/vo/UploadCompleteReqVO.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/UploadCompleteReqVO.java similarity index 88% rename from reelshort-module-media/src/main/java/com/reelshort/media/vo/UploadCompleteReqVO.java rename to fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/UploadCompleteReqVO.java index 4dbcb79..c3f958d 100644 --- a/reelshort-module-media/src/main/java/com/reelshort/media/vo/UploadCompleteReqVO.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/UploadCompleteReqVO.java @@ -1,4 +1,4 @@ -package com.reelshort.media.vo; +package com.fruitshorts.media.vo; import jakarta.validation.constraints.NotBlank; import lombok.Data; diff --git a/reelshort-module-media/src/main/java/com/reelshort/media/vo/UploadInitReqVO.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/UploadInitReqVO.java similarity index 93% rename from reelshort-module-media/src/main/java/com/reelshort/media/vo/UploadInitReqVO.java rename to fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/UploadInitReqVO.java index 4c9b8d3..3ab3c72 100644 --- a/reelshort-module-media/src/main/java/com/reelshort/media/vo/UploadInitReqVO.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/UploadInitReqVO.java @@ -1,4 +1,4 @@ -package com.reelshort.media.vo; +package com.fruitshorts.media.vo; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Positive; diff --git a/reelshort-module-media/src/main/java/com/reelshort/media/vo/UploadInitRespVO.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/UploadInitRespVO.java similarity index 87% rename from reelshort-module-media/src/main/java/com/reelshort/media/vo/UploadInitRespVO.java rename to fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/UploadInitRespVO.java index 2749d44..51d3af6 100644 --- a/reelshort-module-media/src/main/java/com/reelshort/media/vo/UploadInitRespVO.java +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/vo/UploadInitRespVO.java @@ -1,4 +1,4 @@ -package com.reelshort.media.vo; +package com.fruitshorts.media.vo; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/fruitshorts-module-media/src/main/java/com/fruitshorts/media/web/MediaSignatureFilter.java b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/web/MediaSignatureFilter.java new file mode 100644 index 0000000..af2ca66 --- /dev/null +++ b/fruitshorts-module-media/src/main/java/com/fruitshorts/media/web/MediaSignatureFilter.java @@ -0,0 +1,66 @@ +package com.fruitshorts.media.web; + +import com.fruitshorts.media.config.MediaProperties; +import com.fruitshorts.media.service.PlaybackSigner; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +/** + * 片源防盗链:开启 fruitshorts.media.signature-required 后, + * /files/episode/** 必须携带有效的 e / s 参数,否则 403。 + * 封面、轮播图等运营素材不受影响。 + */ +@Component +@RequiredArgsConstructor +public class MediaSignatureFilter extends OncePerRequestFilter { + + private static final AntPathMatcher MATCHER = new AntPathMatcher(); + + private final MediaProperties properties; + private final PlaybackSigner signer; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + if (!properties.isSignatureRequired() || !isProtected(request.getRequestURI())) { + filterChain.doFilter(request, response); + return; + } + boolean valid = signer.verify( + request.getRequestURI(), + request.getParameter(PlaybackSigner.PARAM_EXPIRE), + request.getParameter(PlaybackSigner.PARAM_SIGNATURE)); + if (!valid) { + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write("{\"code\":403,\"message\":\"播放地址无效或已过期\",\"data\":null}"); + return; + } + filterChain.doFilter(request, response); + } + + private boolean isProtected(String uri) { + String prefixes = properties.getProtectedPrefixes(); + if (prefixes == null || prefixes.isBlank()) { + return false; + } + for (String prefix : prefixes.split(",")) { + String trimmed = prefix.trim(); + if (!trimmed.isEmpty() + && (MATCHER.match(trimmed + "**", uri) || uri.startsWith(trimmed))) { + return true; + } + } + return false; + } +} diff --git a/fruitshorts-module-playback/pom.xml b/fruitshorts-module-playback/pom.xml new file mode 100644 index 0000000..012ef19 --- /dev/null +++ b/fruitshorts-module-playback/pom.xml @@ -0,0 +1,35 @@ + + + 4.0.0 + + + com.fruitshorts + fruitshorts + 1.0.0-SNAPSHOT + + + fruitshorts-module-playback + fruitshorts-module-playback + 播放域:播放票据、观看进度、收藏、下载授权 + + + + com.fruitshorts + fruitshorts-framework + + + com.fruitshorts + fruitshorts-module-content + + + com.fruitshorts + fruitshorts-module-media + + + com.fruitshorts + fruitshorts-module-user + + + diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/config/DownloadProperties.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/config/DownloadProperties.java new file mode 100644 index 0000000..56bf3ff --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/config/DownloadProperties.java @@ -0,0 +1,33 @@ +package com.fruitshorts.playback.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 离线下载策略。这些值同时通过 /app/downloads/policy 下发给客户端, + * 客户端不要自己写死,否则改策略要发版。 + */ +@Data +@Component +@ConfigurationProperties(prefix = "fruitshorts.download") +public class DownloadProperties { + + /** 单设备最多同时保有的下载授权数。 */ + private int maxPerDevice = 20; + + /** 下载授权有效期(天)。到期后客户端必须联网续期,否则不可离线播放。 */ + private int licenseDays = 30; + + /** 默认是否仅 Wi-Fi 下载,用户可在客户端改。 */ + private boolean wifiOnlyDefault = true; + + /** 允许下载的最高清晰度,AUTO 表示不限制。 */ + private String maxQuality = "AUTO"; + + /** 预留:单设备下载占用上限(字节),0 表示不限制。 */ + private long maxStorageBytes = 0L; + + /** 过期授权清理任务(cron)。 */ + private String expireCron = "0 25 3 * * *"; +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/DownloadLicense.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/DownloadLicense.java new file mode 100644 index 0000000..e253c76 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/DownloadLicense.java @@ -0,0 +1,38 @@ +package com.fruitshorts.playback.dal.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 离线下载授权:绑定用户 + 剧集 + 设备,有有效期,可吊销。 */ +@Data +@TableName("download_license") +public class DownloadLicense { + + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private Long dramaId; + private Long episodeId; + private String deviceId; + /** 客户端离线播放时用它校验授权,不落库明文以外的信息 */ + private String licenseKey; + /** ACTIVE/EXPIRED/REVOKED */ + private String status; + private LocalDateTime grantedAt; + private LocalDateTime expireAt; + private LocalDateTime revokedAt; + private LocalDateTime createdAt; + + @TableField(updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/DownloadTask.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/DownloadTask.java new file mode 100644 index 0000000..9ed98a2 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/DownloadTask.java @@ -0,0 +1,44 @@ +package com.fruitshorts.playback.dal.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 下载任务与断点续传进度。 + * 分片由客户端直接从 CDN 拉取,服务端只记账,用于跨端展示与恢复下载。 + */ +@Data +@TableName("download_task") +public class DownloadTask { + + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private String deviceId; + private Long dramaId; + private Long episodeId; + private Long licenseId; + private String quality; + /** QUEUED/DOWNLOADING/PAUSED/COMPLETED/FAILED/CANCELED */ + private String status; + private Integer progressPercent; + private Long downloadedBytes; + private Long totalBytes; + private String errorMsg; + private LocalDateTime startedAt; + private LocalDateTime finishedAt; + private LocalDateTime createdAt; + + @TableField(updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/PlaybackTicket.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/PlaybackTicket.java new file mode 100644 index 0000000..8d55896 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/PlaybackTicket.java @@ -0,0 +1,38 @@ +package com.fruitshorts.playback.dal.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 播放票据的签发审计记录;真正的鉴权在签名 URL 上。 */ +@Data +@TableName("playback_ticket") +public class PlaybackTicket { + + @TableId(type = IdType.AUTO) + private Long id; + private String ticketNo; + private Long userId; + private Long dramaId; + private Long episodeId; + private String quality; + private String playUrl; + private LocalDateTime expireAt; + private String ip; + private String deviceId; + /** ISSUED/USED/REVOKED */ + private String status; + private LocalDateTime createdAt; + + @TableField(updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/UserFavorite.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/UserFavorite.java new file mode 100644 index 0000000..9380393 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/UserFavorite.java @@ -0,0 +1,30 @@ +package com.fruitshorts.playback.dal.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 剧集收藏。 */ +@Data +@TableName("user_favorite") +public class UserFavorite { + + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private Long dramaId; + private Integer sort; + private LocalDateTime createdAt; + + @TableField(updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/WatchHistory.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/WatchHistory.java new file mode 100644 index 0000000..320899d --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/WatchHistory.java @@ -0,0 +1,29 @@ +package com.fruitshorts.playback.dal.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 观看流水。只追加不修改,按 watched_at 月分区。 + * 写入时机:一部剧第一次观看、以及切换剧集时各记一条(不是每 10 秒的进度上报都记), + * 否则单集会产生几十条噪音数据。 + */ +@Data +@TableName("watch_history") +public class WatchHistory { + + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private Long dramaId; + private Long episodeId; + private Long positionMs; + private Long durationMs; + private String source; + private String deviceId; + private LocalDateTime watchedAt; +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/WatchProgress.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/WatchProgress.java new file mode 100644 index 0000000..050a2f6 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/entity/WatchProgress.java @@ -0,0 +1,36 @@ +package com.fruitshorts.playback.dal.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 每用户每剧一条续看进度,跨端同步的权威数据。 */ +@Data +@TableName("watch_progress") +public class WatchProgress { + + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private Long dramaId; + private Long episodeId; + private Long positionMs; + private Long durationMs; + private Integer finished; + private Integer playCount; + private String deviceId; + private String source; + private LocalDateTime createdAt; + + @TableField(updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/DownloadLicenseMapper.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/DownloadLicenseMapper.java new file mode 100644 index 0000000..1f8f9bc --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/DownloadLicenseMapper.java @@ -0,0 +1,7 @@ +package com.fruitshorts.playback.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.playback.dal.entity.DownloadLicense; + +public interface DownloadLicenseMapper extends BaseMapper { +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/DownloadTaskMapper.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/DownloadTaskMapper.java new file mode 100644 index 0000000..3077da9 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/DownloadTaskMapper.java @@ -0,0 +1,7 @@ +package com.fruitshorts.playback.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.playback.dal.entity.DownloadTask; + +public interface DownloadTaskMapper extends BaseMapper { +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/PlaybackTicketMapper.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/PlaybackTicketMapper.java new file mode 100644 index 0000000..68b7f19 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/PlaybackTicketMapper.java @@ -0,0 +1,7 @@ +package com.fruitshorts.playback.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.playback.dal.entity.PlaybackTicket; + +public interface PlaybackTicketMapper extends BaseMapper { +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/UserFavoriteMapper.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/UserFavoriteMapper.java new file mode 100644 index 0000000..a2609c6 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/UserFavoriteMapper.java @@ -0,0 +1,7 @@ +package com.fruitshorts.playback.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.playback.dal.entity.UserFavorite; + +public interface UserFavoriteMapper extends BaseMapper { +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/WatchHistoryMapper.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/WatchHistoryMapper.java new file mode 100644 index 0000000..fb23177 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/WatchHistoryMapper.java @@ -0,0 +1,7 @@ +package com.fruitshorts.playback.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.playback.dal.entity.WatchHistory; + +public interface WatchHistoryMapper extends BaseMapper { +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/WatchProgressMapper.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/WatchProgressMapper.java new file mode 100644 index 0000000..6828a61 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/dal/mapper/WatchProgressMapper.java @@ -0,0 +1,7 @@ +package com.fruitshorts.playback.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.playback.dal.entity.WatchProgress; + +public interface WatchProgressMapper extends BaseMapper { +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/DefaultEntitlementPort.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/DefaultEntitlementPort.java new file mode 100644 index 0000000..07b0a87 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/DefaultEntitlementPort.java @@ -0,0 +1,17 @@ +package com.fruitshorts.playback.service; + +import org.springframework.stereotype.Component; + +/** + * 默认实现:一律视为未解锁。第一阶段所有内容都是免费集, + * 付费链路(订单 → 权益)落地后由交易模块提供一个 {@code @Primary} 实现覆盖它, + * 播放链路不需要改代码。 + */ +@Component +public class DefaultEntitlementPort implements EntitlementPort { + + @Override + public boolean isUnlocked(Long userId, Long dramaId, Long episodeId) { + return false; + } +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/DownloadService.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/DownloadService.java new file mode 100644 index 0000000..797df57 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/DownloadService.java @@ -0,0 +1,483 @@ +package com.fruitshorts.playback.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.content.service.EpisodeService; +import com.fruitshorts.media.service.PlaybackSigner; +import com.fruitshorts.playback.config.DownloadProperties; +import com.fruitshorts.playback.dal.entity.DownloadLicense; +import com.fruitshorts.playback.dal.entity.DownloadTask; +import com.fruitshorts.playback.dal.mapper.DownloadLicenseMapper; +import com.fruitshorts.playback.dal.mapper.DownloadTaskMapper; +import com.fruitshorts.playback.vo.DownloadGrantReqVO; +import com.fruitshorts.playback.vo.DownloadLicenseRespVO; +import com.fruitshorts.playback.vo.DownloadPolicyRespVO; +import com.fruitshorts.playback.vo.DownloadProgressReqVO; +import com.fruitshorts.playback.vo.DownloadQueryReqVO; +import com.fruitshorts.playback.vo.DownloadSettingReqVO; +import com.fruitshorts.user.dal.entity.AppUser; +import com.fruitshorts.user.dal.mapper.AppUserMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * 离线下载。 + * + *

职责划分:服务端负责**授权**(谁、哪一集、哪台设备、有效期多久), + * 分片下载由客户端直接向 CDN 拉取,服务端只在 {@code download_task} 里记账, + * 用于进度展示、断点续传恢复与配额统计。这样 1 万并发下载不会打到应用服务器。 + * + *

授权与地址分离:授权有效期 {@code licenseDays} 天,播放地址是短时效签名, + * 客户端恢复下载时重新取一次地址即可,不需要重新走授权流程。 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class DownloadService { + + public static final String STATUS_ACTIVE = "ACTIVE"; + public static final String STATUS_EXPIRED = "EXPIRED"; + public static final String STATUS_REVOKED = "REVOKED"; + + public static final String TASK_QUEUED = "QUEUED"; + public static final String TASK_DOWNLOADING = "DOWNLOADING"; + public static final String TASK_COMPLETED = "COMPLETED"; + public static final String TASK_FAILED = "FAILED"; + public static final String TASK_CANCELED = "CANCELED"; + + private static final SecureRandom RANDOM = new SecureRandom(); + + private final DownloadLicenseMapper licenseMapper; + private final DownloadTaskMapper taskMapper; + private final DramaService dramaService; + private final EpisodeService episodeService; + private final PlaybackSigner signer; + private final EntitlementPort entitlementPort; + private final AppUserMapper appUserMapper; + private final DownloadProperties properties; + + // ---------------- 客户端 ---------------- + + /** 申请/续期下载授权。同一用户+剧集+设备重复申请是幂等的,只会刷新有效期。 */ + @Transactional(rollbackFor = Exception.class) + public DownloadLicenseRespVO grant(Long userId, DownloadGrantReqVO req, String headerDeviceId) { + String deviceId = resolveDevice(req.getDeviceId(), headerDeviceId); + DramaEpisode episode = episodeService.getPublished(req.getEpisodeId()); + Drama drama = dramaService.getPublished(episode.getDramaId()); + + boolean free = episode.getIsFree() != null && episode.getIsFree() == 1; + if (!free && !entitlementPort.isUnlocked(userId, drama.getId(), episode.getId())) { + throw new ServiceException(ErrorCode.EPISODE_NOT_AVAILABLE, "该剧集需要先解锁才能下载"); + } + String source = playableSource(episode); + if (!StringUtils.hasText(source)) { + throw new ServiceException(ErrorCode.EPISODE_NOT_AVAILABLE, "片源还没准备好,暂时无法下载"); + } + + LocalDateTime now = LocalDateTime.now(); + DownloadLicense license = findLicense(userId, episode.getId(), deviceId); + if (license == null) { + long active = countActive(userId, deviceId); + if (active >= properties.getMaxPerDevice()) { + throw new ServiceException(ErrorCode.BAD_REQUEST, + "该设备最多保存 " + properties.getMaxPerDevice() + " 集,请先删除部分下载"); + } + license = new DownloadLicense(); + license.setUserId(userId); + license.setDramaId(drama.getId()); + license.setEpisodeId(episode.getId()); + license.setDeviceId(deviceId); + license.setLicenseKey(newLicenseKey()); + license.setStatus(STATUS_ACTIVE); + license.setGrantedAt(now); + license.setExpireAt(now.plusDays(properties.getLicenseDays())); + licenseMapper.insert(license); + } else { + // 已吊销的重新激活、已过期的续期,都只延长有效期,授权 key 不变 + license.setStatus(STATUS_ACTIVE); + license.setRevokedAt(null); + license.setExpireAt(now.plusDays(properties.getLicenseDays())); + licenseMapper.updateById(license); + } + + DownloadTask task = findTask(userId, episode.getId(), deviceId); + if (task == null) { + task = newTask(userId, deviceId, drama.getId(), episode.getId(), license.getId(), + req.getQuality()); + taskMapper.insert(task); + } else if (!TASK_COMPLETED.equals(task.getStatus())) { + task.setLicenseId(license.getId()); + task.setStatus(TASK_QUEUED); + task.setErrorMsg(""); + taskMapper.updateById(task); + } + + DownloadLicenseRespVO vo = toResp(license, task, drama, episode); + vo.setPlayUrl(signer.sign(source)); + return vo; + } + + public List list(Long userId, String deviceId) { + List licenses = licenseMapper.selectList( + Wrappers.lambdaQuery() + .eq(DownloadLicense::getUserId, userId) + .eq(StringUtils.hasText(deviceId), DownloadLicense::getDeviceId, deviceId) + .orderByDesc(DownloadLicense::getGrantedAt)); + if (licenses.isEmpty()) { + return List.of(); + } + // 批量补剧名与任务状态,避免逐条查库 + Map dramas = new HashMap<>(); + for (Drama drama : dramaService.listByIds(licenses.stream() + .map(DownloadLicense::getDramaId).distinct().toList())) { + dramas.put(drama.getId(), drama); + } + Map episodes = new HashMap<>(); + for (DramaEpisode episode : episodeService.listByIds(licenses.stream() + .map(DownloadLicense::getEpisodeId).distinct().toList())) { + episodes.put(episode.getId(), episode); + } + Map tasks = new HashMap<>(); + for (DownloadTask task : taskMapper.selectList(Wrappers.lambdaQuery() + .eq(DownloadTask::getUserId, userId) + .in(DownloadTask::getEpisodeId, licenses.stream() + .map(DownloadLicense::getEpisodeId).distinct().toList()))) { + tasks.put(taskKey(task.getEpisodeId(), task.getDeviceId()), task); + } + List result = new ArrayList<>(licenses.size()); + for (DownloadLicense license : licenses) { + DownloadTask task = tasks.get(taskKey(license.getEpisodeId(), license.getDeviceId())); + result.add(toResp(license, task, dramas.get(license.getDramaId()), + episodes.get(license.getEpisodeId()))); + } + return result; + } + + /** 重新取一次下载地址(断点续传用),不改变授权有效期。 */ + public DownloadLicenseRespVO refreshUrl(Long userId, Long episodeId, String headerDeviceId) { + String deviceId = resolveDevice(null, headerDeviceId); + DownloadLicense license = findLicense(userId, episodeId, deviceId); + if (license == null) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "没有该剧集的下载授权"); + } + if (STATUS_REVOKED.equals(license.getStatus())) { + throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "该下载授权已被吊销"); + } + if (isExpired(license)) { + throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "下载授权已过期,请重新申请"); + } + DramaEpisode episode = episodeService.getExisting(episodeId); + DownloadLicenseRespVO vo = toResp(license, findTask(userId, episodeId, deviceId), + dramaService.getExisting(license.getDramaId()), episode); + vo.setPlayUrl(signer.sign(playableSource(episode))); + return vo; + } + + /** 上报下载进度,实现断点续传与跨端展示。 */ + @Transactional(rollbackFor = Exception.class) + public DownloadLicenseRespVO reportProgress(Long userId, Long episodeId, DownloadProgressReqVO req, + String headerDeviceId) { + String deviceId = resolveDevice(req.getDeviceId(), headerDeviceId); + DownloadLicense license = findLicense(userId, episodeId, deviceId); + if (license == null) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "没有该剧集的下载授权"); + } + DownloadTask task = findTask(userId, episodeId, deviceId); + if (task == null) { + task = newTask(userId, deviceId, license.getDramaId(), episodeId, license.getId(), null); + taskMapper.insert(task); + } + LocalDateTime now = LocalDateTime.now(); + String status = StringUtils.hasText(req.getStatus()) ? req.getStatus().toUpperCase() : null; + if (status != null) { + if (TASK_DOWNLOADING.equals(status) && task.getStartedAt() == null) { + task.setStartedAt(now); + } + if (TASK_COMPLETED.equals(status)) { + task.setFinishedAt(now); + task.setProgressPercent(100); + } + task.setStatus(status); + } + if (req.getProgressPercent() != null) { + task.setProgressPercent(Math.max(0, Math.min(100, req.getProgressPercent()))); + } + if (req.getDownloadedBytes() != null) { + task.setDownloadedBytes(req.getDownloadedBytes()); + } + if (req.getTotalBytes() != null) { + task.setTotalBytes(req.getTotalBytes()); + } + task.setErrorMsg(req.getErrorMsg() == null ? "" : req.getErrorMsg()); + taskMapper.updateById(task); + + DownloadLicenseRespVO vo = toResp(license, task, dramaService.getExisting(license.getDramaId()), + episodeService.getExisting(episodeId)); + return vo; + } + + @Transactional(rollbackFor = Exception.class) + public void revoke(Long userId, Long episodeId, String deviceId) { + DownloadLicense license = findLicense(userId, episodeId, deviceId); + if (license == null) { + return; + } + revokeLicense(license); + DownloadTask task = findTask(userId, episodeId, deviceId); + if (task != null) { + task.setStatus(TASK_CANCELED); + taskMapper.updateById(task); + } + } + + @Transactional(rollbackFor = Exception.class) + public int clear(Long userId, String deviceId) { + List licenses = licenseMapper.selectList( + Wrappers.lambdaQuery() + .eq(DownloadLicense::getUserId, userId) + .eq(StringUtils.hasText(deviceId), DownloadLicense::getDeviceId, deviceId) + .eq(DownloadLicense::getStatus, STATUS_ACTIVE)); + for (DownloadLicense license : licenses) { + revokeLicense(license); + } + return licenses.size(); + } + + public DownloadPolicyRespVO policy(Long userId, String deviceId) { + AppUser user = appUserMapper.selectById(userId); + DownloadPolicyRespVO vo = new DownloadPolicyRespVO(); + vo.setMaxPerDevice(properties.getMaxPerDevice()); + vo.setLicenseDays(properties.getLicenseDays()); + vo.setMaxQuality(StringUtils.hasText(user == null ? null : user.getDownloadMaxQuality()) + ? user.getDownloadMaxQuality() : properties.getMaxQuality()); + vo.setWifiOnly(user == null || user.getDownloadWifiOnly() == null + ? properties.isWifiOnlyDefault() : user.getDownloadWifiOnly() == 1); + vo.setMaxStorageBytes(properties.getMaxStorageBytes()); + int used = (int) countActive(userId, deviceId); + vo.setUsed(used); + vo.setRemaining(Math.max(0, properties.getMaxPerDevice() - used)); + vo.setExpirePolicyText("下载授权有效期 " + properties.getLicenseDays() + + " 天,到期后需联网续期;过期或已删除的下载不可离线播放。"); + return vo; + } + + @Transactional(rollbackFor = Exception.class) + public void updateSettings(Long userId, DownloadSettingReqVO req) { + AppUser user = appUserMapper.selectById(userId); + if (user == null) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "用户不存在"); + } + if (req.getWifiOnly() != null) { + user.setDownloadWifiOnly(req.getWifiOnly() ? 1 : 0); + } + if (StringUtils.hasText(req.getMaxQuality())) { + user.setDownloadMaxQuality(req.getMaxQuality().toUpperCase()); + } + appUserMapper.updateById(user); + } + + // ---------------- 定时清理 ---------------- + + /** 过期授权统一置为 EXPIRED,客户端联网后即可发现并清理本地文件。 */ + @Scheduled(cron = "${fruitshorts.download.expire-cron:0 25 3 * * *}") + @Transactional(rollbackFor = Exception.class) + public int expireLicenses() { + int updated = licenseMapper.update(null, Wrappers.lambdaUpdate() + .eq(DownloadLicense::getStatus, STATUS_ACTIVE) + .isNotNull(DownloadLicense::getExpireAt) + .lt(DownloadLicense::getExpireAt, LocalDateTime.now()) + .set(DownloadLicense::getStatus, STATUS_EXPIRED)); + if (updated > 0) { + log.info("下载授权过期清理:{} 条", updated); + } + return updated; + } + + // ---------------- 后台 ---------------- + + public PageResult page(DownloadQueryReqVO req) { + Page page = new Page<>( + req.getPageIndex() == null || req.getPageIndex() < 1 ? 1 : req.getPageIndex(), + req.getPageSize() == null || req.getPageSize() < 1 ? 10 : req.getPageSize()); + IPage result = licenseMapper.selectPage(page, + Wrappers.lambdaQuery() + .eq(req.getUserId() != null, DownloadLicense::getUserId, req.getUserId()) + .eq(StringUtils.hasText(req.getStatus()), DownloadLicense::getStatus, req.getStatus()) + .eq(StringUtils.hasText(req.getDeviceId()), DownloadLicense::getDeviceId, + req.getDeviceId()) + .orderByDesc(DownloadLicense::getId)); + List rows = result.getRecords(); + if (rows.isEmpty()) { + return new PageResult<>(0, List.of()); + } + Map dramas = new HashMap<>(); + for (Drama drama : dramaService.listByIds( + rows.stream().map(DownloadLicense::getDramaId).distinct().toList())) { + dramas.put(drama.getId(), drama); + } + Map episodes = new HashMap<>(); + for (DramaEpisode episode : episodeService.listByIds( + rows.stream().map(DownloadLicense::getEpisodeId).distinct().toList())) { + episodes.put(episode.getId(), episode); + } + List list = new ArrayList<>(rows.size()); + for (DownloadLicense license : rows) { + DownloadLicenseRespVO vo = toResp(license, + findTask(license.getUserId(), license.getEpisodeId(), license.getDeviceId()), + dramas.get(license.getDramaId()), episodes.get(license.getEpisodeId())); + list.add(vo); + } + return new PageResult<>(result.getTotal(), list); + } + + @Transactional(rollbackFor = Exception.class) + public void adminRevoke(Long licenseId) { + DownloadLicense license = licenseMapper.selectById(licenseId); + if (license == null) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "下载授权不存在"); + } + revokeLicense(license); + } + + /** 后台看板与排查用:授权与任务的分布。 */ + public Map stats() { + Map data = new LinkedHashMap<>(); + data.put("active", licenseMapper.selectCount(Wrappers.lambdaQuery() + .eq(DownloadLicense::getStatus, STATUS_ACTIVE))); + data.put("expired", licenseMapper.selectCount(Wrappers.lambdaQuery() + .eq(DownloadLicense::getStatus, STATUS_EXPIRED))); + data.put("revoked", licenseMapper.selectCount(Wrappers.lambdaQuery() + .eq(DownloadLicense::getStatus, STATUS_REVOKED))); + data.put("completedTasks", taskMapper.selectCount(Wrappers.lambdaQuery() + .eq(DownloadTask::getStatus, TASK_COMPLETED))); + data.put("failedTasks", taskMapper.selectCount(Wrappers.lambdaQuery() + .eq(DownloadTask::getStatus, TASK_FAILED))); + return data; + } + + // ---------------- 内部 ---------------- + + private void revokeLicense(DownloadLicense license) { + license.setStatus(STATUS_REVOKED); + license.setRevokedAt(LocalDateTime.now()); + licenseMapper.updateById(license); + } + + private boolean isExpired(DownloadLicense license) { + return license.getExpireAt() != null && license.getExpireAt().isBefore(LocalDateTime.now()); + } + + private long countActive(Long userId, String deviceId) { + Long count = licenseMapper.selectCount(Wrappers.lambdaQuery() + .eq(DownloadLicense::getUserId, userId) + .eq(StringUtils.hasText(deviceId), DownloadLicense::getDeviceId, deviceId) + .eq(DownloadLicense::getStatus, STATUS_ACTIVE)); + return count == null ? 0L : count; + } + + private DownloadLicense findLicense(Long userId, Long episodeId, String deviceId) { + return licenseMapper.selectOne(Wrappers.lambdaQuery() + .eq(DownloadLicense::getUserId, userId) + .eq(DownloadLicense::getEpisodeId, episodeId) + .eq(DownloadLicense::getDeviceId, deviceId) + .last("limit 1")); + } + + private DownloadTask findTask(Long userId, Long episodeId, String deviceId) { + return taskMapper.selectOne(Wrappers.lambdaQuery() + .eq(DownloadTask::getUserId, userId) + .eq(DownloadTask::getEpisodeId, episodeId) + .eq(DownloadTask::getDeviceId, deviceId) + .last("limit 1")); + } + + private DownloadTask newTask(Long userId, String deviceId, Long dramaId, Long episodeId, + Long licenseId, String quality) { + DownloadTask task = new DownloadTask(); + task.setUserId(userId); + task.setDeviceId(deviceId); + task.setDramaId(dramaId); + task.setEpisodeId(episodeId); + task.setLicenseId(licenseId == null ? 0L : licenseId); + task.setQuality(StringUtils.hasText(quality) ? quality.toUpperCase() : "AUTO"); + task.setStatus(TASK_QUEUED); + task.setProgressPercent(0); + task.setDownloadedBytes(0L); + task.setTotalBytes(0L); + task.setErrorMsg(""); + return task; + } + + private DownloadLicenseRespVO toResp(DownloadLicense license, DownloadTask task, + Drama drama, DramaEpisode episode) { + DownloadLicenseRespVO vo = new DownloadLicenseRespVO(); + vo.setLicenseId(license.getId()); + vo.setDramaId(license.getDramaId()); + vo.setEpisodeId(license.getEpisodeId()); + vo.setDeviceId(license.getDeviceId()); + vo.setLicenseKey(license.getLicenseKey()); + vo.setStatus(license.getStatus()); + vo.setGrantedAt(license.getGrantedAt()); + vo.setExpireAt(license.getExpireAt()); + if (drama != null) { + vo.setDramaTitle(drama.getTitle()); + } + if (episode != null) { + vo.setEpisodeNo(episode.getEpNo()); + } + if (task != null) { + vo.setTaskStatus(task.getStatus()); + vo.setProgressPercent(task.getProgressPercent()); + vo.setDownloadedBytes(task.getDownloadedBytes()); + vo.setTotalBytes(task.getTotalBytes()); + vo.setErrorMsg(task.getErrorMsg()); + vo.setQuality(task.getQuality()); + } + return vo; + } + + private String playableSource(DramaEpisode episode) { + if (StringUtils.hasText(episode.getHlsUrl())) { + return episode.getHlsUrl(); + } + return episode.getVideoUrl() == null ? "" : episode.getVideoUrl(); + } + + private String resolveDevice(String bodyDeviceId, String headerDeviceId) { + String deviceId = StringUtils.hasText(bodyDeviceId) ? bodyDeviceId.trim() : headerDeviceId; + if (!StringUtils.hasText(deviceId)) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "缺少设备标识(X-Device-Id)"); + } + return deviceId.trim(); + } + + private String newLicenseKey() { + byte[] bytes = new byte[24]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + private String taskKey(Long episodeId, String deviceId) { + return episodeId + "|" + (deviceId == null ? "" : deviceId); + } +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/EntitlementPort.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/EntitlementPort.java new file mode 100644 index 0000000..e7aaf64 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/EntitlementPort.java @@ -0,0 +1,15 @@ +package com.fruitshorts.playback.service; + +/** + * 权益判定端口。 + * + *

权益的**写入**属于交易域(订单支付成功后发权益),还没实现; + * 这里只定义播放侧需要的只读判定,交易域落地后提供一个实现替换默认值: + * 届时在 fruitshorts-module-commerce 里实现本接口并标注 {@code @Primary} 即可, + * 播放链路不需要改动。 + */ +public interface EntitlementPort { + + /** 用户是否已拥有该剧集(或整剧)的有效权益。 */ + boolean isUnlocked(Long userId, Long dramaId, Long episodeId); +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java new file mode 100644 index 0000000..8bb0546 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/FavoriteService.java @@ -0,0 +1,102 @@ +package com.fruitshorts.playback.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.playback.dal.entity.UserFavorite; +import com.fruitshorts.playback.dal.mapper.UserFavoriteMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** 收藏:切换、列表、批量删除。计数走异步聚合,不在事务里更新统计列。 */ +@Service +@RequiredArgsConstructor +public class FavoriteService { + + private final UserFavoriteMapper favoriteMapper; + private final DramaService dramaService; + + /** 返回切换后的状态:true 表示已收藏。 */ + @Transactional(rollbackFor = Exception.class) + public boolean toggle(Long userId, Long dramaId) { + dramaService.getPublished(dramaId); + UserFavorite existing = favoriteMapper.selectOne(Wrappers.lambdaQuery() + .eq(UserFavorite::getUserId, userId) + .eq(UserFavorite::getDramaId, dramaId) + .last("limit 1")); + if (existing != null) { + favoriteMapper.deleteById(existing.getId()); + return false; + } + UserFavorite favorite = new UserFavorite(); + favorite.setUserId(userId); + favorite.setDramaId(dramaId); + favorite.setSort(0); + favoriteMapper.insert(favorite); + return true; + } + + public boolean isFavorite(Long userId, Long dramaId) { + Long count = favoriteMapper.selectCount(Wrappers.lambdaQuery() + .eq(UserFavorite::getUserId, userId) + .eq(UserFavorite::getDramaId, dramaId)); + return count != null && count > 0; + } + + public List dramaIds(Long userId) { + return favoriteMapper.selectList(Wrappers.lambdaQuery() + .eq(UserFavorite::getUserId, userId) + .orderByDesc(UserFavorite::getCreatedAt)) + .stream().map(UserFavorite::getDramaId).toList(); + } + + public List listDramas(Long userId) { + List ids = dramaIds(userId); + if (ids.isEmpty()) { + return List.of(); + } + List dramas = dramaService.listPublishedByIds(ids); + // 按收藏时间排序,已被下架的剧自然被过滤掉 + List ordered = new ArrayList<>(dramas.size()); + for (Long id : ids) { + for (Drama drama : dramas) { + if (drama.getId().equals(id)) { + ordered.add(drama); + break; + } + } + } + return ordered; + } + + @Transactional(rollbackFor = Exception.class) + public void remove(Long userId, Long dramaId) { + favoriteMapper.delete(Wrappers.lambdaQuery() + .eq(UserFavorite::getUserId, userId) + .eq(UserFavorite::getDramaId, dramaId)); + } + + @Transactional(rollbackFor = Exception.class) + public void removeBatch(Long userId, List dramaIds) { + if (dramaIds == null || dramaIds.isEmpty()) { + return; + } + favoriteMapper.delete(Wrappers.lambdaQuery() + .eq(UserFavorite::getUserId, userId) + .in(UserFavorite::getDramaId, dramaIds)); + } + + /** 供收藏数聚合任务调用,标记最近变更时间。 */ + public LocalDateTime lastChangedAt(Long userId, Long dramaId) { + UserFavorite favorite = favoriteMapper.selectOne(Wrappers.lambdaQuery() + .eq(UserFavorite::getUserId, userId) + .eq(UserFavorite::getDramaId, dramaId) + .last("limit 1")); + return favorite == null ? null : favorite.getCreatedAt(); + } +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/PlaybackService.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/PlaybackService.java new file mode 100644 index 0000000..1529052 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/PlaybackService.java @@ -0,0 +1,128 @@ +package com.fruitshorts.playback.service; + +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.content.service.EpisodeService; +import com.fruitshorts.media.dal.entity.MediaRendition; +import com.fruitshorts.media.service.PlaybackSigner; +import com.fruitshorts.media.service.TranscodeService; +import com.fruitshorts.playback.dal.entity.PlaybackTicket; +import com.fruitshorts.playback.dal.mapper.PlaybackTicketMapper; +import com.fruitshorts.playback.vo.PlayRespVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * 播放地址签发。 + * + *

判定顺序(结果由服务端决定,客户端不得自行推断): + * 已有权益 > 免费集 > 试看 > 需金币解锁。 + * + *

可播放时签发短时效签名地址;签名只覆盖路径与过期时间,不含用户信息, + * 这样 CDN 可以做纯 URL 缓存。需要按用户吊销时走票据表与并发设备限制。 + */ +@Service +@RequiredArgsConstructor +public class PlaybackService { + + public static final String ACCESS_FREE = "FREE"; + public static final String ACCESS_ENTITLED = "ENTITLED"; + public static final String ACCESS_COIN_REQUIRED = "COIN_REQUIRED"; + public static final String ACCESS_NO_MEDIA = "NO_MEDIA"; + + private final EpisodeService episodeService; + private final DramaService dramaService; + private final PlaybackTicketMapper ticketMapper; + private final PlaybackSigner signer; + private final EntitlementPort entitlementPort; + private final TranscodeService transcodeService; + + @Transactional(rollbackFor = Exception.class) + public PlayRespVO play(Long userId, Long episodeId, String deviceId, String clientIp) { + DramaEpisode episode = episodeService.getPublished(episodeId); + Drama drama = dramaService.getPublished(episode.getDramaId()); + + boolean free = episode.getIsFree() != null && episode.getIsFree() == 1; + boolean entitled = userId != null && entitlementPort.isUnlocked(userId, drama.getId(), episode.getId()); + String source = playableSource(episode); + + PlayRespVO vo = new PlayRespVO(); + vo.setEpisodeId(episode.getId()); + vo.setDramaId(drama.getId()); + vo.setNumber(episode.getEpNo()); + vo.setTitle(episode.getTitle()); + vo.setDurationSec(episode.getDurationSec()); + vo.setUnlockCoin(episode.getUnlockCoin()); + vo.setPreviewSeconds(episode.getPreviewSeconds()); + vo.setQualities(qualitiesOf(episode.getId())); + + if (!StringUtils.hasText(source)) { + // 还没转码或没上传片源:明确告诉客户端"没有片源",而不是给一个播不了的地址 + vo.setAccessible(false); + vo.setAccessType(ACCESS_NO_MEDIA); + vo.setPlayUrl(""); + return vo; + } + if (!free && !entitled) { + vo.setAccessible(false); + vo.setAccessType(ACCESS_COIN_REQUIRED); + vo.setPlayUrl(""); + return vo; + } + + String signedUrl = signer.sign(source); + vo.setAccessible(true); + vo.setAccessType(entitled && !free ? ACCESS_ENTITLED : ACCESS_FREE); + vo.setPlayUrl(signedUrl); + + if (userId != null) { + recordTicket(userId, drama.getId(), episode.getId(), signedUrl, deviceId, clientIp); + } + return vo; + } + + /** 有 HLS 多码率就用 HLS,否则退回原文件(当前阶段常见形态)。 */ + private String playableSource(DramaEpisode episode) { + if (StringUtils.hasText(episode.getHlsUrl())) { + return episode.getHlsUrl(); + } + return episode.getVideoUrl() == null ? "" : episode.getVideoUrl(); + } + + private List qualitiesOf(Long episodeId) { + List renditions = transcodeService.listRenditions(episodeId); + if (renditions.isEmpty()) { + return List.of("AUTO"); + } + List qualities = new ArrayList<>(renditions.size() + 1); + qualities.add("AUTO"); + for (MediaRendition rendition : renditions) { + qualities.add(rendition.getQuality()); + } + return qualities; + } + + private void recordTicket(Long userId, Long dramaId, Long episodeId, String playUrl, + String deviceId, String clientIp) { + PlaybackTicket ticket = new PlaybackTicket(); + ticket.setTicketNo(UUID.randomUUID().toString().replace("-", "")); + ticket.setUserId(userId); + ticket.setDramaId(dramaId); + ticket.setEpisodeId(episodeId); + ticket.setQuality("AUTO"); + ticket.setPlayUrl(playUrl.length() > 1024 ? playUrl.substring(0, 1024) : playUrl); + ticket.setExpireAt(LocalDateTime.now().plusMinutes(15)); + ticket.setIp(clientIp == null ? "" : clientIp); + ticket.setDeviceId(deviceId == null ? "" : deviceId); + ticket.setStatus("ISSUED"); + ticketMapper.insert(ticket); + } +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/WatchProgressService.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/WatchProgressService.java new file mode 100644 index 0000000..848c1fe --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/service/WatchProgressService.java @@ -0,0 +1,181 @@ +package com.fruitshorts.playback.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fruitshorts.content.dal.entity.Drama; +import com.fruitshorts.content.dal.entity.DramaEpisode; +import com.fruitshorts.content.service.DramaService; +import com.fruitshorts.content.service.EpisodeService; +import com.fruitshorts.playback.dal.entity.WatchProgress; +import com.fruitshorts.playback.dal.entity.WatchHistory; +import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper; +import com.fruitshorts.playback.dal.mapper.WatchProgressMapper; +import com.fruitshorts.playback.vo.ProgressRespVO; +import com.fruitshorts.playback.vo.ProgressSaveReqVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 观看进度。 + * + *

每次上报都写库在高并发下会成为瓶颈(1 万并发 × 每 10 秒一次 ≈ 1000 写/秒), + * 所以生产形态是「先写 Redis,后台任务按 user+drama 合并后批量 UPSERT」。 + * 这里先落地权威表与合并规则,缓存层在接入 Redis 时补齐,接口契约不变。 + * + *

合并规则:以客户端上报的 positionMs 为准,但只在「更靠后」时覆盖, + * 避免用户换设备后进度倒退。 + */ +@Service +@RequiredArgsConstructor +public class WatchProgressService { + + private static final long FINISH_THRESHOLD_MS = 1000L; + + private final WatchProgressMapper progressMapper; + private final WatchHistoryMapper historyMapper; + private final DramaService dramaService; + private final EpisodeService episodeService; + + @Transactional(rollbackFor = Exception.class) + public ProgressRespVO save(Long userId, ProgressSaveReqVO req) { + DramaEpisode episode = episodeService.getPublished(req.getEpisodeId()); + Drama drama = dramaService.getPublished(episode.getDramaId()); + long position = req.getPositionMs() == null ? 0L : Math.max(0L, req.getPositionMs()); + long duration = req.getDurationMs() == null ? 0L : Math.max(0L, req.getDurationMs()); + boolean finished = Boolean.TRUE.equals(req.getFinished()) + || (duration > 0 && position + FINISH_THRESHOLD_MS >= duration); + + WatchProgress existing = progressMapper.selectOne(Wrappers.lambdaQuery() + .eq(WatchProgress::getUserId, userId) + .eq(WatchProgress::getDramaId, drama.getId()) + .last("limit 1")); + if (existing == null) { + WatchProgress progress = new WatchProgress(); + progress.setUserId(userId); + progress.setDramaId(drama.getId()); + progress.setEpisodeId(episode.getId()); + progress.setPositionMs(position); + progress.setDurationMs(duration); + progress.setFinished(finished ? 1 : 0); + progress.setPlayCount(1); + progress.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId()); + progress.setSource(req.getSource() == null ? "" : req.getSource()); + progressMapper.insert(progress); + appendHistory(userId, drama.getId(), episode.getId(), position, duration, + progress.getSource(), progress.getDeviceId()); + // updated_at 由触发器维护,回读一次,保证响应与后续 recent 列表口径一致 + return toResp(progressMapper.selectById(progress.getId())); + } + + boolean sameEpisode = existing.getEpisodeId() != null + && existing.getEpisodeId().equals(episode.getId()); + if (!sameEpisode) { + // 换集:直接采纳新集与新的播放位置 + existing.setEpisodeId(episode.getId()); + existing.setPositionMs(position); + existing.setDurationMs(duration); + existing.setFinished(finished ? 1 : 0); + existing.setPlayCount((existing.getPlayCount() == null ? 0 : existing.getPlayCount()) + 1); + appendHistory(userId, drama.getId(), episode.getId(), position, duration, + req.getSource(), existing.getDeviceId()); + } else { + existing.setDurationMs(Math.max(existing.getDurationMs() == null ? 0L : existing.getDurationMs(), + duration)); + if (position >= (existing.getPositionMs() == null ? 0L : existing.getPositionMs())) { + existing.setPositionMs(position); + } + if (finished) { + existing.setFinished(1); + } + } + existing.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId()); + existing.setSource(req.getSource() == null ? "" : req.getSource()); + progressMapper.updateById(existing); + return toResp(progressMapper.selectById(existing.getId())); + } + + public ProgressRespVO ofDrama(Long userId, Long dramaId) { + WatchProgress progress = progressMapper.selectOne(Wrappers.lambdaQuery() + .eq(WatchProgress::getUserId, userId) + .eq(WatchProgress::getDramaId, dramaId) + .last("limit 1")); + return progress == null ? null : toResp(progress); + } + + /** 最近观看列表("我的-观看记录")。 */ + public List recent(Long userId, int limit) { + List list = progressMapper.selectList(Wrappers.lambdaQuery() + .eq(WatchProgress::getUserId, userId) + .orderByDesc(WatchProgress::getUpdatedAt) + .last("limit " + (limit <= 0 || limit > 100 ? 20 : limit))); + List result = new ArrayList<>(list.size()); + for (WatchProgress item : list) { + result.add(toResp(item)); + } + return result; + } + + @Transactional(rollbackFor = Exception.class) + public void remove(Long userId, Long dramaId) { + progressMapper.delete(Wrappers.lambdaQuery() + .eq(WatchProgress::getUserId, userId) + .eq(WatchProgress::getDramaId, dramaId)); + } + + @Transactional(rollbackFor = Exception.class) + public void clear(Long userId) { + progressMapper.delete(Wrappers.lambdaQuery() + .eq(WatchProgress::getUserId, userId)); + } + + private ProgressRespVO toResp(WatchProgress progress) { + ProgressRespVO vo = new ProgressRespVO(); + vo.setDramaId(progress.getDramaId()); + vo.setEpisodeId(progress.getEpisodeId()); + vo.setPositionMs(progress.getPositionMs()); + vo.setDurationMs(progress.getDurationMs()); + vo.setFinished(progress.getFinished() != null && progress.getFinished() == 1); + vo.setPlayCount(progress.getPlayCount()); + vo.setUpdatedAt(progress.getUpdatedAt()); + return vo; + } + + /** 观看流水明细(客服视图与后续行为分析用),只读。 */ + public List history(Long userId, int limit) { + return historyMapper.selectList(Wrappers.lambdaQuery() + .eq(WatchHistory::getUserId, userId) + .orderByDesc(WatchHistory::getWatchedAt) + .last("limit " + (limit <= 0 || limit > 200 ? 50 : limit))); + } + + /** + * 追加一条观看流水。只在「首次观看一部剧」和「切换剧集」时调用, + * 每 10 秒的进度上报不写这里 —— 否则单集会产出几十条噪音。 + */ + private void appendHistory(Long userId, Long dramaId, Long episodeId, long positionMs, + long durationMs, String source, String deviceId) { + WatchHistory history = new WatchHistory(); + history.setUserId(userId); + history.setDramaId(dramaId); + history.setEpisodeId(episodeId); + history.setPositionMs(positionMs); + history.setDurationMs(durationMs); + history.setSource(source == null ? "" : source); + history.setDeviceId(deviceId == null ? "" : deviceId); + historyMapper.insert(history); + } + + /** 供后续批量合并任务使用:只更新进度字段,避免整行覆盖。 */ + public void mergeInto(Long userId, Long dramaId, Long episodeId, long positionMs) { + progressMapper.update(null, Wrappers.lambdaUpdate() + .eq(WatchProgress::getUserId, userId) + .eq(WatchProgress::getDramaId, dramaId) + .set(WatchProgress::getEpisodeId, episodeId) + .set(WatchProgress::getPositionMs, positionMs) + .set(WatchProgress::getUpdatedAt, LocalDateTime.now())); + } +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadGrantReqVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadGrantReqVO.java new file mode 100644 index 0000000..49cbb28 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadGrantReqVO.java @@ -0,0 +1,18 @@ +package com.fruitshorts.playback.vo; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** 申请离线下载授权。 */ +@Data +public class DownloadGrantReqVO { + + @NotNull(message = "缺少 episodeId") + private Long episodeId; + + /** 期望清晰度,AUTO 表示由客户端按策略决定 */ + private String quality; + + /** 设备标识,绑定授权用;请求头带 X-Device-Id 时可以省略 */ + private String deviceId; +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadLicenseRespVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadLicenseRespVO.java new file mode 100644 index 0000000..2c2dc09 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadLicenseRespVO.java @@ -0,0 +1,33 @@ +package com.fruitshorts.playback.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 下载授权 + 任务状态的合并视图,客户端一处即可渲染下载列表。 */ +@Data +public class DownloadLicenseRespVO { + + private Long licenseId; + private Long dramaId; + private String dramaTitle; + private Long episodeId; + private Integer episodeNo; + private String deviceId; + private String quality; + /** 签名后的下载地址;过期后需要重新申请 */ + private String playUrl; + /** 离线播放校验用 */ + private String licenseKey; + /** ACTIVE/EXPIRED/REVOKED */ + private String status; + private LocalDateTime grantedAt; + private LocalDateTime expireAt; + + /** QUEUED/DOWNLOADING/PAUSED/COMPLETED/FAILED/CANCELED */ + private String taskStatus; + private Integer progressPercent; + private Long downloadedBytes; + private Long totalBytes; + private String errorMsg; +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadPolicyRespVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadPolicyRespVO.java new file mode 100644 index 0000000..34110e9 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadPolicyRespVO.java @@ -0,0 +1,23 @@ +package com.fruitshorts.playback.vo; + +import lombok.Data; + +/** + * 下载策略与配额。客户端不要写死这些值:改策略要能不发版。 + */ +@Data +public class DownloadPolicyRespVO { + + private Integer maxPerDevice; + private Integer licenseDays; + /** 用户当前的"仅 Wi-Fi 下载"偏好 */ + private Boolean wifiOnly; + private String maxQuality; + /** 预留:单设备占用上限(字节),0 表示不限制 */ + private Long maxStorageBytes; + /** 当前设备已用授权数 */ + private Integer used; + private Integer remaining; + /** 过期策略说明,用于客户端文案与客服解释 */ + private String expirePolicyText; +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadProgressReqVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadProgressReqVO.java new file mode 100644 index 0000000..a3dcd54 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadProgressReqVO.java @@ -0,0 +1,16 @@ +package com.fruitshorts.playback.vo; + +import lombok.Data; + +/** 上报下载进度,用于断点续传与跨端展示。 */ +@Data +public class DownloadProgressReqVO { + + private String deviceId; + /** QUEUED/DOWNLOADING/PAUSED/COMPLETED/FAILED/CANCELED */ + private String status; + private Integer progressPercent; + private Long downloadedBytes; + private Long totalBytes; + private String errorMsg; +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadQueryReqVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadQueryReqVO.java new file mode 100644 index 0000000..d4b4474 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadQueryReqVO.java @@ -0,0 +1,15 @@ +package com.fruitshorts.playback.vo; + +import lombok.Data; + +/** 后台下载授权检索条件。 */ +@Data +public class DownloadQueryReqVO { + + private Long userId; + private String deviceId; + /** ACTIVE/EXPIRED/REVOKED */ + private String status; + private Integer pageIndex = 1; + private Integer pageSize = 10; +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadSettingReqVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadSettingReqVO.java new file mode 100644 index 0000000..b0703d6 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/DownloadSettingReqVO.java @@ -0,0 +1,11 @@ +package com.fruitshorts.playback.vo; + +import lombok.Data; + +/** 下载偏好设置。 */ +@Data +public class DownloadSettingReqVO { + + private Boolean wifiOnly; + private String maxQuality; +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/FavoriteToggleReqVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/FavoriteToggleReqVO.java new file mode 100644 index 0000000..6e4708e --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/FavoriteToggleReqVO.java @@ -0,0 +1,12 @@ +package com.fruitshorts.playback.vo; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** 收藏/取消收藏。 */ +@Data +public class FavoriteToggleReqVO { + + @NotNull(message = "缺少 dramaId") + private Long dramaId; +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/PlayRespVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/PlayRespVO.java new file mode 100644 index 0000000..28924b5 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/PlayRespVO.java @@ -0,0 +1,28 @@ +package com.fruitshorts.playback.vo; + +import lombok.Data; + +import java.util.List; + +/** 播放票据响应。 */ +@Data +public class PlayRespVO { + + private Long episodeId; + private Long dramaId; + private Integer number; + private String title; + private Integer durationSec; + /** 是否可直接播放 */ + private Boolean accessible; + /** FREE/ENTITLED/TRIAL/COIN_REQUIRED/NO_MEDIA */ + private String accessType; + /** 签名后的播放地址,accessible=false 时为空 */ + private String playUrl; + /** 可用的清晰度档位 */ + private List qualities; + /** 需要多少金币解锁 */ + private Integer unlockCoin; + /** 试看秒数,0 表示不限制 */ + private Integer previewSeconds; +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressRespVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressRespVO.java new file mode 100644 index 0000000..4950811 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressRespVO.java @@ -0,0 +1,18 @@ +package com.fruitshorts.playback.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 续看进度。 */ +@Data +public class ProgressRespVO { + + private Long dramaId; + private Long episodeId; + private Long positionMs; + private Long durationMs; + private Boolean finished; + private Integer playCount; + private LocalDateTime updatedAt; +} diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressSaveReqVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressSaveReqVO.java new file mode 100644 index 0000000..d9796b0 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/ProgressSaveReqVO.java @@ -0,0 +1,19 @@ +package com.fruitshorts.playback.vo; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** 上报观看进度。客户端建议每 10 秒一次,切换剧集时立即上报一次。 */ +@Data +public class ProgressSaveReqVO { + + @NotNull(message = "缺少 episodeId") + private Long episodeId; + + private Long positionMs; + private Long durationMs; + private Boolean finished; + /** HOME/DETAIL/SEARCH/FAVORITE */ + private String source; + private String deviceId; +} diff --git a/reelshort-module-system/pom.xml b/fruitshorts-module-system/pom.xml similarity index 65% rename from reelshort-module-system/pom.xml rename to fruitshorts-module-system/pom.xml index e3beee4..c646467 100644 --- a/reelshort-module-system/pom.xml +++ b/fruitshorts-module-system/pom.xml @@ -5,19 +5,19 @@ 4.0.0 - com.reelshort - reelshort + com.fruitshorts + fruitshorts 1.0.0-SNAPSHOT - reelshort-module-system - reelshort-module-system + fruitshorts-module-system + fruitshorts-module-system 系统域:管理员、角色、权限、菜单、配置 - com.reelshort - reelshort-framework + com.fruitshorts + fruitshorts-framework diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/AdminUser.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AdminUser.java similarity index 96% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/AdminUser.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AdminUser.java index b71b7c0..6ad111b 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/AdminUser.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AdminUser.java @@ -1,4 +1,4 @@ -package com.reelshort.system.dal.entity; +package com.fruitshorts.system.dal.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.FieldStrategy; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/AdminUserRole.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AdminUserRole.java similarity index 93% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/AdminUserRole.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AdminUserRole.java index f3656ec..310c635 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/AdminUserRole.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/AdminUserRole.java @@ -1,4 +1,4 @@ -package com.reelshort.system.dal.entity; +package com.fruitshorts.system.dal.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/Menu.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/Menu.java similarity index 96% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/Menu.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/Menu.java index 9432907..46d6fa2 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/Menu.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/Menu.java @@ -1,4 +1,4 @@ -package com.reelshort.system.dal.entity; +package com.fruitshorts.system.dal.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.FieldStrategy; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/OperationLog.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/OperationLog.java similarity index 94% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/OperationLog.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/OperationLog.java index 02c6e54..22cc6a8 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/OperationLog.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/OperationLog.java @@ -1,4 +1,4 @@ -package com.reelshort.system.dal.entity; +package com.fruitshorts.system.dal.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/Permission.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/Permission.java similarity index 90% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/Permission.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/Permission.java index e997d37..f6c63ce 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/Permission.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/Permission.java @@ -1,4 +1,4 @@ -package com.reelshort.system.dal.entity; +package com.fruitshorts.system.dal.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/Role.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/Role.java similarity index 95% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/Role.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/Role.java index 7476e84..8df15e9 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/Role.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/Role.java @@ -1,4 +1,4 @@ -package com.reelshort.system.dal.entity; +package com.fruitshorts.system.dal.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.FieldStrategy; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/RoleCategory.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/RoleCategory.java similarity index 95% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/RoleCategory.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/RoleCategory.java index 3154957..df50a99 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/RoleCategory.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/RoleCategory.java @@ -1,4 +1,4 @@ -package com.reelshort.system.dal.entity; +package com.fruitshorts.system.dal.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.FieldStrategy; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/RoleMenu.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/RoleMenu.java similarity index 92% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/RoleMenu.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/RoleMenu.java index 501e02d..7d19307 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/RoleMenu.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/RoleMenu.java @@ -1,4 +1,4 @@ -package com.reelshort.system.dal.entity; +package com.fruitshorts.system.dal.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/RolePermission.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/RolePermission.java similarity index 93% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/RolePermission.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/RolePermission.java index 0f6dae5..d717264 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/RolePermission.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/RolePermission.java @@ -1,4 +1,4 @@ -package com.reelshort.system.dal.entity; +package com.fruitshorts.system.dal.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/SysConfig.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/SysConfig.java similarity index 89% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/SysConfig.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/SysConfig.java index 1fbe8f5..2d24ea0 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/entity/SysConfig.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/SysConfig.java @@ -1,4 +1,4 @@ -package com.reelshort.system.dal.entity; +package com.fruitshorts.system.dal.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/AdminUserMapper.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/AdminUserMapper.java similarity index 56% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/AdminUserMapper.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/AdminUserMapper.java index 295ec0f..0017a16 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/AdminUserMapper.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/AdminUserMapper.java @@ -1,7 +1,7 @@ -package com.reelshort.system.dal.mapper; +package com.fruitshorts.system.dal.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.reelshort.system.dal.entity.AdminUser; +import com.fruitshorts.system.dal.entity.AdminUser; public interface AdminUserMapper extends BaseMapper { } diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/AdminUserRoleMapper.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/AdminUserRoleMapper.java similarity index 57% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/AdminUserRoleMapper.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/AdminUserRoleMapper.java index bb158df..a20951e 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/AdminUserRoleMapper.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/AdminUserRoleMapper.java @@ -1,7 +1,7 @@ -package com.reelshort.system.dal.mapper; +package com.fruitshorts.system.dal.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.reelshort.system.dal.entity.AdminUserRole; +import com.fruitshorts.system.dal.entity.AdminUserRole; public interface AdminUserRoleMapper extends BaseMapper { } diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/MenuMapper.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/MenuMapper.java similarity index 56% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/MenuMapper.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/MenuMapper.java index a5f7abf..390e86e 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/MenuMapper.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/MenuMapper.java @@ -1,7 +1,7 @@ -package com.reelshort.system.dal.mapper; +package com.fruitshorts.system.dal.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.reelshort.system.dal.entity.Menu; +import com.fruitshorts.system.dal.entity.Menu; public interface MenuMapper extends BaseMapper

{ } diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/OperationLogMapper.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/OperationLogMapper.java similarity index 57% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/OperationLogMapper.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/OperationLogMapper.java index 9ce1b94..aa2353a 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/OperationLogMapper.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/OperationLogMapper.java @@ -1,7 +1,7 @@ -package com.reelshort.system.dal.mapper; +package com.fruitshorts.system.dal.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.reelshort.system.dal.entity.OperationLog; +import com.fruitshorts.system.dal.entity.OperationLog; public interface OperationLogMapper extends BaseMapper { } diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/PermissionMapper.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/PermissionMapper.java similarity index 56% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/PermissionMapper.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/PermissionMapper.java index a68a077..37049a0 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/PermissionMapper.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/PermissionMapper.java @@ -1,7 +1,7 @@ -package com.reelshort.system.dal.mapper; +package com.fruitshorts.system.dal.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.reelshort.system.dal.entity.Permission; +import com.fruitshorts.system.dal.entity.Permission; public interface PermissionMapper extends BaseMapper { } diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/RoleCategoryMapper.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/RoleCategoryMapper.java similarity index 57% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/RoleCategoryMapper.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/RoleCategoryMapper.java index d9c5152..8e2c6df 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/RoleCategoryMapper.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/RoleCategoryMapper.java @@ -1,7 +1,7 @@ -package com.reelshort.system.dal.mapper; +package com.fruitshorts.system.dal.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.reelshort.system.dal.entity.RoleCategory; +import com.fruitshorts.system.dal.entity.RoleCategory; public interface RoleCategoryMapper extends BaseMapper { } diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/RoleMapper.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/RoleMapper.java similarity index 56% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/RoleMapper.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/RoleMapper.java index 9961956..794bbff 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/RoleMapper.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/RoleMapper.java @@ -1,7 +1,7 @@ -package com.reelshort.system.dal.mapper; +package com.fruitshorts.system.dal.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.reelshort.system.dal.entity.Role; +import com.fruitshorts.system.dal.entity.Role; public interface RoleMapper extends BaseMapper { } diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/RoleMenuMapper.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/RoleMenuMapper.java similarity index 56% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/RoleMenuMapper.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/RoleMenuMapper.java index 548e57d..3625ffb 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/RoleMenuMapper.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/RoleMenuMapper.java @@ -1,7 +1,7 @@ -package com.reelshort.system.dal.mapper; +package com.fruitshorts.system.dal.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.reelshort.system.dal.entity.RoleMenu; +import com.fruitshorts.system.dal.entity.RoleMenu; public interface RoleMenuMapper extends BaseMapper { } diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/RolePermissionMapper.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/RolePermissionMapper.java similarity index 57% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/RolePermissionMapper.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/RolePermissionMapper.java index c2da5e5..1f4a899 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/RolePermissionMapper.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/RolePermissionMapper.java @@ -1,7 +1,7 @@ -package com.reelshort.system.dal.mapper; +package com.fruitshorts.system.dal.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.reelshort.system.dal.entity.RolePermission; +import com.fruitshorts.system.dal.entity.RolePermission; public interface RolePermissionMapper extends BaseMapper { } diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/SysConfigMapper.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/SysConfigMapper.java similarity index 56% rename from reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/SysConfigMapper.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/SysConfigMapper.java index 5e1090d..883f156 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/dal/mapper/SysConfigMapper.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/SysConfigMapper.java @@ -1,7 +1,7 @@ -package com.reelshort.system.dal.mapper; +package com.fruitshorts.system.dal.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.reelshort.system.dal.entity.SysConfig; +import com.fruitshorts.system.dal.entity.SysConfig; public interface SysConfigMapper extends BaseMapper { } diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/service/AdminUserService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AdminUserService.java similarity index 93% rename from reelshort-module-system/src/main/java/com/reelshort/system/service/AdminUserService.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AdminUserService.java index e516122..d5c5a6d 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/service/AdminUserService.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AdminUserService.java @@ -1,18 +1,18 @@ -package com.reelshort.system.service; +package com.fruitshorts.system.service; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.reelshort.common.exception.ErrorCode; -import com.reelshort.common.exception.ServiceException; -import com.reelshort.common.pojo.PageResult; -import com.reelshort.system.dal.entity.AdminUser; -import com.reelshort.system.dal.entity.AdminUserRole; -import com.reelshort.system.dal.mapper.AdminUserMapper; -import com.reelshort.system.dal.mapper.AdminUserRoleMapper; -import com.reelshort.system.vo.AdminUserPageReqVO; -import com.reelshort.system.vo.AdminUserRespVO; -import com.reelshort.system.vo.AdminUserSaveReqVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.system.dal.entity.AdminUser; +import com.fruitshorts.system.dal.entity.AdminUserRole; +import com.fruitshorts.system.dal.mapper.AdminUserMapper; +import com.fruitshorts.system.dal.mapper.AdminUserRoleMapper; +import com.fruitshorts.system.vo.AdminUserPageReqVO; +import com.fruitshorts.system.vo.AdminUserRespVO; +import com.fruitshorts.system.vo.AdminUserSaveReqVO; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/service/AuthService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AuthService.java similarity index 84% rename from reelshort-module-system/src/main/java/com/reelshort/system/service/AuthService.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AuthService.java index bc9f4e9..12c9c89 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/service/AuthService.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/AuthService.java @@ -1,15 +1,15 @@ -package com.reelshort.system.service; +package com.fruitshorts.system.service; -import com.reelshort.common.exception.ErrorCode; -import com.reelshort.common.exception.ServiceException; -import com.reelshort.framework.security.JwtTokenProvider; -import com.reelshort.framework.security.LoginUser; -import com.reelshort.system.dal.entity.AdminUser; -import com.reelshort.system.dal.entity.Role; -import com.reelshort.system.vo.LoginReqVO; -import com.reelshort.system.vo.LoginRespVO; -import com.reelshort.system.vo.MenuVO; -import com.reelshort.system.vo.ProfileRespVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.framework.security.JwtTokenProvider; +import com.fruitshorts.framework.security.LoginUser; +import com.fruitshorts.system.dal.entity.AdminUser; +import com.fruitshorts.system.dal.entity.Role; +import com.fruitshorts.system.vo.LoginReqVO; +import com.fruitshorts.system.vo.LoginRespVO; +import com.fruitshorts.system.vo.MenuVO; +import com.fruitshorts.system.vo.ProfileRespVO; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/service/ConfigService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/ConfigService.java similarity index 89% rename from reelshort-module-system/src/main/java/com/reelshort/system/service/ConfigService.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/ConfigService.java index a3401a1..02da4a3 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/service/ConfigService.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/ConfigService.java @@ -1,9 +1,9 @@ -package com.reelshort.system.service; +package com.fruitshorts.system.service; import com.baomidou.mybatisplus.core.toolkit.Wrappers; -import com.reelshort.system.dal.entity.SysConfig; -import com.reelshort.system.dal.mapper.SysConfigMapper; -import com.reelshort.system.vo.ConfigItemVO; +import com.fruitshorts.system.dal.entity.SysConfig; +import com.fruitshorts.system.dal.mapper.SysConfigMapper; +import com.fruitshorts.system.vo.ConfigItemVO; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/service/MenuService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/MenuService.java similarity index 93% rename from reelshort-module-system/src/main/java/com/reelshort/system/service/MenuService.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/MenuService.java index 8a5177d..b822e0f 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/service/MenuService.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/MenuService.java @@ -1,13 +1,13 @@ -package com.reelshort.system.service; +package com.fruitshorts.system.service; import com.baomidou.mybatisplus.core.toolkit.Wrappers; -import com.reelshort.common.exception.ErrorCode; -import com.reelshort.common.exception.ServiceException; -import com.reelshort.system.dal.entity.Menu; -import com.reelshort.system.dal.entity.Role; -import com.reelshort.system.dal.mapper.MenuMapper; -import com.reelshort.system.vo.MenuSaveReqVO; -import com.reelshort.system.vo.MenuVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.system.dal.entity.Menu; +import com.fruitshorts.system.dal.entity.Role; +import com.fruitshorts.system.dal.mapper.MenuMapper; +import com.fruitshorts.system.vo.MenuSaveReqVO; +import com.fruitshorts.system.vo.MenuVO; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/service/OperationLogService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/OperationLogService.java similarity index 88% rename from reelshort-module-system/src/main/java/com/reelshort/system/service/OperationLogService.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/OperationLogService.java index 1920e51..e14c24c 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/service/OperationLogService.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/OperationLogService.java @@ -1,17 +1,17 @@ -package com.reelshort.system.service; +package com.fruitshorts.system.service; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.reelshort.common.exception.ErrorCode; -import com.reelshort.common.exception.ServiceException; -import com.reelshort.common.pojo.PageResult; -import com.reelshort.framework.log.OperationLogEntry; -import com.reelshort.framework.log.OperationLogSink; -import com.reelshort.system.dal.entity.OperationLog; -import com.reelshort.system.dal.mapper.OperationLogMapper; -import com.reelshort.system.vo.OperationLogPageReqVO; -import com.reelshort.system.vo.OperationLogRespVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.framework.log.OperationLogEntry; +import com.fruitshorts.framework.log.OperationLogSink; +import com.fruitshorts.system.dal.entity.OperationLog; +import com.fruitshorts.system.dal.mapper.OperationLogMapper; +import com.fruitshorts.system.vo.OperationLogPageReqVO; +import com.fruitshorts.system.vo.OperationLogRespVO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/service/PermissionService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/PermissionService.java similarity index 86% rename from reelshort-module-system/src/main/java/com/reelshort/system/service/PermissionService.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/PermissionService.java index a911aca..fb73e4c 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/service/PermissionService.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/PermissionService.java @@ -1,11 +1,11 @@ -package com.reelshort.system.service; +package com.fruitshorts.system.service; import com.baomidou.mybatisplus.core.toolkit.Wrappers; -import com.reelshort.common.exception.ErrorCode; -import com.reelshort.common.exception.ServiceException; -import com.reelshort.system.dal.entity.Permission; -import com.reelshort.system.dal.mapper.PermissionMapper; -import com.reelshort.system.vo.PermissionGroupVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.system.dal.entity.Permission; +import com.fruitshorts.system.dal.mapper.PermissionMapper; +import com.fruitshorts.system.vo.PermissionGroupVO; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/service/RoleCategoryService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/RoleCategoryService.java similarity index 87% rename from reelshort-module-system/src/main/java/com/reelshort/system/service/RoleCategoryService.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/RoleCategoryService.java index 5237971..0a75285 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/service/RoleCategoryService.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/RoleCategoryService.java @@ -1,14 +1,14 @@ -package com.reelshort.system.service; +package com.fruitshorts.system.service; import com.baomidou.mybatisplus.core.toolkit.Wrappers; -import com.reelshort.common.exception.ErrorCode; -import com.reelshort.common.exception.ServiceException; -import com.reelshort.system.dal.entity.Role; -import com.reelshort.system.dal.entity.RoleCategory; -import com.reelshort.system.dal.mapper.RoleCategoryMapper; -import com.reelshort.system.dal.mapper.RoleMapper; -import com.reelshort.system.vo.RoleCategoryRespVO; -import com.reelshort.system.vo.RoleCategorySaveReqVO; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.system.dal.entity.Role; +import com.fruitshorts.system.dal.entity.RoleCategory; +import com.fruitshorts.system.dal.mapper.RoleCategoryMapper; +import com.fruitshorts.system.dal.mapper.RoleMapper; +import com.fruitshorts.system.vo.RoleCategoryRespVO; +import com.fruitshorts.system.vo.RoleCategorySaveReqVO; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/service/RoleService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/RoleService.java similarity index 90% rename from reelshort-module-system/src/main/java/com/reelshort/system/service/RoleService.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/RoleService.java index 6b2ebae..85bd888 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/service/RoleService.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/RoleService.java @@ -1,26 +1,26 @@ -package com.reelshort.system.service; +package com.fruitshorts.system.service; import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.toolkit.Wrappers; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.reelshort.common.exception.ErrorCode; -import com.reelshort.common.exception.ServiceException; -import com.reelshort.common.pojo.PageResult; -import com.reelshort.system.dal.entity.AdminUserRole; -import com.reelshort.system.dal.entity.Menu; -import com.reelshort.system.dal.entity.Role; -import com.reelshort.system.dal.entity.RoleCategory; -import com.reelshort.system.dal.entity.RoleMenu; -import com.reelshort.system.dal.entity.RolePermission; -import com.reelshort.system.dal.mapper.AdminUserRoleMapper; -import com.reelshort.system.dal.mapper.MenuMapper; -import com.reelshort.system.dal.mapper.RoleCategoryMapper; -import com.reelshort.system.dal.mapper.RoleMapper; -import com.reelshort.system.dal.mapper.RoleMenuMapper; -import com.reelshort.system.dal.mapper.RolePermissionMapper; -import com.reelshort.system.vo.RoleRespVO; -import com.reelshort.system.vo.RoleSaveReqVO; -import com.reelshort.framework.security.PermissionProvider; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.system.dal.entity.AdminUserRole; +import com.fruitshorts.system.dal.entity.Menu; +import com.fruitshorts.system.dal.entity.Role; +import com.fruitshorts.system.dal.entity.RoleCategory; +import com.fruitshorts.system.dal.entity.RoleMenu; +import com.fruitshorts.system.dal.entity.RolePermission; +import com.fruitshorts.system.dal.mapper.AdminUserRoleMapper; +import com.fruitshorts.system.dal.mapper.MenuMapper; +import com.fruitshorts.system.dal.mapper.RoleCategoryMapper; +import com.fruitshorts.system.dal.mapper.RoleMapper; +import com.fruitshorts.system.dal.mapper.RoleMenuMapper; +import com.fruitshorts.system.dal.mapper.RolePermissionMapper; +import com.fruitshorts.system.vo.RoleRespVO; +import com.fruitshorts.system.vo.RoleSaveReqVO; +import com.fruitshorts.framework.security.PermissionProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/AdminUserPageReqVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AdminUserPageReqVO.java similarity index 74% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/AdminUserPageReqVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AdminUserPageReqVO.java index 706e1cb..7845881 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/AdminUserPageReqVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AdminUserPageReqVO.java @@ -1,6 +1,6 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; -import com.reelshort.common.pojo.PageParam; +import com.fruitshorts.common.pojo.PageParam; import lombok.Data; import lombok.EqualsAndHashCode; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/AdminUserRespVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AdminUserRespVO.java similarity index 94% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/AdminUserRespVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AdminUserRespVO.java index 13c1432..c65a0bf 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/AdminUserRespVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AdminUserRespVO.java @@ -1,4 +1,4 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/AdminUserSaveReqVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AdminUserSaveReqVO.java similarity index 93% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/AdminUserSaveReqVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AdminUserSaveReqVO.java index 0cc715a..4bcacc1 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/AdminUserSaveReqVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/AdminUserSaveReqVO.java @@ -1,4 +1,4 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/ConfigItemVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/ConfigItemVO.java similarity index 84% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/ConfigItemVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/ConfigItemVO.java index aa00e7a..1ea3059 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/ConfigItemVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/ConfigItemVO.java @@ -1,4 +1,4 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/LoginReqVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LoginReqVO.java similarity index 87% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/LoginReqVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LoginReqVO.java index 7145159..35874e6 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/LoginReqVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LoginReqVO.java @@ -1,4 +1,4 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; import jakarta.validation.constraints.NotBlank; import lombok.Data; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/LoginRespVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LoginRespVO.java similarity index 92% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/LoginRespVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LoginRespVO.java index 42d866e..4a13498 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/LoginRespVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LoginRespVO.java @@ -1,4 +1,4 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; import lombok.Builder; import lombok.Data; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/MenuSaveReqVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/MenuSaveReqVO.java similarity index 93% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/MenuSaveReqVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/MenuSaveReqVO.java index b61ffe8..95192cf 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/MenuSaveReqVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/MenuSaveReqVO.java @@ -1,4 +1,4 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; import jakarta.validation.constraints.NotBlank; import lombok.Data; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/MenuVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/MenuVO.java similarity index 96% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/MenuVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/MenuVO.java index cfb8313..b129493 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/MenuVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/MenuVO.java @@ -1,4 +1,4 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; import com.fasterxml.jackson.annotation.JsonInclude; import lombok.AllArgsConstructor; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/OperationLogPageReqVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/OperationLogPageReqVO.java similarity index 89% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/OperationLogPageReqVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/OperationLogPageReqVO.java index 0be9f17..2fe9d67 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/OperationLogPageReqVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/OperationLogPageReqVO.java @@ -1,6 +1,6 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; -import com.reelshort.common.pojo.PageParam; +import com.fruitshorts.common.pojo.PageParam; import lombok.Data; import lombok.EqualsAndHashCode; import org.springframework.format.annotation.DateTimeFormat; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/OperationLogRespVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/OperationLogRespVO.java similarity index 95% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/OperationLogRespVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/OperationLogRespVO.java index 1d93b3b..a2e910e 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/OperationLogRespVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/OperationLogRespVO.java @@ -1,4 +1,4 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; import com.fasterxml.jackson.annotation.JsonFormat; import lombok.Data; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/PasswordChangeReqVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/PasswordChangeReqVO.java similarity index 91% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/PasswordChangeReqVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/PasswordChangeReqVO.java index 78074bf..07358ba 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/PasswordChangeReqVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/PasswordChangeReqVO.java @@ -1,4 +1,4 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/PermissionGroupVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/PermissionGroupVO.java similarity index 90% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/PermissionGroupVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/PermissionGroupVO.java index 0b10a33..c159189 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/PermissionGroupVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/PermissionGroupVO.java @@ -1,4 +1,4 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; import lombok.AllArgsConstructor; import lombok.Data; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/ProfileRespVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/ProfileRespVO.java similarity index 93% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/ProfileRespVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/ProfileRespVO.java index 7bb32b4..5868da2 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/ProfileRespVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/ProfileRespVO.java @@ -1,4 +1,4 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; import lombok.Builder; import lombok.Data; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/RoleCategoryRespVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/RoleCategoryRespVO.java similarity index 83% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/RoleCategoryRespVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/RoleCategoryRespVO.java index d54980d..16fd771 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/RoleCategoryRespVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/RoleCategoryRespVO.java @@ -1,4 +1,4 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; import lombok.Data; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/RoleCategorySaveReqVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/RoleCategorySaveReqVO.java similarity index 86% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/RoleCategorySaveReqVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/RoleCategorySaveReqVO.java index 8265854..0efe81f 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/RoleCategorySaveReqVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/RoleCategorySaveReqVO.java @@ -1,4 +1,4 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; import jakarta.validation.constraints.NotBlank; import lombok.Data; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/RolePermissionSaveReqVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/RolePermissionSaveReqVO.java similarity index 86% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/RolePermissionSaveReqVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/RolePermissionSaveReqVO.java index 09bd631..f6692b1 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/RolePermissionSaveReqVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/RolePermissionSaveReqVO.java @@ -1,4 +1,4 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; import jakarta.validation.constraints.NotNull; import lombok.Data; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/RoleRespVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/RoleRespVO.java similarity index 92% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/RoleRespVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/RoleRespVO.java index 4f9adf1..afc71b3 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/RoleRespVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/RoleRespVO.java @@ -1,4 +1,4 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; import lombok.Data; diff --git a/reelshort-module-system/src/main/java/com/reelshort/system/vo/RoleSaveReqVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/RoleSaveReqVO.java similarity index 93% rename from reelshort-module-system/src/main/java/com/reelshort/system/vo/RoleSaveReqVO.java rename to fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/RoleSaveReqVO.java index 6fece8e..9c091bb 100644 --- a/reelshort-module-system/src/main/java/com/reelshort/system/vo/RoleSaveReqVO.java +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/RoleSaveReqVO.java @@ -1,4 +1,4 @@ -package com.reelshort.system.vo; +package com.fruitshorts.system.vo; import jakarta.validation.constraints.NotBlank; import lombok.Data; diff --git a/fruitshorts-module-user/pom.xml b/fruitshorts-module-user/pom.xml new file mode 100644 index 0000000..f7b46be --- /dev/null +++ b/fruitshorts-module-user/pom.xml @@ -0,0 +1,23 @@ + + + 4.0.0 + + + com.fruitshorts + fruitshorts + 1.0.0-SNAPSHOT + + + fruitshorts-module-user + fruitshorts-module-user + 用户域:C 端账号、登录、会话、资料、风控 + + + + com.fruitshorts + fruitshorts-framework + + + diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppAccountDeletion.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppAccountDeletion.java new file mode 100644 index 0000000..b20468b --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppAccountDeletion.java @@ -0,0 +1,36 @@ +package com.fruitshorts.user.dal.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 账号注销申请与冷静期。 */ +@Data +@TableName("app_account_deletion") +public class AppAccountDeletion { + + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + /** pending/cancelled/completed */ + private String status; + private String reason; + private LocalDateTime requestedAt; + private LocalDateTime scheduledAt; + private LocalDateTime cancelledAt; + private LocalDateTime completedAt; + private Long operatorId; + private LocalDateTime createdAt; + + @TableField(updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUser.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUser.java new file mode 100644 index 0000000..197922c --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUser.java @@ -0,0 +1,62 @@ +package com.fruitshorts.user.dal.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** C 端用户。 */ +@Data +@TableName("app_user") +public class AppUser { + + @TableId(type = IdType.AUTO) + private Long id; + /** 对外用户标识,接口只暴露 uid,不暴露自增 ID */ + private String uid; + private String nickname; + private String avatarUrl; + private String gender; + private LocalDate birthday; + private String countryCode; + private String regionCode; + private String language; + private String timezone; + private String currency; + /** normal/limited/banned/deactivated */ + private String status; + private String registerSource; + private String registerChannel; + private String registerDeviceId; + private String registerIp; + private LocalDateTime lastLoginAt; + private String lastLoginIp; + private Integer ageVerified; + private String agreementVersion; + private LocalDateTime agreementAt; + /** 预留:会员等级与到期(第二阶段) */ + private Integer vipLevel; + private LocalDateTime vipExpireAt; + /** 预留:风控等级 */ + private Integer riskLevel; + /** 预留:邀请码与推荐人 */ + private String inviteCode; + private Long referrerUserId; + /** 下载偏好:1 表示仅 Wi-Fi 下载 */ + private Integer downloadWifiOnly; + /** 下载偏好:允许的最高清晰度,AUTO 表示不限制 */ + private String downloadMaxQuality; + private LocalDateTime createdAt; + + @TableField(updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserBanRecord.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserBanRecord.java new file mode 100644 index 0000000..5617068 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserBanRecord.java @@ -0,0 +1,44 @@ +package com.fruitshorts.user.dal.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 封禁/限制与申诉。 */ +@Data +@TableName("app_user_ban_record") +public class AppUserBanRecord { + + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + /** BAN/LIMIT/MUTE */ + private String type; + private String reasonCode; + private String reason; + private String refType; + private Long refId; + private LocalDateTime startAt; + private LocalDateTime endAt; + private Long operatorId; + /** none/pending/approved/rejected */ + private String appealStatus; + private String appealContent; + private LocalDateTime appealAt; + private Long handleBy; + private LocalDateTime handleAt; + private String handleNote; + private LocalDateTime createdAt; + + @TableField(updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserConsent.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserConsent.java new file mode 100644 index 0000000..1d89f80 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserConsent.java @@ -0,0 +1,33 @@ +package com.fruitshorts.user.dal.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 同意管理记录。 */ +@Data +@TableName("app_user_consent") +public class AppUserConsent { + + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private String scene; + private String version; + private Integer agreed; + private String ip; + private String deviceId; + private LocalDateTime createdAt; + + @TableField(updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserCredential.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserCredential.java new file mode 100644 index 0000000..46d8500 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserCredential.java @@ -0,0 +1,37 @@ +package com.fruitshorts.user.dal.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 登录凭证:手机号、邮箱、Apple、Google 各一行。 */ +@Data +@TableName("app_user_credential") +public class AppUserCredential { + + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + /** PHONE/EMAIL/APPLE/GOOGLE */ + private String type; + private String identifier; + private String federationId; + private String appId; + private String passwordHash; + private Integer verified; + private LocalDateTime verifiedAt; + private LocalDateTime lastUsedAt; + private LocalDateTime createdAt; + + @TableField(updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserDevice.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserDevice.java new file mode 100644 index 0000000..f1c9652 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserDevice.java @@ -0,0 +1,34 @@ +package com.fruitshorts.user.dal.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 用户设备,用于下载授权与并发播放数限制。 */ +@Data +@TableName("app_user_device") +public class AppUserDevice { + + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private String deviceId; + private String platform; + private String deviceModel; + private LocalDateTime firstSeenAt; + private LocalDateTime lastSeenAt; + private String status; + private LocalDateTime createdAt; + + @TableField(updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserOtp.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserOtp.java new file mode 100644 index 0000000..85428bf --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserOtp.java @@ -0,0 +1,37 @@ +package com.fruitshorts.user.dal.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** 验证码记录,同时承担反刷频控。 */ +@Data +@TableName("app_user_otp") +public class AppUserOtp { + + @TableId(type = IdType.AUTO) + private Long id; + private String scene; + private String targetType; + private String target; + private String codeHash; + private LocalDateTime expireAt; + private LocalDateTime usedAt; + private String sendIp; + private String sendDeviceId; + private Integer sendCount; + private Integer failCount; + private LocalDateTime createdAt; + + @TableField(updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserSession.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserSession.java new file mode 100644 index 0000000..a82cc63 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/entity/AppUserSession.java @@ -0,0 +1,39 @@ +package com.fruitshorts.user.dal.entity; + +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** C 端会话。refresh token 只存哈希,支持多设备与逐端登出。 */ +@Data +@TableName("app_user_session") +public class AppUserSession { + + @TableId(type = IdType.AUTO) + private Long id; + private Long userId; + private String refreshTokenHash; + private String deviceId; + private String deviceModel; + private String osVersion; + private String appVersion; + private String ip; + private String userAgent; + private String platform; + private LocalDateTime expireAt; + private LocalDateTime revokedAt; + private LocalDateTime lastActiveAt; + private LocalDateTime createdAt; + + @TableField(updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; + + @TableLogic + private Integer deleted; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppAccountDeletionMapper.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppAccountDeletionMapper.java new file mode 100644 index 0000000..1994d78 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppAccountDeletionMapper.java @@ -0,0 +1,7 @@ +package com.fruitshorts.user.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.user.dal.entity.AppAccountDeletion; + +public interface AppAccountDeletionMapper extends BaseMapper { +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserBanRecordMapper.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserBanRecordMapper.java new file mode 100644 index 0000000..3f1efe3 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserBanRecordMapper.java @@ -0,0 +1,7 @@ +package com.fruitshorts.user.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.user.dal.entity.AppUserBanRecord; + +public interface AppUserBanRecordMapper extends BaseMapper { +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserConsentMapper.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserConsentMapper.java new file mode 100644 index 0000000..470169e --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserConsentMapper.java @@ -0,0 +1,7 @@ +package com.fruitshorts.user.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.user.dal.entity.AppUserConsent; + +public interface AppUserConsentMapper extends BaseMapper { +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserCredentialMapper.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserCredentialMapper.java new file mode 100644 index 0000000..757b5ac --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserCredentialMapper.java @@ -0,0 +1,7 @@ +package com.fruitshorts.user.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.user.dal.entity.AppUserCredential; + +public interface AppUserCredentialMapper extends BaseMapper { +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserDeviceMapper.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserDeviceMapper.java new file mode 100644 index 0000000..4483dcd --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserDeviceMapper.java @@ -0,0 +1,7 @@ +package com.fruitshorts.user.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.user.dal.entity.AppUserDevice; + +public interface AppUserDeviceMapper extends BaseMapper { +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserMapper.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserMapper.java new file mode 100644 index 0000000..5e91346 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserMapper.java @@ -0,0 +1,7 @@ +package com.fruitshorts.user.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.user.dal.entity.AppUser; + +public interface AppUserMapper extends BaseMapper { +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserOtpMapper.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserOtpMapper.java new file mode 100644 index 0000000..605398c --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserOtpMapper.java @@ -0,0 +1,7 @@ +package com.fruitshorts.user.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.user.dal.entity.AppUserOtp; + +public interface AppUserOtpMapper extends BaseMapper { +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserSessionMapper.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserSessionMapper.java new file mode 100644 index 0000000..ce6a3b7 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/dal/mapper/AppUserSessionMapper.java @@ -0,0 +1,7 @@ +package com.fruitshorts.user.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.user.dal.entity.AppUserSession; + +public interface AppUserSessionMapper extends BaseMapper { +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/enums/AppUserStatus.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/enums/AppUserStatus.java new file mode 100644 index 0000000..9749994 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/enums/AppUserStatus.java @@ -0,0 +1,25 @@ +package com.fruitshorts.user.enums; + +import java.util.List; + +/** + * 账号状态。 + * 封禁与注销必须同时清理会话,不能只改状态位,否则旧 token 仍然可用。 + */ +public final class AppUserStatus { + + public static final String NORMAL = "normal"; + public static final String LIMITED = "limited"; + public static final String BANNED = "banned"; + public static final String DEACTIVATED = "deactivated"; + + public static final List ALL = List.of(NORMAL, LIMITED, BANNED, DEACTIVATED); + + /** 这些状态下不允许登录。 */ + public static boolean isBlocked(String status) { + return BANNED.equals(status) || DEACTIVATED.equals(status); + } + + private AppUserStatus() { + } +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/enums/CredentialType.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/enums/CredentialType.java new file mode 100644 index 0000000..3d9f96b --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/enums/CredentialType.java @@ -0,0 +1,13 @@ +package com.fruitshorts.user.enums; + +/** 登录凭证类型。 */ +public enum CredentialType { + PHONE, + EMAIL, + APPLE, + GOOGLE; + + public static boolean isThirdParty(String type) { + return APPLE.name().equals(type) || GOOGLE.name().equals(type); + } +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/enums/DeletionStatus.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/enums/DeletionStatus.java new file mode 100644 index 0000000..9451798 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/enums/DeletionStatus.java @@ -0,0 +1,12 @@ +package com.fruitshorts.user.enums; + +/** 注销申请状态。 */ +public final class DeletionStatus { + + public static final String PENDING = "pending"; + public static final String CANCELLED = "cancelled"; + public static final String COMPLETED = "completed"; + + private DeletionStatus() { + } +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/enums/OtpScene.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/enums/OtpScene.java new file mode 100644 index 0000000..484169f --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/enums/OtpScene.java @@ -0,0 +1,18 @@ +package com.fruitshorts.user.enums; + +/** 验证码使用场景。 */ +public enum OtpScene { + REGISTER, + LOGIN, + RESET_PWD, + BIND; + + public static boolean isValid(String scene) { + for (OtpScene item : values()) { + if (item.name().equals(scene)) { + return true; + } + } + return false; + } +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java new file mode 100644 index 0000000..28cf22d --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppAuthService.java @@ -0,0 +1,343 @@ +package com.fruitshorts.user.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.framework.security.JwtTokenProvider; +import com.fruitshorts.user.dal.entity.AppUser; +import com.fruitshorts.user.dal.entity.AppUserConsent; +import com.fruitshorts.user.dal.entity.AppUserCredential; +import com.fruitshorts.user.dal.entity.AppUserDevice; +import com.fruitshorts.user.dal.entity.AppUserSession; +import com.fruitshorts.user.dal.mapper.AppUserConsentMapper; +import com.fruitshorts.user.dal.mapper.AppUserCredentialMapper; +import com.fruitshorts.user.dal.mapper.AppUserDeviceMapper; +import com.fruitshorts.user.dal.mapper.AppUserMapper; +import com.fruitshorts.user.dal.mapper.AppUserSessionMapper; +import com.fruitshorts.user.enums.AppUserStatus; +import com.fruitshorts.user.enums.OtpScene; +import com.fruitshorts.user.vo.AppUserRespVO; +import com.fruitshorts.user.vo.LoginReqVO; +import com.fruitshorts.user.vo.LogoutReqVO; +import com.fruitshorts.user.vo.RegisterReqVO; +import com.fruitshorts.user.vo.TokenRespVO; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * C 端账号:注册、登录、令牌轮换、登出。 + * + *

几条硬规则: + *

    + *
  • access token 用 C 端独立密钥签发,与管理端 token 不可互换。
  • + *
  • refresh token 只存哈希,刷新时轮换,旧的立即失效,防止被盗后长期复用。
  • + *
  • 封禁/注销账号直接拒绝登录,且状态变更时要吊销存量会话。
  • + *
+ */ +@Service +@RequiredArgsConstructor +public class AppAuthService { + + private final AppUserMapper appUserMapper; + private final AppUserCredentialMapper credentialMapper; + private final AppUserSessionMapper sessionMapper; + private final AppUserConsentMapper consentMapper; + private final AppUserDeviceMapper deviceMapper; + private final AppOtpService appOtpService; + private final CredentialHasher hasher; + private final JwtTokenProvider tokenProvider; + private final PasswordEncoder passwordEncoder; + + @Transactional(rollbackFor = Exception.class) + public TokenRespVO register(RegisterReqVO req, String clientIp) { + String targetType = AppIdentifier.normalizeType(req.getTargetType()); + String target = AppIdentifier.normalize(targetType, req.getTarget()); + appOtpService.consume(OtpScene.REGISTER.name(), targetType, target, req.getCode()); + + if (findCredential(targetType, target) != null) { + throw new ServiceException(ErrorCode.DATA_EXISTS, "该账号已注册,请直接登录"); + } + + AppUser user = createUser(req, clientIp); + // created_at / updated_at 由数据库默认值维护,回读一次让响应里的时间字段不为空 + user = appUserMapper.selectById(user.getId()); + AppUserCredential credential = new AppUserCredential(); + credential.setUserId(user.getId()); + credential.setType(targetType); + credential.setIdentifier(target); + credential.setPasswordHash(StringUtils.hasText(req.getPassword()) + ? passwordEncoder.encode(req.getPassword()) + : ""); + credential.setVerified(1); + credential.setVerifiedAt(LocalDateTime.now()); + credential.setLastUsedAt(LocalDateTime.now()); + credential.setFederationId(""); + credential.setAppId(""); + try { + credentialMapper.insert(credential); + } catch (DuplicateKeyException ex) { + // 并发注册:由唯一索引兜底,返回可理解的提示而不是 500 + throw new ServiceException(ErrorCode.DATA_EXISTS, "该账号已注册,请直接登录"); + } + + recordConsent(user.getId(), "PRIVACY", req.getAgreementVersion(), clientIp, req.getDeviceId()); + recordConsent(user.getId(), "TERMS", req.getAgreementVersion(), clientIp, req.getDeviceId()); + if (Boolean.TRUE.equals(req.getMarketingConsent())) { + recordConsent(user.getId(), "MARKETING", req.getAgreementVersion(), clientIp, req.getDeviceId()); + } + touchDevice(user.getId(), req.getDeviceId(), req.getPlatform(), req.getDeviceModel()); + + TokenRespVO resp = issueTokens(user, credential, req.getDeviceId(), req.getDeviceModel(), + req.getOsVersion(), req.getAppVersion(), req.getPlatform(), clientIp); + resp.setNewUser(true); + return resp; + } + + @Transactional(rollbackFor = Exception.class) + public TokenRespVO login(LoginReqVO req, String clientIp) { + String targetType = AppIdentifier.normalizeType(req.getTargetType()); + String target = AppIdentifier.normalize(targetType, req.getTarget()); + boolean byCode = StringUtils.hasText(req.getCode()); + if (!byCode && !StringUtils.hasText(req.getPassword())) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "请提供验证码或密码"); + } + if (byCode) { + appOtpService.consume(OtpScene.LOGIN.name(), targetType, target, req.getCode()); + } + + AppUserCredential credential = findCredential(targetType, target); + if (credential == null) { + throw new ServiceException(ErrorCode.LOGIN_FAILED, "账号不存在,请先注册"); + } + if (!byCode && (!StringUtils.hasText(credential.getPasswordHash()) + || !passwordEncoder.matches(req.getPassword(), credential.getPasswordHash()))) { + throw new ServiceException(ErrorCode.LOGIN_FAILED); + } + + AppUser user = appUserMapper.selectById(credential.getUserId()); + if (user == null) { + throw new ServiceException(ErrorCode.LOGIN_FAILED); + } + if (AppUserStatus.isBlocked(user.getStatus())) { + throw new ServiceException(ErrorCode.ACCOUNT_DISABLED); + } + + credential.setLastUsedAt(LocalDateTime.now()); + credential.setVerified(1); + credential.setVerifiedAt(LocalDateTime.now()); + credentialMapper.updateById(credential); + + user.setLastLoginAt(LocalDateTime.now()); + user.setLastLoginIp(AppIdentifier.nullSafe(clientIp)); + appUserMapper.updateById(user); + touchDevice(user.getId(), req.getDeviceId(), req.getPlatform(), req.getDeviceModel()); + + return issueTokens(user, credential, req.getDeviceId(), req.getDeviceModel(), req.getOsVersion(), + req.getAppVersion(), req.getPlatform(), clientIp); + } + + /** 刷新令牌:轮换 refresh token,旧的立即失效。 */ + @Transactional(rollbackFor = Exception.class) + public TokenRespVO refresh(String refreshToken) { + AppUserSession session = sessionMapper.selectOne(Wrappers.lambdaQuery() + .eq(AppUserSession::getRefreshTokenHash, hasher.sha256(refreshToken)) + .last("limit 1")); + LocalDateTime now = LocalDateTime.now(); + if (session == null || session.getRevokedAt() != null + || (session.getExpireAt() != null && session.getExpireAt().isBefore(now))) { + throw new ServiceException(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录"); + } + AppUser user = appUserMapper.selectById(session.getUserId()); + if (user == null) { + throw new ServiceException(ErrorCode.UNAUTHORIZED); + } + if (AppUserStatus.isBlocked(user.getStatus())) { + revokeAllSessions(user.getId()); + throw new ServiceException(ErrorCode.ACCOUNT_DISABLED); + } + + String nextRefreshToken = hasher.newRefreshToken(); + session.setRefreshTokenHash(hasher.sha256(nextRefreshToken)); + session.setLastActiveAt(now); + session.setExpireAt(now.plus(tokenProvider.appRefreshTtl())); + sessionMapper.updateById(session); + + TokenRespVO resp = new TokenRespVO(); + resp.setAccessToken(tokenProvider.createAppToken(user.getId(), user.getUid(), session.getDeviceId())); + resp.setRefreshToken(nextRefreshToken); + resp.setExpiresIn(tokenProvider.accessTtlSeconds()); + resp.setUser(toResp(user, findCredentialByUser(user.getId()))); + return resp; + } + + @Transactional(rollbackFor = Exception.class) + public void logout(Long userId, LogoutReqVO req) { + if (userId == null) { + return; + } + if (Boolean.TRUE.equals(req.getAllDevices())) { + revokeAllSessions(userId); + return; + } + if (!StringUtils.hasText(req.getRefreshToken())) { + return; + } + AppUserSession session = sessionMapper.selectOne(Wrappers.lambdaQuery() + .eq(AppUserSession::getUserId, userId) + .eq(AppUserSession::getRefreshTokenHash, hasher.sha256(req.getRefreshToken())) + .last("limit 1")); + if (session != null && session.getRevokedAt() == null) { + session.setRevokedAt(LocalDateTime.now()); + sessionMapper.updateById(session); + } + } + + /** 封禁、注销、改密后必须调用,否则旧 token 在有效期内仍然可用。 */ + @Transactional(rollbackFor = Exception.class) + public void revokeAllSessions(Long userId) { + sessionMapper.update(null, Wrappers.lambdaUpdate() + .eq(AppUserSession::getUserId, userId) + .isNull(AppUserSession::getRevokedAt) + .set(AppUserSession::getRevokedAt, LocalDateTime.now())); + } + + public AppUserCredential findCredential(String type, String identifier) { + return credentialMapper.selectOne(Wrappers.lambdaQuery() + .eq(AppUserCredential::getType, type) + .eq(AppUserCredential::getIdentifier, identifier) + .last("limit 1")); + } + + public AppUserCredential findCredentialByUser(Long userId) { + return credentialMapper.selectOne(Wrappers.lambdaQuery() + .eq(AppUserCredential::getUserId, userId) + .orderByAsc(AppUserCredential::getId) + .last("limit 1")); + } + + public List credentialsOf(Long userId) { + return credentialMapper.selectList(Wrappers.lambdaQuery() + .eq(AppUserCredential::getUserId, userId) + .orderByAsc(AppUserCredential::getId)); + } + + public AppUserRespVO toResp(AppUser user, AppUserCredential credential) { + AppUserRespVO vo = new AppUserRespVO(); + vo.setUid(user.getUid()); + vo.setNickname(user.getNickname()); + vo.setAvatarUrl(user.getAvatarUrl()); + vo.setGender(user.getGender()); + vo.setCountryCode(user.getCountryCode()); + vo.setRegionCode(user.getRegionCode()); + vo.setLanguage(user.getLanguage()); + vo.setTimezone(user.getTimezone()); + vo.setCurrency(user.getCurrency()); + vo.setStatus(user.getStatus()); + vo.setCreatedAt(user.getCreatedAt()); + vo.setLastLoginAt(user.getLastLoginAt()); + vo.setVipLevel(user.getVipLevel()); + vo.setVipExpireAt(user.getVipExpireAt()); + if (credential != null) { + vo.setMaskedTarget(AppIdentifier.mask(credential.getType(), credential.getIdentifier())); + } + return vo; + } + + private TokenRespVO issueTokens(AppUser user, AppUserCredential credential, String deviceId, + String deviceModel, String osVersion, String appVersion, + String platform, String clientIp) { + String refreshToken = hasher.newRefreshToken(); + AppUserSession session = new AppUserSession(); + session.setUserId(user.getId()); + session.setRefreshTokenHash(hasher.sha256(refreshToken)); + session.setDeviceId(AppIdentifier.nullSafe(deviceId)); + session.setDeviceModel(AppIdentifier.nullSafe(deviceModel)); + session.setOsVersion(AppIdentifier.nullSafe(osVersion)); + session.setAppVersion(AppIdentifier.nullSafe(appVersion)); + session.setPlatform(AppIdentifier.nullSafe(platform)); + session.setIp(AppIdentifier.nullSafe(clientIp)); + session.setUserAgent(""); + session.setExpireAt(LocalDateTime.now().plus(tokenProvider.appRefreshTtl())); + session.setLastActiveAt(LocalDateTime.now()); + sessionMapper.insert(session); + + TokenRespVO resp = new TokenRespVO(); + resp.setAccessToken(tokenProvider.createAppToken(user.getId(), user.getUid(), session.getDeviceId())); + resp.setRefreshToken(refreshToken); + resp.setExpiresIn(tokenProvider.accessTtlSeconds()); + resp.setUser(toResp(user, credential)); + return resp; + } + + private AppUser createUser(RegisterReqVO req, String clientIp) { + AppUser user = new AppUser(); + user.setUid(hasher.newUid()); + user.setNickname(StringUtils.hasText(req.getNickname()) + ? req.getNickname() + : "User" + user.getUid().substring(1, 7)); + user.setAvatarUrl(""); + user.setGender(""); + String country = StringUtils.hasText(req.getCountryCode()) ? req.getCountryCode() : "ID"; + user.setCountryCode(country); + user.setRegionCode(country); + user.setLanguage("id-ID"); + user.setTimezone("Asia/Jakarta"); + user.setCurrency("IDR"); + user.setStatus(AppUserStatus.NORMAL); + user.setRegisterSource("APP"); + user.setRegisterChannel(""); + user.setRegisterDeviceId(AppIdentifier.nullSafe(req.getDeviceId())); + user.setRegisterIp(AppIdentifier.nullSafe(clientIp)); + user.setAgeVerified(0); + user.setAgreementVersion(AppIdentifier.nullSafe(req.getAgreementVersion())); + user.setAgreementAt(LocalDateTime.now()); + user.setVipLevel(0); + user.setRiskLevel(0); + user.setInviteCode(""); + user.setReferrerUserId(0L); + appUserMapper.insert(user); + return user; + } + + private void recordConsent(Long userId, String scene, String version, String clientIp, String deviceId) { + AppUserConsent consent = new AppUserConsent(); + consent.setUserId(userId); + consent.setScene(scene); + consent.setVersion(AppIdentifier.nullSafe(version)); + consent.setAgreed(1); + consent.setIp(AppIdentifier.nullSafe(clientIp)); + consent.setDeviceId(AppIdentifier.nullSafe(deviceId)); + consentMapper.insert(consent); + } + + private void touchDevice(Long userId, String deviceId, String platform, String deviceModel) { + if (!StringUtils.hasText(deviceId)) { + return; + } + AppUserDevice device = deviceMapper.selectOne(Wrappers.lambdaQuery() + .eq(AppUserDevice::getUserId, userId) + .eq(AppUserDevice::getDeviceId, deviceId) + .last("limit 1")); + if (device == null) { + device = new AppUserDevice(); + device.setUserId(userId); + device.setDeviceId(deviceId); + device.setPlatform(AppIdentifier.nullSafe(platform)); + device.setDeviceModel(AppIdentifier.nullSafe(deviceModel)); + device.setFirstSeenAt(LocalDateTime.now()); + device.setLastSeenAt(LocalDateTime.now()); + device.setStatus("active"); + deviceMapper.insert(device); + return; + } + device.setLastSeenAt(LocalDateTime.now()); + deviceMapper.updateById(device); + } +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppIdentifier.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppIdentifier.java new file mode 100644 index 0000000..6a82499 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppIdentifier.java @@ -0,0 +1,66 @@ +package com.fruitshorts.user.service; + +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.user.enums.CredentialType; +import org.springframework.util.StringUtils; + +import java.util.Locale; + +/** 手机号/邮箱的规范化与脱敏。 */ +public final class AppIdentifier { + + private AppIdentifier() { + } + + public static String normalizeType(String raw) { + String type = raw == null ? "" : raw.trim().toUpperCase(Locale.ROOT); + if (!CredentialType.PHONE.name().equals(type) && !CredentialType.EMAIL.name().equals(type)) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "仅支持手机号或邮箱"); + } + return type; + } + + /** + * 手机号统一成 E.164;首发市场是印尼,所以本地 08xx 补 62 前缀, + * 其余情况按国际格式补 +。 + */ + public static String normalize(String targetType, String raw) { + if (!StringUtils.hasText(raw)) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "请输入手机号或邮箱"); + } + String value = raw.trim(); + if (CredentialType.EMAIL.name().equals(targetType)) { + return value.toLowerCase(Locale.ROOT); + } + String digits = value.replaceAll("[^0-9+]", ""); + if (digits.startsWith("+")) { + return digits; + } + if (digits.startsWith("0")) { + return "+62" + digits.substring(1); + } + return "+" + digits; + } + + public static String mask(String targetType, String identifier) { + if (!StringUtils.hasText(identifier)) { + return ""; + } + if (CredentialType.EMAIL.name().equals(targetType)) { + int at = identifier.indexOf('@'); + if (at <= 0) { + return "***"; + } + return identifier.charAt(0) + "***" + identifier.substring(at); + } + if (identifier.length() <= 7) { + return "****"; + } + return identifier.substring(0, 4) + "****" + identifier.substring(identifier.length() - 3); + } + + public static String nullSafe(String value) { + return value == null ? "" : value; + } +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppOtpService.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppOtpService.java new file mode 100644 index 0000000..4e4f904 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppOtpService.java @@ -0,0 +1,117 @@ +package com.fruitshorts.user.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.user.dal.entity.AppUserOtp; +import com.fruitshorts.user.dal.mapper.AppUserOtpMapper; +import com.fruitshorts.user.enums.OtpScene; +import com.fruitshorts.user.vo.OtpSendReqVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.Locale; + +/** + * 验证码服务:发送频控 + 校验消费。 + * + *

频控是登录链路的第一道防线:短信成本与刷号风险都在这里拦住。 + * 阈值先按保守值给,上线后按真实到达率与风控数据调。 + */ +@Service +@RequiredArgsConstructor +public class AppOtpService { + + private static final int OTP_TTL_MINUTES = 5; + private static final int OTP_RESEND_SECONDS = 60; + private static final int OTP_DAILY_LIMIT_PER_TARGET = 10; + private static final int OTP_HOURLY_LIMIT_PER_IP = 20; + private static final int OTP_MAX_VERIFY_FAIL = 5; + + private final AppUserOtpMapper otpMapper; + private final OtpSender otpSender; + private final CredentialHasher hasher; + + @Transactional(rollbackFor = Exception.class) + public void send(OtpSendReqVO req, String clientIp) { + String scene = req.getScene() == null ? "" : req.getScene().trim().toUpperCase(Locale.ROOT); + if (!OtpScene.isValid(scene)) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "不支持的验证码场景"); + } + String targetType = AppIdentifier.normalizeType(req.getTargetType()); + String target = AppIdentifier.normalize(targetType, req.getTarget()); + checkFrequency(target, clientIp); + + String code = hasher.newOtpCode(); + AppUserOtp record = new AppUserOtp(); + record.setScene(scene); + record.setTargetType(targetType); + record.setTarget(target); + record.setCodeHash(hasher.hashOtp(code)); + record.setExpireAt(LocalDateTime.now().plusMinutes(OTP_TTL_MINUTES)); + record.setSendIp(AppIdentifier.nullSafe(clientIp)); + record.setSendDeviceId(AppIdentifier.nullSafe(req.getDeviceId())); + record.setSendCount(1); + record.setFailCount(0); + otpMapper.insert(record); + + otpSender.send(targetType, target, scene, code); + } + + /** 校验通过立即标记已使用,保证一个验证码只能用一次。 */ + @Transactional(rollbackFor = Exception.class) + public void consume(String scene, String targetType, String target, String code) { + AppUserOtp record = otpMapper.selectOne(Wrappers.lambdaQuery() + .eq(AppUserOtp::getTargetType, targetType) + .eq(AppUserOtp::getTarget, target) + .eq(AppUserOtp::getScene, scene) + .isNull(AppUserOtp::getUsedAt) + .orderByDesc(AppUserOtp::getId) + .last("limit 1")); + if (record == null) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "请先获取验证码"); + } + if (record.getExpireAt() != null && record.getExpireAt().isBefore(LocalDateTime.now())) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "验证码已过期"); + } + if (record.getFailCount() != null && record.getFailCount() >= OTP_MAX_VERIFY_FAIL) { + throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "验证码错误次数过多,请重新获取"); + } + if (!hasher.hashOtp(code).equals(record.getCodeHash())) { + record.setFailCount((record.getFailCount() == null ? 0 : record.getFailCount()) + 1); + otpMapper.updateById(record); + throw new ServiceException(ErrorCode.BAD_REQUEST, "验证码不正确"); + } + record.setUsedAt(LocalDateTime.now()); + otpMapper.updateById(record); + } + + private void checkFrequency(String target, String clientIp) { + LocalDateTime now = LocalDateTime.now(); + AppUserOtp last = otpMapper.selectOne(Wrappers.lambdaQuery() + .eq(AppUserOtp::getTarget, target) + .orderByDesc(AppUserOtp::getId) + .last("limit 1")); + if (last != null && last.getCreatedAt() != null + && last.getCreatedAt().isAfter(now.minusSeconds(OTP_RESEND_SECONDS))) { + throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "验证码发送过于频繁,请稍后再试"); + } + Long todayCount = otpMapper.selectCount(Wrappers.lambdaQuery() + .eq(AppUserOtp::getTarget, target) + .ge(AppUserOtp::getCreatedAt, now.toLocalDate().atStartOfDay())); + if (todayCount != null && todayCount >= OTP_DAILY_LIMIT_PER_TARGET) { + throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "今日验证码次数已达上限"); + } + if (StringUtils.hasText(clientIp)) { + Long ipCount = otpMapper.selectCount(Wrappers.lambdaQuery() + .eq(AppUserOtp::getSendIp, clientIp) + .ge(AppUserOtp::getCreatedAt, now.minusHours(1))); + if (ipCount != null && ipCount >= OTP_HOURLY_LIMIT_PER_IP) { + throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "当前网络请求过于频繁"); + } + } + } +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppUserAdminService.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppUserAdminService.java new file mode 100644 index 0000000..36ca5c1 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppUserAdminService.java @@ -0,0 +1,155 @@ +package com.fruitshorts.user.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.common.pojo.PageResult; +import com.fruitshorts.user.dal.entity.AppUser; +import com.fruitshorts.user.dal.entity.AppUserBanRecord; +import com.fruitshorts.user.dal.entity.AppUserCredential; +import com.fruitshorts.user.dal.mapper.AppUserBanRecordMapper; +import com.fruitshorts.user.dal.mapper.AppUserCredentialMapper; +import com.fruitshorts.user.dal.mapper.AppUserMapper; +import com.fruitshorts.user.enums.AppUserStatus; +import com.fruitshorts.user.vo.AppUserQueryReqVO; +import com.fruitshorts.user.vo.AppUserAdminRespVO; +import com.fruitshorts.user.vo.AppUserRespVO; +import org.springframework.beans.BeanUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 后台用户管理:检索、详情、状态处置。 + * + *

所有状态变更都会吊销该用户全部会话,避免"封了但还能用"的情况。 + */ +@Service +@RequiredArgsConstructor +public class AppUserAdminService { + + private final AppUserMapper appUserMapper; + private final AppUserCredentialMapper credentialMapper; + private final AppUserBanRecordMapper banRecordMapper; + private final AppAuthService authService; + + public PageResult page(AppUserQueryReqVO req) { + Page page = new Page<>( + req.getPageIndex() == null || req.getPageIndex() < 1 ? 1 : req.getPageIndex(), + req.getPageSize() == null || req.getPageSize() < 1 ? 10 : req.getPageSize()); + + IPage result; + if (StringUtils.hasText(req.getKeyword())) { + // uid / 昵称 直接查;手机号与邮箱要先在凭证表命中 userId + List matchedUserIds = userIdsByCredential(req.getKeyword()); + result = appUserMapper.selectPage(page, Wrappers.lambdaQuery() + .and(wrapper -> { + wrapper.like(AppUser::getUid, req.getKeyword()) + .or().like(AppUser::getNickname, req.getKeyword()); + if (!matchedUserIds.isEmpty()) { + wrapper.or().in(AppUser::getId, matchedUserIds); + } + }) + .eq(StringUtils.hasText(req.getStatus()), AppUser::getStatus, req.getStatus()) + .eq(StringUtils.hasText(req.getCountryCode()), AppUser::getCountryCode, + req.getCountryCode()) + .eq(StringUtils.hasText(req.getRegisterSource()), AppUser::getRegisterSource, + req.getRegisterSource()) + .orderByDesc(AppUser::getId)); + } else { + result = appUserMapper.selectPage(page, Wrappers.lambdaQuery() + .eq(StringUtils.hasText(req.getStatus()), AppUser::getStatus, req.getStatus()) + .eq(StringUtils.hasText(req.getCountryCode()), AppUser::getCountryCode, + req.getCountryCode()) + .eq(StringUtils.hasText(req.getRegisterSource()), AppUser::getRegisterSource, + req.getRegisterSource()) + .orderByDesc(AppUser::getId)); + } + return new PageResult<>(result.getTotal(), toRespList(result.getRecords())); + } + + public AppUserAdminRespVO detail(Long id) { + AppUser user = appUserMapper.selectById(id); + if (user == null) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "用户不存在"); + } + return toAdminResp(user); + } + + /** 修改账号状态。封禁/注销会立即踢下线。 */ + @Transactional(rollbackFor = Exception.class) + public void updateStatus(Long id, String status, String reason, Long operatorId) { + if (!AppUserStatus.ALL.contains(status)) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "不支持的账号状态: " + status); + } + AppUser user = appUserMapper.selectById(id); + if (user == null) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "用户不存在"); + } + user.setStatus(status); + appUserMapper.updateById(user); + if (AppUserStatus.isBlocked(status)) { + authService.revokeAllSessions(id); + } + if (AppUserStatus.BANNED.equals(status) || AppUserStatus.LIMITED.equals(status)) { + AppUserBanRecord record = new AppUserBanRecord(); + record.setUserId(id); + record.setType(AppUserStatus.BANNED.equals(status) ? "BAN" : "LIMIT"); + record.setReasonCode(""); + record.setReason(StringUtils.hasText(reason) ? reason : ""); + record.setRefType("ADMIN"); + record.setRefId(0L); + record.setStartAt(LocalDateTime.now()); + record.setOperatorId(operatorId == null ? 0L : operatorId); + record.setAppealStatus("none"); + record.setAppealContent(""); + record.setHandleBy(0L); + record.setHandleNote(""); + banRecordMapper.insert(record); + } + } + + public List banRecords(Long userId) { + return banRecordMapper.selectList(Wrappers.lambdaQuery() + .eq(AppUserBanRecord::getUserId, userId) + .orderByDesc(AppUserBanRecord::getId)); + } + + private List userIdsByCredential(String keyword) { + List credentials = credentialMapper.selectList( + Wrappers.lambdaQuery() + .like(AppUserCredential::getIdentifier, keyword.trim().toLowerCase()) + .orderByDesc(AppUserCredential::getId) + .last("limit 50")); + List ids = new ArrayList<>(); + for (AppUserCredential credential : credentials) { + if (!ids.contains(credential.getUserId())) { + ids.add(credential.getUserId()); + } + } + return ids; + } + + private List toRespList(List users) { + List list = new ArrayList<>(users.size()); + for (AppUser user : users) { + list.add(toAdminResp(user)); + } + return list; + } + + private AppUserAdminRespVO toAdminResp(AppUser user) { + AppUserRespVO base = authService.toResp(user, authService.findCredentialByUser(user.getId())); + AppUserAdminRespVO vo = new AppUserAdminRespVO(); + BeanUtils.copyProperties(base, vo); + vo.setId(user.getId()); + return vo; + } +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppUserService.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppUserService.java new file mode 100644 index 0000000..da7cfcf --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppUserService.java @@ -0,0 +1,147 @@ +package com.fruitshorts.user.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.user.dal.entity.AppAccountDeletion; +import com.fruitshorts.user.dal.entity.AppUser; +import com.fruitshorts.user.dal.entity.AppUserConsent; +import com.fruitshorts.user.dal.entity.AppUserDevice; +import com.fruitshorts.user.dal.mapper.AppAccountDeletionMapper; +import com.fruitshorts.user.dal.mapper.AppUserConsentMapper; +import com.fruitshorts.user.dal.mapper.AppUserDeviceMapper; +import com.fruitshorts.user.dal.mapper.AppUserMapper; +import com.fruitshorts.user.enums.DeletionStatus; +import com.fruitshorts.user.vo.AppUserRespVO; +import com.fruitshorts.user.vo.DeactivateReqVO; +import com.fruitshorts.user.vo.ProfileUpdateReqVO; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** C 端用户资料、设备、注销与数据导出。 */ +@Service +@RequiredArgsConstructor +public class AppUserService { + + /** 注销冷静期天数。商店合规要求可撤销,且期间数据仍可用。 */ + private static final int DELETION_COOLING_OFF_DAYS = 15; + + private final AppUserMapper appUserMapper; + private final AppUserConsentMapper consentMapper; + private final AppUserDeviceMapper deviceMapper; + private final AppAccountDeletionMapper deletionMapper; + private final AppAuthService authService; + + public AppUser getExisting(Long userId) { + AppUser user = userId == null ? null : appUserMapper.selectById(userId); + if (user == null) { + throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "用户不存在"); + } + return user; + } + + public AppUserRespVO me(Long userId) { + AppUser user = getExisting(userId); + return authService.toResp(user, authService.findCredentialByUser(userId)); + } + + @Transactional(rollbackFor = Exception.class) + public AppUserRespVO updateProfile(Long userId, ProfileUpdateReqVO req) { + AppUser user = getExisting(userId); + if (StringUtils.hasText(req.getNickname())) { + user.setNickname(req.getNickname().trim()); + } + if (req.getAvatarUrl() != null) { + user.setAvatarUrl(req.getAvatarUrl().trim()); + } + if (req.getGender() != null) { + user.setGender(req.getGender().trim()); + } + if (req.getBirthday() != null) { + user.setBirthday(req.getBirthday()); + } + if (StringUtils.hasText(req.getLanguage())) { + user.setLanguage(req.getLanguage().trim()); + } + if (StringUtils.hasText(req.getTimezone())) { + user.setTimezone(req.getTimezone().trim()); + } + if (StringUtils.hasText(req.getRegionCode())) { + user.setRegionCode(req.getRegionCode().trim()); + } + appUserMapper.updateById(user); + return authService.toResp(user, authService.findCredentialByUser(userId)); + } + + public List devices(Long userId) { + return deviceMapper.selectList(Wrappers.lambdaQuery() + .eq(AppUserDevice::getUserId, userId) + .orderByDesc(AppUserDevice::getLastSeenAt)); + } + + public AppAccountDeletion pendingDeletion(Long userId) { + return deletionMapper.selectOne(Wrappers.lambdaQuery() + .eq(AppAccountDeletion::getUserId, userId) + .eq(AppAccountDeletion::getStatus, DeletionStatus.PENDING) + .last("limit 1")); + } + + /** + * 申请注销:立即登出全部设备并进入冷静期,冷静期内可撤销。 + * 真正的数据清理由定时任务在 scheduledAt 之后执行。 + */ + @Transactional(rollbackFor = Exception.class) + public LocalDateTime requestDeactivation(Long userId, DeactivateReqVO req) { + AppUser user = getExisting(userId); + if (pendingDeletion(userId) != null) { + throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "注销申请已在处理中"); + } + LocalDateTime scheduledAt = LocalDateTime.now().plusDays(DELETION_COOLING_OFF_DAYS); + AppAccountDeletion record = new AppAccountDeletion(); + record.setUserId(user.getId()); + record.setStatus(DeletionStatus.PENDING); + record.setReason(req == null || req.getReason() == null ? "" : req.getReason()); + record.setRequestedAt(LocalDateTime.now()); + record.setScheduledAt(scheduledAt); + record.setOperatorId(0L); + deletionMapper.insert(record); + authService.revokeAllSessions(userId); + return scheduledAt; + } + + @Transactional(rollbackFor = Exception.class) + public void cancelDeactivation(Long userId) { + AppAccountDeletion record = pendingDeletion(userId); + if (record == null) { + throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "没有待处理的注销申请"); + } + record.setStatus(DeletionStatus.CANCELLED); + record.setCancelledAt(LocalDateTime.now()); + deletionMapper.updateById(record); + } + + /** 数据导出(商店与隐私法规要求):账号侧数据;内容侧数据由接口层补齐。 */ + public Map exportAccount(Long userId) { + AppUser user = getExisting(userId); + Map data = new LinkedHashMap<>(); + data.put("profile", authService.toResp(user, null)); + data.put("credentials", authService.credentialsOf(userId).stream() + .map(item -> Map.of( + "type", item.getType(), + "masked", AppIdentifier.mask(item.getType(), item.getIdentifier()), + "verified", item.getVerified() != null && item.getVerified() == 1)) + .toList()); + data.put("consents", consentMapper.selectList(Wrappers.lambdaQuery() + .eq(AppUserConsent::getUserId, userId) + .orderByAsc(AppUserConsent::getId))); + data.put("devices", devices(userId)); + return data; + } +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppUserStatusCheckerImpl.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppUserStatusCheckerImpl.java new file mode 100644 index 0000000..58ed8e2 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/AppUserStatusCheckerImpl.java @@ -0,0 +1,29 @@ +package com.fruitshorts.user.service; + +import com.fruitshorts.framework.security.AppUserStatusChecker; +import com.fruitshorts.user.dal.entity.AppUser; +import com.fruitshorts.user.dal.mapper.AppUserMapper; +import com.fruitshorts.user.enums.AppUserStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 账号状态校验:走主键查询。 + * 接入 Redis 后这里加一层 5–10 秒的短缓存,既能挡住被处置账号, + * 又不会让每个请求都打库。 + */ +@Component +@RequiredArgsConstructor +public class AppUserStatusCheckerImpl implements AppUserStatusChecker { + + private final AppUserMapper appUserMapper; + + @Override + public boolean isActive(Long userId) { + if (userId == null) { + return false; + } + AppUser user = appUserMapper.selectById(userId); + return user != null && !AppUserStatus.isBlocked(user.getStatus()); + } +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/CredentialHasher.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/CredentialHasher.java new file mode 100644 index 0000000..bc87f6b --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/CredentialHasher.java @@ -0,0 +1,69 @@ +package com.fruitshorts.user.service; + +import com.fruitshorts.framework.security.JwtProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import java.util.HexFormat; + +/** + * 凭证哈希工具。 + * + *

验证码只有 6 位,直接 SHA-256 在库泄露后可被瞬间爆破,所以用服务端密钥做 HMAC(加盐): + * 没有密钥就无法离线枚举。refresh token 是 256 位随机串,用 SHA-256 即可。 + */ +@Component +@RequiredArgsConstructor +public class CredentialHasher { + + private static final SecureRandom RANDOM = new SecureRandom(); + + private final JwtProperties jwtProperties; + + public String hashOtp(String code) { + return hmac("otp|" + code); + } + + public String sha256(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception ex) { + throw new IllegalStateException("哈希失败", ex); + } + } + + public String newRefreshToken() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + public String newOtpCode() { + return String.format("%06d", RANDOM.nextInt(1_000_000)); + } + + /** 对外用户标识:不用自增 ID,避免被枚举出用户规模。 */ + public String newUid() { + byte[] bytes = new byte[6]; + RANDOM.nextBytes(bytes); + return "U" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes).toUpperCase(); + } + + private String hmac(String value) { + try { + byte[] key = (jwtProperties.getAppSecret() + "|credential-pepper").getBytes(StandardCharsets.UTF_8); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); + } catch (Exception ex) { + throw new IllegalStateException("验证码哈希失败", ex); + } + } +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/LogOtpSender.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/LogOtpSender.java new file mode 100644 index 0000000..39dc982 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/LogOtpSender.java @@ -0,0 +1,27 @@ +package com.fruitshorts.user.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * 开发用发送器:只打日志,不真正发短信。 + * 生产环境把 fruitshorts.otp.sender 配成 sms / email 并实现对应的 OtpSender。 + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "fruitshorts.otp.sender", havingValue = "log", matchIfMissing = true) +public class LogOtpSender implements OtpSender { + + @Override + public void send(String targetType, String target, String scene, String code) { + log.info("[OTP] 场景={} 渠道={} 接收方={} 验证码={}", scene, targetType, mask(target), code); + } + + private String mask(String target) { + if (target == null || target.length() <= 6) { + return "****"; + } + return target.substring(0, 3) + "****" + target.substring(target.length() - 3); + } +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/OtpSender.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/OtpSender.java new file mode 100644 index 0000000..9297874 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/service/OtpSender.java @@ -0,0 +1,16 @@ +package com.fruitshorts.user.service; + +/** + * 验证码投递通道。第一阶段只有日志实现,接入供应商时新增一个实现类即可, + * 需要多供应商轮询时在实现里做路由,业务代码不感知。 + */ +public interface OtpSender { + + /** + * @param targetType PHONE/EMAIL + * @param target E.164 手机号或邮箱 + * @param scene REGISTER/LOGIN/RESET_PWD/BIND + * @param code 明文验证码,实现方负责按渠道模板组装内容 + */ + void send(String targetType, String target, String scene, String code); +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/AppUserAdminRespVO.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/AppUserAdminRespVO.java new file mode 100644 index 0000000..588c447 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/AppUserAdminRespVO.java @@ -0,0 +1,17 @@ +package com.fruitshorts.user.vo; + +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 后台用户视图。 + * + *

比客户端那份多一个自增 id —— 后台的详情、封禁、流水接口都按 id 寻址; + * 客户端侧仍然只认 uid,不暴露自增 ID(能被枚举出用户规模)。 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class AppUserAdminRespVO extends AppUserRespVO { + + private Long id; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/AppUserQueryReqVO.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/AppUserQueryReqVO.java new file mode 100644 index 0000000..e92b925 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/AppUserQueryReqVO.java @@ -0,0 +1,16 @@ +package com.fruitshorts.user.vo; + +import lombok.Data; + +/** 后台用户检索条件。 */ +@Data +public class AppUserQueryReqVO { + + /** uid / 昵称 / 手机号 / 邮箱 模糊匹配 */ + private String keyword; + private String status; + private String countryCode; + private String registerSource; + private Integer pageIndex = 1; + private Integer pageSize = 10; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/AppUserRespVO.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/AppUserRespVO.java new file mode 100644 index 0000000..14749ff --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/AppUserRespVO.java @@ -0,0 +1,28 @@ +package com.fruitshorts.user.vo; + +import lombok.Data; + +import java.time.LocalDateTime; + +/** 对外用户信息,只暴露 uid,不含自增 ID 与手机号明文。 */ +@Data +public class AppUserRespVO { + + private String uid; + private String nickname; + private String avatarUrl; + private String gender; + private String countryCode; + private String regionCode; + private String language; + private String timezone; + private String currency; + private String status; + /** 脱敏后的手机号/邮箱,用于"我的"页展示 */ + private String maskedTarget; + private LocalDateTime createdAt; + private LocalDateTime lastLoginAt; + /** 预留:会员等级与到期(第二阶段) */ + private Integer vipLevel; + private LocalDateTime vipExpireAt; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/DeactivateReqVO.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/DeactivateReqVO.java new file mode 100644 index 0000000..30d0ed0 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/DeactivateReqVO.java @@ -0,0 +1,11 @@ +package com.fruitshorts.user.vo; + +import lombok.Data; + +/** 申请注销:进入冷静期,期间可撤销。 */ +@Data +public class DeactivateReqVO { + + private String reason; + private String password; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/LoginReqVO.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/LoginReqVO.java new file mode 100644 index 0000000..7c59fc7 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/LoginReqVO.java @@ -0,0 +1,27 @@ +package com.fruitshorts.user.vo; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** 手机号 / 邮箱登录:验证码与密码二选一。 */ +@Data +public class LoginReqVO { + + @NotBlank(message = "请指定登录方式") + private String targetType; + + @NotBlank(message = "请输入手机号或邮箱") + private String target; + + /** 验证码登录 */ + private String code; + + /** 密码登录 */ + private String password; + + private String deviceId; + private String deviceModel; + private String osVersion; + private String appVersion; + private String platform; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/LogoutReqVO.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/LogoutReqVO.java new file mode 100644 index 0000000..c168bf7 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/LogoutReqVO.java @@ -0,0 +1,11 @@ +package com.fruitshorts.user.vo; + +import lombok.Data; + +/** 登出。allDevices=true 时吊销该用户全部会话。 */ +@Data +public class LogoutReqVO { + + private String refreshToken; + private Boolean allDevices; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/OtpSendReqVO.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/OtpSendReqVO.java new file mode 100644 index 0000000..7e5f34b --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/OtpSendReqVO.java @@ -0,0 +1,26 @@ +package com.fruitshorts.user.vo; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** 发送验证码。 */ +@Data +public class OtpSendReqVO { + + /** REGISTER/LOGIN/RESET_PWD/BIND */ + @NotBlank(message = "请指定验证码场景") + private String scene; + + /** PHONE/EMAIL */ + @NotBlank(message = "请指定验证方式") + private String targetType; + + /** E.164 手机号或邮箱 */ + @NotBlank(message = "请输入手机号或邮箱") + private String target; + + private String deviceId; + + /** 预留:图形验证码票据,触发频控阈值后必填 */ + private String captchaTicket; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/ProfileUpdateReqVO.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/ProfileUpdateReqVO.java new file mode 100644 index 0000000..0aa1ff8 --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/ProfileUpdateReqVO.java @@ -0,0 +1,18 @@ +package com.fruitshorts.user.vo; + +import lombok.Data; + +import java.time.LocalDate; + +/** 基础资料修改。所有字段可选,只更新传入的部分。 */ +@Data +public class ProfileUpdateReqVO { + + private String nickname; + private String avatarUrl; + private String gender; + private LocalDate birthday; + private String language; + private String timezone; + private String regionCode; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/RefreshReqVO.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/RefreshReqVO.java new file mode 100644 index 0000000..8a8ebcc --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/RefreshReqVO.java @@ -0,0 +1,12 @@ +package com.fruitshorts.user.vo; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** 用 refresh token 换新的 access token,并轮换 refresh token。 */ +@Data +public class RefreshReqVO { + + @NotBlank(message = "缺少 refreshToken") + private String refreshToken; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/RegisterReqVO.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/RegisterReqVO.java new file mode 100644 index 0000000..a804d6c --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/RegisterReqVO.java @@ -0,0 +1,35 @@ +package com.fruitshorts.user.vo; + +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +/** 手机号 / 邮箱注册。 */ +@Data +public class RegisterReqVO { + + @NotBlank(message = "请指定注册方式") + private String targetType; + + @NotBlank(message = "请输入手机号或邮箱") + private String target; + + @NotBlank(message = "请输入验证码") + private String code; + + /** 可选:同时设置密码,之后可用密码登录 */ + private String password; + + private String nickname; + private String countryCode; + private String deviceId; + private String deviceModel; + private String osVersion; + private String appVersion; + private String platform; + + /** 协议版本,注册即视为同意最新版隐私政策与用户协议 */ + private String agreementVersion; + + /** 预留:营销与个性化广告同意,未同意不影响注册 */ + private Boolean marketingConsent; +} diff --git a/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/TokenRespVO.java b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/TokenRespVO.java new file mode 100644 index 0000000..6e5b67d --- /dev/null +++ b/fruitshorts-module-user/src/main/java/com/fruitshorts/user/vo/TokenRespVO.java @@ -0,0 +1,18 @@ +package com.fruitshorts.user.vo; + +import lombok.Data; + +/** 登录/刷新返回的令牌与用户摘要。 */ +@Data +public class TokenRespVO { + + private String accessToken; + /** 不透明随机串,只在客户端保存,服务端只存哈希 */ + private String refreshToken; + /** access token 剩余有效期(秒) */ + private long expiresIn; + private String tokenType = "Bearer"; + private AppUserRespVO user; + /** 本次登录是否新建了账号(验证码登录可能顺带注册) */ + private boolean newUser; +} diff --git a/pom.xml b/pom.xml index f387275..6bf41d1 100644 --- a/pom.xml +++ b/pom.xml @@ -11,21 +11,23 @@ - com.reelshort - reelshort + com.fruitshorts + fruitshorts 1.0.0-SNAPSHOT pom - reelshort - ReelShort 短剧服务 + fruitshorts + FruitShorts 短剧服务 - reelshort-common - reelshort-framework - reelshort-module-system - reelshort-module-content - reelshort-module-media - reelshort-admin-api - reelshort-app-api + fruitshorts-common + fruitshorts-framework + fruitshorts-module-system + fruitshorts-module-content + fruitshorts-module-media + fruitshorts-module-user + fruitshorts-module-playback + fruitshorts-admin-api + fruitshorts-app-api @@ -42,28 +44,38 @@ - com.reelshort - reelshort-common + com.fruitshorts + fruitshorts-common ${project.version} - com.reelshort - reelshort-framework + com.fruitshorts + fruitshorts-framework ${project.version} - com.reelshort - reelshort-module-system + com.fruitshorts + fruitshorts-module-system ${project.version} - com.reelshort - reelshort-module-content + com.fruitshorts + fruitshorts-module-content ${project.version} - com.reelshort - reelshort-module-media + com.fruitshorts + fruitshorts-module-media + ${project.version} + + + com.fruitshorts + fruitshorts-module-user + ${project.version} + + + com.fruitshorts + fruitshorts-module-playback ${project.version} diff --git a/reelshort-app-api/src/main/java/com/reelshort/app/controller/AppDramaController.java b/reelshort-app-api/src/main/java/com/reelshort/app/controller/AppDramaController.java deleted file mode 100644 index ebda094..0000000 --- a/reelshort-app-api/src/main/java/com/reelshort/app/controller/AppDramaController.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.reelshort.app.controller; - -import com.reelshort.app.service.AppCatalogService; -import com.reelshort.app.vo.AppDramaDetailVO; -import com.reelshort.app.vo.AppPlayTicketVO; -import com.reelshort.common.pojo.Result; -import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/app") -@RequiredArgsConstructor -public class AppDramaController { - - private final AppCatalogService appCatalogService; - - /** 详情:包含剧集列表,客户端进详情页一次请求即可渲染 */ - @GetMapping("/dramas/{id}") - public Result detail(@PathVariable Long id) { - return Result.ok(appCatalogService.dramaDetail(id)); - } - - /** 播放票据:返回可访问性与播放地址,未解锁时只返回需要多少金币 */ - @GetMapping("/episodes/{id}/play") - public Result play(@PathVariable Long id) { - return Result.ok(appCatalogService.playTicket(id)); - } -} diff --git a/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppPlayTicketVO.java b/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppPlayTicketVO.java deleted file mode 100644 index 58dd616..0000000 --- a/reelshort-app-api/src/main/java/com/reelshort/app/vo/AppPlayTicketVO.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.reelshort.app.vo; - -import lombok.Data; - -/** 播放票据:客户端据此决定直接播放还是弹解锁 */ -@Data -public class AppPlayTicketVO { - - private Long episodeId; - private Long dramaId; - private Integer epNo; - private String title; - private Integer durationSec; - private Boolean accessible; - /** FREE / COIN_REQUIRED / VIP_REQUIRED / NO_MEDIA */ - private String accessType; - private Integer unlockCoin; - /** 可访问时的播放地址(相对路径,客户端拼上服务地址) */ - private String playUrl; -} diff --git a/reelshort-framework/src/main/java/com/reelshort/framework/security/JwtProperties.java b/reelshort-framework/src/main/java/com/reelshort/framework/security/JwtProperties.java deleted file mode 100644 index a8c707d..0000000 --- a/reelshort-framework/src/main/java/com/reelshort/framework/security/JwtProperties.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.reelshort.framework.security; - -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -@Data -@Component -@ConfigurationProperties(prefix = "reelshort.jwt") -public class JwtProperties { - - /** 管理端签名密钥,长度必须 >= 32 字节。生产环境通过环境变量注入。 */ - private String adminSecret = "reelshort-admin-dev-secret-change-me-in-production-32b"; - - /** 管理端 token 有效期(小时)。 */ - private int adminTtlHours = 12; -} diff --git a/reelshort-framework/src/main/java/com/reelshort/framework/security/JwtTokenProvider.java b/reelshort-framework/src/main/java/com/reelshort/framework/security/JwtTokenProvider.java deleted file mode 100644 index c8977c7..0000000 --- a/reelshort-framework/src/main/java/com/reelshort/framework/security/JwtTokenProvider.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.reelshort.framework.security; - -import com.reelshort.common.exception.ErrorCode; -import com.reelshort.common.exception.ServiceException; -import io.jsonwebtoken.Claims; -import io.jsonwebtoken.Jwts; -import io.jsonwebtoken.security.Keys; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import javax.crypto.SecretKey; -import java.nio.charset.StandardCharsets; -import java.time.Duration; -import java.util.Date; -import java.util.List; - -@Component -@RequiredArgsConstructor -public class JwtTokenProvider { - - private static final String CLAIM_USER_ID = "userId"; - private static final String CLAIM_ROLE_CODE = "roleCode"; - private static final String CLAIM_PERMISSIONS = "permissions"; - - private final JwtProperties properties; - - public String createAdminToken(LoginUser loginUser) { - Date now = new Date(); - Date expiration = new Date(now.getTime() + Duration.ofHours(properties.getAdminTtlHours()).toMillis()); - return Jwts.builder() - .subject(loginUser.username()) - .claim(CLAIM_USER_ID, loginUser.userId()) - .claim(CLAIM_ROLE_CODE, loginUser.roleCode()) - .claim(CLAIM_PERMISSIONS, loginUser.permissions()) - .issuedAt(now) - .expiration(expiration) - .signWith(adminKey()) - .compact(); - } - - @SuppressWarnings("unchecked") - public LoginUser parseAdminToken(String token) { - try { - Claims claims = Jwts.parser() - .verifyWith(adminKey()) - .build() - .parseSignedClaims(token) - .getPayload(); - Number userId = claims.get(CLAIM_USER_ID, Number.class); - return new LoginUser( - userId == null ? null : userId.longValue(), - claims.getSubject(), - claims.get(CLAIM_ROLE_CODE, String.class), - (List) claims.get(CLAIM_PERMISSIONS, List.class)); - } catch (Exception ex) { - throw new ServiceException(ErrorCode.UNAUTHORIZED); - } - } - - private SecretKey adminKey() { - byte[] bytes = properties.getAdminSecret().getBytes(StandardCharsets.UTF_8); - if (bytes.length < 32) { - throw new IllegalStateException("reelshort.jwt.admin-secret 长度必须不少于 32 字节"); - } - return Keys.hmacShaKeyFor(bytes); - } -} diff --git a/reelshort-framework/src/main/java/com/reelshort/framework/security/SecurityUtils.java b/reelshort-framework/src/main/java/com/reelshort/framework/security/SecurityUtils.java deleted file mode 100644 index dcd298f..0000000 --- a/reelshort-framework/src/main/java/com/reelshort/framework/security/SecurityUtils.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.reelshort.framework.security; - -import com.reelshort.common.exception.ErrorCode; -import com.reelshort.common.exception.ServiceException; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; - -public final class SecurityUtils { - - private SecurityUtils() { - } - - public static LoginUser getLoginUser() { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication != null && authentication.getPrincipal() instanceof LoginUser loginUser) { - return loginUser; - } - throw new ServiceException(ErrorCode.UNAUTHORIZED); - } - - public static Long getUserId() { - return getLoginUser().userId(); - } - - public static String getUsername() { - return getLoginUser().username(); - } -} diff --git a/reelshort-module-media/src/main/java/com/reelshort/media/config/MediaProperties.java b/reelshort-module-media/src/main/java/com/reelshort/media/config/MediaProperties.java deleted file mode 100644 index 6a435d6..0000000 --- a/reelshort-module-media/src/main/java/com/reelshort/media/config/MediaProperties.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.reelshort.media.config; - -import lombok.Data; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.stereotype.Component; - -@Data -@Component -@ConfigurationProperties(prefix = "reelshort.media") -public class MediaProperties { - - /** 本地存储根目录,可以是相对路径(相对应用启动目录)。 */ - private String rootDir = "./data/uploads"; - - /** 对外访问前缀,与静态资源映射保持一致。 */ - private String publicPrefix = "/files"; - - /** 分片大小(字节),默认 5MB。 */ - private long chunkSize = 5L * 1024 * 1024; - - /** 单个文件大小上限(字节),默认 2GB。 */ - private long maxFileSize = 2L * 1024 * 1024 * 1024; -} diff --git a/sql/data-phase1.sql b/sql/data-phase1.sql new file mode 100644 index 0000000..9b96f07 --- /dev/null +++ b/sql/data-phase1.sql @@ -0,0 +1,83 @@ +-- FruitShorts 短剧服务 · 第一阶段增量数据(PostgreSQL 18) +-- +-- 只放「代码已经实现、需要权限点才能调通」的数据。 +-- 菜单(sys_menu)暂时不加:前端页面还没做,加了会出现指向不存在组件的动态路由, +-- 等 fruitshorts-web 的 App 用户管理页落地后再补菜单与角色菜单绑定。 +-- +-- 幂等:可重复执行。 + +-- 先对齐 IDENTITY 序列:历史种子数据用了显式 ID,序列没有前移, +-- 不对齐的话下面的插入会从 1 开始而撞主键。 +DO $$ +DECLARE + t text; + seq text; + maxid bigint; +BEGIN + FOREACH t IN ARRAY ARRAY['sys_permission','sys_menu','sys_role_permission','sys_role_menu', + 'product','app_user','app_user_credential','orders','wallet'] LOOP + seq := pg_get_serial_sequence(t, 'id'); + IF seq IS NULL THEN + CONTINUE; + END IF; + EXECUTE format('SELECT COALESCE(MAX(id), 0) FROM %I', t) INTO maxid; + IF maxid > 0 THEN + PERFORM setval(seq, maxid); + END IF; + END LOOP; +END $$; + +INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES + ('app-user:list', '查看 App 用户', '用户管理', 'BUTTON', 101), + ('app-user:update', '处置 App 用户', '用户管理', 'BUTTON', 102) +ON CONFLICT (code) DO NOTHING; + +INSERT INTO sys_permission (code, name, group_name, type, sort) VALUES + ('download:list', '查看下载授权', '用户管理', 'BUTTON', 103), + ('download:revoke', '吊销下载授权', '用户管理', 'BUTTON', 104) +ON CONFLICT (code) DO NOTHING; + +-- 超管默认拥有全部权限点 +INSERT INTO sys_role_permission (role_id, permission_id) +SELECT r.id, p.id +FROM sys_role r +CROSS JOIN sys_permission p +WHERE r.code = 'super_admin' + AND p.code IN ('app-user:list', 'app-user:update', 'download:list', 'download:revoke') +ON CONFLICT DO NOTHING; + +-- 第一阶段新增的白名单数据(当前阶段都是免费内容,先把演示商品占位好) +INSERT INTO product (code, name, type, platform, store_product_id, coin_amount, bonus_coin, + price_amount, currency, region_codes, sort, status) +SELECT 'coin_100', '100 金币', 'COIN_PACK', 'ALL', '', 100, 0, 0.99, 'USD', 'ID', 1, 1 +WHERE NOT EXISTS (SELECT 1 FROM product WHERE code = 'coin_100'); + +INSERT INTO product (code, name, type, platform, store_product_id, coin_amount, bonus_coin, + price_amount, currency, region_codes, sort, status) +SELECT 'coin_550', '550 金币', 'COIN_PACK', 'ALL', '', 500, 50, 4.99, 'USD', 'ID', 2, 1 +WHERE NOT EXISTS (SELECT 1 FROM product WHERE code = 'coin_550'); + +INSERT INTO product (code, name, type, platform, store_product_id, coin_amount, bonus_coin, + price_amount, currency, region_codes, sort, status) +SELECT 'coin_1200', '1200 金币', 'COIN_PACK', 'ALL', '', 1000, 200, 9.99, 'USD', 'ID', 3, 1 +WHERE NOT EXISTS (SELECT 1 FROM product WHERE code = 'coin_1200'); + +-- ---------- 菜单:用户管理 ---------- +-- C 端用户与后台管理员是两套账号体系,单独挂一个一级目录,避免和管理员列表混淆。 +-- component 指向 src/views/userManagement/AppUsersView.vue,前端按这个字符串动态加载。 +INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon, + permission_code, type, always_show, affix, sort) VALUES + (15, 0, '用户管理', '/user', 'UserCenter', '#', '/user/app-users', 'ep:user', 'app-user:list', 'CATALOG', 1, 0, 4), + (16, 15, 'App 用户', 'app-users', 'app-users', 'views/userManagement/AppUsersView', '', '', 'app-user:list', 'MENU', 0, 0, 1) +ON CONFLICT (id) DO UPDATE SET parent_id = EXCLUDED.parent_id, title = EXCLUDED.title, + route_path = EXCLUDED.route_path, route_name = EXCLUDED.route_name, component = EXCLUDED.component, + redirect = EXCLUDED.redirect, icon = EXCLUDED.icon, permission_code = EXCLUDED.permission_code, + type = EXCLUDED.type, always_show = EXCLUDED.always_show, affix = EXCLUDED.affix, sort = EXCLUDED.sort; + +INSERT INTO sys_role_menu (role_id, menu_id) +SELECT r.id, m.id +FROM sys_role r +CROSS JOIN sys_menu m +WHERE r.code = 'super_admin' + AND m.id IN (15, 16) +ON CONFLICT DO NOTHING; diff --git a/sql/data.sql b/sql/data.sql index c877c50..2a9f080 100644 --- a/sql/data.sql +++ b/sql/data.sql @@ -1,4 +1,4 @@ --- ReelShort 短剧服务 · 初始化数据(PostgreSQL 18,可重复执行) +-- FruitShorts 短剧服务 · 初始化数据(PostgreSQL 18,可重复执行) -- 演示账号:admin / admin123(其余种子账号密码相同) -- -- MySQL 的 ON DUPLICATE KEY UPDATE 在这里换成 ON CONFLICT (id) DO UPDATE; @@ -128,7 +128,7 @@ ON CONFLICT DO NOTHING; -- ---------- 系统配置 ---------- INSERT INTO sys_config (config_key, config_value, remark) VALUES - ('workspace.name', 'ReelShort Studio', '工作空间名称'), + ('workspace.name', 'FruitShorts Studio', '工作空间名称'), ('content.default-status', 'draft', '内容默认状态') ON CONFLICT (config_key) DO UPDATE SET config_value = EXCLUDED.config_value, remark = EXCLUDED.remark; diff --git a/sql/partition-maintenance.sql b/sql/partition-maintenance.sql new file mode 100644 index 0000000..e58b165 --- /dev/null +++ b/sql/partition-maintenance.sql @@ -0,0 +1,167 @@ +-- FruitShorts 短剧服务 · 分区维护(PostgreSQL 18) +-- +-- 为什么要单独一个脚本: +-- 声明式分区只定义了"分片规则",不会自动建分区。如果只建了 default 分区, +-- 所有数据都会堆在 default 里,分区等于白做,而且越晚拆越贵。 +-- 所以这里提供三个东西:建分区的函数、清理过期分区的函数、以及现有库的转换脚本。 +-- +-- 应用侧也有兜底:com.fruitshorts.framework.partition.PartitionMaintainer +-- 启动时 + 每天凌晨各跑一次 ensure,保证不需要人工介入也不会漏建月份。 +-- +-- 幂等:可重复执行。 + +-- ============================================================ +-- 一、分区管理函数 +-- ============================================================ + +/** + * 为按月分区的表补齐「从 p_from 所在月开始、连续 p_months 个月」的分区。 + * 分区命名 _YYYYMM,已存在则跳过,返回实际新建的个数。 + */ +CREATE OR REPLACE FUNCTION ensure_monthly_partitions( + p_table text, + p_from date DEFAULT CURRENT_DATE, + p_months int DEFAULT 3 +) RETURNS int AS $$ +DECLARE + i int; + v_start date; + v_end date; + v_name text; + v_created int := 0; +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_partitioned_table pt + JOIN pg_class c ON c.oid = pt.partrelid + WHERE c.relname = p_table) THEN + RAISE EXCEPTION '% 不是分区表,无法维护分区', p_table; + END IF; + + FOR i IN 0 .. (p_months - 1) LOOP + v_start := (date_trunc('month', p_from) + (i || ' month')::interval)::date; + v_end := (v_start + interval '1 month')::date; + v_name := format('%s_%s', p_table, to_char(v_start, 'YYYYMM')); + IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = v_name) THEN + EXECUTE format('CREATE TABLE %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)', + v_name, p_table, v_start, v_end); + v_created := v_created + 1; + END IF; + END LOOP; + RETURN v_created; +END; +$$ LANGUAGE plpgsql; + +/** + * 删除 p_before 之前的月分区,返回删除个数。 + * 只认
_YYYYMM 这种命名的分区,default 分区不动。 + */ +CREATE OR REPLACE FUNCTION drop_partitions_before( + p_table text, + p_before date +) RETURNS int AS $$ +DECLARE + rec record; + dropped int := 0; + cutoff text := to_char(p_before, 'YYYYMM'); +BEGIN + FOR rec IN + SELECT c.relname + FROM pg_inherits i + JOIN pg_class c ON c.oid = i.inhrelid + JOIN pg_class p ON p.oid = i.inhparent + WHERE p.relname = p_table + AND c.relname ~ ('^' || p_table || '_[0-9]{6}$') + LOOP + IF substring(rec.relname from '([0-9]{6})$') < cutoff THEN + EXECUTE format('DROP TABLE IF EXISTS %I', rec.relname); + dropped := dropped + 1; + END IF; + END LOOP; + RETURN dropped; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================ +-- 二、把两张高写入表转成按月分区(只处理空表,有数据直接中止) +-- ============================================================ + +DO $$ +BEGIN + -- playback_ticket:每次签发播放票据一行,1 万并发下量级最大 + IF EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'playback_ticket') + AND NOT EXISTS (SELECT 1 FROM pg_partitioned_table pt + JOIN pg_class c ON c.oid = pt.partrelid + WHERE c.relname = 'playback_ticket') THEN + IF (SELECT count(*) FROM playback_ticket) > 0 THEN + RAISE EXCEPTION 'playback_ticket 已有数据,请先归档再转分区(勿直接删表)'; + END IF; + DROP TABLE playback_ticket; + END IF; + + CREATE TABLE IF NOT EXISTS playback_ticket ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY, + ticket_no VARCHAR(40) NOT NULL, + user_id BIGINT NOT NULL DEFAULT 0, + drama_id BIGINT NOT NULL, + episode_id BIGINT NOT NULL, + quality VARCHAR(16) NOT NULL DEFAULT 'AUTO', + play_url VARCHAR(1024) NOT NULL DEFAULT '', + expire_at TIMESTAMP(3) NOT NULL, + ip VARCHAR(64) NOT NULL DEFAULT '', + device_id VARCHAR(128) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL DEFAULT 'ISSUED', + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, created_at) + ) PARTITION BY RANGE (created_at); + + -- 分区表的唯一索引必须包含分区键,所以带上 created_at; + -- ticket_no 是 UUID,实际唯一性不受影响。 + CREATE UNIQUE INDEX IF NOT EXISTS uk_playback_ticket_no + ON playback_ticket (ticket_no, created_at); + CREATE INDEX IF NOT EXISTS idx_playback_ticket_user + ON playback_ticket (user_id, created_at DESC); + + -- order_event:订单状态流转流水,支付争议的证据链 + IF EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'order_event') + AND NOT EXISTS (SELECT 1 FROM pg_partitioned_table pt + JOIN pg_class c ON c.oid = pt.partrelid + WHERE c.relname = 'order_event') THEN + IF (SELECT count(*) FROM order_event) > 0 THEN + RAISE EXCEPTION 'order_event 已有数据,请先归档再转分区(勿直接删表)'; + END IF; + DROP TABLE order_event; + END IF; + + CREATE TABLE IF NOT EXISTS order_event ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY, + order_no VARCHAR(40) NOT NULL, + event VARCHAR(32) NOT NULL, + from_status VARCHAR(16) NOT NULL DEFAULT '', + to_status VARCHAR(16) NOT NULL DEFAULT '', + source VARCHAR(16) NOT NULL DEFAULT 'SERVER', + payload TEXT, + operator_id BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, created_at) + ) PARTITION BY RANGE (created_at); + + CREATE INDEX IF NOT EXISTS idx_order_event_no + ON order_event (order_no, created_at); +END $$; + +-- ============================================================ +-- 三、预建当前月 + 未来 3 个月的分区 +-- ============================================================ + +SELECT ensure_monthly_partitions('watch_history', CURRENT_DATE, 4); +SELECT ensure_monthly_partitions('playback_ticket', CURRENT_DATE, 4); +SELECT ensure_monthly_partitions('order_event', CURRENT_DATE, 4); + +-- ============================================================ +-- 四、保留策略(默认只演示,不自动删;上线后由应用定时任务按配置执行) +-- ============================================================ +-- 需要手工清理时执行: +-- SELECT drop_partitions_before('watch_history', CURRENT_DATE - INTERVAL '24 months'); +-- SELECT drop_partitions_before('playback_ticket', CURRENT_DATE - INTERVAL '6 months'); +-- SELECT drop_partitions_before('order_event', CURRENT_DATE - INTERVAL '36 months'); diff --git a/sql/schema-phase1.sql b/sql/schema-phase1.sql new file mode 100644 index 0000000..4a6206e --- /dev/null +++ b/sql/schema-phase1.sql @@ -0,0 +1,905 @@ +-- FruitShorts 短剧服务 · 第一阶段增量建表(PostgreSQL 18) +-- +-- 对应 docs/prd-phase1.md 的 10 个模块,只做**增量**:不修改已有表结构, +-- 已有表缺的字段用 ALTER TABLE ... ADD COLUMN IF NOT EXISTS 追加。 +-- 全部语句幂等,可重复执行。 +-- +-- 执行顺序:本脚本 → sql/partition-maintenance.sql +-- watch_history / playback_ticket / order_event 在本脚本里就定义成按月分区; +-- 具体的月分区由 sql/partition-maintenance.sql 创建(并把已存在的普通表转成分区表)。 +-- +-- 命名与类型约定(与 sql/schema.sql 保持一致): +-- BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY +-- 布尔语义用 SMALLINT(1/0),时间用 TIMESTAMP(3),默认 CURRENT_TIMESTAMP +-- 逻辑删除 deleted SMALLINT NOT NULL DEFAULT 0 +-- updated_at 由 set_updated_at() 触发器维护 +-- 标注「预留」的字段第一阶段只建不写,为第二至四阶段留位 + +-- ============================================================ +-- 一、已有表补字段(第一阶段必须 / 预留) +-- ============================================================ + +-- 短剧:内容分级、可播区域、版权窗口、标签、创作者归属 +ALTER TABLE drama ADD COLUMN IF NOT EXISTS creator_id BIGINT NOT NULL DEFAULT 0; +ALTER TABLE drama ADD COLUMN IF NOT EXISTS tags VARCHAR(255) NOT NULL DEFAULT ''; +ALTER TABLE drama ADD COLUMN IF NOT EXISTS rating VARCHAR(16) NOT NULL DEFAULT 'ALL'; -- ALL/R13/R17/R21,印尼内容分级 +ALTER TABLE drama ADD COLUMN IF NOT EXISTS region_codes VARCHAR(255) NOT NULL DEFAULT ''; -- 空=全区域;否则逗号分隔 ISO 国家码 +ALTER TABLE drama ADD COLUMN IF NOT EXISTS copyright_owner VARCHAR(128) NOT NULL DEFAULT ''; +ALTER TABLE drama ADD COLUMN IF NOT EXISTS copyright_start_at TIMESTAMP(3); +ALTER TABLE drama ADD COLUMN IF NOT EXISTS copyright_end_at TIMESTAMP(3); -- 到期自动下架 +ALTER TABLE drama ADD COLUMN IF NOT EXISTS subtitle_langs VARCHAR(255) NOT NULL DEFAULT ''; -- 预留:多语言字幕(第三阶段) +ALTER TABLE drama ADD COLUMN IF NOT EXISTS trailer_url VARCHAR(512) NOT NULL DEFAULT ''; +ALTER TABLE drama ADD COLUMN IF NOT EXISTS recommend_weight INT NOT NULL DEFAULT 0; -- 预留:推荐权重(第三阶段) +ALTER TABLE drama ADD COLUMN IF NOT EXISTS is_vip_only SMALLINT NOT NULL DEFAULT 0; -- 预留:会员专属(第二阶段) +ALTER TABLE drama ADD COLUMN IF NOT EXISTS audit_status VARCHAR(16) NOT NULL DEFAULT 'approved'; -- 审核状态,创作者内容用 +ALTER TABLE drama ADD COLUMN IF NOT EXISTS audit_note VARCHAR(500) NOT NULL DEFAULT ''; +COMMENT ON COLUMN drama.region_codes IS '可播区域 ISO 国家码,逗号分隔,空=不限'; + +-- 剧集:转码产物、试看、字幕 +ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS transcode_status VARCHAR(16) NOT NULL DEFAULT 'none'; -- none/queued/running/success/failed +ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS hls_url VARCHAR(512) NOT NULL DEFAULT ''; -- 多码率 master playlist +ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS preview_seconds INT NOT NULL DEFAULT 0; -- 试看秒数,0=不限制 +ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS subtitle_url VARCHAR(512) NOT NULL DEFAULT ''; +ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS subtitle_lang VARCHAR(16) NOT NULL DEFAULT ''; +ALTER TABLE drama_episode ADD COLUMN IF NOT EXISTS is_vip_only SMALLINT NOT NULL DEFAULT 0; -- 预留:会员专属(第二阶段) + +-- 媒资:CDN 地址与转码状态 +ALTER TABLE media_asset ADD COLUMN IF NOT EXISTS cdn_url VARCHAR(512) NOT NULL DEFAULT ''; +ALTER TABLE media_asset ADD COLUMN IF NOT EXISTS transcode_status VARCHAR(16) NOT NULL DEFAULT 'none'; +ALTER TABLE media_asset ADD COLUMN IF NOT EXISTS storage_type VARCHAR(16) NOT NULL DEFAULT 'LOCAL'; -- LOCAL/OSS,预留对象存储切换 + +-- ============================================================ +-- 二、用户域(PRD 02 用户账号与登录) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS app_user ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + uid VARCHAR(32) NOT NULL, -- 对外展示的用户标识,避免暴露自增 ID + nickname VARCHAR(64) NOT NULL DEFAULT '', + avatar_url VARCHAR(512) NOT NULL DEFAULT '', + gender VARCHAR(16) NOT NULL DEFAULT '', -- male/female/unknown + birthday DATE, + country_code VARCHAR(8) NOT NULL DEFAULT 'ID', -- 注册国家 + region_code VARCHAR(16) NOT NULL DEFAULT 'ID', -- 印尼本地运营区域,预留到省级 + language VARCHAR(16) NOT NULL DEFAULT 'id-ID', + timezone VARCHAR(64) NOT NULL DEFAULT 'Asia/Jakarta', + currency VARCHAR(8) NOT NULL DEFAULT 'IDR', + status VARCHAR(16) NOT NULL DEFAULT 'normal', -- normal/limited/banned/deactivated + register_source VARCHAR(32) NOT NULL DEFAULT 'APP', -- APP/H5/ADMIN + register_channel VARCHAR(64) NOT NULL DEFAULT '', -- 预留:投放渠道,归因用 + register_device_id VARCHAR(128) NOT NULL DEFAULT '', + register_ip VARCHAR(64) NOT NULL DEFAULT '', + last_login_at TIMESTAMP(3), + last_login_ip VARCHAR(64) NOT NULL DEFAULT '', + age_verified SMALLINT NOT NULL DEFAULT 0, + agreement_version VARCHAR(32) NOT NULL DEFAULT '', + agreement_at TIMESTAMP(3), + vip_level SMALLINT NOT NULL DEFAULT 0, -- 预留:会员等级(第二阶段) + vip_expire_at TIMESTAMP(3), -- 预留:会员到期(第二阶段) + risk_level SMALLINT NOT NULL DEFAULT 0, -- 预留:风控等级 + invite_code VARCHAR(32) NOT NULL DEFAULT '', -- 预留:邀请码 + referrer_user_id BIGINT NOT NULL DEFAULT 0, -- 预留:推荐人 + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_app_user_uid ON app_user (uid); +CREATE INDEX IF NOT EXISTS idx_app_user_status ON app_user (status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_app_user_created ON app_user (created_at DESC); +COMMENT ON TABLE app_user IS 'C 端用户'; + +CREATE TABLE IF NOT EXISTS app_user_credential ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + type VARCHAR(16) NOT NULL, -- PHONE/EMAIL/APPLE/GOOGLE + identifier VARCHAR(191) NOT NULL, -- E.164 手机号 / 邮箱 / 第三方唯一标识 + federation_id VARCHAR(191) NOT NULL DEFAULT '', -- 预留:微信等第三方 unionid + app_id VARCHAR(64) NOT NULL DEFAULT '', -- 第三方登录的 appId + password_hash VARCHAR(100) NOT NULL DEFAULT '', -- 手机号/邮箱密码登录用,第三方为空 + verified SMALLINT NOT NULL DEFAULT 0, + verified_at TIMESTAMP(3), + last_used_at TIMESTAMP(3), + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_app_credential_identifier ON app_user_credential (type, identifier); +CREATE INDEX IF NOT EXISTS idx_app_credential_user ON app_user_credential (user_id); +COMMENT ON TABLE app_user_credential IS 'C 端登录凭证(手机号/邮箱/第三方)'; + +CREATE TABLE IF NOT EXISTS app_user_session ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + refresh_token_hash VARCHAR(64) NOT NULL, -- SHA-256,不存明文 + device_id VARCHAR(128) NOT NULL DEFAULT '', + device_model VARCHAR(128) NOT NULL DEFAULT '', + os_version VARCHAR(64) NOT NULL DEFAULT '', + app_version VARCHAR(32) NOT NULL DEFAULT '', + ip VARCHAR(64) NOT NULL DEFAULT '', + user_agent VARCHAR(255) NOT NULL DEFAULT '', + platform VARCHAR(16) NOT NULL DEFAULT '', -- IOS/ANDROID + expire_at TIMESTAMP(3) NOT NULL, + revoked_at TIMESTAMP(3), + last_active_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_app_session_token ON app_user_session (refresh_token_hash); +CREATE INDEX IF NOT EXISTS idx_app_session_user ON app_user_session (user_id, revoked_at); +COMMENT ON TABLE app_user_session IS 'C 端会话(可多设备)'; + +CREATE TABLE IF NOT EXISTS app_user_otp ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + scene VARCHAR(16) NOT NULL, -- REGISTER/LOGIN/RESET_PWD/BIND + target_type VARCHAR(16) NOT NULL, -- PHONE/EMAIL + target VARCHAR(191) NOT NULL, + code_hash VARCHAR(64) NOT NULL, -- SHA-256(验证码+盐),不存明文 + expire_at TIMESTAMP(3) NOT NULL, + used_at TIMESTAMP(3), + send_ip VARCHAR(64) NOT NULL DEFAULT '', + send_device_id VARCHAR(128) NOT NULL DEFAULT '', + send_count INT NOT NULL DEFAULT 1, + fail_count INT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_app_otp_target ON app_user_otp (target_type, target, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_app_otp_ip ON app_user_otp (send_ip, created_at DESC); +COMMENT ON TABLE app_user_otp IS '验证码发送与校验记录(同时用于反刷频控)'; + +CREATE TABLE IF NOT EXISTS app_user_consent ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + scene VARCHAR(32) NOT NULL, -- PRIVACY/TERMS/MARKETING/PERSONALIZED_AD + version VARCHAR(32) NOT NULL DEFAULT '', + agreed SMALLINT NOT NULL DEFAULT 1, + ip VARCHAR(64) NOT NULL DEFAULT '', + device_id VARCHAR(128) NOT NULL DEFAULT '', + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_app_consent_user ON app_user_consent (user_id, scene); +COMMENT ON TABLE app_user_consent IS '同意管理记录,商店审核与隐私合规要求可追溯'; + +CREATE TABLE IF NOT EXISTS app_user_device ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + device_id VARCHAR(128) NOT NULL, + platform VARCHAR(16) NOT NULL DEFAULT '', + device_model VARCHAR(128) NOT NULL DEFAULT '', + first_seen_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_seen_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + status VARCHAR(16) NOT NULL DEFAULT 'active', -- active/removed + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_app_device ON app_user_device (user_id, device_id); +COMMENT ON TABLE app_user_device IS '用户设备,用于下载授权与并发播放数限制'; + +CREATE TABLE IF NOT EXISTS app_user_ban_record ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + type VARCHAR(16) NOT NULL, -- BAN/LIMIT/MUTE + reason_code VARCHAR(32) NOT NULL DEFAULT '', + reason VARCHAR(500) NOT NULL DEFAULT '', + ref_type VARCHAR(32) NOT NULL DEFAULT '', -- 触发来源:REPORT/CONTENT/AUDIT + ref_id BIGINT NOT NULL DEFAULT 0, + start_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + end_at TIMESTAMP(3), + operator_id BIGINT NOT NULL DEFAULT 0, + appeal_status VARCHAR(16) NOT NULL DEFAULT 'none', -- none/pending/approved/rejected + appeal_content VARCHAR(1000) NOT NULL DEFAULT '', + appeal_at TIMESTAMP(3), + handle_by BIGINT NOT NULL DEFAULT 0, + handle_at TIMESTAMP(3), + handle_note VARCHAR(500) NOT NULL DEFAULT '', + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_app_ban_user ON app_user_ban_record (user_id, start_at DESC); +COMMENT ON TABLE app_user_ban_record IS '封禁/限制与申诉记录'; + +CREATE TABLE IF NOT EXISTS app_account_deletion ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending/cancelled/completed + reason VARCHAR(255) NOT NULL DEFAULT '', + requested_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + scheduled_at TIMESTAMP(3) NOT NULL, -- 冷静期结束时间,商店合规要求 + cancelled_at TIMESTAMP(3), + completed_at TIMESTAMP(3), + operator_id BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_app_deletion_user ON app_account_deletion (user_id) WHERE status = 'pending'; +COMMENT ON TABLE app_account_deletion IS '账号注销申请与冷静期'; + +-- ============================================================ +-- 三、播放与记录域(PRD 04 播放器 / 05 记录收藏下载) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS watch_progress ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + drama_id BIGINT NOT NULL, + episode_id BIGINT NOT NULL, + position_ms BIGINT NOT NULL DEFAULT 0, + duration_ms BIGINT NOT NULL DEFAULT 0, + finished SMALLINT NOT NULL DEFAULT 0, + play_count INT NOT NULL DEFAULT 1, + device_id VARCHAR(128) NOT NULL DEFAULT '', + source VARCHAR(16) NOT NULL DEFAULT '', -- HOME/DETAIL/SEARCH/FAVORITE + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_watch_progress ON watch_progress (user_id, drama_id); +CREATE INDEX IF NOT EXISTS idx_watch_progress_user ON watch_progress (user_id, updated_at DESC); +COMMENT ON TABLE watch_progress IS '每用户每剧一条续看进度(跨端同步的权威数据)'; + +-- 观看流水:量大,按 watched_at 做声明式月分区,便于按期归档与清理 +CREATE TABLE IF NOT EXISTS watch_history ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY, + user_id BIGINT NOT NULL, + drama_id BIGINT NOT NULL, + episode_id BIGINT NOT NULL, + position_ms BIGINT NOT NULL DEFAULT 0, + duration_ms BIGINT NOT NULL DEFAULT 0, + source VARCHAR(16) NOT NULL DEFAULT '', + device_id VARCHAR(128) NOT NULL DEFAULT '', + watched_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, watched_at) +) PARTITION BY RANGE (watched_at); +CREATE TABLE IF NOT EXISTS watch_history_default PARTITION OF watch_history DEFAULT; +CREATE INDEX IF NOT EXISTS idx_watch_history_user ON watch_history (user_id, watched_at DESC); +CREATE INDEX IF NOT EXISTS idx_watch_history_drama ON watch_history (drama_id, watched_at DESC); +COMMENT ON TABLE watch_history IS '观看流水(月分区,保留 12–24 个月)'; + +CREATE TABLE IF NOT EXISTS user_favorite ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + drama_id BIGINT NOT NULL, + sort INT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_favorite ON user_favorite (user_id, drama_id); +CREATE INDEX IF NOT EXISTS idx_user_favorite_user ON user_favorite (user_id, created_at DESC); +COMMENT ON TABLE user_favorite IS '剧集收藏'; + +-- 播放票据:写入量最大的表之一,按 created_at 月分区。 +-- 分区表的唯一索引必须包含分区键,所以主键是 (id, created_at)、 +-- 唯一索引是 (ticket_no, created_at);ticket_no 是 UUID,实际唯一性不受影响。 +-- 月分区本身由 sql/partition-maintenance.sql 建(含未来 3 个月)。 +CREATE TABLE IF NOT EXISTS playback_ticket ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY, + ticket_no VARCHAR(40) NOT NULL, + user_id BIGINT NOT NULL DEFAULT 0, -- 0 表示未登录的免费集 + drama_id BIGINT NOT NULL, + episode_id BIGINT NOT NULL, + quality VARCHAR(16) NOT NULL DEFAULT 'AUTO', + play_url VARCHAR(1024) NOT NULL DEFAULT '', + expire_at TIMESTAMP(3) NOT NULL, + ip VARCHAR(64) NOT NULL DEFAULT '', + device_id VARCHAR(128) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL DEFAULT 'ISSUED', -- ISSUED/USED/REVOKED + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0, + PRIMARY KEY (id, created_at) +) PARTITION BY RANGE (created_at); +CREATE UNIQUE INDEX IF NOT EXISTS uk_playback_ticket_no + ON playback_ticket (ticket_no, created_at); +CREATE INDEX IF NOT EXISTS idx_playback_ticket_user + ON playback_ticket (user_id, created_at DESC); +COMMENT ON TABLE playback_ticket IS '播放票据签发审计;真正的鉴权在签名 URL 上(月分区)'; + +CREATE TABLE IF NOT EXISTS download_license ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + drama_id BIGINT NOT NULL, + episode_id BIGINT NOT NULL, + device_id VARCHAR(128) NOT NULL, + license_key VARCHAR(128) NOT NULL DEFAULT '', -- 本地解密密钥(加密后存) + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE/EXPIRED/REVOKED + granted_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + expire_at TIMESTAMP(3), + revoked_at TIMESTAMP(3), + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_download_license ON download_license (user_id, episode_id, device_id); +CREATE INDEX IF NOT EXISTS idx_download_license_user ON download_license (user_id, status); +COMMENT ON TABLE download_license IS '离线下载授权与设备绑定(换机/吊销)'; + +-- ============================================================ +-- 四、互动域(PRD 06 评论、点赞与分享) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS content_comment ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + drama_id BIGINT NOT NULL, + episode_id BIGINT NOT NULL DEFAULT 0, + user_id BIGINT NOT NULL, + parent_id BIGINT NOT NULL DEFAULT 0, + root_id BIGINT NOT NULL DEFAULT 0, + content VARCHAR(1000) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending/published/rejected/deleted + audit_type VARCHAR(16) NOT NULL DEFAULT '', -- MACHINE/HUMAN + audit_reason VARCHAR(255) NOT NULL DEFAULT '', + like_count INT NOT NULL DEFAULT 0, + reply_count INT NOT NULL DEFAULT 0, + report_count INT NOT NULL DEFAULT 0, + ip VARCHAR(64) NOT NULL DEFAULT '', + device_id VARCHAR(128) NOT NULL DEFAULT '', + country_code VARCHAR(8) NOT NULL DEFAULT '', + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_comment_drama ON content_comment (drama_id, status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_comment_root ON content_comment (root_id, created_at); +CREATE INDEX IF NOT EXISTS idx_comment_user ON content_comment (user_id, created_at DESC); +COMMENT ON TABLE content_comment IS '评论与回复(先审后发)'; + +CREATE TABLE IF NOT EXISTS content_like ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + target_type VARCHAR(16) NOT NULL, -- DRAMA/EPISODE/COMMENT + target_id BIGINT NOT NULL, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_content_like ON content_like (user_id, target_type, target_id); +CREATE INDEX IF NOT EXISTS idx_content_like_target ON content_like (target_type, target_id); +COMMENT ON TABLE content_like IS '点赞关系;计数值异步聚合'; + +CREATE TABLE IF NOT EXISTS content_report ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + reporter_id BIGINT NOT NULL, + target_type VARCHAR(16) NOT NULL, -- COMMENT/USER/DRAMA + target_id BIGINT NOT NULL, + reason_code VARCHAR(32) NOT NULL, + description VARCHAR(500) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending/handled/rejected + handle_by BIGINT NOT NULL DEFAULT 0, + handle_at TIMESTAMP(3), + handle_note VARCHAR(500) NOT NULL DEFAULT '', + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_report_target ON content_report (target_type, target_id, status); +CREATE INDEX IF NOT EXISTS idx_report_status ON content_report (status, created_at DESC); +COMMENT ON TABLE content_report IS '举报工单'; + +CREATE TABLE IF NOT EXISTS user_blocklist ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + blocked_user_id BIGINT NOT NULL, + source VARCHAR(16) NOT NULL DEFAULT 'MANUAL', -- MANUAL/COMMENT + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_user_blocklist ON user_blocklist (user_id, blocked_user_id); +COMMENT ON TABLE user_blocklist IS '拉黑关系'; + +CREATE TABLE IF NOT EXISTS sensitive_word ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + word VARCHAR(64) NOT NULL, + category VARCHAR(32) NOT NULL DEFAULT '', -- POLITICS/PORN/ABUSE/AD/RELIGION + level VARCHAR(16) NOT NULL DEFAULT 'REVIEW', -- BLOCK/REVIEW/REPLACE + replace_text VARCHAR(64) NOT NULL DEFAULT '*', + language VARCHAR(16) NOT NULL DEFAULT 'id-ID', + status SMALLINT NOT NULL DEFAULT 1, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_sensitive_word ON sensitive_word (language, word); +COMMENT ON TABLE sensitive_word IS '敏感词库'; + +CREATE TABLE IF NOT EXISTS content_audit_record ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + biz_type VARCHAR(16) NOT NULL, -- COMMENT/DRAMA/AVATAR/CREATOR + biz_id BIGINT NOT NULL, + round INT NOT NULL DEFAULT 1, + auditor_type VARCHAR(16) NOT NULL DEFAULT 'MACHINE', -- MACHINE/HUMAN + auditor_id BIGINT NOT NULL DEFAULT 0, + result VARCHAR(16) NOT NULL, -- PASS/REJECT/NEED_MORE + reason_code VARCHAR(32) NOT NULL DEFAULT '', + reason_text VARCHAR(500) NOT NULL DEFAULT '', + snapshot TEXT, -- 审核时的内容快照,留证 + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_audit_biz ON content_audit_record (biz_type, biz_id, round DESC); +COMMENT ON TABLE content_audit_record IS '审核留证(机审 + 人审都记)'; + +CREATE TABLE IF NOT EXISTS content_share ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL DEFAULT 0, + drama_id BIGINT NOT NULL, + episode_id BIGINT NOT NULL DEFAULT 0, + channel VARCHAR(32) NOT NULL DEFAULT '', -- WHATSAPP/IG/TIKTOK/COPYLINK + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_share_drama ON content_share (drama_id, created_at DESC); +COMMENT ON TABLE content_share IS '分享事件,用于渠道效果与裂变分析'; + +-- ============================================================ +-- 五、交易域(PRD 09 分集收费与支付) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS wallet ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + coin_balance BIGINT NOT NULL DEFAULT 0, + total_recharged_coin BIGINT NOT NULL DEFAULT 0, + total_consumed_coin BIGINT NOT NULL DEFAULT 0, + total_refunded_coin BIGINT NOT NULL DEFAULT 0, + version INT NOT NULL DEFAULT 0, -- 乐观锁 + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_wallet_user ON wallet (user_id); +COMMENT ON TABLE wallet IS '金币钱包;所有变动必须同时写 coin_ledger'; + +CREATE TABLE IF NOT EXISTS product ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + code VARCHAR(64) NOT NULL, + name VARCHAR(128) NOT NULL, + type VARCHAR(16) NOT NULL DEFAULT 'COIN_PACK', -- COIN_PACK/VIP/EPISODE_PACK(VIP 为第二阶段预留) + platform VARCHAR(16) NOT NULL DEFAULT 'ALL', -- ALL/APPLE/GOOGLE + store_product_id VARCHAR(128) NOT NULL DEFAULT '', -- 商店商品 ID + coin_amount BIGINT NOT NULL DEFAULT 0, + bonus_coin BIGINT NOT NULL DEFAULT 0, -- 赠送金币 + price_amount NUMERIC(12,2) NOT NULL DEFAULT 0, + currency VARCHAR(8) NOT NULL DEFAULT 'IDR', + display_price VARCHAR(32) NOT NULL DEFAULT '', -- 商店返回的本地化价格 + region_codes VARCHAR(255) NOT NULL DEFAULT '', -- 空=全区域 + vip_days INT NOT NULL DEFAULT 0, -- 预留:会员天数 + sort INT NOT NULL DEFAULT 0, + status SMALLINT NOT NULL DEFAULT 1, + effective_at TIMESTAMP(3), + expire_at TIMESTAMP(3), + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_product_code ON product (code); +CREATE INDEX IF NOT EXISTS idx_product_platform ON product (platform, status); +COMMENT ON TABLE product IS '商品配置(金币包;会员与活动价为后续阶段预留)'; + +CREATE TABLE IF NOT EXISTS orders ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + order_no VARCHAR(40) NOT NULL, + user_id BIGINT NOT NULL, + product_id BIGINT NOT NULL DEFAULT 0, + product_code VARCHAR(64) NOT NULL DEFAULT '', + product_name VARCHAR(128) NOT NULL DEFAULT '', + type VARCHAR(16) NOT NULL DEFAULT 'COIN_PACK', + platform VARCHAR(16) NOT NULL DEFAULT '', -- APPLE/GOOGLE + store_product_id VARCHAR(128) NOT NULL DEFAULT '', + store_transaction_id VARCHAR(128) NOT NULL DEFAULT '', -- 幂等键之一 + original_transaction_id VARCHAR(128) NOT NULL DEFAULT '', -- 订阅续期追溯(预留) + amount NUMERIC(12,2) NOT NULL DEFAULT 0, + currency VARCHAR(8) NOT NULL DEFAULT 'IDR', + coin_amount BIGINT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'CREATED', -- CREATED/PAID/DELIVERED/FAILED/REFUNDING/REFUNDED/CLOSED + quantity INT NOT NULL DEFAULT 1, + region_code VARCHAR(16) NOT NULL DEFAULT '', + channel VARCHAR(64) NOT NULL DEFAULT '', -- 归因渠道 + experiment_id VARCHAR(64) NOT NULL DEFAULT '', -- 预留:定价实验分组 + receipt TEXT, + verify_at TIMESTAMP(3), + paid_at TIMESTAMP(3), + delivered_at TIMESTAMP(3), + fail_reason VARCHAR(500) NOT NULL DEFAULT '', + refund_at TIMESTAMP(3), + refund_reason VARCHAR(255) NOT NULL DEFAULT '', + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_orders_no ON orders (order_no); +CREATE UNIQUE INDEX IF NOT EXISTS uk_orders_store_txn ON orders (platform, store_transaction_id) + WHERE store_transaction_id <> ''; +CREATE INDEX IF NOT EXISTS idx_orders_user ON orders (user_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_orders_status ON orders (status, created_at DESC); +COMMENT ON TABLE orders IS '订单;orders 为保留字规避(order 是 SQL 关键字)'; + +-- 订单状态流转流水,同样按月分区(同 playback_ticket,主键带分区键) +CREATE TABLE IF NOT EXISTS order_event ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY, + order_no VARCHAR(40) NOT NULL, + event VARCHAR(32) NOT NULL, -- CREATE/VERIFY_OK/VERIFY_FAIL/DELIVER/REFUND/NOTIFY + from_status VARCHAR(16) NOT NULL DEFAULT '', + to_status VARCHAR(16) NOT NULL DEFAULT '', + source VARCHAR(16) NOT NULL DEFAULT 'SERVER', -- SERVER/STORE/ADMIN/COMPENSATE + payload TEXT, + operator_id BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id, created_at) +) PARTITION BY RANGE (created_at); +CREATE INDEX IF NOT EXISTS idx_order_event_no ON order_event (order_no, created_at); +COMMENT ON TABLE order_event IS '订单状态流转流水,支付争议时的证据链(月分区)'; + +CREATE TABLE IF NOT EXISTS store_receipt ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + platform VARCHAR(16) NOT NULL, + transaction_id VARCHAR(128) NOT NULL, + original_transaction_id VARCHAR(128) NOT NULL DEFAULT '', + order_no VARCHAR(40) NOT NULL DEFAULT '', + user_id BIGINT NOT NULL DEFAULT 0, + product_id VARCHAR(128) NOT NULL DEFAULT '', + purchase_at TIMESTAMP(3), + raw_payload TEXT, -- 商店原始回执,留证 + verify_status VARCHAR(16) NOT NULL DEFAULT 'PENDING',-- PENDING/OK/FAIL + verify_response TEXT, + verify_at TIMESTAMP(3), + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_store_receipt ON store_receipt (platform, transaction_id); +COMMENT ON TABLE store_receipt IS '商店票据去重与校验留证(重放防护)'; + +CREATE TABLE IF NOT EXISTS coin_ledger ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + change_amount BIGINT NOT NULL, -- 正数入账,负数出账 + balance_after BIGINT NOT NULL, + biz_type VARCHAR(16) NOT NULL, -- RECHARGE/UNLOCK/REFUND/REWARD/ADMIN + biz_id VARCHAR(64) NOT NULL DEFAULT '', -- 订单号 / 剧集号 + idempotent_key VARCHAR(64) NOT NULL, -- 幂等键,重复请求直接返回 + remark VARCHAR(255) NOT NULL DEFAULT '', + operator_id BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_coin_ledger_idem ON coin_ledger (idempotent_key); +CREATE INDEX IF NOT EXISTS idx_coin_ledger_user ON coin_ledger (user_id, created_at DESC); +COMMENT ON TABLE coin_ledger IS '金币流水,钱包余额的唯一事实来源'; + +CREATE TABLE IF NOT EXISTS entitlement ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + drama_id BIGINT NOT NULL DEFAULT 0, + episode_id BIGINT NOT NULL DEFAULT 0, -- 0 表示整剧权益 + type VARCHAR(16) NOT NULL DEFAULT 'EPISODE', -- EPISODE/DRAMA/VIP(预留) + source VARCHAR(16) NOT NULL DEFAULT 'ORDER', -- ORDER/ACTIVITY/ADMIN/TRIAL + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE/EXPIRED/REVOKED + order_no VARCHAR(40) NOT NULL DEFAULT '', + start_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + expire_at TIMESTAMP(3), + revoke_reason VARCHAR(255) NOT NULL DEFAULT '', + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_entitlement ON entitlement (user_id, type, drama_id, episode_id); +CREATE INDEX IF NOT EXISTS idx_entitlement_user ON entitlement (user_id, status); +COMMENT ON TABLE entitlement IS '权益;播放判定优先读这里'; + +CREATE TABLE IF NOT EXISTS refund_record ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + order_no VARCHAR(40) NOT NULL, + platform VARCHAR(16) NOT NULL DEFAULT '', + refund_id VARCHAR(128) NOT NULL DEFAULT '', + amount NUMERIC(12,2) NOT NULL DEFAULT 0, + currency VARCHAR(8) NOT NULL DEFAULT 'IDR', + reason VARCHAR(255) NOT NULL DEFAULT '', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING', -- PENDING/RECOVERED/IGNORED + notified_at TIMESTAMP(3), + handled_at TIMESTAMP(3), + handle_note VARCHAR(500) NOT NULL DEFAULT '', + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_refund_order ON refund_record (order_no); +CREATE INDEX IF NOT EXISTS idx_refund_status ON refund_record (status, created_at DESC); +COMMENT ON TABLE refund_record IS '退款与撤销事件,触发权益回收'; + +CREATE TABLE IF NOT EXISTS reconcile_task ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + biz_date DATE NOT NULL, + platform VARCHAR(16) NOT NULL DEFAULT 'ALL', + total_count INT NOT NULL DEFAULT 0, + matched_count INT NOT NULL DEFAULT 0, + diff_count INT NOT NULL DEFAULT 0, + amount_sum NUMERIC(14,2) NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'RUNNING', -- RUNNING/SUCCESS/FAILED + detail TEXT, + started_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + finished_at TIMESTAMP(3), + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_reconcile_date ON reconcile_task (biz_date DESC, platform); +COMMENT ON TABLE reconcile_task IS '每日对账任务(与商店账单比对)'; + +-- ============================================================ +-- 六、创作者域(PRD 07 创作者中心) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS creator ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, -- 关联 C 端账号 + code VARCHAR(32) NOT NULL, + name VARCHAR(128) NOT NULL, + type VARCHAR(16) NOT NULL DEFAULT 'INDIVIDUAL', -- INDIVIDUAL/COMPANY + contact_name VARCHAR(64) NOT NULL DEFAULT '', + contact_mobile VARCHAR(32) NOT NULL DEFAULT '', + contact_email VARCHAR(128) NOT NULL DEFAULT '', + country_code VARCHAR(8) NOT NULL DEFAULT 'ID', + status VARCHAR(16) NOT NULL DEFAULT 'pending', -- pending/approved/rejected/frozen + audit_note VARCHAR(500) NOT NULL DEFAULT '', + audit_by BIGINT NOT NULL DEFAULT 0, + audit_at TIMESTAMP(3), + settlement_currency VARCHAR(8) NOT NULL DEFAULT 'IDR', + bank_account VARCHAR(255) NOT NULL DEFAULT '', -- 预留:加密存储(第二阶段结算) + tax_no VARCHAR(64) NOT NULL DEFAULT '', -- 预留:税号 + kyc_status VARCHAR(16) NOT NULL DEFAULT 'none', -- 预留:KYC 状态 + share_rate NUMERIC(5,4) NOT NULL DEFAULT 0, -- 预留:分成比例(第二阶段) + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_creator_user ON creator (user_id); +CREATE UNIQUE INDEX IF NOT EXISTS uk_creator_code ON creator (code); +CREATE INDEX IF NOT EXISTS idx_creator_status ON creator (status, created_at DESC); +COMMENT ON TABLE creator IS '创作者主体与资质'; + +CREATE TABLE IF NOT EXISTS creator_document ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + creator_id BIGINT NOT NULL, + type VARCHAR(32) NOT NULL, -- ID_CARD/BUSINESS_LICENSE/CONTRACT + file_url VARCHAR(512) NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + audit_note VARCHAR(500) NOT NULL DEFAULT '', + expire_at TIMESTAMP(3), -- 证件有效期 + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_creator_doc ON creator_document (creator_id, type); +COMMENT ON TABLE creator_document IS '创作者资质文件'; + +CREATE TABLE IF NOT EXISTS content_submission ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + creator_id BIGINT NOT NULL, + drama_id BIGINT NOT NULL, + type VARCHAR(16) NOT NULL DEFAULT 'CREATE', -- CREATE/UPDATE/PUBLISH/OFFLINE + status VARCHAR(16) NOT NULL DEFAULT 'DRAFT', -- DRAFT/SUBMITTED/REVIEWING/APPROVED/REJECTED/WITHDRAWN + version INT NOT NULL DEFAULT 1, + submit_at TIMESTAMP(3), + reviewer_id BIGINT NOT NULL DEFAULT 0, + review_at TIMESTAMP(3), + review_note VARCHAR(500) NOT NULL DEFAULT '', + reject_code VARCHAR(32) NOT NULL DEFAULT '', + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_submission_creator ON content_submission (creator_id, status, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_submission_status ON content_submission (status, submit_at); +COMMENT ON TABLE content_submission IS '创作者内容提交与审核流转'; + +CREATE TABLE IF NOT EXISTS creator_stat_daily ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + creator_id BIGINT NOT NULL, + drama_id BIGINT NOT NULL DEFAULT 0, + stat_date DATE NOT NULL, + play_count BIGINT NOT NULL DEFAULT 0, + finish_count BIGINT NOT NULL DEFAULT 0, + favorite_count BIGINT NOT NULL DEFAULT 0, + comment_count BIGINT NOT NULL DEFAULT 0, + unlock_count BIGINT NOT NULL DEFAULT 0, + coin_income BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_creator_stat ON creator_stat_daily (creator_id, drama_id, stat_date); +COMMENT ON TABLE creator_stat_daily IS '创作者数据看板日汇总'; + +CREATE TABLE IF NOT EXISTS creator_settlement ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + creator_id BIGINT NOT NULL, + period VARCHAR(16) NOT NULL, -- 2026-10 + revenue BIGINT NOT NULL DEFAULT 0, + share_rate NUMERIC(5,4) NOT NULL DEFAULT 0, + amount NUMERIC(14,2) NOT NULL DEFAULT 0, + currency VARCHAR(8) NOT NULL DEFAULT 'IDR', + status VARCHAR(16) NOT NULL DEFAULT 'PENDING', -- 预留:第二阶段的创作者结算 + paid_at TIMESTAMP(3), + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_creator_settlement ON creator_settlement (creator_id, period); +COMMENT ON TABLE creator_settlement IS '预留:创作者结算(第二阶段)'; + +-- ============================================================ +-- 七、媒资与转码域(PRD 07 转码流水线 / 04 多码率播放) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS transcode_task ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + media_asset_id BIGINT NOT NULL DEFAULT 0, + drama_id BIGINT NOT NULL DEFAULT 0, + episode_id BIGINT NOT NULL DEFAULT 0, + source_url VARCHAR(512) NOT NULL DEFAULT '', + profile VARCHAR(32) NOT NULL DEFAULT 'default', -- 转码模板(码率阶梯组合) + status VARCHAR(16) NOT NULL DEFAULT 'QUEUED', -- QUEUED/RUNNING/SUCCESS/FAILED/CANCELED + progress INT NOT NULL DEFAULT 0, + retry_count INT NOT NULL DEFAULT 0, + error_msg VARCHAR(500) NOT NULL DEFAULT '', + started_at TIMESTAMP(3), + finished_at TIMESTAMP(3), + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_transcode_status ON transcode_task (status, created_at); +CREATE INDEX IF NOT EXISTS idx_transcode_episode ON transcode_task (episode_id); +COMMENT ON TABLE transcode_task IS '转码任务队列(本地 worker / 云转码)'; + +CREATE TABLE IF NOT EXISTS media_rendition ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + media_asset_id BIGINT NOT NULL DEFAULT 0, + drama_id BIGINT NOT NULL DEFAULT 0, + episode_id BIGINT NOT NULL DEFAULT 0, + quality VARCHAR(16) NOT NULL, -- 360P/480P/720P/1080P + format VARCHAR(16) NOT NULL DEFAULT 'HLS', + play_url VARCHAR(512) NOT NULL DEFAULT '', + object_key VARCHAR(512) NOT NULL DEFAULT '', + width INT NOT NULL DEFAULT 0, + height INT NOT NULL DEFAULT 0, + video_bitrate INT NOT NULL DEFAULT 0, -- kbps + audio_bitrate INT NOT NULL DEFAULT 0, + size_bytes BIGINT NOT NULL DEFAULT 0, + duration_sec INT NOT NULL DEFAULT 0, + segment_count INT NOT NULL DEFAULT 0, + status VARCHAR(16) NOT NULL DEFAULT 'ready', + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_rendition ON media_rendition (episode_id, quality, format); +CREATE INDEX IF NOT EXISTS idx_rendition_episode ON media_rendition (episode_id); +COMMENT ON TABLE media_rendition IS '多码率产物;播放票据按此生成 master playlist'; + +-- ============================================================ +-- 八、运营补充(PRD 08 平台运营后台 / 10 上线) +-- ============================================================ + +CREATE TABLE IF NOT EXISTS ops_notice ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + title VARCHAR(128) NOT NULL, + content VARCHAR(2000) NOT NULL DEFAULT '', + target VARCHAR(16) NOT NULL DEFAULT 'ALL', -- ALL/USER/CREATOR + type VARCHAR(16) NOT NULL DEFAULT 'SYSTEM', -- SYSTEM/AUDIT/PAYMENT/ACTIVITY + status SMALLINT NOT NULL DEFAULT 1, + publish_at TIMESTAMP(3), + expire_at TIMESTAMP(3), + created_by BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_notice_target ON ops_notice (target, status, publish_at DESC); +COMMENT ON TABLE ops_notice IS '系统公告(创作者审核结果、活动、支付通知)'; + +CREATE TABLE IF NOT EXISTS ops_daily_stat ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + stat_date DATE NOT NULL, + new_user_count BIGINT NOT NULL DEFAULT 0, + active_user_count BIGINT NOT NULL DEFAULT 0, + play_count BIGINT NOT NULL DEFAULT 0, + finish_count BIGINT NOT NULL DEFAULT 0, + paid_user_count BIGINT NOT NULL DEFAULT 0, + paid_order_count BIGINT NOT NULL DEFAULT 0, + revenue_amount NUMERIC(14,2) NOT NULL DEFAULT 0, + refund_amount NUMERIC(14,2) NOT NULL DEFAULT 0, + currency VARCHAR(8) NOT NULL DEFAULT 'IDR', + cdn_traffic_bytes BIGINT NOT NULL DEFAULT 0, -- 成本看板:单位播放成本 + sms_count BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_ops_daily_stat ON ops_daily_stat (stat_date); +COMMENT ON TABLE ops_daily_stat IS '经营大盘日汇总(含成本口径)'; + +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + biz_type VARCHAR(32) NOT NULL, -- ORDER/ENTITLEMENT/USER/CREATOR/PRICE + biz_id VARCHAR(64) NOT NULL DEFAULT '', + action VARCHAR(32) NOT NULL, + before_value TEXT, + after_value TEXT, + operator_id BIGINT NOT NULL DEFAULT 0, + operator_name VARCHAR(64) NOT NULL DEFAULT '', + ip VARCHAR(64) NOT NULL DEFAULT '', + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_audit_log_biz ON audit_log (biz_type, biz_id, created_at DESC); +COMMENT ON TABLE audit_log IS '业务对象变更审计(支付、权益、定价等敏感操作)'; + +-- ============================================================ +-- 九、updated_at 触发器(与已有表保持一致) +-- ============================================================ + +DO $$ +DECLARE + t TEXT; +BEGIN + FOREACH t IN ARRAY ARRAY[ + 'app_user', 'app_user_credential', 'app_user_session', 'app_user_otp', + 'app_user_device', 'app_user_ban_record', 'app_account_deletion', + 'watch_progress', 'user_favorite', 'playback_ticket', 'download_license', + 'content_comment', 'content_report', 'sensitive_word', 'content_audit_record', + 'wallet', 'product', 'orders', 'store_receipt', 'entitlement', 'refund_record', + 'reconcile_task', 'creator', 'creator_document', 'content_submission', + 'creator_stat_daily', 'creator_settlement', + 'transcode_task', 'media_rendition', + 'ops_notice', 'ops_daily_stat' + ] + LOOP + EXECUTE format('DROP TRIGGER IF EXISTS trg_%s_updated_at ON %I', t, t); + EXECUTE format( + 'CREATE TRIGGER trg_%s_updated_at BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION set_updated_at()', + t, t); + END LOOP; +END +$$; + +-- ============================================================ +-- 十、离线下载(PRD 05) +-- ============================================================ + +-- 用户侧下载偏好:仅 Wi-Fi 下载、最高清晰度 +ALTER TABLE app_user ADD COLUMN IF NOT EXISTS download_wifi_only SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE app_user ADD COLUMN IF NOT EXISTS download_max_quality VARCHAR(16) NOT NULL DEFAULT 'AUTO'; + +-- 下载任务与断点续传进度。分片由客户端直接向 CDN 拉取,服务端只记账: +-- 用它实现"下载中/已暂停/已完成"的跨端展示、断点续传的进度恢复,以及配额统计。 +CREATE TABLE IF NOT EXISTS download_task ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + device_id VARCHAR(128) NOT NULL DEFAULT '', + drama_id BIGINT NOT NULL, + episode_id BIGINT NOT NULL, + license_id BIGINT NOT NULL DEFAULT 0, + quality VARCHAR(16) NOT NULL DEFAULT 'AUTO', + status VARCHAR(16) NOT NULL DEFAULT 'QUEUED', -- QUEUED/DOWNLOADING/PAUSED/COMPLETED/FAILED/CANCELED + progress_percent INT NOT NULL DEFAULT 0, + downloaded_bytes BIGINT NOT NULL DEFAULT 0, + total_bytes BIGINT NOT NULL DEFAULT 0, + error_msg VARCHAR(500) NOT NULL DEFAULT '', + started_at TIMESTAMP(3), + finished_at TIMESTAMP(3), + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + deleted SMALLINT NOT NULL DEFAULT 0 +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_download_task ON download_task (user_id, episode_id, device_id); +CREATE INDEX IF NOT EXISTS idx_download_task_user ON download_task (user_id, status, updated_at DESC); +COMMENT ON TABLE download_task IS '离线下载任务与断点续传进度(分片由客户端下载,服务端记账)'; + +DROP TRIGGER IF EXISTS trg_download_task_updated_at ON download_task; +CREATE TRIGGER trg_download_task_updated_at BEFORE UPDATE ON download_task + FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/sql/schema.sql b/sql/schema.sql index 309d990..d19e9df 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -1,4 +1,4 @@ --- ReelShort 短剧服务 · 建表脚本(PostgreSQL 18) +-- FruitShorts 短剧服务 · 建表脚本(PostgreSQL 18) -- -- 与 MySQL 版本的对应关系: -- AUTO_INCREMENT -> GENERATED BY DEFAULT AS IDENTITY @@ -246,7 +246,7 @@ CREATE INDEX IF NOT EXISTS idx_content_banner_slot ON content_banner (slot, stat COMMENT ON TABLE content_banner IS '轮播图'; -- ========== 媒资域 ========== --- 本地存储版本:文件落在 reelshort.media.root-dir 下,通过 /files/** 访问 +-- 本地存储版本:文件落在 fruitshorts.media.root-dir 下,通过 /files/** 访问 CREATE TABLE IF NOT EXISTS media_asset ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,