diff --git a/lesingle-aicreate-client/src/views/EditInfo.vue b/lesingle-aicreate-client/src/views/EditInfo.vue index 9731ab3..5f5b9b4 100644 --- a/lesingle-aicreate-client/src/views/EditInfo.vue +++ b/lesingle-aicreate-client/src/views/EditInfo.vue @@ -195,15 +195,6 @@ async function handleSave() { store.workDetail = null // 清除缓存 router.push(`/dubbing/${workId.value}`) } catch (e) { - // 容错:保存报错时检查实际状态,可能已经成功但重试导致CAS失败 - try { - const check = await getWorkDetail(workId.value) - if (check?.data?.status >= 4) { - store.workDetail = null - router.push(`/dubbing/${workId.value}`) - return - } - } catch { /* ignore */ } alert(e.message || '保存失败,请重试') } finally { saving.value = false diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/leai/controller/LeaiProxyController.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/leai/controller/LeaiProxyController.java index d82bd35..11fc46e 100644 --- a/lesingle-creation-backend/src/main/java/com/lesingle/modules/leai/controller/LeaiProxyController.java +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/leai/controller/LeaiProxyController.java @@ -122,6 +122,7 @@ public class LeaiProxyController { /** * 查询作品详情 * GET /leai-proxy/work/{id} → 乐读派 /api/v1/query/work/{id} + * 合并本地 DB 元数据,确保二次编目后的修改不丢失 */ @GetMapping("/work/{id}") @Operation(summary = "查询作品详情代理") @@ -130,7 +131,10 @@ public class LeaiProxyController { params.put("orgId", leaiConfig.getOrgId()); params.put("phone", getPhoneOrThrow()); log.info("[乐读派代理] 查询作品详情, workId={}", id); - return jsonOk(leaiApiClient.proxyGet("/query/work/" + id, params)); + String responseBody = leaiApiClient.proxyGet("/query/work/" + id, params); + // 合并本地元数据(二次编目后 LeAI 可能返回旧数据,用本地 DB 覆盖) + responseBody = leaiSyncService.mergeLocalMetadata(id, responseBody); + return jsonOk(responseBody); } /** @@ -160,6 +164,7 @@ public class LeaiProxyController { body.putAll(requestBody); log.info("[乐读派代理] 编辑作品, workId={}", id); String responseBody = leaiApiClient.proxyPut("/update/work/" + id, body); + log.debug("[乐读派代理] 编辑作品响应, workId={}, response={}", id, responseBody); syncLocalAfterLeaiUpdate(id, responseBody, requestBody); return jsonOk(responseBody); } diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/leai/service/ILeaiSyncService.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/leai/service/ILeaiSyncService.java index ff744d9..25adddf 100644 --- a/lesingle-creation-backend/src/main/java/com/lesingle/modules/leai/service/ILeaiSyncService.java +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/leai/service/ILeaiSyncService.java @@ -31,4 +31,14 @@ public interface ILeaiSyncService { * @param proxyRequestBody 前端传入的 JSON(通常含 author、subtitle、intro、tags 等,不含 orgId/phone) */ void applyLocalMetadataFromProxyPut(String remoteWorkId, Map proxyRequestBody); + + /** + * 将本地 DB 中已保存的元数据(author、description)合并到 LeAI B2 响应 JSON 中, + * 避免二次编目后 LeAI 返回旧数据覆盖用户的本地编辑。 + * + * @param remoteWorkId 乐读派作品 ID + * @param leaiResponse LeAI B2 接口返回的原始 JSON 字符串 + * @return 合并本地元数据后的 JSON 字符串 + */ + String mergeLocalMetadata(String remoteWorkId, String leaiResponse); } diff --git a/lesingle-creation-backend/src/main/java/com/lesingle/modules/leai/service/LeaiSyncService.java b/lesingle-creation-backend/src/main/java/com/lesingle/modules/leai/service/LeaiSyncService.java index 8437d87..8ec8e8c 100644 --- a/lesingle-creation-backend/src/main/java/com/lesingle/modules/leai/service/LeaiSyncService.java +++ b/lesingle-creation-backend/src/main/java/com/lesingle/modules/leai/service/LeaiSyncService.java @@ -2,6 +2,8 @@ package com.lesingle.modules.leai.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.lesingle.common.enums.Visibility; import com.lesingle.modules.leai.enums.LeaiCreationStatus; import com.lesingle.modules.leai.util.LeaiUtil; @@ -35,6 +37,7 @@ public class LeaiSyncService implements ILeaiSyncService { private final UgcWorkPageMapper ugcWorkPageMapper; private final LeaiApiClient leaiApiClient; private final SysUserMapper sysUserMapper; + private final ObjectMapper objectMapper; /** * V4.0 核心同步逻辑 @@ -424,6 +427,7 @@ public class LeaiSyncService implements ILeaiSyncService { } @Override + @SuppressWarnings("unchecked") public void applyLocalMetadataFromProxyPut(String remoteWorkId, Map proxyRequestBody) { if (remoteWorkId == null || remoteWorkId.isEmpty() || proxyRequestBody == null || proxyRequestBody.isEmpty()) { return; @@ -435,6 +439,13 @@ public class LeaiSyncService implements ILeaiSyncService { LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(UgcWork::getId, work.getId()); boolean any = false; + + // 用于存储 subtitle/intro/tags 到 ai_meta,供 mergeLocalMetadata 精确读取 + Map aiMetaMap = new HashMap<>(); + if (work.getAiMeta() instanceof Map) { + aiMetaMap.putAll((Map) work.getAiMeta()); + } + if (proxyRequestBody.containsKey("author")) { wrapper.set(UgcWork::getAuthorName, LeaiUtil.toString(proxyRequestBody.get("author"), null)); any = true; @@ -460,11 +471,28 @@ public class LeaiSyncService implements ILeaiSyncService { } desc = desc.trim(); wrapper.set(UgcWork::getDescription, desc.isEmpty() ? null : desc); + // 精确存储 subtitle/intro 到 ai_meta,避免 description 反拆分丢失信息 + aiMetaMap.put("_subtitle", sub); + aiMetaMap.put("_intro", intro); any = true; } + if (proxyRequestBody.containsKey("tags")) { + Object tags = proxyRequestBody.get("tags"); + if (tags instanceof List) { + aiMetaMap.put("_tags", tags); + any = true; + } + } if (!any) { return; } + // LambdaUpdateWrapper.set() 不会自动应用 JacksonTypeHandler, + // 需要手动序列化为 JSON 字符串,否则 Map 会以 Java toString 格式存入 DB + try { + wrapper.set(UgcWork::getAiMeta, objectMapper.writeValueAsString(aiMetaMap)); + } catch (Exception e) { + log.warn("[ProxyPut] ai_meta 序列化失败,跳过 ai_meta 写入: remoteWorkId={}", remoteWorkId, e); + } wrapper.set(UgcWork::getModifyTime, LocalDateTime.now()); ugcWorkMapper.update(null, wrapper); log.info("[ProxyPut] 本地元数据已覆盖 remoteWorkId={}", remoteWorkId); @@ -490,4 +518,112 @@ public class LeaiSyncService implements ILeaiSyncService { SysUser user = sysUserMapper.selectOne(wrapper); return user != null ? user.getId() : null; } + + /** + * 将本地 DB 中已保存的元数据(author、subtitle/intro、title、tags)合并到 LeAI B2 响应 JSON 中, + * 避免二次编目后 LeAI 返回旧数据覆盖用户的本地编辑。 + *

+ * 场景:用户在 DUBBED(5) 状态下再次编辑绘本信息 → applyLocalMetadataFromProxyPut 已将新数据 + * 写入本地 DB → 但前端 loadWork() 通过 getWorkDetail 从 LeAI 拉取数据时拿到旧值。 + * 此方法在代理层将本地 DB 的元数据覆盖回 LeAI 响应,确保前端看到最新编辑。 + */ + @Override + @SuppressWarnings("unchecked") + public String mergeLocalMetadata(String remoteWorkId, String leaiResponse) { + if (remoteWorkId == null || remoteWorkId.isEmpty() || leaiResponse == null || leaiResponse.isEmpty()) { + return leaiResponse; + } + UgcWork localWork = findByRemoteWorkId(remoteWorkId); + if (localWork == null) { + return leaiResponse; + } + + try { + Map root = objectMapper.readValue(leaiResponse, new TypeReference>() {}); + // 定位实际的作品数据对象(可能在 root.data 中,也可能直接就是 root) + Map data = (Map) root.get("data"); + if (data == null) { + // 没有 data 包装层,直接用 root(说明已经是作品对象) + data = root; + } + + boolean localUpdated = false; + + // 1. 覆盖 author + if (localWork.getAuthorName() != null && !localWork.getAuthorName().isEmpty()) { + String remoteAuthor = LeaiUtil.toString(data.get("author"), null); + if (!localWork.getAuthorName().equals(remoteAuthor)) { + data.put("author", localWork.getAuthorName()); + localUpdated = true; + } + } + + // 2. 覆盖 title + if (localWork.getTitle() != null && !localWork.getTitle().isEmpty()) { + String remoteTitle = LeaiUtil.toString(data.get("title"), null); + if (!localWork.getTitle().equals(remoteTitle)) { + data.put("title", localWork.getTitle()); + localUpdated = true; + } + } + + // 3. 覆盖 subtitle/intro:优先从 ai_meta 精确读取,兜底用 description 反拆分 + String localSubtitle = null; + String localIntro = null; + if (localWork.getAiMeta() instanceof Map) { + Map metaMap = (Map) localWork.getAiMeta(); + if (metaMap.containsKey("_subtitle")) { + localSubtitle = LeaiUtil.toString(metaMap.get("_subtitle"), ""); + } + if (metaMap.containsKey("_intro")) { + localIntro = LeaiUtil.toString(metaMap.get("_intro"), ""); + } + } + // 兜底:ai_meta 中没有精确值时,从 description 反拆分 + if (localSubtitle == null && localIntro == null + && localWork.getDescription() != null && !localWork.getDescription().isEmpty()) { + String desc = localWork.getDescription(); + int newlineIdx = desc.indexOf('\n'); + if (newlineIdx >= 0) { + localSubtitle = desc.substring(0, newlineIdx); + localIntro = desc.substring(newlineIdx + 1); + } else { + localIntro = desc; + } + } + if (localSubtitle != null) { + String remoteSubtitle = LeaiUtil.toString(data.get("subtitle"), ""); + if (!localSubtitle.equals(remoteSubtitle)) { + data.put("subtitle", localSubtitle); + localUpdated = true; + } + } + if (localIntro != null) { + String remoteIntro = LeaiUtil.toString(data.get("intro"), ""); + if (!localIntro.equals(remoteIntro)) { + data.put("intro", localIntro); + localUpdated = true; + } + } + + // 4. 覆盖 tags:从 ai_meta 读取 + if (localWork.getAiMeta() instanceof Map) { + Map metaMap = (Map) localWork.getAiMeta(); + if (metaMap.containsKey("_tags") && metaMap.get("_tags") instanceof List) { + List localTags = (List) metaMap.get("_tags"); + data.put("tags", localTags); + localUpdated = true; + } + } + + if (localUpdated) { + log.info("[合并元数据] 本地元数据已覆盖LeAI响应 remoteWorkId={}", remoteWorkId); + return objectMapper.writeValueAsString(root); + } + return leaiResponse; + } catch (Exception e) { + log.warn("[合并元数据] 解析LeAI响应失败,返回原始数据: remoteWorkId={}", remoteWorkId, e); + return leaiResponse; + } + } } diff --git a/lesingle-creation-frontend/src/views/public/create/views/EditInfoView.vue b/lesingle-creation-frontend/src/views/public/create/views/EditInfoView.vue index 2fda880..ac03897 100644 --- a/lesingle-creation-frontend/src/views/public/create/views/EditInfoView.vue +++ b/lesingle-creation-frontend/src/views/public/create/views/EditInfoView.vue @@ -257,11 +257,6 @@ async function saveFormToServer() { } return true } catch (e) { - // 容错:保存报错时检查实际状态,可能已经成功但重试导致 CAS 失败 - try { - const check = await getWorkDetail(resolvedWorkIdStr()) - if (check?.status >= 4) return true - } catch { /* ignore */ } message.error(e.message || '保存失败,请重试') return false }