feat: 拆分 UgcWork.status 为 status(String) + leaiStatus(Integer),修复作品库 Tab 筛选失败

根因:UgcWork.status (Integer) 同时承载「乐读派创作进度」和「本地发布状态」,
前端用字符串筛选时无法匹配。

改动:
- 新增 V17 迁移脚本:拆分 status 为 VARCHAR + 新增 leai_status INT
- 新增 WorkPublishStatus 枚举 (draft/unpublished/pending_review/published/rejected)
- 新增 LeaiCreationStatus 常量类 (FAILED~DUBBED)
- LeaiSyncService:写入 leaiStatus,CATALOGED 时自动推 status 到 unpublished
- 所有公众端 Service:status 直接使用字符串枚举值,删除 Integer 映射
- 新增 Playwright E2E 测试验证 12 个场景全部通过

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
En 2026-04-10 02:27:09 +08:00
parent 322fd2c4ad
commit 400fc97ebb
11 changed files with 434 additions and 124 deletions

View File

@ -0,0 +1,19 @@
package com.competition.modules.leai.enums;
/**
* 乐读派 AI 创作进度常量
* <p>
* 对应 t_ugc_work.leai_status 字段INT
*/
public final class LeaiCreationStatus {
public static final int FAILED = -1;
public static final int DRAFT = 0;
public static final int PENDING = 1;
public static final int PROCESSING = 2;
public static final int COMPLETED = 3;
public static final int CATALOGED = 4;
public static final int DUBBED = 5;
private LeaiCreationStatus() {}
}

View File

