feat: 乐读派作品本库表单读写接口与创作壳数据流调整

- 新增 PublicLeaiWorkController:GET/PUT /public/leai-works/{remoteWorkId}/work-form

- PublicLeaiWorkFormService:编目元数据、分页快照、本库详情查询

- LeaiSyncService 抽取 applyCatalogMetadata、savePagesFromLeaiPageList 供复用

- 前端 public API:saveLeaiWorkForm、getLeaiWorkFormDetail;编目/配音/欢迎/生成页对接

- 补充 docs/design/public/ugc-work-status-redesign.md 说明

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-15 17:10:52 +08:00
parent ee9a519d57
commit 1542d0bd71
10 changed files with 372 additions and 40 deletions

View File

@ -417,3 +417,19 @@ CREATE INDEX idx_ugc_work_leai_status ON t_ugc_work(leai_status);
- `unpublished` 强调"还没发布"的暂时性,跟"已发布 published"形成对立
- 跟 Medium 的 Draft / Unlisted / Published 三态命名风格一致
- 本地化为"未发布"也是中文用户最直观的理解
## 附录 C创作壳编目/分页本库保存2026-04-15
**背景**:乐读派 `PUT /update/work` 在部分远端环境下仅允许 status 3/4 编辑元数据已配音5等场景会与「本库需保存编目/分页快照」冲突。
**约定**
- **公众端创作壳**`EditInfoView`、`DubbingView` 完成配音后)保存编目字段与分页(含各页 `audioUrl`)时,使用 **`PUT /api/public/leai-works/{remoteWorkId}/work-form`**,直写 `t_ugc_work` / `t_ugc_work_page`**不经过** `PUT /leai-proxy/work/{id}`
- **鉴权**:当前登录用户须为 `t_ugc_work.user_id` 对应作品;`remote_work_id` 与路径参数一致。
- **字段**:编目与 `updateWork` 请求体对齐(`author`、`title`、`subtitle`、`intro`、`tags`);可选 `pageList`(乐读派 `pageList` 形态:`imageUrl`、`text`、`audioUrl` 等)。配音仍通过现有 **`/leai-proxy/voice`、`/leai-proxy/batch-audio`** 等与乐读派交互,仅**本库快照**走上述接口。
- **状态**:编目写入成功且本地 `leai_status >= 3` 时,若发布状态仍为 `draft`,可升为 `unpublished``pageList` 中**每一页**均有非空 `audioUrl` 时,可将本地 `leai_status` 置为 `5`DUBBED
**与《企业同步创作数据 核心三步 V4.0》的关系**
- Webhook、`GET /leai-proxy/work/{id}`B2仍是**远端乐读派状态与 pageList** 的同步通道;本接口解决的是**本库元数据与分页快照**的权威写入,避免依赖乐读派 PUT 编目。
- 远端状态前进仍以 V4.0 规则(`remote_status > local_status` 等)为准;本接口不改变 Webhook 语义,仅补充「创作壳保存」路径。

View File

