chore(merge): 合并用户系统协议与播放改动

This commit is contained in:
Zhou Weidong committed 2026-10-23 12:34:26 +08:00
commit df2da25833
38 files changed
+906 -40

No files matched your search

+22 -2
View File
@@ -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` |
@@ -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<Long, DramaEpisode> 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<AppUserSession> 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<Long, Drama> dramas,
Map<Long, DramaEpisode> episodes) {
Map<Long, DramaEpisode> 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());
@@ -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<LegalDocumentVO>> list() {
return Result.ok(legalDocumentService.list());
}
@Operation(summary = "保存协议")
@PutMapping
@PreAuthorize("@ss.hasPermission('system:role')")
@OperationLog(module = "后台管理", action = "修改服务条款或隐私协议")
public Result<Void> save(@RequestBody List<LegalDocumentVO> items) {
legalDocumentService.save(items);
return Result.ok();
}
}
@@ -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<Long, List<AppUserSession>> groupByUser(Collection<Long> userIds) {
Map<Long, List<AppUserSession>> grouped = new HashMap<>();
if (userIds == null || userIds.isEmpty()) {
return grouped;
}
List<AppUserSession> sessions = sessionMapper.selectList(Wrappers.<AppUserSession>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<AppUserSession> 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);
}
}
@@ -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<AdminWatchHistoryVO> page(WatchHistoryQueryReqVO req) {
@@ -125,10 +127,13 @@ public class WatchHistoryAdminService {
Map<Long, AppUser> users = loadUsers(rows);
Map<Long, Drama> dramas = loadDramas(rows);
Map<Long, DramaEpisode> episodes = loadEpisodes(rows);
Map<Long, List<AppUserSession>> sessions = sessionSystem.groupByUser(
rows.stream().map(WatchHistory::getUserId).distinct().toList());
List<AdminWatchHistoryVO> 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);
@@ -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;
}
@@ -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;
}
@@ -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;
}
}
@@ -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
@@ -68,7 +68,7 @@ public class AppAuthController {
@Operation(summary = "刷新令牌(旧 refresh token 立即失效)")
@PostMapping("/refresh")
public Result<TokenRespVO> 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 时吊销全部。 */
@@ -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;
@@ -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")
@@ -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<String> privacy(@PathVariable String lang) {
return page(LegalPages.PATH_PRIVACY, lang);
}
/** 服务条款。语言只接受 zh、en、id。 */
@GetMapping("/" + LegalPages.PATH_TERMS + "/{lang}")
public ResponseEntity<String> terms(@PathVariable String lang) {
return page(LegalPages.PATH_TERMS, lang);
}
private ResponseEntity<String> 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);
}
}
@@ -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<script>alert(1)</script>\n\n**粗体**");
assertTrue(html.contains("<html lang=\"zh\">"));
assertFalse(html.contains("<h1>"));
assertTrue(html.contains("<p>第一行</p>"));
assertTrue(html.contains("&lt;script&gt;"));
assertTrue(html.contains("<strong>粗体</strong>"));
}
@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"));
}
}
@@ -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);
@@ -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);
@@ -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",
/* 媒资:封面与视频用 <img>/<video> 直接访问,无法带请求头。
@@ -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<WatchProgress> {
/** 含逻辑删除行。唯一索引不区分 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);
}
@@ -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<PlayTrackVO> tracksOf(Long episodeId, String signedAuto) {
List<PlayTrackVO> 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();
@@ -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.<WatchProgress>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.<WatchProgress>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) {
@@ -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;
}
@@ -23,6 +23,8 @@ public class PlayRespVO {
private String playUrl;
/** 可用的清晰度档位 */
private List<String> qualities;
/** 各档签名地址。AUTO 是主播放列表,其余是单档 HLS。 */
private List<PlayTrackVO> tracks;
/** 雪碧图 WebVTT 时间轴地址;没有产物时为 null */
private String thumbnailsUrl;
/** 需要多少金币解锁 */
@@ -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;
}
@@ -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;
+5
View File
@@ -21,5 +21,10 @@
<groupId>com.fruitshorts</groupId>
<artifactId>fruitshorts-framework</artifactId>
</dependency>
<dependency>
<groupId>org.commonmark</groupId>
<artifactId>commonmark</artifactId>
<version>0.22.0</version>
</dependency>
</dependencies>
</project>
@@ -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;
}
@@ -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<LegalDocument> {
}
@@ -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<LegalDocumentVO> list() {
Map<String, LegalDocument> stored = index(legalDocumentMapper.selectList(null));
List<LegalDocumentVO> 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<LegalDocumentVO> 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.<LegalDocument>lambdaQuery()
.eq(LegalDocument::getDocCode, code)
.eq(LegalDocument::getLang, lang)
.last("limit 1"));
}
private Map<String, LegalDocument> index(List<LegalDocument> rows) {
Map<String, LegalDocument> 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);
}
}
@@ -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 """
<!DOCTYPE html>
<html lang="%s">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title></title>
<style>
body{margin:0;background:#111;color:#f4f4f5;font:16px/1.7 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
main{max-width:720px;margin:0 auto;padding:28px 20px 48px}
article>*:first-child{margin-top:0}
article p{margin:0 0 12px}
article h1,article h2,article h3{font-size:18px;line-height:1.4;margin:20px 0 8px}
article a{color:#ff6b2c}
article ul,article ol{margin:0 0 12px;padding-left:1.2em}
article code{font-family:ui-monospace,monospace}
article pre{overflow:auto;padding:12px;background:#1c1c1e;border-radius:8px}
</style>
</head>
<body><main><article>%s</article></main></body>
</html>
""".formatted(escape(lang), body);
}
/** 转义 HTML 特殊字符。 */
public static String escape(String raw) {
if (raw == null || raw.isEmpty()) {
return "";
}
return raw.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace("\"", "&quot;");
}
}
@@ -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<String> DOC_CODES = List.of(TERMS, PRIVACY);
public static final List<String> 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;
}
}
@@ -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;
}
@@ -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.<AppUserSession>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);
@@ -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<AppUserAdminRespVO> page(AppUserQueryReqVO req) {
@@ -74,7 +79,9 @@ public class AppUserAdminService {
req.getRegisterSource())
.orderByDesc(AppUser::getId));
}
return new PageResult<>(result.getTotal(), toRespList(result.getRecords()));
List<AppUserAdminRespVO> 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<AppUserAdminRespVO> list) {
if (list.isEmpty()) {
return;
}
List<Long> ids = new ArrayList<>(list.size());
for (AppUserAdminRespVO vo : list) {
if (vo.getId() != null) {
ids.add(vo.getId());
}
}
if (ids.isEmpty()) {
return;
}
List<AppUserSession> sessions = sessionMapper.selectList(Wrappers.<AppUserSession>lambdaQuery()
.in(AppUserSession::getUserId, ids)
.isNull(AppUserSession::getRevokedAt)
.orderByDesc(AppUserSession::getLastActiveAt)
.orderByDesc(AppUserSession::getId));
Map<Long, AppUserSession> 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());
}
}
}
@@ -16,4 +16,10 @@ import lombok.EqualsAndHashCode;
public class AppUserAdminRespVO extends AppUserRespVO {
private Long id;
/** 最近一次登录的系统:ANDROID / IOS / WEB。 */
private String platform;
/** 最近一次登录的系统版本。 */
private String osVersion;
}
@@ -11,4 +11,10 @@ public class RefreshReqVO {
@NotBlank(message = "缺少 refreshToken")
private String refreshToken;
/** 客户端系统:ANDROID / IOS / WEB。刷新时顺带更新,避免必须重新登录才有记录。 */
private String platform;
/** 系统版本,例如 Android 16。 */
private String osVersion;
}
@@ -211,15 +211,7 @@ class TranscodeClaimAndStorageTest {
transcodeService.succeed(6L, java.util.List.of(rendition));
@SuppressWarnings("unchecked")
ArgumentCaptor<Wrapper<MediaRendition>> captor = ArgumentCaptor.forClass(Wrapper.class);
verify(mediaRenditionMapper).delete(captor.capture());
assertThat(captor.getValue().getSqlSegment())
.containsIgnoringCase("media_asset_id")
.containsIgnoringCase("episode_id")
.containsIgnoringCase("OR");
assertThat(captor.getValue().getSqlSegment())
.doesNotContainIgnoringCase("AND (");
verify(mediaRenditionMapper).deletePhysically(99L, 31L);
verify(mediaRenditionMapper).insert(rendition);
}
+40
View File
@@ -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;
+11
View File
@@ -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,