@ -4,6 +4,7 @@ import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.competition.common.exception.BusinessException;
import com.competition.modules.leai.config.LeaiConfig;
import com.competition.modules.leai.enums.LeaiCreationStatus;
import com.competition.modules.leai.util.LeaiUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
@ -38,14 +39,14 @@ public class LeaiApiClient {
private final LeaiConfig leaiConfig;
private final ObjectMapper objectMapper;
// 状态常量V4.0 数值状态机
public static final int STATUS_FAILED = -1;
public static final int STATUS_DRAFT = 0;
public static final int STATUS_PENDING = 1;
public static final int STATUS_PROCESSING = 2;
public static final int STATUS_COMPLETED = 3;
public static final int STATUS_CATALOGED = 4;
public static final int STATUS_DUBBED = 5;
// 状态常量引用 LeaiCreationStatus保持向后兼容
public static final int STATUS_FAILED = LeaiCreationStatus.FAILED;
public static final int STATUS_DRAFT = LeaiCreationStatus.DRAFT;
public static final int STATUS_PENDING = LeaiCreationStatus.PENDING;
public static final int STATUS_PROCESSING = LeaiCreationStatus.PROCESSING;
public static final int STATUS_COMPLETED = LeaiCreationStatus.COMPLETED;
public static final int STATUS_CATALOGED = LeaiCreationStatus.CATALOGED;
public static final int STATUS_DUBBED = LeaiCreationStatus.DUBBED;
/**
* 换取 Session Token

View File

@ -3,11 +3,13 @@ package com.competition.modules.leai.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.competition.common.enums.Visibility;
import com.competition.modules.leai.enums.LeaiCreationStatus;
import com.competition.modules.leai.util.LeaiUtil;
import com.competition.modules.sys.entity.SysUser;
import com.competition.modules.sys.mapper.SysUserMapper;
import com.competition.modules.ugc.entity.UgcWork;
import com.competition.modules.ugc.entity.UgcWorkPage;
import com.competition.modules.ugc.enums.WorkPublishStatus;
import com.competition.modules.ugc.mapper.UgcWorkMapper;
import com.competition.modules.ugc.mapper.UgcWorkPageMapper;
import lombok.RequiredArgsConstructor;
@ -61,16 +63,16 @@ public class LeaiSyncService implements ILeaiSyncService {
return;
}
int localStatus = localWork.getStatus() != null ? localWork.getStatus() : 0;
int localLeaiStatus = localWork.getLeaiStatus() != null ? localWork.getLeaiStatus() : 0;
if (remoteStatus == LeaiApiClient.STATUS_FAILED) {
if (remoteStatus == LeaiCreationStatus.FAILED) {
// FAILED(-1): 创作失败强制更新
updateFailed(localWork, remoteData);
log.info("[{}] 强制更新(FAILED) remoteWorkId={}", source, remoteWorkId);
return;
}
if (remoteStatus == LeaiApiClient.STATUS_PROCESSING) {
if (remoteStatus == LeaiCreationStatus.PROCESSING) {
// PROCESSING(2): 创作进行中强制更新进度
updateProcessing(localWork, remoteData);
log.info("[{}] 强制更新(PROCESSING) remoteWorkId={}, progress={}",
@ -78,23 +80,23 @@ public class LeaiSyncService implements ILeaiSyncService {
return;
}
if (remoteStatus > localStatus) {
if (remoteStatus > localLeaiStatus) {
// 状态前进: 全量更新
// status=3(COMPLETED): pageList 包含图片 URL
// status=4(CATALOGED): title/author 已更新
// status=5(DUBBED): pageList audioUrl 已填充
// leaiStatus=3(COMPLETED): pageList 包含图片 URL
// leaiStatus=4(CATALOGED): title/author 已更新
// leaiStatus=5(DUBBED): pageList audioUrl 已填充
updateStatusForward(localWork, remoteData, remoteStatus);
log.info("[{}] 状态更新 remoteWorkId={}: {} -> {}", source, remoteWorkId, localStatus, remoteStatus);
log.info("[{}] 状态更新 remoteWorkId={}: {} -> {}", source, remoteWorkId, localLeaiStatus, remoteStatus);
return;
}
// 旧数据或重复推送忽略状态更新
// 但如果 remoteStatus >= 3 且本地缺少页面数据需要补充拉取
if (remoteStatus >= LeaiApiClient.STATUS_COMPLETED && !hasPages(localWork.getId())) {
if (remoteStatus >= LeaiCreationStatus.COMPLETED && !hasPages(localWork.getId())) {
log.info("[{}] 状态未变但页面缺失,补充拉取: remoteWorkId={}, status={}", source, remoteWorkId, remoteStatus);
ensurePagesSaved(localWork.getId(), remoteWorkId, remoteData.get("pageList"));
} else {
log.debug("[{}] 跳过 remoteWorkId={}, remote={} <= local={}", source, remoteWorkId, remoteStatus, localStatus);
log.debug("[{}] 跳过 remoteWorkId={}, remote={} <= local={}", source, remoteWorkId, remoteStatus, localLeaiStatus);
}
}
@ -105,7 +107,12 @@ public class LeaiSyncService implements ILeaiSyncService {
UgcWork work = new UgcWork();
work.setRemoteWorkId(remoteWorkId);
work.setTitle(LeaiUtil.toString(remoteData.get("title"), "未命名作品"));
work.setStatus(LeaiUtil.toInt(remoteData.get("status"), LeaiApiClient.STATUS_PENDING));
int leaiStatus = LeaiUtil.toInt(remoteData.get("status"), LeaiCreationStatus.PENDING);
work.setLeaiStatus(leaiStatus);
// 本地发布状态创作进度 >= CATALOGED 时自动设为 unpublished否则为 draft
work.setStatus(leaiStatus >= LeaiCreationStatus.CATALOGED
? WorkPublishStatus.UNPUBLISHED.getValue()
: WorkPublishStatus.DRAFT.getValue());
work.setVisibility(Visibility.PRIVATE.getValue());
work.setIsDeleted(0);
work.setIsRecommended(false);
@ -156,8 +163,8 @@ public class LeaiSyncService implements ILeaiSyncService {
ugcWorkMapper.insert(work);
// 如果 status >= 3确保页面数据已保存
if (work.getStatus() != null && work.getStatus() >= LeaiApiClient.STATUS_COMPLETED) {
// 如果 leaiStatus >= 3确保页面数据已保存
if (work.getLeaiStatus() != null && work.getLeaiStatus() >= LeaiCreationStatus.COMPLETED) {
ensurePagesSaved(work.getId(), remoteWorkId, remoteData.get("pageList"));
}
}
@ -168,7 +175,7 @@ public class LeaiSyncService implements ILeaiSyncService {
private void updateFailed(UgcWork work, Map<String, Object> remoteData) {
LambdaUpdateWrapper<UgcWork> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(UgcWork::getId, work.getId())
.set(UgcWork::getStatus, LeaiApiClient.STATUS_FAILED)
.set(UgcWork::getLeaiStatus, LeaiCreationStatus.FAILED)
.set(UgcWork::getFailReason, LeaiUtil.toString(remoteData.get("failReason"), "未知错误"))
.set(UgcWork::getModifyTime, LocalDateTime.now());
ugcWorkMapper.update(null, wrapper);
@ -180,7 +187,7 @@ public class LeaiSyncService implements ILeaiSyncService {
private void updateProcessing(UgcWork work, Map<String, Object> remoteData) {
LambdaUpdateWrapper<UgcWork> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(UgcWork::getId, work.getId())
.set(UgcWork::getStatus, LeaiApiClient.STATUS_PROCESSING);
.set(UgcWork::getLeaiStatus, LeaiCreationStatus.PROCESSING);
if (remoteData.containsKey("progress")) {
wrapper.set(UgcWork::getProgress, LeaiUtil.toInt(remoteData.get("progress"), 0));
@ -215,11 +222,17 @@ public class LeaiSyncService implements ILeaiSyncService {
* 状态前进全量更新
*/
private void updateStatusForward(UgcWork work, Map<String, Object> remoteData, int remoteStatus) {
// CAS 乐观锁确保并发安全只有当前 status < remoteStatus 时才更新
// CAS 乐观锁确保并发安全只有当前 leaiStatus < remoteStatus 时才更新
LambdaUpdateWrapper<UgcWork> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(UgcWork::getId, work.getId())
.lt(UgcWork::getStatus, remoteStatus)
.set(UgcWork::getStatus, remoteStatus);
.lt(UgcWork::getLeaiStatus, remoteStatus)
.set(UgcWork::getLeaiStatus, remoteStatus);
// leaiStatus 推进到 CATALOGED 且当前 status 仍为 draft 自动设 status unpublished
if (remoteStatus >= LeaiCreationStatus.CATALOGED
&& WorkPublishStatus.DRAFT.getValue().equals(work.getStatus())) {
wrapper.set(UgcWork::getStatus, WorkPublishStatus.UNPUBLISHED.getValue());
}
// 更新可变字段
if (remoteData.containsKey("title")) {
@ -267,8 +280,8 @@ public class LeaiSyncService implements ILeaiSyncService {
return;
}
// status >= 3 时确保页面数据已保存
if (remoteStatus >= LeaiApiClient.STATUS_COMPLETED) {
// leaiStatus >= 3 时确保页面数据已保存
if (remoteStatus >= LeaiCreationStatus.COMPLETED) {
ensurePagesSaved(work.getId(), work.getRemoteWorkId(), remoteData.get("pageList"));
}
}

View File

@ -10,6 +10,7 @@ import com.competition.modules.sys.entity.SysUser;
import com.competition.modules.sys.mapper.SysUserMapper;
import com.competition.modules.ugc.entity.UgcReviewLog;
import com.competition.modules.ugc.entity.UgcWork;
import com.competition.modules.ugc.enums.WorkPublishStatus;
import com.competition.modules.ugc.mapper.UgcReviewLogMapper;
import com.competition.modules.ugc.mapper.UgcWorkMapper;
import lombok.RequiredArgsConstructor;
@ -33,26 +34,16 @@ public class PublicContentReviewService {
private final UgcReviewLogMapper ugcReviewLogMapper;
private final SysUserMapper sysUserMapper;
/** 与 UgcWork.status 整型及写库逻辑一致 */
private static final int ST_DRAFT = 0;
private static final int ST_PENDING = 1;
private static final int ST_PROCESSING = 2;
private static final int ST_COMPLETED = 3;
private static final int ST_CATALOGED = 4;
private static final int ST_DUBBED = 5;
private static final int ST_REJECTED = -1;
private static final int ST_TAKEN_DOWN = -2;
/**
* 获取各状态统计作品审核页待审核数 + 今日审核维度
*/
public Map<String, Long> getStats() {
Map<String, Long> stats = new LinkedHashMap<>();
stats.put("pending", countByStatusInt(ST_PENDING));
stats.put("pending_review", countByStatusInt(ST_PENDING));
stats.put("published", countPublishedStatuses());
stats.put("rejected", countByStatusInt(ST_REJECTED));
stats.put("taken_down", countByStatusInt(ST_TAKEN_DOWN));
stats.put("pending", countByStatus(WorkPublishStatus.PENDING_REVIEW.getValue()));
stats.put("pending_review", countByStatus(WorkPublishStatus.PENDING_REVIEW.getValue()));
stats.put("published", countByStatus(WorkPublishStatus.PUBLISHED.getValue()));
stats.put("rejected", countByStatus(WorkPublishStatus.REJECTED.getValue()));
stats.put("taken_down", countByStatus(WorkPublishStatus.UNPUBLISHED.getValue()));
LocalDateTime dayStart = LocalDate.now().atStartOfDay();
stats.put("todayReviewed", countTodayReviewLogs(dayStart));
stats.put("todayApproved", countTodayLogsByAction("approve", dayStart));
@ -70,11 +61,11 @@ public class PublicContentReviewService {
LambdaQueryWrapper<UgcWork> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(UgcWork::getIsDeleted, 0);
if (StringUtils.hasText(status)) {
List<Integer> codes = parseStatusFilter(status);
if (codes.isEmpty()) {
wrapper.eq(UgcWork::getStatus, Integer.MIN_VALUE);
List<String> statusValues = parseStatusFilter(status);
if (statusValues.isEmpty()) {
wrapper.eq(UgcWork::getStatus, "__never_match__");
} else {
wrapper.in(UgcWork::getStatus, codes);
wrapper.in(UgcWork::getStatus, statusValues);
}
}
if (StringUtils.hasText(keyword)) {
@ -139,7 +130,7 @@ public class PublicContentReviewService {
if (work == null) {
throw new BusinessException(404, "作品不存在");
}
work.setStatus(3); // 3=COMPLETED/PUBLISHED
work.setStatus(WorkPublishStatus.PUBLISHED.getValue());
work.setReviewNote(note);
work.setReviewerId(operatorId);
work.setReviewTime(LocalDateTime.now());
@ -158,7 +149,7 @@ public class PublicContentReviewService {
if (work == null) {
throw new BusinessException(404, "作品不存在");
}
work.setStatus(-1); // -1=FAILED/REJECTED
work.setStatus(WorkPublishStatus.REJECTED.getValue());
work.setReviewNote(note);
work.setReviewerId(operatorId);
work.setReviewTime(LocalDateTime.now());
@ -196,7 +187,7 @@ public class PublicContentReviewService {
if (work == null) {
throw new BusinessException(404, "作品不存在");
}
work.setStatus(1); // 1=PENDING
work.setStatus(WorkPublishStatus.UNPUBLISHED.getValue());
work.setModifyTime(LocalDateTime.now());
ugcWorkMapper.updateById(work);
createLog(id, "revoke", null, null, operatorId);
@ -211,7 +202,7 @@ public class PublicContentReviewService {
if (work == null) {
throw new BusinessException(404, "作品不存在");
}
work.setStatus(-2); // -2=TAKEN_DOWN
work.setStatus(WorkPublishStatus.UNPUBLISHED.getValue());
work.setModifyTime(LocalDateTime.now());
ugcWorkMapper.updateById(work);
createLog(id, "takedown", reason, null, operatorId);
@ -226,7 +217,7 @@ public class PublicContentReviewService {
if (work == null) {
throw new BusinessException(404, "作品不存在");
}
work.setStatus(3); // 3=COMPLETED/PUBLISHED
work.setStatus(WorkPublishStatus.PUBLISHED.getValue());
work.setModifyTime(LocalDateTime.now());
ugcWorkMapper.updateById(work);
createLog(id, "restore", null, null, operatorId);
@ -257,20 +248,18 @@ public class PublicContentReviewService {
*/
public Map<String, Object> getManagementStats() {
Map<String, Object> stats = new LinkedHashMap<>();
// 与作品管理列表默认筛选 status=published,taken_down 一致已上架/已编目/已配音 + 已下架不含草稿/待审等
stats.put("total", countPublishedStatuses() + countByStatusInt(ST_TAKEN_DOWN));
stats.put("total", countByStatus(WorkPublishStatus.PUBLISHED.getValue()) + countByStatus(WorkPublishStatus.UNPUBLISHED.getValue()));
LocalDateTime dayStart = LocalDate.now().atStartOfDay();
// 与作品管理今日新增Tab 列表一致当日创建且为已上架(3/4/5)或已下架(-2)
stats.put("todayNew", ugcWorkMapper.selectCount(
new LambdaQueryWrapper<UgcWork>()
.eq(UgcWork::getIsDeleted, 0)
.ge(UgcWork::getCreateTime, dayStart)
.in(UgcWork::getStatus, ST_COMPLETED, ST_CATALOGED, ST_DUBBED, ST_TAKEN_DOWN)));
.in(UgcWork::getStatus, WorkPublishStatus.PUBLISHED.getValue(), WorkPublishStatus.UNPUBLISHED.getValue())));
stats.put("totalViews", sumAllViewCounts());
stats.put("pendingReview", countByStatusInt(ST_PENDING));
stats.put("published", countPublishedStatuses());
stats.put("rejected", countByStatusInt(ST_REJECTED));
stats.put("takenDown", countByStatusInt(ST_TAKEN_DOWN));
stats.put("pendingReview", countByStatus(WorkPublishStatus.PENDING_REVIEW.getValue()));
stats.put("published", countByStatus(WorkPublishStatus.PUBLISHED.getValue()));
stats.put("rejected", countByStatus(WorkPublishStatus.REJECTED.getValue()));
stats.put("takenDown", countByStatus(WorkPublishStatus.UNPUBLISHED.getValue()));
stats.put("recommended", ugcWorkMapper.selectCount(
new LambdaQueryWrapper<UgcWork>()
.eq(UgcWork::getIsDeleted, 0)
@ -310,50 +299,43 @@ public class PublicContentReviewService {
/**
* 解析筛选参数单状态或逗号分隔多状态 published,taken_down
* status 字段已改为 VARCHAR直接使用字符串枚举值
*/
private List<Integer> parseStatusFilter(String status) {
private List<String> parseStatusFilter(String status) {
String[] parts = status.split(",");
return Stream.of(parts)
.map(String::trim)
.filter(StringUtils::hasText)
.flatMap(token -> resolveStatusCodes(token).stream())
.flatMap(token -> resolveStatusValues(token).stream())
.distinct()
.collect(Collectors.toList());
}
private List<Integer> resolveStatusCodes(String token) {
private List<String> resolveStatusValues(String token) {
switch (token) {
case "pending_review":
return List.of(ST_PENDING);
return List.of(WorkPublishStatus.PENDING_REVIEW.getValue());
case "published":
return List.of(ST_COMPLETED, ST_CATALOGED, ST_DUBBED);
return List.of(WorkPublishStatus.PUBLISHED.getValue());
case "rejected":
return List.of(ST_REJECTED);
return List.of(WorkPublishStatus.REJECTED.getValue());
case "taken_down":
return List.of(ST_TAKEN_DOWN);
case "unpublished":
return List.of(WorkPublishStatus.UNPUBLISHED.getValue());
case "draft":
return List.of(ST_DRAFT);
case "processing":
return List.of(ST_PROCESSING);
return List.of(WorkPublishStatus.DRAFT.getValue());
default:
return Collections.emptyList();
}
}
private long countByStatusInt(int status) {
private long countByStatus(String status) {
return ugcWorkMapper.selectCount(
new LambdaQueryWrapper<UgcWork>()
.eq(UgcWork::getIsDeleted, 0)
.eq(UgcWork::getStatus, status));
}
private long countPublishedStatuses() {
return ugcWorkMapper.selectCount(
new LambdaQueryWrapper<UgcWork>()
.eq(UgcWork::getIsDeleted, 0)
.in(UgcWork::getStatus, ST_COMPLETED, ST_CATALOGED, ST_DUBBED));
}
private long countTodayReviewLogs(LocalDateTime dayStart) {
return ugcReviewLogMapper.selectCount(
new LambdaQueryWrapper<UgcReviewLog>()
@ -381,33 +363,6 @@ public class PublicContentReviewService {
return ((Number) maps.get(0).get("sv")).longValue();
}
/**
* 将作品状态整型转为前端使用的字符串码 WorkReview / WorkManagement 一致
*/
private String mapWorkStatusToString(Integer code) {
if (code == null) {
return "draft";
}
switch (code) {
case ST_DRAFT:
return "draft";
case ST_PENDING:
return "pending_review";
case ST_PROCESSING:
return "processing";
case ST_COMPLETED:
case ST_CATALOGED:
case ST_DUBBED:
return "published";
case ST_REJECTED:
return "rejected";
case ST_TAKEN_DOWN:
return "taken_down";
default:
return "draft";
}
}
private void createLog(Long workId, String action, String reason, String note, Long operatorId) {
UgcReviewLog logEntry = new UgcReviewLog();
logEntry.setTargetType("work");
@ -427,9 +382,8 @@ public class PublicContentReviewService {
vo.put("title", work.getTitle());
vo.put("coverUrl", work.getCoverUrl());
vo.put("description", work.getDescription());
Integer statusCode = work.getStatus();
vo.put("statusCode", statusCode);
vo.put("status", mapWorkStatusToString(statusCode));
vo.put("statusCode", work.getLeaiStatus());
vo.put("status", work.getStatus());
vo.put("visibility", work.getVisibility());
vo.put("isRecommended", work.getIsRecommended());
vo.put("viewCount", work.getViewCount());

View File

@ -6,8 +6,10 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.competition.common.enums.Visibility;
import com.competition.common.exception.BusinessException;
import com.competition.common.result.PageResult;
import com.competition.modules.leai.enums.LeaiCreationStatus;
import com.competition.modules.ugc.entity.UgcWork;
import com.competition.modules.ugc.entity.UgcWorkPage;
import com.competition.modules.ugc.enums.WorkPublishStatus;
import com.competition.modules.ugc.mapper.UgcWorkMapper;
import com.competition.modules.ugc.mapper.UgcWorkPageMapper;
import lombok.RequiredArgsConstructor;
@ -33,7 +35,8 @@ public class PublicCreationService {
UgcWork work = new UgcWork();
work.setUserId(userId);
work.setTitle("未命名作品");
work.setStatus(0); // DRAFT
work.setLeaiStatus(LeaiCreationStatus.DRAFT);
work.setStatus(WorkPublishStatus.DRAFT.getValue());
work.setOriginalImageUrl(originalImageUrl);
work.setVoiceInputUrl(voiceInputUrl);
work.setTextInput(textInput);
@ -62,7 +65,7 @@ public class PublicCreationService {
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("id", work.getId());
result.put("status", work.getStatus());
result.put("status", work.getLeaiStatus());
result.put("progress", work.getProgress());
result.put("progressMessage", work.getProgressMessage());
result.put("remoteWorkId", work.getRemoteWorkId());
@ -85,7 +88,7 @@ public class PublicCreationService {
result.put("title", work.getTitle());
result.put("coverUrl", work.getCoverUrl());
result.put("description", work.getDescription());
result.put("status", work.getStatus());
result.put("status", work.getLeaiStatus());
result.put("progress", work.getProgress());
result.put("progressMessage", work.getProgressMessage());
result.put("originalImageUrl", work.getOriginalImageUrl());

View File

@ -11,6 +11,7 @@ import com.competition.modules.sys.entity.SysUser;
import com.competition.modules.sys.mapper.SysUserMapper;
import com.competition.modules.ugc.entity.UgcWork;
import com.competition.modules.ugc.entity.UgcWorkPage;
import com.competition.modules.ugc.enums.WorkPublishStatus;
import com.competition.modules.ugc.mapper.UgcWorkMapper;
import com.competition.modules.ugc.mapper.UgcWorkPageMapper;
import lombok.RequiredArgsConstructor;
@ -25,11 +26,6 @@ import java.util.*;
@RequiredArgsConstructor
public class PublicGalleryService {
/** 与 PublicContentReviewService、UgcWork.status 一致:已上架可对外的作品 */
private static final int ST_COMPLETED = 3;
private static final int ST_CATALOGED = 4;
private static final int ST_DUBBED = 5;
private final UgcWorkMapper ugcWorkMapper;
private final UgcWorkPageMapper ugcWorkPageMapper;
private final SysUserMapper sysUserMapper;
@ -43,7 +39,7 @@ public class PublicGalleryService {
public PageResult<Map<String, Object>> getGalleryList(int page, int pageSize, Long tagId,
String category, String sortBy, String keyword) {
LambdaQueryWrapper<UgcWork> wrapper = new LambdaQueryWrapper<>();
wrapper.in(UgcWork::getStatus, ST_COMPLETED, ST_CATALOGED, ST_DUBBED)
wrapper.eq(UgcWork::getStatus, WorkPublishStatus.PUBLISHED.getValue())
.eq(UgcWork::getVisibility, Visibility.PUBLIC.getValue())
.eq(UgcWork::getIsDeleted, 0);
if (tagId != null) {
@ -80,7 +76,7 @@ public class PublicGalleryService {
public List<Map<String, Object>> getRecommended() {
LambdaQueryWrapper<UgcWork> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(UgcWork::getIsRecommended, true)
.in(UgcWork::getStatus, ST_COMPLETED, ST_CATALOGED, ST_DUBBED)
.eq(UgcWork::getStatus, WorkPublishStatus.PUBLISHED.getValue())
.eq(UgcWork::getVisibility, Visibility.PUBLIC.getValue())
.eq(UgcWork::getIsDeleted, 0)
.orderByDesc(UgcWork::getPublishTime)
@ -151,7 +147,7 @@ public class PublicGalleryService {
public PageResult<Map<String, Object>> getUserPublicWorks(Long userId, int page, int pageSize) {
LambdaQueryWrapper<UgcWork> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(UgcWork::getUserId, userId)
.in(UgcWork::getStatus, ST_COMPLETED, ST_CATALOGED, ST_DUBBED)
.eq(UgcWork::getStatus, WorkPublishStatus.PUBLISHED.getValue())
.eq(UgcWork::getVisibility, Visibility.PUBLIC.getValue())
.eq(UgcWork::getIsDeleted, 0)
.orderByDesc(UgcWork::getPublishTime);

View File

@ -10,6 +10,7 @@ import com.competition.common.result.PageResult;
import com.competition.modules.ugc.entity.UgcWork;
import com.competition.modules.ugc.entity.UgcWorkPage;
import com.competition.modules.ugc.entity.UgcWorkTag;
import com.competition.modules.ugc.enums.WorkPublishStatus;
import com.competition.modules.ugc.mapper.UgcWorkMapper;
import com.competition.modules.ugc.mapper.UgcWorkPageCountRow;
import com.competition.modules.ugc.mapper.UgcWorkPageMapper;
@ -45,7 +46,7 @@ public class PublicUserWorkService {
work.setCoverUrl(coverUrl);
work.setDescription(description);
work.setVisibility(visibility != null ? visibility : Visibility.PRIVATE.getValue());
work.setStatus(0); // 0=DRAFT
work.setStatus(WorkPublishStatus.DRAFT.getValue());
work.setViewCount(0);
work.setLikeCount(0);
work.setFavoriteCount(0);
@ -184,10 +185,12 @@ public class PublicUserWorkService {
if (work == null || work.getIsDeleted() == 1 || !work.getUserId().equals(userId)) {
throw new BusinessException(404, "作品不存在或无权操作");
}
if (work.getStatus() != 0 && work.getStatus() != -1) {
String currentStatus = work.getStatus();
if (!WorkPublishStatus.UNPUBLISHED.getValue().equals(currentStatus)
&& !WorkPublishStatus.REJECTED.getValue().equals(currentStatus)) {
throw new BusinessException(400, "当前状态不可发布");
}
work.setStatus(1); // 1=PENDING
work.setStatus(WorkPublishStatus.PENDING_REVIEW.getValue());
work.setModifyTime(LocalDateTime.now());
ugcWorkMapper.updateById(work);
}

View File

@ -42,8 +42,12 @@ public class UgcWork implements Serializable {
@Schema(description = "可见范围", allowableValues = {"public", "designated", "internal", "private"})
private String visibility;
@Schema(description = "作品状态:-1=FAILED, 0=DRAFT, 1=PENDING, 2=PROCESSING, 3=COMPLETED, 4=CATALOGED, 5=DUBBED")
private Integer status;
@Schema(description = "本地发布状态: draft/unpublished/pending_review/published/rejected")
private String status;
@Schema(description = "乐读派创作进度: -1=FAILED, 0=DRAFT, 1=PENDING, 2=PROCESSING, 3=COMPLETED, 4=CATALOGED, 5=DUBBED")
@TableField("leai_status")
private Integer leaiStatus;
@Schema(description = "审核备注")
@TableField("review_note")

View File

@ -0,0 +1,31 @@
package com.competition.modules.ugc.enums;
/**
* 作品本地发布状态枚举
* <p>
* 与前端 WorkStatus 类型定义完全一致'draft' | 'unpublished' | 'pending_review' | 'published' | 'rejected'
*/
public enum WorkPublishStatus {
DRAFT("draft", "草稿"),
UNPUBLISHED("unpublished", "未发布"),
PENDING_REVIEW("pending_review", "审核中"),
PUBLISHED("published", "已发布"),
REJECTED("rejected", "被拒绝");
private final String value;
private final String label;
WorkPublishStatus(String value, String label) {
this.value = value;
this.label = label;
}
public String getValue() {
return value;
}
public String getLabel() {
return label;
}
}

View File

@ -0,0 +1,31 @@
-- V17: 拆分 UgcWork.status 字段
-- 原 status (INT) 同时承载「乐读派创作进度」和「本地发布状态」,导致前端筛选失败
-- 拆为leai_status (INT) = 乐读派创作进度status (VARCHAR) = 本地发布状态
-- 1. 新增 leai_status 字段
ALTER TABLE t_ugc_work ADD COLUMN leai_status INT NOT NULL DEFAULT 0
COMMENT '乐读派创作进度: -1=FAILED, 0=DRAFT, 1=PENDING, 2=PROCESSING, 3=COMPLETED, 4=CATALOGED, 5=DUBBED';
-- 2. 把现有 status (INT) 值复制到 leai_status
UPDATE t_ugc_work SET leai_status = status;
-- 3. 改 status 字段类型为 VARCHAR
ALTER TABLE t_ugc_work MODIFY COLUMN status VARCHAR(20) NOT NULL DEFAULT 'draft'
COMMENT '本地发布状态: draft/unpublished/pending_review/published/rejected';
-- 4. 按 leai_status 回填本地 status保守策略
UPDATE t_ugc_work SET status = CASE
WHEN leai_status = -2 THEN 'unpublished'
WHEN leai_status = -1 THEN 'draft'
WHEN leai_status = 0 THEN 'draft'
WHEN leai_status = 1 THEN 'unpublished'
WHEN leai_status = 2 THEN 'draft'
WHEN leai_status = 3 THEN 'unpublished'
WHEN leai_status = 4 THEN 'unpublished'
WHEN leai_status = 5 THEN 'unpublished'
ELSE 'draft'
END;
-- 5. 索引
CREATE INDEX idx_ugc_work_status ON t_ugc_work(status);
CREATE INDEX idx_ugc_work_leai_status ON t_ugc_work(leai_status);

View File

@ -0,0 +1,255 @@
import { test, expect, request as requestFactory, type APIRequestContext } from '@playwright/test'
/**
* UGC
*
* API localhost:8580
*/
// ── 配置 ──
const API_BASE = process.env.API_BASE_URL || 'http://localhost:8580/api'
const AUTH = {
username: process.env.TEST_USERNAME || 'demo',
password: process.env.TEST_PASSWORD || 'demo123456',
tenantCode: process.env.TEST_TENANT_CODE || 'gdlib',
}
const VALID_STATUSES = ['draft', 'unpublished', 'pending_review', 'published', 'rejected']
// ── Helper ──
function url(path: string) {
return `${API_BASE}${path}`
}
// ── Fixture ──
type Fixtures = { api: APIRequestContext }
const apiTest = test.extend<Fixtures>({
api: async ({}, use) => {
// 用 requestFactory顶层 import创建独立上下文来登录
const loginCtx = await requestFactory.newContext({})
const loginResp = await loginCtx.post(url('/public/auth/login'), {
data: { username: AUTH.username, password: AUTH.password, tenantCode: AUTH.tenantCode },
})
const loginJson = await loginResp.json()
await loginCtx.dispose()
if (loginJson.code !== 200 || !loginJson.data?.token) {
throw new Error(`登录失败: ${JSON.stringify(loginJson)}`)
}
const token = loginJson.data.token
// 创建带 auth header 的 API 上下文
const api = await requestFactory.newContext({
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
})
await use(api)
await api.dispose()
},
})
// ══════════════════════════════════════════════════════
// 1. 我的作品列表 — status 是字符串枚举
// ══════════════════════════════════════════════════════
apiTest('S-01 我的作品列表 — status 字段为字符串', async ({ api }) => {
const resp = await api.get(url('/public/works?page=1&pageSize=5'))
const json = await resp.json()
expect(json.code).toBe(200)
expect(json.data.list).toBeInstanceOf(Array)
for (const work of json.data.list) {
expect(
VALID_STATUSES.includes(work.status),
`作品 id=${work.id} 的 status="${work.status}" 不是合法字符串枚举值`,
).toBe(true)
}
console.log(`✓ S-01: 返回 ${json.data.list.length} 条作品status 全部为字符串`)
})
apiTest('S-02 我的作品列表 — 按 status=draft 筛选', async ({ api }) => {
const resp = await api.get(url('/public/works?page=1&pageSize=50&status=draft'))
const json = await resp.json()
expect(json.code).toBe(200)
for (const work of json.data.list) {
expect(work.status).toBe('draft')
}
console.log(`✓ S-02: draft 筛选返回 ${json.data.list.length}`)
})
apiTest('S-03 我的作品列表 — 按 status=unpublished 筛选', async ({ api }) => {
const resp = await api.get(url('/public/works?page=1&pageSize=50&status=unpublished'))
const json = await resp.json()
expect(json.code).toBe(200)
for (const work of json.data.list) {
expect(work.status).toBe('unpublished')
}
console.log(`✓ S-03: unpublished 筛选返回 ${json.data.list.length}`)
})
apiTest('S-04 我的作品列表 — 按 status=published 筛选', async ({ api }) => {
const resp = await api.get(url('/public/works?page=1&pageSize=50&status=published'))
const json = await resp.json()
expect(json.code).toBe(200)
for (const work of json.data.list) {
expect(work.status).toBe('published')
}
console.log(`✓ S-04: published 筛选返回 ${json.data.list.length}`)
})
apiTest('S-05 我的作品列表 — 按 status=pending_review 筛选', async ({ api }) => {
const resp = await api.get(url('/public/works?page=1&pageSize=50&status=pending_review'))
const json = await resp.json()
expect(json.code).toBe(200)
for (const work of json.data.list) {
expect(work.status).toBe('pending_review')
}
console.log(`✓ S-05: pending_review 筛选返回 ${json.data.list.length}`)
})
apiTest('S-06 我的作品列表 — 按 status=rejected 筛选', async ({ api }) => {
const resp = await api.get(url('/public/works?page=1&pageSize=50&status=rejected'))
const json = await resp.json()
expect(json.code).toBe(200)
for (const work of json.data.list) {
expect(work.status).toBe('rejected')
}
console.log(`✓ S-06: rejected 筛选返回 ${json.data.list.length}`)
})
// ══════════════════════════════════════════════════════
// 2. 创建作品 — 初始 status 为 draft
// ══════════════════════════════════════════════════════
apiTest('S-07 创建作品 — 初始 status 为 draft', async ({ api }) => {
const resp = await api.post(url('/public/works'), {
data: {
title: `[E2E] 状态验证_${Date.now()}`,
description: 'Playwright 自动创建',
visibility: 'private',
},
})
const json = await resp.json()
expect(json.code).toBe(200)
expect(json.data.status).toBe('draft')
expect(typeof json.data.status).toBe('string')
console.log(`✓ S-07: 创建作品 id=${json.data.id}, status="${json.data.status}"`)
// 清理
if (json.data.id) {
await api.delete(url(`/public/works/${json.data.id}`))
}
})
// ══════════════════════════════════════════════════════
// 3. draft 不可发布
// ══════════════════════════════════════════════════════
apiTest('S-08 发布作品 — draft 状态不可发布', async ({ api }) => {
const createResp = await api.post(url('/public/works'), {
data: { title: `[E2E] 发布测试_${Date.now()}`, visibility: 'private' },
})
const createJson = await createResp.json()
expect(createJson.code).toBe(200)
const workId = createJson.data.id
const publishResp = await api.post(url(`/public/works/${workId}/publish`))
const publishJson = await publishResp.json()
expect(publishJson.code).toBe(400)
console.log(`✓ S-08: draft 发布被拒绝code=${publishJson.code}, msg="${publishJson.message}"`)
await api.delete(url(`/public/works/${workId}`))
})
// ══════════════════════════════════════════════════════
// 4. 创作历史
// ══════════════════════════════════════════════════════
apiTest('S-09 创作历史 — 作品 status 为字符串', async ({ api }) => {
const resp = await api.get(url('/public/creation/history?page=1&pageSize=5'))
const json = await resp.json()
expect(json.code).toBe(200)
if (json.data?.list?.length > 0) {
for (const work of json.data.list) {
expect(
VALID_STATUSES.includes(work.status),
`创作历史 id=${work.id} status="${work.status}" 不合法`,
).toBe(true)
}
}
console.log(`✓ S-09: 创作历史 ${json.data?.list?.length ?? 0}`)
})
// ══════════════════════════════════════════════════════
// 5. 作品详情
// ══════════════════════════════════════════════════════
apiTest('S-10 作品详情 — status + leaiStatus 字段', async ({ api }) => {
const listResp = await api.get(url('/public/works?page=1&pageSize=1'))
const listJson = await listResp.json()
if (listJson.data?.list?.length > 0) {
const workId = listJson.data.list[0].id
const detailResp = await api.get(url(`/public/works/${workId}`))
const detailJson = await detailResp.json()
expect(detailJson.code).toBe(200)
const work = detailJson.data.work
// status 必须是合法字符串
expect(VALID_STATUSES.includes(work.status)).toBe(true)
// leaiStatus 应为数字
if (work.leaiStatus != null) {
expect(typeof work.leaiStatus).toBe('number')
}
console.log(`✓ S-10: 作品详情 status="${work.status}", leaiStatus=${work.leaiStatus}`)
} else {
console.log('⚠ S-10: 没有作品可测试详情')
}
})
// ══════════════════════════════════════════════════════
// 6. 作品广场
// ══════════════════════════════════════════════════════
apiTest('S-11 作品广场 — 只返回已发布作品', async ({ api }) => {
const resp = await api.get(url('/public/gallery?page=1&pageSize=10'))
const json = await resp.json()
expect(json.code).toBe(200)
if (json.data?.list?.length > 0) {
for (const work of json.data.list) {
expect(work.status).toBe('published')
}
}
console.log(`✓ S-11: 广场 ${json.data?.list?.length ?? 0} 条已发布作品`)
})
// ══════════════════════════════════════════════════════
// 7. 收藏列表
// ══════════════════════════════════════════════════════
apiTest('S-12 我的收藏 — status 为字符串', async ({ api }) => {
const resp = await api.get(url('/public/mine/favorites?page=1&pageSize=5'))
const json = await resp.json()
expect(json.code).toBe(200)
if (json.data?.list?.length > 0) {
for (const item of json.data.list) {
if (item.status) {
expect(VALID_STATUSES.includes(item.status)).toBe(true)
}
}
}
console.log(`✓ S-12: 收藏 ${json.data?.list?.length ?? 0}`)
})