From ab58641bbee274abb08129a3a0928d7ed46002a6 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Fri, 23 Oct 2026 12:34:13 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(user):=20=E5=B1=95=E7=A4=BA=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E6=9C=80=E8=BF=91=E4=BD=BF=E7=94=A8=E7=9A=84=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/AppUserAdminController.java | 15 ++++- .../service/AppSessionSystemService.java | 62 +++++++++++++++++++ .../service/WatchHistoryAdminService.java | 13 +++- .../admin/vo/AdminWatchHistoryVO.java | 4 ++ .../admin/vo/AppUserWatchHistoryVO.java | 4 ++ .../service/AppSessionSystemServiceTest.java | 48 ++++++++++++++ .../service/WatchHistoryAdminServiceTest.java | 2 +- .../app/controller/AppAuthController.java | 2 +- .../user/service/AppAuthService.java | 12 ++++ .../user/service/AppUserAdminService.java | 46 +++++++++++++- .../user/vo/AppUserAdminRespVO.java | 6 ++ .../com/fruitshorts/user/vo/RefreshReqVO.java | 6 ++ 12 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/AppSessionSystemService.java create mode 100644 fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/AppSessionSystemServiceTest.java 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 index 3fa3400..495e4fa 100644 --- 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 @@ -4,6 +4,7 @@ package com.fruitshorts.admin.controller; import com.fruitshorts.common.pojo.PageResult; import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.admin.service.AppSessionSystemService; import com.fruitshorts.admin.vo.AppUserWatchHistoryVO; import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.content.dal.entity.DramaEpisode; @@ -13,6 +14,7 @@ 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.user.dal.entity.AppUserSession; import com.fruitshorts.playback.service.WatchProgressService; import com.fruitshorts.user.dal.entity.AppUserBanRecord; import com.fruitshorts.user.service.AppUserAdminService; @@ -44,6 +46,7 @@ public class AppUserAdminController { private final WatchProgressService watchProgressService; private final DramaService dramaService; private final EpisodeService episodeService; + private final AppSessionSystemService sessionSystem; @Operation(summary = "App 用户分页查询") @GetMapping("/page") @@ -83,11 +86,15 @@ public class AppUserAdminController { 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()); + List sessions = sessionSystem.groupByUser(List.of(id)) + .getOrDefault(id, List.of()); + return Result.ok(rows.stream() + .map(row -> toVo(row, dramas, episodes, sessionSystem.match(sessions, row.getDeviceId()))) + .toList()); } private AppUserWatchHistoryVO toVo(WatchHistory row, Map dramas, - Map episodes) { + Map episodes, AppUserSession session) { AppUserWatchHistoryVO vo = new AppUserWatchHistoryVO(); vo.setId(row.getId()); vo.setDramaId(row.getDramaId()); @@ -100,6 +107,10 @@ public class AppUserAdminController { long position = row.getPositionMs() == null ? 0L : row.getPositionMs(); long duration = row.getDurationMs() == null ? 0L : row.getDurationMs(); vo.setFinished(duration > 0 && position + 1000L >= duration); + if (session != null) { + vo.setPlatform(session.getPlatform()); + vo.setOsVersion(session.getOsVersion()); + } Drama drama = dramas.get(row.getDramaId()); if (drama != null) { vo.setDramaTitle(drama.getTitle()); diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/AppSessionSystemService.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/AppSessionSystemService.java new file mode 100644 index 0000000..4389a39 --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/AppSessionSystemService.java @@ -0,0 +1,62 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fruitshorts.user.dal.entity.AppUserSession; +import com.fruitshorts.user.dal.mapper.AppUserSessionMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 按用户最近会话解析系统。同一设备优先,否则用仍有效的最近一次登录。 + */ +@Service +@RequiredArgsConstructor +public class AppSessionSystemService { + + private final AppUserSessionMapper sessionMapper; + + /** 按用户分组,组内按最近活跃倒序。 */ + public Map> groupByUser(Collection userIds) { + Map> grouped = new HashMap<>(); + if (userIds == null || userIds.isEmpty()) { + return grouped; + } + List sessions = sessionMapper.selectList(Wrappers.lambdaQuery() + .in(AppUserSession::getUserId, userIds) + .orderByDesc(AppUserSession::getLastActiveAt) + .orderByDesc(AppUserSession::getId)); + for (AppUserSession session : sessions) { + grouped.computeIfAbsent(session.getUserId(), key -> new ArrayList<>()).add(session); + } + return grouped; + } + + /** 观看记录优先匹配同一设备,其次取未吊销会话。 */ + public AppUserSession match(List sessions, String deviceId) { + if (sessions == null || sessions.isEmpty()) { + return null; + } + if (StringUtils.hasText(deviceId)) { + for (AppUserSession session : sessions) { + if (deviceId.equals(session.getDeviceId())) { + return session; + } + } + } + for (AppUserSession session : sessions) { + if (session.getRevokedAt() == null) { + return session; + } + } + return sessions.get(0); + } +} diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/WatchHistoryAdminService.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/WatchHistoryAdminService.java index 15b2baa..ca5fe75 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/WatchHistoryAdminService.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/service/WatchHistoryAdminService.java @@ -17,6 +17,7 @@ import com.fruitshorts.content.enums.ContentStatus; import com.fruitshorts.playback.dal.entity.WatchHistory; import com.fruitshorts.playback.dal.mapper.WatchHistoryMapper; import com.fruitshorts.user.dal.entity.AppUser; +import com.fruitshorts.user.dal.entity.AppUserSession; import com.fruitshorts.user.dal.mapper.AppUserMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -46,6 +47,7 @@ public class WatchHistoryAdminService { private final AppUserMapper appUserMapper; private final DramaMapper dramaMapper; private final DramaEpisodeMapper dramaEpisodeMapper; + private final AppSessionSystemService sessionSystem; /** 按后台筛选条件分页查询观看记录。 */ public PageResult page(WatchHistoryQueryReqVO req) { @@ -125,10 +127,13 @@ public class WatchHistoryAdminService { Map users = loadUsers(rows); Map dramas = loadDramas(rows); Map episodes = loadEpisodes(rows); + Map> sessions = sessionSystem.groupByUser( + rows.stream().map(WatchHistory::getUserId).distinct().toList()); List list = new ArrayList<>(rows.size()); for (WatchHistory row : rows) { list.add(toVo(row, users.get(row.getUserId()), dramas.get(row.getDramaId()), - episodes.get(row.getEpisodeId()))); + episodes.get(row.getEpisodeId()), + sessionSystem.match(sessions.get(row.getUserId()), row.getDeviceId()))); } return list; } @@ -161,7 +166,7 @@ public class WatchHistoryAdminService { } private AdminWatchHistoryVO toVo(WatchHistory row, AppUser user, Drama drama, - DramaEpisode episode) { + DramaEpisode episode, AppUserSession session) { AdminWatchHistoryVO vo = new AdminWatchHistoryVO(); vo.setId(row.getId()); vo.setUserId(row.getUserId()); @@ -172,6 +177,10 @@ public class WatchHistoryAdminService { vo.setSource(row.getSource()); vo.setDeviceId(row.getDeviceId()); vo.setWatchedAt(row.getWatchedAt()); + if (session != null) { + vo.setPlatform(session.getPlatform()); + vo.setOsVersion(session.getOsVersion()); + } long position = row.getPositionMs() == null ? 0L : row.getPositionMs(); long duration = row.getDurationMs() == null ? 0L : row.getDurationMs(); vo.setFinished(duration > 0L && position + FINISHED_TOLERANCE_MS >= duration); diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminWatchHistoryVO.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminWatchHistoryVO.java index e6140d2..079126c 100644 --- a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminWatchHistoryVO.java +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/vo/AdminWatchHistoryVO.java @@ -29,5 +29,9 @@ public class AdminWatchHistoryVO { private Boolean finished; private String source; private String deviceId; + /** ANDROID / IOS / WEB,来自该次设备的登录会话。 */ + private String platform; + /** 系统版本,例如 Android 16。 */ + private String osVersion; private LocalDateTime watchedAt; } 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 index 7bd5557..237db95 100644 --- 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 @@ -28,5 +28,9 @@ public class AppUserWatchHistoryVO { private Boolean finished; private String source; private String deviceId; + /** ANDROID / IOS / WEB,来自该次设备的登录会话。 */ + private String platform; + /** 系统版本。 */ + private String osVersion; private LocalDateTime watchedAt; } diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/AppSessionSystemServiceTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/AppSessionSystemServiceTest.java new file mode 100644 index 0000000..c913f55 --- /dev/null +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/AppSessionSystemServiceTest.java @@ -0,0 +1,48 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.service; + +import com.fruitshorts.user.dal.entity.AppUserSession; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 观看记录匹配登录系统。 */ +class AppSessionSystemServiceTest { + + private final AppSessionSystemService service = new AppSessionSystemService(null); + + @Test + void should_use_same_device_session_when_watch_history_has_device() { + AppUserSession older = session("device-1", "ANDROID", "14", null); + AppUserSession newer = session("device-2", "IOS", "18", null); + + AppUserSession matched = service.match(List.of(newer, older), "device-1"); + + assertThat(matched.getPlatform()).isEqualTo("ANDROID"); + assertThat(matched.getOsVersion()).isEqualTo("14"); + } + + @Test + void should_use_active_session_when_device_does_not_match() { + AppUserSession revoked = session("device-9", "WEB", "1", LocalDateTime.now()); + AppUserSession active = session("device-2", "IOS", "18", null); + + AppUserSession matched = service.match(List.of(revoked, active), "missing"); + + assertThat(matched.getPlatform()).isEqualTo("IOS"); + } + + private AppUserSession session(String deviceId, String platform, String osVersion, + LocalDateTime revokedAt) { + AppUserSession session = new AppUserSession(); + session.setDeviceId(deviceId); + session.setPlatform(platform); + session.setOsVersion(osVersion); + session.setRevokedAt(revokedAt); + return session; + } +} diff --git a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/WatchHistoryAdminServiceTest.java b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/WatchHistoryAdminServiceTest.java index e5a2c5f..ace401d 100644 --- a/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/WatchHistoryAdminServiceTest.java +++ b/fruitshorts-admin-api/src/test/java/com/fruitshorts/admin/service/WatchHistoryAdminServiceTest.java @@ -58,7 +58,7 @@ class WatchHistoryAdminServiceTest { dramaMapper = mock(DramaMapper.class); dramaEpisodeMapper = mock(DramaEpisodeMapper.class); service = new WatchHistoryAdminService(watchHistoryMapper, appUserMapper, dramaMapper, - dramaEpisodeMapper); + dramaEpisodeMapper, mock(AppSessionSystemService.class)); } @Test 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 index c514761..5a381fd 100644 --- 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 @@ -68,7 +68,7 @@ public class AppAuthController { @Operation(summary = "刷新令牌(旧 refresh token 立即失效)") @PostMapping("/refresh") public Result refresh(@Valid @RequestBody RefreshReqVO req) { - return Result.ok(authService.refresh(req.getRefreshToken())); + return Result.ok(authService.refresh(req.getRefreshToken(), req.getPlatform(), req.getOsVersion())); } /** 登出:默认只吊销当前设备的会话,allDevices=true 时吊销全部。 */ 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 index c6ccaf9..ac845dd 100644 --- 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 @@ -294,6 +294,12 @@ public class AppAuthService { /** 刷新令牌:轮换 refresh token,旧的立即失效。 */ @Transactional(rollbackFor = Exception.class) public TokenRespVO refresh(String refreshToken) { + return refresh(refreshToken, null, null); + } + + /** 刷新令牌,并在客户端带上系统信息时更新最近一次使用的系统。 */ + @Transactional(rollbackFor = Exception.class) + public TokenRespVO refresh(String refreshToken, String platform, String osVersion) { AppUserSession session = sessionMapper.selectOne(Wrappers.lambdaQuery() .eq(AppUserSession::getRefreshTokenHash, hasher.sha256(refreshToken)) .last("limit 1")); @@ -313,6 +319,12 @@ public class AppAuthService { String nextRefreshToken = hasher.newRefreshToken(); session.setRefreshTokenHash(hasher.sha256(nextRefreshToken)); + if (StringUtils.hasText(platform)) { + session.setPlatform(platform); + } + if (StringUtils.hasText(osVersion)) { + session.setOsVersion(osVersion); + } session.setLastActiveAt(now); session.setExpireAt(now.plus(tokenProvider.appRefreshTtl())); sessionMapper.updateById(session); 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 index 158f031..8ebd681 100644 --- 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 @@ -11,9 +11,11 @@ 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.entity.AppUserSession; 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.dal.mapper.AppUserSessionMapper; import com.fruitshorts.user.enums.AppUserStatus; import com.fruitshorts.user.vo.AppUserQueryReqVO; import com.fruitshorts.user.vo.AppUserAdminRespVO; @@ -26,7 +28,9 @@ import org.springframework.util.StringUtils; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * 后台用户管理:检索、详情、状态处置。 @@ -40,6 +44,7 @@ public class AppUserAdminService { private final AppUserMapper appUserMapper; private final AppUserCredentialMapper credentialMapper; private final AppUserBanRecordMapper banRecordMapper; + private final AppUserSessionMapper sessionMapper; private final AppAuthService authService; public PageResult page(AppUserQueryReqVO req) { @@ -74,7 +79,9 @@ public class AppUserAdminService { req.getRegisterSource()) .orderByDesc(AppUser::getId)); } - return new PageResult<>(result.getTotal(), toRespList(result.getRecords())); + List list = toRespList(result.getRecords()); + fillLatestSystem(list); + return new PageResult<>(result.getTotal(), list); } public AppUserAdminRespVO detail(Long id) { @@ -82,7 +89,9 @@ public class AppUserAdminService { if (user == null) { throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "用户不存在"); } - return toAdminResp(user); + AppUserAdminRespVO vo = toAdminResp(user); + fillLatestSystem(List.of(vo)); + return vo; } /** 修改账号状态。封禁/注销会立即踢下线。 */ @@ -154,4 +163,37 @@ public class AppUserAdminService { vo.setId(user.getId()); return vo; } + + /** 用最近一次未吊销会话上的系统信息,供后台用户列表展示。 */ + private void fillLatestSystem(List list) { + if (list.isEmpty()) { + return; + } + List ids = new ArrayList<>(list.size()); + for (AppUserAdminRespVO vo : list) { + if (vo.getId() != null) { + ids.add(vo.getId()); + } + } + if (ids.isEmpty()) { + return; + } + List sessions = sessionMapper.selectList(Wrappers.lambdaQuery() + .in(AppUserSession::getUserId, ids) + .isNull(AppUserSession::getRevokedAt) + .orderByDesc(AppUserSession::getLastActiveAt) + .orderByDesc(AppUserSession::getId)); + Map latest = new HashMap<>(); + for (AppUserSession session : sessions) { + latest.putIfAbsent(session.getUserId(), session); + } + for (AppUserAdminRespVO vo : list) { + AppUserSession session = latest.get(vo.getId()); + if (session == null) { + continue; + } + vo.setPlatform(session.getPlatform()); + vo.setOsVersion(session.getOsVersion()); + } + } } 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 index d38f535..f4ebd9a 100644 --- 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 @@ -16,4 +16,10 @@ import lombok.EqualsAndHashCode; public class AppUserAdminRespVO extends AppUserRespVO { private Long id; + + /** 最近一次登录的系统:ANDROID / IOS / WEB。 */ + private String platform; + + /** 最近一次登录的系统版本。 */ + private String osVersion; } 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 index 90089b0..a5daa2d 100644 --- 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 @@ -11,4 +11,10 @@ public class RefreshReqVO { @NotBlank(message = "缺少 refreshToken") private String refreshToken; + + /** 客户端系统:ANDROID / IOS / WEB。刷新时顺带更新,避免必须重新登录才有记录。 */ + private String platform; + + /** 系统版本,例如 Android 16。 */ + private String osVersion; } From 5a80e6693f74ae461a30d1b8ddd43f5081bd4d35 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Fri, 23 Oct 2026 12:34:13 +0800 Subject: [PATCH 2/8] =?UTF-8?q?feat(playback):=20=E6=92=AD=E6=94=BE?= =?UTF-8?q?=E7=A5=A8=E6=8D=AE=E8=BF=94=E5=9B=9E=E5=90=84=E6=B8=85=E6=99=B0?= =?UTF-8?q?=E5=BA=A6=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/service/PlaybackServiceTest.java | 25 +++++++++++++++++++ .../playback/service/PlaybackService.java | 25 +++++++++++++++++++ .../fruitshorts/playback/vo/PlayRespVO.java | 2 ++ .../fruitshorts/playback/vo/PlayTrackVO.java | 16 ++++++++++++ 4 files changed, 68 insertions(+) create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/PlayTrackVO.java diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/PlaybackServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/PlaybackServiceTest.java index b6762e9..08681d4 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/PlaybackServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/PlaybackServiceTest.java @@ -5,6 +5,7 @@ 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.ThumbnailUrlResolver; import com.fruitshorts.media.service.TranscodeService; @@ -147,6 +148,30 @@ class PlaybackServiceTest { verify(signer).sign("/files/episode/15/master.m3u8"); } + @Test + void should_sign_each_ready_rendition_when_episode_has_multiple_qualities() { + DramaEpisode episode = episode(16L, 1, 0, "/files/episode/origin.mp4"); + episode.setHlsUrl("/files/transcoded/16/master.m3u8"); + given(episodeService.getPublished(16L)).willReturn(episode); + given(signer.sign(anyString())).willAnswer(invocation -> invocation.getArgument(0) + "?s=1"); + MediaRendition ready = new MediaRendition(); + ready.setQuality("720p"); + ready.setPlayUrl("/files/transcoded/16/720p/index.m3u8"); + ready.setStatus("ready"); + MediaRendition failed = new MediaRendition(); + failed.setQuality("1080p"); + failed.setPlayUrl("/files/transcoded/16/1080p/index.m3u8"); + failed.setStatus("failed"); + given(transcodeService.listRenditions(16L)).willReturn(List.of(ready, failed)); + + PlayRespVO resp = playbackService.play(null, 16L, "device-9", "203.0.113.8"); + + assertThat(resp.getTracks()).extracting(track -> track.getQuality()) + .containsExactly("AUTO", "720p"); + assertThat(resp.getTracks().get(1).getPlayUrl()) + .isEqualTo("/files/transcoded/16/720p/index.m3u8?s=1"); + } + private DramaEpisode episode(Long id, int isFree, int unlockCoin, String videoUrl) { DramaEpisode episode = new DramaEpisode(); episode.setId(id); 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 index 0404c61..8dff5f3 100644 --- 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 @@ -13,6 +13,7 @@ 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 com.fruitshorts.playback.vo.PlayTrackVO; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -87,6 +88,7 @@ public class PlaybackService { vo.setAccessible(true); vo.setAccessType(entitled && !free ? ACCESS_ENTITLED : ACCESS_FREE); vo.setPlayUrl(signedUrl); + vo.setTracks(tracksOf(episode.getId(), signedUrl)); if (userId != null) { recordTicket(userId, drama.getId(), episode.getId(), signedUrl, deviceId, clientIp); @@ -115,6 +117,29 @@ public class PlaybackService { return qualities; } + /** AUTO 用主列表,其余档位用各自的 index.m3u8,地址都签名。 */ + private List tracksOf(Long episodeId, String signedAuto) { + List tracks = new ArrayList<>(); + tracks.add(track("AUTO", signedAuto)); + for (MediaRendition rendition : transcodeService.listRenditions(episodeId)) { + if (!StringUtils.hasText(rendition.getPlayUrl())) { + continue; + } + if (!"ready".equalsIgnoreCase(rendition.getStatus())) { + continue; + } + tracks.add(track(rendition.getQuality(), signer.sign(rendition.getPlayUrl()))); + } + return tracks; + } + + private PlayTrackVO track(String quality, String playUrl) { + PlayTrackVO track = new PlayTrackVO(); + track.setQuality(quality); + track.setPlayUrl(playUrl); + return track; + } + private void recordTicket(Long userId, Long dramaId, Long episodeId, String playUrl, String deviceId, String clientIp) { PlaybackTicket ticket = new PlaybackTicket(); 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 index c2c2682..3ddecfe 100644 --- 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 @@ -23,6 +23,8 @@ public class PlayRespVO { private String playUrl; /** 可用的清晰度档位 */ private List qualities; + /** 各档签名地址。AUTO 是主播放列表,其余是单档 HLS。 */ + private List tracks; /** 雪碧图 WebVTT 时间轴地址;没有产物时为 null */ private String thumbnailsUrl; /** 需要多少金币解锁 */ diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/PlayTrackVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/PlayTrackVO.java new file mode 100644 index 0000000..7c8f3db --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/PlayTrackVO.java @@ -0,0 +1,16 @@ +// Author: fruit.com.cn + +package com.fruitshorts.playback.vo; + +import lombok.Data; + +/** 一档可切换的清晰度及其签名播放地址。 */ +@Data +public class PlayTrackVO { + + /** AUTO 或 360p/480p/720p/1080p。 */ + private String quality; + + /** 签名后的播放地址,客户端原样使用。 */ + private String playUrl; +} From 9c25b1b3974c40b5e6e6d81621f6456d02eef712 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Fri, 23 Oct 2026 12:34:13 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(playback):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E5=B7=B2=E5=88=A0=E9=99=A4=E7=9A=84=E8=A7=82=E7=9C=8B=E8=BF=9B?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/WatchProgressServiceTest.java | 26 +++++++ .../dal/mapper/WatchProgressMapper.java | 26 +++++++ .../service/WatchProgressService.java | 77 ++++++++++++++----- .../playback/vo/ProgressRespVO.java | 2 + 4 files changed, 111 insertions(+), 20 deletions(-) diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/WatchProgressServiceTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/WatchProgressServiceTest.java index 513f712..d72060f 100644 --- a/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/WatchProgressServiceTest.java +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/playback/service/WatchProgressServiceTest.java @@ -15,6 +15,7 @@ import com.fruitshorts.playback.dal.entity.WatchProgress; 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 org.apache.ibatis.builder.MapperBuilderAssistant; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -28,6 +29,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -108,6 +110,30 @@ class WatchProgressServiceTest { return progress; } + @Test + void should_revive_deleted_progress_when_same_drama_is_reported_again() { + DramaEpisode episode = episode(88L, 1); + episode.setDramaId(5L); + given(episodeService.getPublished(88L)).willReturn(episode); + given(dramaService.getPublished(5L)).willReturn(drama(5L, "剧名", 10)); + WatchProgress hidden = progress(5L, 88L); + hidden.setId(9L); + hidden.setUserId(7L); + hidden.setDeleted(1); + given(progressMapper.selectAny(7L, 5L)).willReturn(hidden); + given(progressMapper.selectById(9L)).willReturn(hidden); + ProgressSaveReqVO req = new ProgressSaveReqVO(); + req.setEpisodeId(88L); + req.setPositionMs(1_000L); + req.setDurationMs(2_000L); + + progressService.save(7L, req); + + verify(progressMapper, never()).insert(any(WatchProgress.class)); + verify(progressMapper).updateAny(hidden); + assertThat(hidden.getDeleted()).isZero(); + } + private Drama drama(Long id, String title, int totalEpisodes) { Drama drama = new Drama(); drama.setId(id); 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 index a0368f8..3a7c23d 100644 --- 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 @@ -4,6 +4,32 @@ package com.fruitshorts.playback.dal.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.fruitshorts.playback.dal.entity.WatchProgress; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; public interface WatchProgressMapper extends BaseMapper { + + /** 含逻辑删除行。唯一索引不区分 deleted,再看同一部剧必须能找回旧行。 */ + @Select(""" + SELECT * FROM watch_progress + WHERE user_id = #{userId} AND drama_id = #{dramaId} + LIMIT 1 + """) + WatchProgress selectAny(@Param("userId") Long userId, @Param("dramaId") Long dramaId); + + /** 恢复并更新。手写 SQL 不带 deleted=0 条件,逻辑删除行也能写回。 */ + @Update(""" + UPDATE watch_progress SET + deleted = 0, + episode_id = #{episodeId}, + position_ms = #{positionMs}, + duration_ms = #{durationMs}, + finished = #{finished}, + play_count = #{playCount}, + device_id = #{deviceId}, + source = #{source} + WHERE id = #{id} + """) + int updateAny(WatchProgress progress); } 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 index 343b56a..29a98a1 100644 --- 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 @@ -17,6 +17,7 @@ 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.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -58,30 +59,23 @@ public class WatchProgressService { 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); + LoadedProgress loaded = loadForSave(userId, drama.getId(), episode, position, duration, + finished, req); + WatchProgress existing = loaded.progress(); + if (loaded.created()) { appendHistory(userId, drama.getId(), episode.getId(), position, duration, - progress.getSource(), progress.getDeviceId()); - // updated_at 由触发器维护,回读一次,保证响应与后续 recent 列表口径一致 - return toResp(progressMapper.selectById(progress.getId())); + existing.getSource(), existing.getDeviceId()); + return toResp(progressMapper.selectById(existing.getId())); } + boolean hidden = existing.getDeleted() != null && existing.getDeleted() != 0; boolean sameEpisode = existing.getEpisodeId() != null && existing.getEpisodeId().equals(episode.getId()); + if (hidden && sameEpisode) { + existing.setPlayCount((existing.getPlayCount() == null ? 0 : existing.getPlayCount()) + 1); + appendHistory(userId, drama.getId(), episode.getId(), position, duration, + req.getSource(), existing.getDeviceId()); + } if (!sameEpisode) { // 换集:直接采纳新集与新的播放位置 existing.setEpisodeId(episode.getId()); @@ -103,10 +97,52 @@ public class WatchProgressService { } existing.setDeviceId(req.getDeviceId() == null ? "" : req.getDeviceId()); existing.setSource(req.getSource() == null ? "" : req.getSource()); - progressMapper.updateById(existing); + if (hidden) { + existing.setDeleted(0); + progressMapper.updateAny(existing); + } else { + progressMapper.updateById(existing); + } return toResp(progressMapper.selectById(existing.getId())); } + private LoadedProgress loadForSave(Long userId, Long dramaId, DramaEpisode episode, long position, + long duration, boolean finished, ProgressSaveReqVO req) { + WatchProgress existing = progressMapper.selectAny(userId, dramaId); + if (existing != null) { + return new LoadedProgress(existing, false); + } + WatchProgress created = newProgress(userId, dramaId, episode, position, duration, finished, req); + try { + progressMapper.insert(created); + return new LoadedProgress(created, true); + } catch (DuplicateKeyException ex) { + WatchProgress raced = progressMapper.selectAny(userId, dramaId); + if (raced == null) { + throw ex; + } + return new LoadedProgress(raced, false); + } + } + + private WatchProgress newProgress(Long userId, Long dramaId, DramaEpisode episode, long position, + long duration, boolean finished, ProgressSaveReqVO req) { + WatchProgress progress = new WatchProgress(); + progress.setUserId(userId); + progress.setDramaId(dramaId); + 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()); + return progress; + } + + private record LoadedProgress(WatchProgress progress, boolean created) { + } + public ProgressRespVO ofDrama(Long userId, Long dramaId) { WatchProgress progress = progressMapper.selectOne(Wrappers.lambdaQuery() .eq(WatchProgress::getUserId, userId) @@ -185,6 +221,7 @@ public class WatchProgressService { vo.setDramaTitle(drama.getTitle()); vo.setDramaCover(drama.getCoverUrl()); vo.setTotalEpisodes(drama.getTotalEpisodes()); + vo.setViewCount(drama.getViewCount()); } DramaEpisode episode = episodeById.get(progress.getEpisodeId()); if (episode != null) { 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 index efb7da5..4dd00ea 100644 --- 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 @@ -20,6 +20,8 @@ public class ProgressRespVO { private Integer episodeNumber; /** 短剧总集数;短剧不存在时为空。 */ private Integer totalEpisodes; + /** 短剧热度,取 drama.view_count。不是用户自己的播放次数。 */ + private Long viewCount; private Long positionMs; private Long durationMs; private Boolean finished; From 16ba79a336d9eae961d1ca93472757736566e786 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Fri, 23 Oct 2026 12:34:13 +0800 Subject: [PATCH 4/8] =?UTF-8?q?feat(interaction):=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=89=B9=E9=87=8F=E5=8F=96=E6=B6=88=E6=94=B6=E8=97=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/controller/AppFavoriteController.java | 1 + .../playback/vo/FavoriteRemoveReqVO.java | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) create mode 100644 fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/FavoriteRemoveReqVO.java 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 index ef68fd8..7bc4183 100644 --- 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 @@ -9,6 +9,7 @@ import com.fruitshorts.common.pojo.Result; import com.fruitshorts.content.dal.entity.Drama; import com.fruitshorts.framework.security.SecurityUtils; import com.fruitshorts.playback.service.FavoriteService; +import com.fruitshorts.playback.vo.FavoriteRemoveReqVO; import com.fruitshorts.playback.vo.FavoriteToggleReqVO; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; diff --git a/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/FavoriteRemoveReqVO.java b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/FavoriteRemoveReqVO.java new file mode 100644 index 0000000..7dda519 --- /dev/null +++ b/fruitshorts-module-playback/src/main/java/com/fruitshorts/playback/vo/FavoriteRemoveReqVO.java @@ -0,0 +1,16 @@ +// Author: fruit.com.cn + +package com.fruitshorts.playback.vo; + +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import lombok.Data; + +/** 批量取消收藏。 */ +@Data +public class FavoriteRemoveReqVO { + + @NotEmpty(message = "缺少 dramaIds") + private List<@NotNull Long> dramaIds; +} From 22edf34ff107cf302b11a8f57475b66b171c049f Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Fri, 23 Oct 2026 12:34:13 +0800 Subject: [PATCH 5/8] =?UTF-8?q?feat(system):=20=E6=94=AF=E6=8C=81=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E7=BB=B4=E6=8A=A4=E4=B8=89=E8=AF=AD=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../LegalDocumentAdminController.java | 47 +++++++ .../app/controller/LegalPageController.java | 44 ++++++ .../app/service/LegalPageHtmlTest.java | 33 +++++ fruitshorts-module-system/pom.xml | 5 + .../system/dal/entity/LegalDocument.java | 31 +++++ .../dal/mapper/LegalDocumentMapper.java | 10 ++ .../system/service/LegalDocumentService.java | 125 ++++++++++++++++++ .../system/service/LegalPageHtml.java | 56 ++++++++ .../system/service/LegalPages.java | 47 +++++++ .../system/vo/LegalDocumentVO.java | 21 +++ sql/patch-2026-10-23-legal-documents.sql | 40 ++++++ sql/schema.sql | 11 ++ 12 files changed, 470 insertions(+) create mode 100644 fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/LegalDocumentAdminController.java create mode 100644 fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/LegalPageController.java create mode 100644 fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/LegalPageHtmlTest.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/LegalDocument.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/LegalDocumentMapper.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalDocumentService.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalPageHtml.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalPages.java create mode 100644 fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LegalDocumentVO.java create mode 100644 sql/patch-2026-10-23-legal-documents.sql diff --git a/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/LegalDocumentAdminController.java b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/LegalDocumentAdminController.java new file mode 100644 index 0000000..506a15d --- /dev/null +++ b/fruitshorts-admin-api/src/main/java/com/fruitshorts/admin/controller/LegalDocumentAdminController.java @@ -0,0 +1,47 @@ +// Author: fruit.com.cn + +package com.fruitshorts.admin.controller; + +import com.fruitshorts.common.pojo.Result; +import com.fruitshorts.framework.log.OperationLog; +import com.fruitshorts.system.service.LegalDocumentService; +import com.fruitshorts.system.vo.LegalDocumentVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 后台维护服务条款和隐私协议。公开页面由客户端服务按语言输出。 + */ +@Tag(name = "后台-协议") +@RestController +@RequestMapping("/admin/legal-documents") +@RequiredArgsConstructor +public class LegalDocumentAdminController { + + private final LegalDocumentService legalDocumentService; + + @Operation(summary = "协议列表(服务条款、隐私协议,各含中英印尼语)") + @GetMapping + @PreAuthorize("@ss.hasAnyPermission('system:role','system:menu')") + public Result> list() { + return Result.ok(legalDocumentService.list()); + } + + @Operation(summary = "保存协议") + @PutMapping + @PreAuthorize("@ss.hasPermission('system:role')") + @OperationLog(module = "后台管理", action = "修改服务条款或隐私协议") + public Result save(@RequestBody List items) { + legalDocumentService.save(items); + return Result.ok(); + } +} diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/LegalPageController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/LegalPageController.java new file mode 100644 index 0000000..e65f00b --- /dev/null +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/LegalPageController.java @@ -0,0 +1,44 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.controller; + +import com.fruitshorts.system.service.LegalDocumentService; +import com.fruitshorts.system.service.LegalPages; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RestController; + +/** + * 协议公开页。客户端按当前语言打开 /privacy/zh 或 /terms/en,不需要登录。 + */ +@RestController +@RequiredArgsConstructor +public class LegalPageController { + + private static final MediaType HTML = MediaType.parseMediaType("text/html;charset=UTF-8"); + + private final LegalDocumentService legalDocumentService; + + /** 隐私协议。语言只接受 zh、en、id。 */ + @GetMapping("/" + LegalPages.PATH_PRIVACY + "/{lang}") + public ResponseEntity privacy(@PathVariable String lang) { + return page(LegalPages.PATH_PRIVACY, lang); + } + + /** 服务条款。语言只接受 zh、en、id。 */ + @GetMapping("/" + LegalPages.PATH_TERMS + "/{lang}") + public ResponseEntity terms(@PathVariable String lang) { + return page(LegalPages.PATH_TERMS, lang); + } + + private ResponseEntity page(String path, String lang) { + String html = legalDocumentService.render(path, lang); + if (html == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok().contentType(HTML).body(html); + } +} diff --git a/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/LegalPageHtmlTest.java b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/LegalPageHtmlTest.java new file mode 100644 index 0000000..2a8b1d7 --- /dev/null +++ b/fruitshorts-app-api/src/test/java/com/fruitshorts/app/service/LegalPageHtmlTest.java @@ -0,0 +1,33 @@ +// Author: fruit.com.cn + +package com.fruitshorts.app.service; + +import com.fruitshorts.system.service.LegalPageHtml; +import com.fruitshorts.system.service.LegalPages; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class LegalPageHtmlTest { + + @Test + void should_render_markdown_without_an_extra_title() { + String html = LegalPageHtml.render("zh", "第一行\n\n\n\n**粗体**"); + + assertTrue(html.contains("")); + assertFalse(html.contains("

")); + assertTrue(html.contains("

第一行

")); + assertTrue(html.contains("<script>")); + assertTrue(html.contains("粗体")); + } + + @Test + void should_map_public_paths_to_document_codes() { + assertEquals(LegalPages.PRIVACY, LegalPages.docCodeOfPath("privacy")); + assertEquals(LegalPages.TERMS, LegalPages.docCodeOfPath("terms")); + assertNull(LegalPages.docCodeOfPath("other")); + } +} diff --git a/fruitshorts-module-system/pom.xml b/fruitshorts-module-system/pom.xml index 6021e51..b6f09a5 100644 --- a/fruitshorts-module-system/pom.xml +++ b/fruitshorts-module-system/pom.xml @@ -21,5 +21,10 @@ com.fruitshorts fruitshorts-framework + + org.commonmark + commonmark + 0.22.0 + diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/LegalDocument.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/LegalDocument.java new file mode 100644 index 0000000..18bf640 --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/entity/LegalDocument.java @@ -0,0 +1,31 @@ +// Author: fruit.com.cn + +package com.fruitshorts.system.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; + +/** + * 服务条款与隐私协议。每种文档按语言各存一份。 + */ +@Data +@TableName("legal_document") +public class LegalDocument { + + @TableId(type = IdType.ASSIGN_ID) + private Long id; + + /** terms 或 privacy。 */ + private String docCode; + + /** zh、en、id。 */ + private String lang; + + private String title; + private String content; + private LocalDateTime updatedAt; +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/LegalDocumentMapper.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/LegalDocumentMapper.java new file mode 100644 index 0000000..b168337 --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/dal/mapper/LegalDocumentMapper.java @@ -0,0 +1,10 @@ +// Author: fruit.com.cn + +package com.fruitshorts.system.dal.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.fruitshorts.system.dal.entity.LegalDocument; + +/** 协议正文。 */ +public interface LegalDocumentMapper extends BaseMapper { +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalDocumentService.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalDocumentService.java new file mode 100644 index 0000000..9c3565c --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalDocumentService.java @@ -0,0 +1,125 @@ +// Author: fruit.com.cn + +package com.fruitshorts.system.service; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import com.fruitshorts.common.exception.ErrorCode; +import com.fruitshorts.common.exception.ServiceException; +import com.fruitshorts.system.dal.entity.LegalDocument; +import com.fruitshorts.system.dal.mapper.LegalDocumentMapper; +import com.fruitshorts.system.vo.LegalDocumentVO; +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.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 服务条款与隐私协议:后台保存三语文案,公开页按路径和语言输出 HTML。 + */ +@Service +@RequiredArgsConstructor +public class LegalDocumentService { + + private final LegalDocumentMapper legalDocumentMapper; + + /** 六份协议。库里没有的语言用缺省文案补齐,方便后台第一次打开就能编辑。 */ + public List list() { + Map stored = index(legalDocumentMapper.selectList(null)); + List result = new ArrayList<>(); + for (String code : LegalPages.DOC_CODES) { + for (String lang : LegalPages.LANGS) { + result.add(toView(code, lang, stored.get(LegalPages.key(code, lang)))); + } + } + return result; + } + + /** 按文档和语言覆盖保存。未知种类或语言直接拒绝。 */ + @Transactional(rollbackFor = Exception.class) + public void save(List items) { + if (items == null || items.isEmpty()) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "请提交协议内容"); + } + for (LegalDocumentVO item : items) { + upsert(item); + } + } + + /** + * 公开页面。路径只能是 privacy 或 terms,语言只能是 zh、en、id。 + * 不合法时返回 null,由控制器回 404。 + */ + public String render(String path, String lang) { + String code = LegalPages.docCodeOfPath(path); + if (code == null || !LegalPages.LANGS.contains(lang)) { + return null; + } + return LegalPageHtml.render(lang, contentOf(code, lang, find(code, lang))); + } + + private void upsert(LegalDocumentVO item) { + String code = item == null ? null : item.getDocCode(); + String lang = item == null ? null : item.getLang(); + if (!LegalPages.DOC_CODES.contains(code) || !LegalPages.LANGS.contains(lang)) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "协议种类或语言不正确"); + } + String title = item.getTitle() == null ? "" : item.getTitle().trim(); + String content = item.getContent() == null ? "" : item.getContent(); + if (title.length() > LegalPages.TITLE_MAX || content.length() > LegalPages.CONTENT_MAX) { + throw new ServiceException(ErrorCode.BAD_REQUEST, "协议标题或正文过长"); + } + LegalDocument row = find(code, lang); + if (row == null) { + row = new LegalDocument(); + row.setDocCode(code); + row.setLang(lang); + } + row.setTitle(title); + row.setContent(content); + row.setUpdatedAt(LocalDateTime.now()); + if (row.getId() == null) { + legalDocumentMapper.insert(row); + } else { + legalDocumentMapper.updateById(row); + } + } + + private LegalDocument find(String code, String lang) { + return legalDocumentMapper.selectOne(Wrappers.lambdaQuery() + .eq(LegalDocument::getDocCode, code) + .eq(LegalDocument::getLang, lang) + .last("limit 1")); + } + + private Map index(List rows) { + Map stored = new HashMap<>(); + if (rows == null) { + return stored; + } + for (LegalDocument row : rows) { + stored.put(LegalPages.key(row.getDocCode(), row.getLang()), row); + } + return stored; + } + + private LegalDocumentVO toView(String code, String lang, LegalDocument row) { + LegalDocumentVO view = new LegalDocumentVO(); + view.setDocCode(code); + view.setLang(lang); + view.setTitle(""); + view.setContent(row == null ? LegalPages.defaultContent(lang) : row.getContent()); + return view; + } + + private String contentOf(String code, String lang, LegalDocument row) { + if (row != null && row.getContent() != null) { + return row.getContent(); + } + return LegalPages.defaultContent(lang); + } +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalPageHtml.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalPageHtml.java new file mode 100644 index 0000000..6ef68bd --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalPageHtml.java @@ -0,0 +1,56 @@ +// Author: fruit.com.cn + +package com.fruitshorts.system.service; + +import org.commonmark.parser.Parser; +import org.commonmark.renderer.html.HtmlRenderer; + +/** + * 把协议 Markdown 包成手机上可读的 HTML。不另加标题,原文里的 HTML 会转义。 + */ +public final class LegalPageHtml { + + private static final Parser PARSER = Parser.builder().build(); + private static final HtmlRenderer RENDERER = HtmlRenderer.builder().escapeHtml(true).build(); + + private LegalPageHtml() { + } + + /** 生成完整页面。语言只用于 html lang 属性。 */ + public static String render(String lang, String markdown) { + String body = RENDERER.render(PARSER.parse(markdown == null ? "" : markdown)); + return """ + + + + + + + + +
%s
+ + """.formatted(escape(lang), body); + } + + /** 转义 HTML 特殊字符。 */ + public static String escape(String raw) { + if (raw == null || raw.isEmpty()) { + return ""; + } + return raw.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalPages.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalPages.java new file mode 100644 index 0000000..2de0d94 --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/service/LegalPages.java @@ -0,0 +1,47 @@ +// Author: fruit.com.cn + +package com.fruitshorts.system.service; + +import java.util.List; + +/** + * 协议种类、公开路径和缺省文案。路径按语言区分,例如 /privacy/zh、/terms/en。 + */ +public final class LegalPages { + + public static final String TERMS = "terms"; + public static final String PRIVACY = "privacy"; + public static final String PATH_TERMS = "terms"; + public static final String PATH_PRIVACY = "privacy"; + public static final List DOC_CODES = List.of(TERMS, PRIVACY); + public static final List LANGS = List.of("zh", "en", "id"); + public static final int TITLE_MAX = 80; + public static final int CONTENT_MAX = 100000; + + private LegalPages() { + } + + /** 公开路径对应的文档编码。无法识别时返回 null。 */ + public static String docCodeOfPath(String path) { + if (PATH_PRIVACY.equals(path)) { + return PRIVACY; + } + if (PATH_TERMS.equals(path)) { + return TERMS; + } + return null; + } + + /** 后台还没保存时页面上使用的 Markdown 正文。 */ + public static String defaultContent(String lang) { + return switch (lang) { + case "en" -> "This page is published by FruitShorts and can be edited as Markdown in the admin console."; + case "id" -> "Halaman ini dipublikasikan oleh FruitShorts dan dapat diubah sebagai Markdown di konsol admin."; + default -> "本页由水果短剧发布,可在管理后台用 Markdown 修改。"; + }; + } + + public static String key(String docCode, String lang) { + return docCode + ":" + lang; + } +} diff --git a/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LegalDocumentVO.java b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LegalDocumentVO.java new file mode 100644 index 0000000..f0544eb --- /dev/null +++ b/fruitshorts-module-system/src/main/java/com/fruitshorts/system/vo/LegalDocumentVO.java @@ -0,0 +1,21 @@ +// Author: fruit.com.cn + +package com.fruitshorts.system.vo; + +import lombok.Data; + +/** + * 后台读写的一份协议。公开页面不走这个结构,直接返回 HTML。 + */ +@Data +public class LegalDocumentVO { + + /** terms 或 privacy。 */ + private String docCode; + + /** zh、en、id。 */ + private String lang; + + private String title; + private String content; +} diff --git a/sql/patch-2026-10-23-legal-documents.sql b/sql/patch-2026-10-23-legal-documents.sql new file mode 100644 index 0000000..34ea9ad --- /dev/null +++ b/sql/patch-2026-10-23-legal-documents.sql @@ -0,0 +1,40 @@ +-- Author: fruit.com.cn +-- 服务条款与隐私协议:三语正文 + 后台菜单 + +CREATE TABLE IF NOT EXISTS legal_document ( + id BIGINT PRIMARY KEY, + doc_code VARCHAR(16) NOT NULL, + lang VARCHAR(8) NOT NULL, + title VARCHAR(128) NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_legal_document_code_lang ON legal_document (doc_code, lang); +COMMENT ON TABLE legal_document IS '服务条款与隐私协议'; + +INSERT INTO legal_document (id, doc_code, lang, title, content) VALUES + (910001, 'terms', 'zh', '', '本页由水果短剧发布,可在管理后台用 Markdown 修改。'), + (910002, 'terms', 'en', '', 'This page is published by FruitShorts and can be edited as Markdown in the admin console.'), + (910003, 'terms', 'id', '', 'Halaman ini dipublikasikan oleh FruitShorts dan dapat diubah sebagai Markdown di konsol admin.'), + (910004, 'privacy', 'zh', '', '本页由水果短剧发布,可在管理后台用 Markdown 修改。'), + (910005, 'privacy', 'en', '', 'This page is published by FruitShorts and can be edited as Markdown in the admin console.'), + (910006, 'privacy', 'id', '', 'Halaman ini dipublikasikan oleh FruitShorts dan dapat diubah sebagai Markdown di konsol admin.') +ON CONFLICT (doc_code, lang) DO NOTHING; + +INSERT INTO sys_menu (id, parent_id, title, route_path, route_name, component, redirect, icon, + permission_code, type, always_show, affix, sort) VALUES + (38, 6, '协议设置', 'legal-documents', 'legal-documents', + 'views/systemManagement/LegalDocumentView', '', '', + 'system:role', 'MENU', 0, 0, 8) +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 (id, role_id, menu_id) +SELECT r.id * 1000000 + m.id, r.id, m.id +FROM sys_role r +CROSS JOIN sys_menu m +WHERE r.code = 'super_admin' AND m.id = 38 +ON CONFLICT DO NOTHING; diff --git a/sql/schema.sql b/sql/schema.sql index ad7c011..3eed332 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -127,6 +127,17 @@ CREATE TABLE IF NOT EXISTS sys_config ( CREATE UNIQUE INDEX IF NOT EXISTS uk_sys_config_key ON sys_config (config_key); COMMENT ON TABLE sys_config IS '系统配置'; +CREATE TABLE IF NOT EXISTS legal_document ( + id BIGINT PRIMARY KEY, + doc_code VARCHAR(16) NOT NULL, -- terms / privacy + lang VARCHAR(8) NOT NULL, -- zh / en / id + title VARCHAR(128) NOT NULL DEFAULT '', + content TEXT NOT NULL DEFAULT '', + updated_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE UNIQUE INDEX IF NOT EXISTS uk_legal_document_code_lang ON legal_document (doc_code, lang); +COMMENT ON TABLE legal_document IS '服务条款与隐私协议'; + CREATE TABLE IF NOT EXISTS sys_operation_log ( id BIGINT PRIMARY KEY, admin_user_id BIGINT NOT NULL DEFAULT 0, From 87e20c31a95eeab9818c5e29b2a198870f9e3b09 Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Fri, 23 Oct 2026 12:34:13 +0800 Subject: [PATCH 6/8] =?UTF-8?q?feat(security):=20=E5=85=81=E8=AE=B8?= =?UTF-8?q?=E5=8C=BF=E5=90=8D=E6=90=9C=E7=B4=A2=E4=B8=8E=E6=89=93=E5=BC=80?= =?UTF-8?q?=E5=8D=8F=E8=AE=AE=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/fruitshorts/app/controller/AppSearchController.java | 2 +- .../com/fruitshorts/framework/security/SecurityConfig.java | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppSearchController.java b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppSearchController.java index ebb9a93..e37ff47 100644 --- a/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppSearchController.java +++ b/fruitshorts-app-api/src/main/java/com/fruitshorts/app/controller/AppSearchController.java @@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; -/** 客户端搜索:按标题或简介模糊匹配已上线短剧。 */ +/** 客户端搜索:按标题或简介模糊匹配已上线短剧。不需要登录。 */ @Tag(name = "客户端-搜索") @RestController @RequestMapping("/app") diff --git a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java index e9370e2..1c1f523 100644 --- a/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java +++ b/fruitshorts-framework/src/main/java/com/fruitshorts/framework/security/SecurityConfig.java @@ -40,11 +40,15 @@ public class SecurityConfig { "/app/categories/**", "/app/banners/**", "/app/dramas/**", + "/app/search", "/app/episodes/*/play", "/app/config/**", /* 启动配置与公告:客户端登录前就要拿(POST /app/creator/apply 等仍要登录) */ "/app/config", "/app/notices", + /* 服务条款与隐私协议:登录前就要能打开,按语言返回 HTML */ + "/privacy/**", + "/terms/**", /* 埋点上报:游客浏览也要记录,匿名可访问 */ "/app/events", /* 媒资:封面与视频用 /