diff --git a/docs/api-phase1.md b/docs/api-phase1.md index 11d1a52..735e6e6 100644 --- a/docs/api-phase1.md +++ b/docs/api-phase1.md @@ -170,7 +170,7 @@ app.register.max-per-device-per-day | GET | `/app/feed?types=ANIME,LIVE&categoryId=7&cursor=&size=20` | 推荐流;支持内容类型与分类叠加过滤,游标翻页 | | GET | `/app/categories` | 分类列表 | | GET | `/app/dramas/{id}` | 详情(含剧集列表,免费集带**已签名**播放地址) | -| GET | `/app/search?keyword=&pageIndex=&pageSize=` | 搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 | +| GET | `/app/search?keyword=&pageIndex=&pageSize=` | **匿名**。搜索已上线短剧:按标题/简介模糊匹配,分页返回卡片列表 | > 卡片(`AppDramaCardVO`)统一新增 `language` 字段(取值如 `zh-CN` / `id`), > 首页、发现页、分类、收藏、搜索等所有下发卡片的场景都带该字段,客户端可据此做语言筛选。 @@ -264,7 +264,11 @@ app.register.max-per-device-per-day "accessible": true, "accessType": "FREE", "playUrl": "/files/episode/202610/xxx.mp4?e=1760000000&s=9f2c...", - "qualities": ["AUTO"], + "qualities": ["AUTO", "720p", "1080p"], + "tracks": [ + {"quality": "AUTO", "playUrl": "/files/transcoded/88/master.m3u8?e=1760000000&s=9f2c..."}, + {"quality": "720p", "playUrl": "/files/transcoded/88/720p/index.m3u8?e=1760000000&s=a1b2..."} + ], "thumbnailsUrl": "/files/transcoded/88/thumbnails/thumbnails.vtt?e=1760000000&s=8a1d...", "unlockCoin": 0, "previewSeconds": 0, @@ -296,6 +300,7 @@ app.register.max-per-device-per-day | POST | `/app/favorites/toggle` | 收藏/取消收藏,返回切换后状态 | | GET | `/app/favorites` | 收藏分页;`pageIndex` 默认 1,`pageSize` 默认 20、上限 100 | | DELETE | `/app/favorites/{dramaId}` | 取消收藏 | +| POST | `/app/favorites/remove` | 批量取消收藏,请求体 `{ "dramaIds": ["12", "15"] }` | 进度合并规则:同一集内只前进不后退;换集直接采纳新集;`finished` 只在接近片尾或客户端显式上报时置位。 这样用户换设备不会看到进度倒退。 @@ -354,6 +359,7 @@ app.register.max-per-device-per-day "dramaCover": "/files/drama_cover/12.jpg", "episodeNumber": 6, "totalEpisodes": 68, + "viewCount": "1800", "positionMs": "80000", "durationMs": "120000", "finished": false, @@ -375,6 +381,7 @@ app.register.max-per-device-per-day | `dramaCover` | string / null | 短剧封面 | | `episodeNumber` | int / null | 当前续看集号 `ep_no` | | `totalEpisodes` | int / null | 短剧总集数 | +| `viewCount` | Long(JSON 字符串)/ null | 短剧热度,取 `drama.view_count`。`playCount` 是用户自己的播放次数 | ### 1.6 离线下载(已实现) @@ -543,6 +550,17 @@ GET /app/notices?pageIndex=1&pageSize=20 `expire_at` 为空或未过期的公告;按 `publish_at desc, id desc` 排序。 **表里没有数据时返回 `total=0, list=[]`(HTTP 与 code 都是成功),不是 404。** +#### 1.9.2a 协议公开页(匿名 HTML) + +客户端按当前界面语言打开,不走 `{code,message,data}` 包装。正文在后台「协议设置」用 Markdown 维护,页面不另加标题。 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/privacy/{lang}` | 隐私协议。`lang` 为 `zh` / `en` / `id` | +| GET | `/terms/{lang}` | 服务条款。语言同上 | + +响应 `Content-Type: text/html;charset=UTF-8`。语言或路径不在上表时 HTTP 404。库里还没有该语言时返回内置缺省文案,不返回空页。 + #### 1.9.3 `POST /app/creator/apply`(登录) ```json @@ -604,6 +622,8 @@ GET /app/notices?pageIndex=1&pageSize=20 | 角色分类 | PUT · DELETE | `/admin/role-categories/{id}` | `system:role-category` | | 系统配置 | GET | `/admin/configs` | `system:role` / `system:menu` | | 系统配置 | PUT | `/admin/configs` | `system:role` | +| 协议 | GET | `/admin/legal-documents` | `system:role` / `system:menu` | +| 协议 | PUT | `/admin/legal-documents` | `system:role` | | 操作日志 | GET | `/admin/operation-logs/page` · `/{id}` | `log:list` | | 字典 | GET | `/admin/dicts/drama-category` · `/content-status` | `drama:list` | 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/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-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-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-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-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-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-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-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", /* 媒资:封面与视频用 /