diff --git a/backend-java/src/main/java/com/competition/modules/leai/enums/LeaiCreationStatus.java b/backend-java/src/main/java/com/competition/modules/leai/enums/LeaiCreationStatus.java new file mode 100644 index 0000000..ca5b782 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/leai/enums/LeaiCreationStatus.java @@ -0,0 +1,19 @@ +package com.competition.modules.leai.enums; + +/** + * 乐读派 AI 创作进度常量 + *

+ * 对应 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() {} +} diff --git a/backend-java/src/main/java/com/competition/modules/leai/service/LeaiApiClient.java b/backend-java/src/main/java/com/competition/modules/leai/service/LeaiApiClient.java index ed72484..c06ca00 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/service/LeaiApiClient.java +++ b/backend-java/src/main/java/com/competition/modules/leai/service/LeaiApiClient.java @@ -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 diff --git a/backend-java/src/main/java/com/competition/modules/leai/service/LeaiSyncService.java b/backend-java/src/main/java/com/competition/modules/leai/service/LeaiSyncService.java index 3876aaf..f0a83cd 100644 --- a/backend-java/src/main/java/com/competition/modules/leai/service/LeaiSyncService.java +++ b/backend-java/src/main/java/com/competition/modules/leai/service/LeaiSyncService.java @@ -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 remoteData) { LambdaUpdateWrapper 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 remoteData) { LambdaUpdateWrapper 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 remoteData, int remoteStatus) { - // CAS 乐观锁:确保并发安全,只有当前 status < remoteStatus 时才更新 + // CAS 乐观锁:确保并发安全,只有当前 leaiStatus < remoteStatus 时才更新 LambdaUpdateWrapper 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")); } } diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/PublicContentReviewService.java b/backend-java/src/main/java/com/competition/modules/pub/service/PublicContentReviewService.java index 3037c1d..340c6a1 100644 --- a/backend-java/src/main/java/com/competition/modules/pub/service/PublicContentReviewService.java +++ b/backend-java/src/main/java/com/competition/modules/pub/service/PublicContentReviewService.java @@ -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 getStats() { Map 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 wrapper = new LambdaQueryWrapper<>(); wrapper.eq(UgcWork::getIsDeleted, 0); if (StringUtils.hasText(status)) { - List codes = parseStatusFilter(status); - if (codes.isEmpty()) { - wrapper.eq(UgcWork::getStatus, Integer.MIN_VALUE); + List 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 getManagementStats() { Map 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() .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() .eq(UgcWork::getIsDeleted, 0) @@ -310,50 +299,43 @@ public class PublicContentReviewService { /** * 解析筛选参数:单状态或逗号分隔多状态(如 published,taken_down) + * status 字段已改为 VARCHAR,直接使用字符串枚举值 */ - private List parseStatusFilter(String status) { + private List 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 resolveStatusCodes(String token) { + private List 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() .eq(UgcWork::getIsDeleted, 0) .eq(UgcWork::getStatus, status)); } - private long countPublishedStatuses() { - return ugcWorkMapper.selectCount( - new LambdaQueryWrapper() - .eq(UgcWork::getIsDeleted, 0) - .in(UgcWork::getStatus, ST_COMPLETED, ST_CATALOGED, ST_DUBBED)); - } - private long countTodayReviewLogs(LocalDateTime dayStart) { return ugcReviewLogMapper.selectCount( new LambdaQueryWrapper() @@ -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()); diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/PublicCreationService.java b/backend-java/src/main/java/com/competition/modules/pub/service/PublicCreationService.java index 153b823..8d4fc94 100644 --- a/backend-java/src/main/java/com/competition/modules/pub/service/PublicCreationService.java +++ b/backend-java/src/main/java/com/competition/modules/pub/service/PublicCreationService.java @@ -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 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()); diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/PublicGalleryService.java b/backend-java/src/main/java/com/competition/modules/pub/service/PublicGalleryService.java index 2fb0d10..a6d9f6f 100644 --- a/backend-java/src/main/java/com/competition/modules/pub/service/PublicGalleryService.java +++ b/backend-java/src/main/java/com/competition/modules/pub/service/PublicGalleryService.java @@ -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> getGalleryList(int page, int pageSize, Long tagId, String category, String sortBy, String keyword) { LambdaQueryWrapper 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> getRecommended() { LambdaQueryWrapper 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> getUserPublicWorks(Long userId, int page, int pageSize) { LambdaQueryWrapper 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); diff --git a/backend-java/src/main/java/com/competition/modules/pub/service/PublicUserWorkService.java b/backend-java/src/main/java/com/competition/modules/pub/service/PublicUserWorkService.java index 843b7d1..55539d8 100644 --- a/backend-java/src/main/java/com/competition/modules/pub/service/PublicUserWorkService.java +++ b/backend-java/src/main/java/com/competition/modules/pub/service/PublicUserWorkService.java @@ -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); } diff --git a/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWork.java b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWork.java index 7df5643..c9b1f23 100644 --- a/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWork.java +++ b/backend-java/src/main/java/com/competition/modules/ugc/entity/UgcWork.java @@ -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") diff --git a/backend-java/src/main/java/com/competition/modules/ugc/enums/WorkPublishStatus.java b/backend-java/src/main/java/com/competition/modules/ugc/enums/WorkPublishStatus.java new file mode 100644 index 0000000..fe26bc8 --- /dev/null +++ b/backend-java/src/main/java/com/competition/modules/ugc/enums/WorkPublishStatus.java @@ -0,0 +1,31 @@ +package com.competition.modules.ugc.enums; + +/** + * 作品本地发布状态枚举 + *

+ * 与前端 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; + } +} diff --git a/backend-java/src/main/resources/db/migration/V17__split_work_status.sql b/backend-java/src/main/resources/db/migration/V17__split_work_status.sql new file mode 100644 index 0000000..4a051c9 --- /dev/null +++ b/backend-java/src/main/resources/db/migration/V17__split_work_status.sql @@ -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); diff --git a/frontend/e2e/public/work-status-split.spec.ts b/frontend/e2e/public/work-status-split.spec.ts new file mode 100644 index 0000000..be1e346 --- /dev/null +++ b/frontend/e2e/public/work-status-split.spec.ts @@ -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({ + 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} 条`) +})