@ -1,5 +1,8 @@
package com.lesingle.modules.leai.service;
import com.lesingle.modules.ugc.entity.UgcWork;
import java.util.List;
import java.util.Map;
/**
@ -32,6 +35,19 @@ public interface ILeaiSyncService {
*/
void applyLocalMetadataFromProxyPut(String remoteWorkId, Map<String, Object> proxyRequestBody);
/**
* 将编目字段写入本地作品 {@link #applyLocalMetadataFromProxyPut} 同字段逻辑
* 调用方须已校验作品归属
*
* @return 是否至少更新了一列业务字段不含仅 modifyTime
*/
boolean applyCatalogMetadata(UgcWork work, Map<String, Object> body);
/**
* 用乐读派 pageList 形态的数据覆盖本地 t_ugc_work_page先删后插并同步首图到 cover_url
*/
void savePagesFromLeaiPageList(Long workId, List<Map<String, Object>> pageList);
/**
* 将本地 DB 中已保存的元数据authordescription合并到 LeAI B2 响应 JSON
* 避免二次编目后 LeAI 返回旧数据覆盖用户的本地编辑

View File

@ -436,30 +436,40 @@ public class LeaiSyncService implements ILeaiSyncService {
if (work == null) {
return;
}
if (applyCatalogMetadata(work, proxyRequestBody)) {
log.info("[ProxyPut] 本地元数据已覆盖 remoteWorkId={}", remoteWorkId);
}
}
@Override
@SuppressWarnings("unchecked")
public boolean applyCatalogMetadata(UgcWork work, Map<String, Object> body) {
if (work == null || body == null || body.isEmpty()) {
return false;
}
LambdaUpdateWrapper<UgcWork> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(UgcWork::getId, work.getId());
boolean any = false;
// 用于存储 subtitle/intro/tags ai_meta mergeLocalMetadata 精确读取
Map<String, Object> aiMetaMap = new HashMap<>();
if (work.getAiMeta() instanceof Map) {
aiMetaMap.putAll((Map<String, Object>) work.getAiMeta());
}
if (proxyRequestBody.containsKey("author")) {
wrapper.set(UgcWork::getAuthorName, LeaiUtil.toString(proxyRequestBody.get("author"), null));
if (body.containsKey("author")) {
wrapper.set(UgcWork::getAuthorName, LeaiUtil.toString(body.get("author"), null));
any = true;
}
if (proxyRequestBody.containsKey("title")) {
wrapper.set(UgcWork::getTitle, LeaiUtil.toString(proxyRequestBody.get("title"), null));
if (body.containsKey("title")) {
wrapper.set(UgcWork::getTitle, LeaiUtil.toString(body.get("title"), null));
any = true;
}
if (proxyRequestBody.containsKey("intro") || proxyRequestBody.containsKey("subtitle")) {
String sub = proxyRequestBody.containsKey("subtitle")
? LeaiUtil.toString(proxyRequestBody.get("subtitle"), "")
if (body.containsKey("intro") || body.containsKey("subtitle")) {
String sub = body.containsKey("subtitle")
? LeaiUtil.toString(body.get("subtitle"), "")
: "";
String intro = proxyRequestBody.containsKey("intro")
? LeaiUtil.toString(proxyRequestBody.get("intro"), "")
String intro = body.containsKey("intro")
? LeaiUtil.toString(body.get("intro"), "")
: "";
String desc;
if (sub.isEmpty()) {
@ -471,31 +481,36 @@ 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 (body.containsKey("tags")) {
Object tags = body.get("tags");
if (tags instanceof List) {
aiMetaMap.put("_tags", tags);
any = true;
}
}
if (!any) {
return;
return false;
}
// 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);
log.warn("[Catalog] ai_meta 序列化失败,跳过 ai_meta 写入: workId={}", work.getId(), e);
}
wrapper.set(UgcWork::getModifyTime, LocalDateTime.now());
ugcWorkMapper.update(null, wrapper);
log.info("[ProxyPut] 本地元数据已覆盖 remoteWorkId={}", remoteWorkId);
return true;
}
@Override
public void savePagesFromLeaiPageList(Long workId, List<Map<String, Object>> pageList) {
if (pageList == null || pageList.isEmpty()) {
return;
}
savePageList(workId, pageList);
}
/**

View File

@ -0,0 +1,40 @@
package com.lesingle.modules.pub.controller;
import com.lesingle.common.result.Result;
import com.lesingle.common.util.SecurityUtil;
import com.lesingle.modules.pub.service.PublicLeaiWorkFormService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* 公众端乐读派创作作品表单编目/分页直写本库
*/
@Tag(name = "公众端-乐读派作品表单")
@RestController
@RequestMapping("/public/leai-works")
@RequiredArgsConstructor
public class PublicLeaiWorkController {
private final PublicLeaiWorkFormService publicLeaiWorkFormService;
@GetMapping("/{remoteWorkId}/work-form")
@Operation(summary = "查询本库作品详情(编目/分页快照,不经乐读派 B2 GET")
public Result<Map<String, Object>> getWorkForm(@PathVariable String remoteWorkId) {
Long userId = SecurityUtil.getCurrentUserId();
return Result.success(publicLeaiWorkFormService.getWorkFormDetail(userId, remoteWorkId));
}
@PutMapping("/{remoteWorkId}/work-form")
@Operation(summary = "保存编目/分页快照(仅本库,不经乐读派 PUT")
public Result<Void> saveWorkForm(
@PathVariable String remoteWorkId,
@RequestBody Map<String, Object> body) {
Long userId = SecurityUtil.getCurrentUserId();
publicLeaiWorkFormService.saveWorkForm(userId, remoteWorkId, body);
return Result.success();
}
}

View File

