feat: 编目与配音页 work-form 整包提交(详情快照+字段覆盖)

docs: 新增乐读派作品表单 PUT/GET 外部对接说明
Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-16 09:49:49 +08:00
parent 9ed641c6e3
commit b2ae6653d5
3 changed files with 265 additions and 79 deletions

View File

@ -0,0 +1,169 @@
# 乐读派作品表单本库快照API
面向外部系统与本平台前端「创作壳」对齐:**不经过乐读派 B2 的 GET/PUT 作品详情**,直接读写本库 `t_ugc_work` / `t_ugc_work_page` 中的编目与分页快照。
| 项 | 说明 |
|----|------|
| Base URL | 部署域名 + **context-path `/api`**(见 `application.yml` `server.servlet.context-path` |
| 完整路径前缀 | `/api/public/leai-works` |
| 认证 | **需要登录**。请求头携带 `Authorization: Bearer <JWT>`;与 Web 端一致时可加 `X-Tenant-Code`、`X-Tenant-Id`(见前端 `request.ts` |
| 安全说明 | 该路径**不在** Spring Security 匿名白名单中,未带有效 Token 将返回 **401** |
---
## 1. 保存编目 / 分页快照
### 请求
| 项 | 值 |
|----|-----|
| Method | **PUT** |
| URL | `/api/public/leai-works/{remoteWorkId}/work-form` |
| Content-Type | `application/json` |
### 路径参数
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `remoteWorkId` | string | 是 | 乐读派侧作品 ID可能为大整数**建议按字符串传输**;路径中需 URL 编码) |
**示例**
```http
PUT /api/public/leai-works/2044362486899150848/work-form HTTP/1.1
Host: <your-host>
Authorization: Bearer <access_token>
Content-Type: application/json
{
"title": "绘本标题",
"author": "作者署名",
"subtitle": "副标题",
"intro": "简介",
"tags": ["冒险", "成长"],
"pageList": [
{
"pageNum": 1,
"imageUrl": "https://...",
"text": "本页文案",
"audioUrl": "https://..."
}
]
}
```
### 请求体JSON 对象)
- **不能为空**`{}` 会返回 **400**`请求体不能为空`)。
- 服务端从 body 中读取两类数据:
1. **编目字段**(可选子集):仅处理以下键,其余顶层键若存在会被忽略(除 `pageList` 外):
`author`、`title`、`subtitle`、`intro`、`tags`
2. **分页**`pageList` 为数组时,按乐读派 `pageList` 形态写入本库分页表。
| 字段 | 类型 | 说明 |
|------|------|------|
| `title` | string | 标题 |
| `author` | string | 作者署名 |
| `subtitle` | string | 副标题 |
| `intro` | string | 简介 |
| `tags` | string[] | 标签列表 |
| `pageList` | object[] | 分页列表;元素字段见下表 |
`pageList` 元素:
| 字段 | 类型 | 说明 |
|------|------|------|
| `pageNum` | number | 页码(与后端 `page_no` 对应) |
| `imageUrl` | string | 插图 URL |
| `text` | string | 本页文案 |
| `audioUrl` | string | 配音 URL可为空字符串表示未配 |
### 业务规则(摘要)
- 作品必须属于**当前登录用户**且 `remote_work_id` 匹配,否则 **404**`作品不存在或无权操作`)。
- 若本次请求更新了编目,且作品为草稿、且乐读派进度已 ≥「生成完成」,可能将发布状态从草稿置为未发布(见 `PublicLeaiWorkFormService`)。
- 若 `pageList` 非空,且**每一页**的 `audioUrl` 均为非空白字符串,则将 `leai_status` 更新为 **已配音5**
### 成功响应
HTTP **200**Body 为统一包装:
```json
{
"code": 200,
"message": "success",
"data": null
}
```
`data` 可能为 `null`,以实际返回为准。)
### 错误响应
| HTTP | codebody | 典型 message |
|------|----------------|--------------|
| 400 | 400 | `remoteWorkId 无效` / `请求体不能为空` |
| 401 | 401 | 未认证或 Token 无效 |
| 404 | 404 | `作品不存在或无权操作` |
错误体结构与项目统一 `Result` 一致(含 `message` 等字段)。
---
## 2. 查询本库作品详情(建议与 PUT 配合:先 GET 再改后 PUT
便于外部系统做「读-改-写」合并,避免只提交部分字段时覆盖丢失其它字段。
| 项 | 值 |
|----|-----|
| Method | **GET** |
| URL | `/api/public/leai-works/{remoteWorkId}/work-form` |
| 认证 | 同 PUT**Bearer JWT** |
### 成功响应 `data` 结构(对象)
| 字段 | 类型 | 说明 |
|------|------|------|
| `workId` | string | 与路径中 `remoteWorkId` 一致(乐读派 ID |
| `status` | number | 乐读派创作进度 `leai_status`,见下表 |
| `title` | string | 标题 |
| `author` | string | 作者 |
| `coverUrl` | string | 封面 URL |
| `subtitle` | string | 副标题 |
| `intro` | string | 简介 |
| `tags` | array | 标签 |
| `pageList` | object[] | 分页;元素含 `pageNum`、`imageUrl`、`text`、`audioUrl` |
### `status``leai_status`)取值参考
| 值 | 含义 |
|----|------|
| -1 | 失败 |
| 0 | 草稿 |
| 1 | 待处理 |
| 2 | 处理中 |
| 3 | 生成完成 |
| 4 | 已编目 |
| 5 | 已配音 |
(定义见后端 `LeaiCreationStatus`。)
---
## 3. 对接建议
1. **大整数 ID**`remoteWorkId` 在部分语言中超出 IEEE754 安全整数,**全程使用字符串**。
2. **合并提交**:若只改编目,可先 **GET** 全量,在内存中覆盖 `author`/`subtitle`/`intro`/`tags`/`title` 等字段后再 **PUT** 全量,与当前前端「快照 + 覆盖」策略一致,避免清空 `pageList`
3. **仅改分页**:可只带 `pageList`(及必须的非空 body编目键可选。
4. **Swagger**:若服务开启 Knife4j可在 `/doc.html` 查看 **公众端-乐读派作品表单** 分组(以部署为准)。
---
## 4. 实现位置(源码索引)
| 说明 | 路径 |
|------|------|
| Controller | `lesingle-creation-backend/.../pub/controller/PublicLeaiWorkController.java` |
| 业务 | `lesingle-creation-backend/.../pub/service/PublicLeaiWorkFormService.java` |
文档版本与接口行为以服务端代码为准;若行为变更请同步更新本文档。

View File

@ -146,18 +146,28 @@ import {
ArrowRightOutlined, ArrowRightOutlined,
} from '@ant-design/icons-vue' } from '@ant-design/icons-vue'
import PageHeader from '@/components/aicreate/PageHeader.vue' import PageHeader from '@/components/aicreate/PageHeader.vue'
import { voicePage, ossUpload, batchUpdateAudio, finishDubbing } from '@/api/aicreate' import { voicePage, ossUpload } from '@/api/aicreate'
import { getLeaiWorkFormDetail, saveLeaiWorkForm } from '@/api/public' import { getLeaiWorkFormDetail, saveLeaiWorkForm } from '@/api/public'
import { useAicreateStore } from '@/stores/aicreate'
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const store = useAicreateStore() const workId = computed(() => route.params.workId)
const workId = computed(() => route.params.workId || store.workId)
const loading = ref(true) const loading = ref(true)
const submitting = ref(false) const submitting = ref(false)
/** getLeaiWorkFormDetail 完整快照;提交时在快照上覆盖当前页的 pageList及配音结果整包提交避免丢失编目等字段 */
const workFormSnapshot = ref<Record<string, unknown> | null>(null)
function cloneWorkFormSnapshot(raw: Record<string, unknown> | null) {
if (raw == null) return null
try {
return JSON.parse(JSON.stringify(raw)) as Record<string, unknown>
} catch {
return null
}
}
const pages = ref<any[]>([]) const pages = ref<any[]>([])
const idx = ref(0) const idx = ref(0)
const toast = ref('') const toast = ref('')
@ -430,23 +440,22 @@ async function voiceAllConfirm() {
} }
} }
/** 乐读派分页形态 + 可选编目字段,写入本库 t_ugc_work / t_ugc_work_page */ /** 详情快照 + 当前分页/配音,整包 PUT避免只传 pageList 时清空编目等字段 */
function buildWorkFormPayload() { function buildWorkFormPayload() {
const pageList = pages.value.map(p => ({ const base = workFormSnapshot.value
if (!base) {
throw new Error('作品详情未加载')
}
const body = cloneWorkFormSnapshot(base)
if (!body) {
throw new Error('数据异常')
}
body.pageList = pages.value.map(p => ({
pageNum: p.pageNum, pageNum: p.pageNum,
imageUrl: p.imageUrl, imageUrl: p.imageUrl,
text: p.text, text: p.text,
audioUrl: p.audioUrl || undefined, 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 return body
} }
@ -457,25 +466,23 @@ async function finish() {
const pendingLocal = pages.value.filter(p => p.localBlob) const pendingLocal = pages.value.filter(p => p.localBlob)
if (pendingLocal.length > 0) { if (pendingLocal.length > 0) {
const audioPages = []
for (let i = 0; i < pendingLocal.length; i++) { for (let i = 0; i < pendingLocal.length; i++) {
const p = pendingLocal[i] const p = pendingLocal[i]
showToast(`上传录音 ${i + 1}/${pendingLocal.length}`) showToast(`上传录音 ${i + 1}/${pendingLocal.length}`)
const ext = p.localBlob.type?.includes('webm') ? 'webm' : 'm4a' const ext = p.localBlob.type?.includes('webm') ? 'webm' : 'm4a'
const ossUrl = await ossUpload(p.localBlob, { type: 'aud', ext }) const ossUrl = await ossUpload(p.localBlob, { type: 'aud', ext })
audioPages.push({ pageNum: p.pageNum, audioUrl: ossUrl })
p.audioUrl = ossUrl p.audioUrl = ossUrl
p.localBlob = null p.localBlob = null
} }
showToast('保存配音…') showToast('保存配音…')
await batchUpdateAudio(workId.value, audioPages)
} else { } else {
showToast('完成配音…') showToast('完成配音…')
await finishDubbing(workId.value)
} }
await saveLeaiWorkForm(String(workId.value || ''), buildWorkFormPayload()) // PUT /public/leai-works/{remoteWorkId}/work-form POST /leai-proxy/batch-audio
store.workDetail = null const payload = buildWorkFormPayload()
await saveLeaiWorkForm(String(workId.value || ''), payload)
workFormSnapshot.value = cloneWorkFormSnapshot(payload)
showToast('配音完成') showToast('配音完成')
setTimeout( setTimeout(
() => () =>
@ -489,12 +496,14 @@ async function finish() {
try { try {
const check = await getLeaiWorkFormDetail(String(workId.value || '')) const check = await getLeaiWorkFormDetail(String(workId.value || ''))
if (check?.status >= 5) { if (check?.status >= 5) {
workFormSnapshot.value = cloneWorkFormSnapshot(check as Record<string, unknown>)
try { try {
await saveLeaiWorkForm(String(workId.value || ''), buildWorkFormPayload()) const retryPayload = buildWorkFormPayload()
await saveLeaiWorkForm(String(workId.value || ''), retryPayload)
workFormSnapshot.value = cloneWorkFormSnapshot(retryPayload)
} catch { } catch {
/* 本库无记录时忽略 */ /* 本库无记录时忽略 */
} }
store.workDetail = null
showToast('配音已完成') showToast('配音已完成')
setTimeout( setTimeout(
() => () =>
@ -515,23 +524,11 @@ async function finish() {
// --- Load --- // --- Load ---
async function loadWork() { async function loadWork() {
store.reset();
loading.value = true loading.value = true
try { try {
if (!store.workDetail || store.workDetail.workId !== workId.value) {
store.workDetail = null
const res = await getLeaiWorkFormDetail(String(workId.value || '')) const res = await getLeaiWorkFormDetail(String(workId.value || ''))
store.workDetail = res workFormSnapshot.value = cloneWorkFormSnapshot(res as Record<string, unknown>)
} const w = res as any
const w = store.workDetail
// (CATALOGED) COMPLETED DUBBED
// getRouteByStatus(DUBBEDEditInfo)
if (w.status < STATUS.CATALOGED) {
const wid = String(w.workId ?? workId.value ?? '')
const nextRoute = getRouteByStatus(w.status, wid)
if (nextRoute) { router.replace(nextRoute); return }
}
pages.value = (w.pageList || []).map((p: any) => ({ pages.value = (w.pageList || []).map((p: any) => ({
pageNum: p.pageNum, pageNum: p.pageNum,
@ -541,7 +538,9 @@ async function loadWork() {
localBlob: null, localBlob: null,
isAiVoice: p.audioUrl ? true : null, isAiVoice: p.audioUrl ? true : null,
})) }))
} catch { /* fallback */ } } catch {
workFormSnapshot.value = null
}
loading.value = false loading.value = false
} }

View File

@ -14,7 +14,7 @@ export default { name: 'EditInfoView' }
<!-- 封面预览 --> <!-- 封面预览 -->
<div class="cover-preview" v-if="coverUrl"> <div class="cover-preview" v-if="coverUrl">
<img :src="coverUrl" class="cover-img" /> <img :src="coverUrl" class="cover-img" />
<div class="cover-title-overlay">{{ store.workDetail?.title || '未命名' }}</div> <div class="cover-title-overlay">{{ catalogMeta.title || '未命名' }}</div>
</div> </div>
<!-- 基本信息 --> <!-- 基本信息 -->
@ -132,14 +132,10 @@ import {
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import PageHeader from '@/components/aicreate/PageHeader.vue' import PageHeader from '@/components/aicreate/PageHeader.vue'
import { getLeaiWorkFormDetail, publicUserWorksApi, saveLeaiWorkForm } 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'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const store = useAicreateStore() const workId = computed(() => route.params.workId)
const workId = computed(() => route.params.workId || store.workId)
/** 乐读派 workId 可能超过 JS 安全整数,请求与比较一律用路由字符串 */ /** 乐读派 workId 可能超过 JS 安全整数,请求与比较一律用路由字符串 */
function resolvedWorkIdStr() { function resolvedWorkIdStr() {
@ -152,12 +148,28 @@ const loading = ref(true)
const processing = ref(false) const processing = ref(false)
const coverUrl = ref('') const coverUrl = ref('')
/** 只读:详情接口中的非表单项(封面标题、流程状态),不参与 v-model */
const catalogMeta = ref({ title: '', status: 0 })
/** 仅编辑:作者 / 副标题 / 简介 / 标签(与 saveLeaiWorkForm 编目字段一致) */
const form = ref({ author: '', subtitle: '', intro: '' }) const form = ref({ author: '', subtitle: '', intro: '' })
const selectedTags = ref([]) const selectedTags = ref([])
const addingTag = ref(false) const addingTag = ref(false)
const newTag = ref('') const newTag = ref('')
const tagInput = ref(null) const tagInput = ref(null)
/** getLeaiWorkFormDetail 返回的完整快照(深拷贝),保存时用表单同名字段覆盖后再整包提交,避免丢失 pageList 等未编辑字段 */
const workFormSnapshot = ref(null)
function cloneWorkFormSnapshot(raw) {
if (raw == null) return null
try {
return JSON.parse(JSON.stringify(raw))
} catch {
return null
}
}
const PRESET_TAGS = ['冒险', '成长', '友谊', '魔法', '勇敢', '快乐', '温暖', '探索', '梦想', '好奇'] const PRESET_TAGS = ['冒险', '成长', '友谊', '魔法', '勇敢', '快乐', '温暖', '探索', '梦想', '好奇']
const availablePresets = computed(() => const availablePresets = computed(() =>
PRESET_TAGS.filter(t => !selectedTags.value.includes(t)) PRESET_TAGS.filter(t => !selectedTags.value.includes(t))
@ -187,6 +199,21 @@ function confirmAddTag() {
addingTag.value = false addingTag.value = false
} }
/** 将详情接口数据灌入表单与只读展示(本页不持有整份 workDetail仅映射所需字段 */
function applyDetailToForm(w) {
catalogMeta.value = {
title: w.title || '',
status: Number(w.status) || 0,
}
form.value = {
author: w.author || '',
subtitle: w.subtitle || '',
intro: w.intro || '',
}
selectedTags.value = Array.isArray(w.tags) && w.tags.length ? [...w.tags] : ['冒险']
coverUrl.value = w.coverUrl || w.pageList?.[0]?.imageUrl || ''
}
async function loadWork() { async function loadWork() {
const id = resolvedWorkIdStr() const id = resolvedWorkIdStr()
if (!id) { if (!id) {
@ -195,24 +222,11 @@ async function loadWork() {
} }
loading.value = true loading.value = true
try { try {
// keep-alive store
store.workDetail = null
const res = await getLeaiWorkFormDetail(id) const res = await getLeaiWorkFormDetail(id)
store.workDetail = res workFormSnapshot.value = cloneWorkFormSnapshot(res)
const w = store.workDetail applyDetailToForm(res)
// DUBBED/ status
if (w.status > STATUS.DUBBED) {
const nextRoute = getRouteByStatus(w.status, id)
if (nextRoute) { router.replace(nextRoute); return }
}
form.value.author = w.author || ''
form.value.subtitle = w.subtitle || ''
form.value.intro = w.intro || ''
selectedTags.value = Array.isArray(w.tags) && w.tags.length ? [...w.tags] : ['冒险']
coverUrl.value = w.coverUrl || w.pageList?.[0]?.imageUrl || ''
} catch (e) { } catch (e) {
workFormSnapshot.value = null
// fallback: proceed with empty form // fallback: proceed with empty form
} finally { } finally {
loading.value = false loading.value = false
@ -241,19 +255,28 @@ async function saveFormToServer() {
message.error('作品 ID 无效') message.error('作品 ID 无效')
return false return false
} }
const data = { tags: selectedTags.value } const base = workFormSnapshot.value
data.author = form.value.author.trim() if (!base || typeof base !== 'object') {
data.subtitle = form.value.subtitle.trim() message.error('作品详情未加载,请刷新后重试')
data.intro = form.value.intro.trim() return false
await saveLeaiWorkForm(id, data)
if (store.workDetail) {
store.workDetail.author = data.author
store.workDetail.subtitle = data.subtitle
store.workDetail.intro = data.intro
store.workDetail.tags = [...selectedTags.value]
} }
const payload = cloneWorkFormSnapshot(base)
if (!payload) {
message.error('数据异常,请刷新后重试')
return false
}
payload.author = form.value.author.trim()
payload.subtitle = form.value.subtitle.trim()
payload.intro = form.value.intro.trim()
payload.tags = [...selectedTags.value]
await saveLeaiWorkForm(id, payload)
workFormSnapshot.value = cloneWorkFormSnapshot(payload)
form.value.author = payload.author
form.value.subtitle = payload.subtitle
form.value.intro = payload.intro
selectedTags.value = Array.isArray(payload.tags) ? [...payload.tags] : [...selectedTags.value]
return true return true
} catch (e) { } catch (e) {
message.error(e.message || '保存失败,请重试') message.error(e.message || '保存失败,请重试')
@ -267,7 +290,6 @@ async function handleSave() {
processing.value = true processing.value = true
try { try {
if (await saveFormToServer()) { if (await saveFormToServer()) {
store.workDetail = null
router.push({ router.push({
name: 'PublicCreateSaveSuccess', name: 'PublicCreateSaveSuccess',
params: { workId: resolvedWorkIdStr() }, params: { workId: resolvedWorkIdStr() },
@ -284,7 +306,6 @@ async function handleGoDubbing() {
processing.value = true processing.value = true
try { try {
if (await saveFormToServer()) { if (await saveFormToServer()) {
store.workDetail = null
router.push(`/p/create/dubbing/${resolvedWorkIdStr()}`) router.push(`/p/create/dubbing/${resolvedWorkIdStr()}`)
} }
} finally { } finally {
@ -336,7 +357,6 @@ async function handlePublish() {
} }
} }
store.workDetail = null
message.success('提交成功,作品已进入审核,可在作品库「审核中」查看进度') message.success('提交成功,作品已进入审核,可在作品库「审核中」查看进度')
router.push({ router.push({
name: 'PublicCreateSaveSuccess', name: 'PublicCreateSaveSuccess',
@ -360,8 +380,6 @@ watch(
) )
onActivated(() => { onActivated(() => {
store.reset();
clearExtractDraft()
loadWork() loadWork()
nextTick(() => { if (tagInput.value) tagInput.value.focus() }) nextTick(() => { if (tagInput.value) tagInput.value.focus() })
}) })