@ -0,0 +1,201 @@
package com.lesingle.modules.pub.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.lesingle.common.exception.BusinessException;
import com.lesingle.modules.leai.enums.LeaiCreationStatus;
import com.lesingle.modules.leai.service.ILeaiSyncService;
import com.lesingle.modules.leai.util.LeaiUtil;
import com.lesingle.modules.ugc.entity.UgcWork;
import com.lesingle.modules.ugc.entity.UgcWorkPage;
import com.lesingle.modules.ugc.enums.WorkPublishStatus;
import com.lesingle.modules.ugc.mapper.UgcWorkMapper;
import com.lesingle.modules.ugc.mapper.UgcWorkPageMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 公众端创作壳编目/分页快照直写本库不经过乐读派 PUT /update/work
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PublicLeaiWorkFormService {
private final UgcWorkMapper ugcWorkMapper;
private final UgcWorkPageMapper ugcWorkPageMapper;
private final ILeaiSyncService leaiSyncService;
/**
* @param body 可含 authortitlesubtitleintrotagspageList乐读派 pageList 形态
*/
@Transactional(rollbackFor = Exception.class)
public void saveWorkForm(Long userId, String remoteWorkId, Map<String, Object> body) {
if (remoteWorkId == null || remoteWorkId.isBlank()) {
throw new BusinessException(400, "remoteWorkId 无效");
}
if (body == null || body.isEmpty()) {
throw new BusinessException(400, "请求体不能为空");
}
UgcWork work = findOwnedWork(userId, remoteWorkId);
boolean catalogTouched = false;
Map<String, Object> catalogOnly = filterCatalogKeys(body);
if (!catalogOnly.isEmpty()) {
catalogTouched = leaiSyncService.applyCatalogMetadata(work, catalogOnly);
}
if (catalogTouched) {
UgcWork fresh = ugcWorkMapper.selectById(work.getId());
if (fresh != null
&& WorkPublishStatus.DRAFT.getValue().equals(fresh.getStatus())
&& fresh.getLeaiStatus() != null
&& fresh.getLeaiStatus() >= LeaiCreationStatus.COMPLETED) {
LambdaUpdateWrapper<UgcWork> uw = new LambdaUpdateWrapper<>();
uw.eq(UgcWork::getId, work.getId())
.set(UgcWork::getStatus, WorkPublishStatus.UNPUBLISHED.getValue())
.set(UgcWork::getModifyTime, LocalDateTime.now());
ugcWorkMapper.update(null, uw);
log.info("[WorkForm] draft→unpublished workId={}, remoteWorkId={}", work.getId(), remoteWorkId);
}
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> pageList = (List<Map<String, Object>>) body.get("pageList");
if (pageList != null && !pageList.isEmpty()) {
leaiSyncService.savePagesFromLeaiPageList(work.getId(), pageList);
if (allPagesHaveNonBlankAudio(pageList)) {
LambdaUpdateWrapper<UgcWork> uw = new LambdaUpdateWrapper<>();
uw.eq(UgcWork::getId, work.getId())
.set(UgcWork::getLeaiStatus, LeaiCreationStatus.DUBBED)
.set(UgcWork::getModifyTime, LocalDateTime.now());
ugcWorkMapper.update(null, uw);
log.info("[WorkForm] leai_status→DUBBED workId={}, remoteWorkId={}", work.getId(), remoteWorkId);
}
}
}
/**
* 本库作品详情乐读派详情形态编目/分页均以本地为准不经 B2 GET
* 字段与前端创作壳对 {@code getWorkDetail} 的用法对齐workIdstatustitleauthorsubtitleintrotagspageListcoverUrl
*/
public Map<String, Object> getWorkFormDetail(Long userId, String remoteWorkId) {
UgcWork work = findOwnedWork(userId, remoteWorkId);
Map<String, Object> out = new LinkedHashMap<>();
out.put("workId", work.getRemoteWorkId());
int status = work.getLeaiStatus() != null ? work.getLeaiStatus() : LeaiCreationStatus.PENDING;
out.put("status", status);
out.put("title", work.getTitle());
out.put("author", work.getAuthorName());
out.put("coverUrl", work.getCoverUrl());
CatalogView cv = extractCatalogView(work);
out.put("subtitle", cv.subtitle != null ? cv.subtitle : "");
out.put("intro", cv.intro != null ? cv.intro : "");
out.put("tags", cv.tags);
LambdaQueryWrapper<UgcWorkPage> pq = new LambdaQueryWrapper<>();
pq.eq(UgcWorkPage::getWorkId, work.getId()).orderByAsc(UgcWorkPage::getPageNo);
List<UgcWorkPage> rows = ugcWorkPageMapper.selectList(pq);
List<Map<String, Object>> pageList = new ArrayList<>(rows.size());
for (UgcWorkPage p : rows) {
Map<String, Object> pm = new LinkedHashMap<>();
pm.put("pageNum", p.getPageNo());
pm.put("imageUrl", p.getImageUrl());
pm.put("text", p.getText());
pm.put("audioUrl", p.getAudioUrl());
pageList.add(pm);
}
out.put("pageList", pageList);
return out;
}
/** 与 {@link com.lesingle.modules.leai.service.LeaiSyncService#mergeLocalMetadata} 读取本地副标题/简介/标签规则一致 */
@SuppressWarnings("unchecked")
private CatalogView extractCatalogView(UgcWork localWork) {
String localSubtitle = null;
String localIntro = null;
List<Object> tags = new ArrayList<>();
if (localWork.getAiMeta() instanceof Map) {
Map<String, Object> metaMap = (Map<String, Object>) localWork.getAiMeta();
if (metaMap.containsKey("_subtitle")) {
localSubtitle = LeaiUtil.toString(metaMap.get("_subtitle"), "");
}
if (metaMap.containsKey("_intro")) {
localIntro = LeaiUtil.toString(metaMap.get("_intro"), "");
}
if (metaMap.containsKey("_tags") && metaMap.get("_tags") instanceof List) {
for (Object o : (List<Object>) metaMap.get("_tags")) {
tags.add(o);
}
}
}
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;
}
}
return new CatalogView(localSubtitle, localIntro, tags);
}
private record CatalogView(String subtitle, String intro, List<Object> tags) {}
private Map<String, Object> filterCatalogKeys(Map<String, Object> body) {
Map<String, Object> m = new HashMap<>();
for (String k : List.of("author", "title", "subtitle", "intro", "tags")) {
if (body.containsKey(k)) {
m.put(k, body.get(k));
}
}
return m;
}
private UgcWork findOwnedWork(Long userId, String remoteWorkId) {
LambdaQueryWrapper<UgcWork> q = new LambdaQueryWrapper<>();
q.eq(UgcWork::getRemoteWorkId, remoteWorkId)
.eq(UgcWork::getUserId, userId)
.eq(UgcWork::getIsDeleted, 0);
UgcWork work = ugcWorkMapper.selectOne(q);
if (work == null) {
throw new BusinessException(404, "作品不存在或无权操作");
}
return work;
}
/**
* 每一页均有非空白 audioUrl 时视为配音完成快照
*/
private boolean allPagesHaveNonBlankAudio(List<Map<String, Object>> pageList) {
for (Map<String, Object> p : pageList) {
if (p == null) {
return false;
}
Object au = p.get("audioUrl");
String s = au != null ? au.toString().trim() : "";
if (s.isEmpty()) {
return false;
}
}
return true;
}
}

View File

@ -598,6 +598,25 @@ export const publicUserWorksApi = {
) => publicApi.post(`/public/works/${id}/pages`, { pages }),
};
/**
* remoteWorkId/ PUT /leai-proxy/work
*/
export function saveLeaiWorkForm(
remoteWorkId: string,
body: Record<string, unknown>,
): Promise<void> {
const id = encodeURIComponent(remoteWorkId);
return publicApi.put(`/public/leai-works/${id}/work-form`, body);
}
/** 本库作品详情(与乐读派 B2 详情字段对齐),不经 GET /leai-proxy/work */
export function getLeaiWorkFormDetail(
remoteWorkId: string,
): Promise<Record<string, unknown>> {
const id = encodeURIComponent(remoteWorkId);
return publicApi.get(`/public/leai-works/${id}/work-form`);
}
// ==================== AI 创作流程 ====================
export const publicCreationApi = {

View File

@ -162,7 +162,10 @@ function friendlyStage(pct: number, msg: string): string {
function saveWorkId(id: string) {
store.workId = id
if (id) {
localStorage.setItem('le_workId', id)
const urlWorkId = new URLSearchParams(window.location.search).get('workId');
if (!urlWorkId) {
localStorage.setItem('le_workId', id)
}
} else {
localStorage.removeItem('le_workId')
}
@ -380,17 +383,15 @@ onMounted(() => {
// workId
const urlWorkId = new URLSearchParams(window.location.search).get('workId')
console.log('store.workId', urlWorkId, window.location.search)
if (urlWorkId) {
saveWorkId(urlWorkId)
} else {
if (!urlWorkId) {
restoreWorkId()
}
try {
getWorkDetailApi(store.workId)
} catch (error) {
console.log('error', error);
}
if (store.workId) {
try {
getWorkDetailApi(store.workId)
} catch (error) {
console.log('error', error);
}
submitted = true
progress.value = 0
stage.value = '正在查询创作进度…'

View File

@ -166,7 +166,8 @@ import {
ArrowRightOutlined,
} from '@ant-design/icons-vue'
import PageHeader from '@/components/aicreate/PageHeader.vue'
import { getWorkDetail, voicePage, ossUpload, batchUpdateAudio, finishDubbing } from '@/api/aicreate'
import { voicePage, ossUpload, batchUpdateAudio, finishDubbing } from '@/api/aicreate'
import { getLeaiWorkFormDetail, saveLeaiWorkForm } from '@/api/public'
import { useAicreateStore } from '@/stores/aicreate'
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
@ -449,6 +450,26 @@ async function voiceAllConfirm() {
}
}
/** 乐读派分页形态 + 可选编目字段,写入本库 t_ugc_work / t_ugc_work_page */
function buildWorkFormPayload() {
const pageList = pages.value.map(p => ({
pageNum: p.pageNum,
imageUrl: p.imageUrl,
text: p.text,
audioUrl: p.audioUrl || undefined,
}))
const wd = store.workDetail
const body = { pageList } as Record<string, unknown>
if (wd) {
if (wd.author) body.author = wd.author
if (wd.subtitle != null && wd.subtitle !== '') body.subtitle = wd.subtitle
if (wd.intro != null && wd.intro !== '') body.intro = wd.intro
if (Array.isArray(wd.tags) && wd.tags.length) body.tags = [...wd.tags]
if (wd.title) body.title = wd.title
}
return body
}
// --- ---
async function finish() {
submitting.value = true
@ -473,6 +494,7 @@ async function finish() {
await finishDubbing(workId.value)
}
await saveLeaiWorkForm(String(workId.value || ''), buildWorkFormPayload())
store.workDetail = null
showToast('配音完成')
setTimeout(
@ -485,8 +507,13 @@ async function finish() {
)
} catch (e: any) {
try {
const check = await getWorkDetail(workId.value)
const check = await getLeaiWorkFormDetail(String(workId.value || ''))
if (check?.status >= 5) {
try {
await saveLeaiWorkForm(String(workId.value || ''), buildWorkFormPayload())
} catch {
/* 本库无记录时忽略 */
}
store.workDetail = null
showToast('配音已完成')
setTimeout(
@ -512,7 +539,7 @@ async function loadWork() {
try {
if (!store.workDetail || store.workDetail.workId !== workId.value) {
store.workDetail = null
const res = await getWorkDetail(workId.value)
const res = await getLeaiWorkFormDetail(String(workId.value || ''))
store.workDetail = res
}
const w = store.workDetail

View File

@ -131,8 +131,7 @@ import {
} from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import PageHeader from '@/components/aicreate/PageHeader.vue'
import { getWorkDetail, updateWork } from '@/api/aicreate'
import { publicUserWorksApi } from '@/api/public'
import { getLeaiWorkFormDetail, publicUserWorksApi, saveLeaiWorkForm } from '@/api/public'
import { useAicreateStore } from '@/stores/aicreate'
import { clearExtractDraft } from '@/utils/aicreate/extractDraft'
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
@ -198,7 +197,7 @@ async function loadWork() {
try {
// keep-alive store
store.workDetail = null
const res = await getWorkDetail(id)
const res = await getLeaiWorkFormDetail(id)
store.workDetail = res
const w = store.workDetail
@ -212,7 +211,7 @@ async function loadWork() {
form.value.subtitle = w.subtitle || ''
form.value.intro = w.intro || ''
selectedTags.value = Array.isArray(w.tags) && w.tags.length ? [...w.tags] : ['冒险']
coverUrl.value = w.pageList?.[0]?.imageUrl || ''
coverUrl.value = w.coverUrl || w.pageList?.[0]?.imageUrl || ''
} catch (e) {
// fallback: proceed with empty form
} finally {
@ -247,7 +246,7 @@ async function saveFormToServer() {
data.subtitle = form.value.subtitle.trim()
data.intro = form.value.intro.trim()
await updateWork(id, data)
await saveLeaiWorkForm(id, data)
if (store.workDetail) {
store.workDetail.author = data.author
@ -361,6 +360,7 @@ watch(
)
onActivated(() => {
store.reset();
clearExtractDraft()
loadWork()
nextTick(() => { if (tagInput.value) tagInput.value.focus() })

View File

@ -207,10 +207,8 @@ async function runWelcomeEntry() {
return
}
// 3) le_workId
const storedWid = localStorage.getItem('le_workId')
if (storedWid && store.sessionToken) {
const ok = await resumeLeaiWorkFromApi(storedWid, router, store)
if (store.sessionToken) {
const ok = await resumeLeaiWorkFromApi(store.workId, router, store)
if (ok) return
}
@ -225,7 +223,6 @@ async function runWelcomeEntry() {
store.selectedStyle = ''
store.workId = ''
store.workDetail = null
localStorage.removeItem('le_workId')
router.replace('/p/create/characters')
}
} finally {