fix: 乐读派作品元数据本地同步与创作流程路由修复

- 后端:PUT 代理成功码兼容 0/200;syncWork 在状态未变时同步元数据;请求体覆盖本地 t_ugc_work
- 前端:EditInfo 强制拉详情、workId 字符串化、副标题/简介完整提交
- 评委管理:联系方式增加手机号校验
- Dubbing:仅未编目完成时按状态跳转,避免已配音作品从编辑页进配音被立即打回

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-15 10:51:25 +08:00
parent b11cb4b9d7
commit eff55b6f7b
6 changed files with 255 additions and 171 deletions

View File

@ -160,26 +160,28 @@ public class LeaiProxyController {
body.putAll(requestBody);
log.info("[乐读派代理] 编辑作品, workId={}", id);
String responseBody = leaiApiClient.proxyPut("/update/work/" + id, body);
syncLocalAfterLeaiUpdate(id, responseBody);
syncLocalAfterLeaiUpdate(id, responseBody, requestBody);
return jsonOk(responseBody);
}
/**
* 解析乐读派 update 响应并同步本地 t_ugc_work避免仅依赖 Webhook 导致发布前状态滞后
* 解析乐读派 update 响应并同步本地 t_ugc_work避免仅依赖 Webhook 导致发布前状态滞后
* 成功后额外用本次请求体覆盖本地元数据与乐读派查询结果解耦避免远端滞后
*/
private void syncLocalAfterLeaiUpdate(String remoteWorkId, String responseJson) {
if (responseJson == null || responseJson.isEmpty()) {
return;
}
private void syncLocalAfterLeaiUpdate(String remoteWorkId, String responseJson, Map<String, Object> proxyRequestBody) {
if (responseJson != null && !responseJson.isEmpty()) {
try {
Map<String, Object> root = objectMapper.readValue(responseJson, new TypeReference<Map<String, Object>>() {});
boolean ok = true;
if (root.containsKey("code")) {
int code = LeaiUtil.toInt(root.get("code"), 200);
if (code != 200) {
log.debug("[乐读派代理] update 响应非成功,跳过本地同步: code={}", code);
return;
// 乐读派部分接口成功为 0与前端 publicApi 拦截器一致
if (code != 200 && code != 0) {
ok = false;
log.debug("[乐读派代理] update 响应非成功,跳过远端拉取同步: code={}", code);
}
}
if (ok) {
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) root.get("data");
if (data == null) {
@ -187,15 +189,21 @@ public class LeaiProxyController {
}
if (data.get("status") == null) {
data = leaiApiClient.fetchWorkDetail(remoteWorkId);
if (data == null) {
return;
}
}
if (data != null) {
leaiSyncService.syncWork(remoteWorkId, data, "Proxy[update.work]");
}
}
} catch (Exception e) {
log.warn("[乐读派代理] 编辑作品后同步本地失败: remoteWorkId={}", remoteWorkId, e);
}
}
try {
leaiSyncService.applyLocalMetadataFromProxyPut(remoteWorkId, proxyRequestBody);
} catch (Exception e) {
log.warn("[乐读派代理] 本地元数据覆盖失败: remoteWorkId={}", remoteWorkId, e);
}
}
/**
* 批量更新配音

View File

@ -23,4 +23,12 @@ public interface ILeaiSyncService {
* @param source 来源标识用于日志 "Webhook[work.status_changed]"
*/
void syncWork(String remoteWorkId, Map<String, Object> remoteData, String source);
/**
* PUT 代理成功后用本次请求体中的元数据直接覆盖本地 t_ugc_work与乐读派查询解耦避免远端滞后
*
* @param remoteWorkId 乐读派作品 ID
* @param proxyRequestBody 前端传入的 JSON通常含 authorsubtitleintrotags 不含 orgId/phone
*/
void applyLocalMetadataFromProxyPut(String remoteWorkId, Map<String, Object> proxyRequestBody);
}

View File

@ -29,6 +29,8 @@ import java.util.*;
@RequiredArgsConstructor
public class LeaiSyncService implements ILeaiSyncService {
private static final String SOURCE_PROXY_UPDATE = "Proxy[update.work]";
private final UgcWorkMapper ugcWorkMapper;
private final UgcWorkPageMapper ugcWorkPageMapper;
private final LeaiApiClient leaiApiClient;
@ -90,6 +92,16 @@ public class LeaiSyncService implements ILeaiSyncService {
return;
}
// PUT /update/work 仅改作者/副标题/简介时乐读派 status 不变不走状态前进需单独同步元数据
if (remoteStatus >= LeaiCreationStatus.CATALOGED
&& remoteStatus == localLeaiStatus
&& source != null
&& source.contains(SOURCE_PROXY_UPDATE)) {
applyMetadataFromRemote(localWork, remoteData);
log.info("[{}] 元数据同步(状态未变) remoteWorkId={}", source, remoteWorkId);
return;
}
// 旧数据或重复推送忽略状态更新
// 但如果 remoteStatus >= 3 且本地缺少页面数据需要补充拉取
if (remoteStatus >= LeaiCreationStatus.COMPLETED && !hasPages(localWork.getId())) {
@ -370,6 +382,94 @@ public class LeaiSyncService implements ILeaiSyncService {
}
}
/**
* 乐读派编辑元数据后 status 不变时 title/author/副标题与简介 写入本地 leaiStatus CAS
*/
private void applyMetadataFromRemote(UgcWork work, Map<String, Object> remoteData) {
LambdaUpdateWrapper<UgcWork> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(UgcWork::getId, work.getId());
boolean any = false;
if (remoteData.containsKey("title")) {
wrapper.set(UgcWork::getTitle, LeaiUtil.toString(remoteData.get("title"), null));
any = true;
}
if (remoteData.containsKey("author")) {
wrapper.set(UgcWork::getAuthorName, LeaiUtil.toString(remoteData.get("author"), null));
any = true;
}
if (remoteData.containsKey("intro") || remoteData.containsKey("subtitle")) {
String sub = remoteData.containsKey("subtitle")
? LeaiUtil.toString(remoteData.get("subtitle"), "")
: "";
String intro = remoteData.containsKey("intro")
? LeaiUtil.toString(remoteData.get("intro"), "")
: "";
String desc;
if (sub.isEmpty()) {
desc = intro;
} else if (intro.isEmpty()) {
desc = sub;
} else {
desc = sub + "\n" + intro;
}
desc = desc.trim();
wrapper.set(UgcWork::getDescription, desc.isEmpty() ? null : desc);
any = true;
}
if (!any) {
return;
}
wrapper.set(UgcWork::getModifyTime, LocalDateTime.now());
ugcWorkMapper.update(null, wrapper);
}
@Override
public void applyLocalMetadataFromProxyPut(String remoteWorkId, Map<String, Object> proxyRequestBody) {
if (remoteWorkId == null || remoteWorkId.isEmpty() || proxyRequestBody == null || proxyRequestBody.isEmpty()) {
return;
}
UgcWork work = findByRemoteWorkId(remoteWorkId);
if (work == null) {
return;
}
LambdaUpdateWrapper<UgcWork> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(UgcWork::getId, work.getId());
boolean any = false;
if (proxyRequestBody.containsKey("author")) {
wrapper.set(UgcWork::getAuthorName, LeaiUtil.toString(proxyRequestBody.get("author"), null));
any = true;
}
if (proxyRequestBody.containsKey("title")) {
wrapper.set(UgcWork::getTitle, LeaiUtil.toString(proxyRequestBody.get("title"), null));
any = true;
}
if (proxyRequestBody.containsKey("intro") || proxyRequestBody.containsKey("subtitle")) {
String sub = proxyRequestBody.containsKey("subtitle")
? LeaiUtil.toString(proxyRequestBody.get("subtitle"), "")
: "";
String intro = proxyRequestBody.containsKey("intro")
? LeaiUtil.toString(proxyRequestBody.get("intro"), "")
: "";
String desc;
if (sub.isEmpty()) {
desc = intro;
} else if (intro.isEmpty()) {
desc = sub;
} else {
desc = sub + "\n" + intro;
}
desc = desc.trim();
wrapper.set(UgcWork::getDescription, desc.isEmpty() ? null : desc);
any = true;
}
if (!any) {
return;
}
wrapper.set(UgcWork::getModifyTime, LocalDateTime.now());
ugcWorkMapper.update(null, wrapper);
log.info("[ProxyPut] 本地元数据已覆盖 remoteWorkId={}", remoteWorkId);
}
/**
* 通过 remoteWorkId 查找本地作品
*/

View File

@ -4,34 +4,35 @@
<template #title>评委管理</template>
<template #extra>
<a-space>
<a-button
v-permission="'judge:create'"
type="primary"
@click="handleAdd"
>
<template #icon><PlusOutlined /></template>
<a-button v-permission="'judge:create'" type="primary" @click="handleAdd">
<template #icon>
<PlusOutlined />
</template>
新增
</a-button>
<a-tooltip title="功能开发中,敬请期待">
<a-button v-permission="'judge:create'" disabled>
<template #icon><UploadOutlined /></template>
<template #icon>
<UploadOutlined />
</template>
导入
</a-button>
</a-tooltip>
<a-tooltip title="功能开发中,敬请期待">
<a-button v-permission="'judge:read'" disabled>
<template #icon><DownloadOutlined /></template>
<template #icon>
<DownloadOutlined />
</template>
导出
</a-button>
</a-tooltip>
<a-popconfirm
v-permission="'judge:delete'"
title="确定要删除选中的评委吗?"
<a-popconfirm v-permission="'judge:delete'" title="确定要删除选中的评委吗?"
:disabled="selectedRowKeys.length === 0 || selectedRows.every(r => r.isPlatform)"
@confirm="handleBatchDelete"
>
@confirm="handleBatchDelete">
<a-button danger :disabled="selectedRowKeys.length === 0 || selectedRows.every(r => r.isPlatform)">
<template #icon><DeleteOutlined /></template>
<template #icon>
<DeleteOutlined />
</template>
删除
</a-button>
</a-popconfirm>
@ -40,70 +41,42 @@
</a-card>
<!-- 搜索表单 -->
<a-form
:model="searchParams"
layout="inline"
class="search-form"
@finish="handleSearch"
>
<a-form :model="searchParams" layout="inline" class="search-form" @finish="handleSearch">
<a-form-item label="所属单位">
<a-input
v-model:value="searchParams.organization"
placeholder="请输入所属单位"
allow-clear
style="width: 200px"
/>
<a-input v-model:value="searchParams.organization" placeholder="请输入所属单位" allow-clear style="width: 200px" />
</a-form-item>
<a-form-item label="姓名">
<a-input
v-model:value="searchParams.nickname"
placeholder="请输入姓名"
allow-clear
style="width: 150px"
/>
<a-input v-model:value="searchParams.nickname" placeholder="请输入姓名" allow-clear style="width: 150px" />
</a-form-item>
<a-form-item label="账号">
<a-input
v-model:value="searchParams.username"
placeholder="请输入账号"
allow-clear
style="width: 150px"
/>
<a-input v-model:value="searchParams.username" placeholder="请输入账号" allow-clear style="width: 150px" />
</a-form-item>
<a-form-item label="状态">
<a-select
v-model:value="searchParams.status"
placeholder="请选择状态"
allow-clear
style="width: 120px"
@change="handleSearch"
>
<a-select v-model:value="searchParams.status" placeholder="请选择状态" allow-clear style="width: 120px"
@change="handleSearch">
<a-select-option value="disabled">停用</a-select-option>
<a-select-option value="enabled">启用</a-select-option>
</a-select>
</a-form-item>
<a-form-item>
<a-button type="primary" html-type="submit">
<template #icon><SearchOutlined /></template>
<template #icon>
<SearchOutlined />
</template>
搜索
</a-button>
<a-button style="margin-left: 8px" @click="handleReset">
<template #icon><ReloadOutlined /></template>
<template #icon>
<ReloadOutlined />
</template>
重置
</a-button>
</a-form-item>
</a-form>
<!-- 数据表格 -->
<a-table
:columns="columns"
:data-source="dataSource"
:loading="loading"
:pagination="pagination"
:row-selection="rowSelection"
row-key="id"
@change="handleTableChange"
>
<a-table :columns="columns" :data-source="dataSource" :loading="loading" :pagination="pagination"
:row-selection="rowSelection" row-key="id" @change="handleTableChange">
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'index'">
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
@ -126,9 +99,7 @@
</a-tag>
</template>
<template v-else-if="column.key === 'ongoingContests'">
<template
v-if="record.contestJudges && record.contestJudges.length > 0"
>
<template v-if="record.contestJudges && record.contestJudges.length > 0">
<a-tooltip>
<template #title>
<div v-for="cj in record.contestJudges" :key="cj.contest.id">
@ -145,31 +116,17 @@
<template v-else-if="column.key === 'action'">
<a-space>
<template v-if="!record.isPlatform">
<a-button
v-permission="'judge:update'"
type="link"
size="small"
@click="handleToggleStatus(record)"
>
<a-button v-permission="'judge:update'" type="link" size="small" @click="handleToggleStatus(record)">
{{ record.status === "enabled" ? "冻结" : "解冻" }}
</a-button>
</template>
<template v-if="!record.isPlatform">
<a-button
v-permission="'judge:update'"
type="link"
size="small"
@click="handleEdit(record)"
>
<a-button v-permission="'judge:update'" type="link" size="small" @click="handleEdit(record)">
编辑
</a-button>
</template>
<a-popconfirm
v-if="!record.isPlatform"
v-permission="'judge:delete'"
title="确定要删除这个评委吗?"
@confirm="handleDelete(record.id)"
>
<a-popconfirm v-if="!record.isPlatform" v-permission="'judge:delete'" title="确定要删除这个评委吗?"
@confirm="handleDelete(record.id)">
<a-button type="link" danger size="small">删除</a-button>
</a-popconfirm>
</a-space>
@ -178,34 +135,14 @@
</a-table>
<!-- 新增/编辑评委抽屉 -->
<a-drawer
v-model:open="drawerVisible"
:title="isEditing ? '编辑评委' : '新增评委'"
placement="right"
width="500px"
:footer-style="{ textAlign: 'right' }"
>
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 18 }"
>
<a-drawer v-model:open="drawerVisible" :title="isEditing ? '编辑评委' : '新增评委'" placement="right" width="500px"
:footer-style="{ textAlign: 'right' }">
<a-form ref="formRef" :model="form" :rules="rules" :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
<a-form-item label="账号" name="username">
<a-input
v-model:value="form.username"
placeholder="请输入账号"
:maxlength="50"
:disabled="isEditing"
/>
<a-input v-model:value="form.username" placeholder="请输入账号" :maxlength="50" :disabled="isEditing" />
</a-form-item>
<a-form-item label="姓名" name="nickname">
<a-input
v-model:value="form.nickname"
placeholder="请输入姓名"
:maxlength="50"
/>
<a-input v-model:value="form.nickname" placeholder="请输入姓名" :maxlength="50" />
</a-form-item>
<a-form-item label="性别" name="gender">
<a-radio-group v-model:value="form.gender">
@ -214,31 +151,18 @@
</a-radio-group>
</a-form-item>
<a-form-item label="所属单位" name="organization">
<a-input
v-model:value="form.organization"
placeholder="请输入所属单位"
:maxlength="100"
/>
<a-input v-model:value="form.organization" placeholder="请输入所属单位" :maxlength="100" />
</a-form-item>
<a-form-item label="联系方式" name="phone">
<a-input
v-model:value="form.phone"
placeholder="请输入手机号"
:maxlength="11"
/>
<a-input v-model:value="form.phone" placeholder="请输入手机号" :maxlength="11" />
</a-form-item>
<a-form-item label="初始密码" name="password">
<a-input-password
v-model:value="form.password"
:placeholder="isEditing ? '请输入新密码' : '请输入初始密码'"
:maxlength="50"
/>
<a-input-password v-model:value="form.password" :placeholder="isEditing ? '请输入新密码' : '请输入初始密码'"
:maxlength="50" />
</a-form-item>
</a-form>
<template #footer>
<a-button style="margin-right: 8px" @click="handleCancel"
>取消</a-button
>
<a-button style="margin-right: 8px" @click="handleCancel">取消</a-button>
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">
确定
</a-button>
@ -592,9 +516,16 @@ $primary: #6366f1;
.ant-card-head {
border-bottom: none;
.ant-card-head-title { font-size: 18px; font-weight: 600; }
.ant-card-head-title {
font-size: 18px;
font-weight: 600;
}
}
.ant-card-body {
padding: 0;
}
.ant-card-body { padding: 0; }
}
:deep(.ant-table-wrapper) {
@ -603,9 +534,19 @@ $primary: #6366f1;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
overflow: hidden;
.ant-table-thead > tr > th { background: #fafafa; font-weight: 600; }
.ant-table-tbody > tr:hover > td { background: rgba($primary, 0.03); }
.ant-table-pagination { padding: 16px; margin: 0; }
.ant-table-thead>tr>th {
background: #fafafa;
font-weight: 600;
}
.ant-table-tbody>tr:hover>td {
background: rgba($primary, 0.03);
}
.ant-table-pagination {
padding: 16px;
margin: 0;
}
}
}

View File

@ -517,8 +517,11 @@ async function loadWork() {
}
const w = store.workDetail
if (w.status >= STATUS.DUBBED) {
const nextRoute = getRouteByStatus(w.status, w.workId)
// (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 }
}

View File

@ -115,7 +115,7 @@ export default { name: 'EditInfoView' }
</template>
<script setup>
import { ref, computed, onActivated, nextTick } from 'vue'
import { ref, computed, onActivated, nextTick, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import {
LoadingOutlined,
@ -142,6 +142,13 @@ const route = useRoute()
const store = useAicreateStore()
const workId = computed(() => route.params.workId || store.workId)
/** 乐读派 workId 可能超过 JS 安全整数,请求与比较一律用路由字符串 */
function resolvedWorkIdStr() {
const id = workId.value
if (id == null || id === '') return ''
return String(id)
}
const loading = ref(true)
const processing = ref(false)
const coverUrl = ref('')
@ -182,19 +189,22 @@ function confirmAddTag() {
}
async function loadWork() {
const id = resolvedWorkIdStr()
if (!id) {
loading.value = false
return
}
loading.value = true
try {
// workId
if (!store.workDetail || store.workDetail.workId !== workId.value) {
// keep-alive store
store.workDetail = null
const res = await getWorkDetail(workId.value)
const res = await getWorkDetail(id)
store.workDetail = res
}
const w = store.workDetail
// DUBBED/ status
if (w.status > STATUS.DUBBED) {
const nextRoute = getRouteByStatus(w.status, w.workId)
const nextRoute = getRouteByStatus(w.status, id)
if (nextRoute) { router.replace(nextRoute); return }
}
@ -227,24 +237,29 @@ function validate() {
*/
async function saveFormToServer() {
try {
const id = resolvedWorkIdStr()
if (!id) {
message.error('作品 ID 无效')
return false
}
const data = { tags: selectedTags.value }
data.author = form.value.author.trim()
if (form.value.subtitle.trim()) data.subtitle = form.value.subtitle.trim()
if (form.value.intro.trim()) data.intro = form.value.intro.trim()
data.subtitle = form.value.subtitle.trim()
data.intro = form.value.intro.trim()
await updateWork(workId.value, data)
await updateWork(id, data)
if (store.workDetail) {
store.workDetail.author = data.author
if (data.subtitle) store.workDetail.subtitle = data.subtitle
if (data.intro) store.workDetail.intro = data.intro
store.workDetail.subtitle = data.subtitle
store.workDetail.intro = data.intro
store.workDetail.tags = [...selectedTags.value]
}
return true
} catch (e) {
// CAS
try {
const check = await getWorkDetail(workId.value)
const check = await getWorkDetail(resolvedWorkIdStr())
if (check?.status >= 4) return true
} catch { /* ignore */ }
message.error(e.message || '保存失败,请重试')
@ -261,7 +276,7 @@ async function handleSave() {
store.workDetail = null
router.push({
name: 'PublicCreateSaveSuccess',
params: { workId: String(workId.value || '') },
params: { workId: resolvedWorkIdStr() },
})
}
} finally {
@ -276,7 +291,7 @@ async function handleGoDubbing() {
try {
if (await saveFormToServer()) {
store.workDetail = null
router.push(`/p/create/dubbing/${workId.value}`)
router.push(`/p/create/dubbing/${resolvedWorkIdStr()}`)
}
} finally {
processing.value = false
@ -308,7 +323,7 @@ async function handlePublish() {
try {
if (!(await saveFormToServer())) return
const rw = String(workId.value || '').trim()
const rw = resolvedWorkIdStr().trim()
let ugcId = await findUgcIdByRemoteWorkId(rw)
if (ugcId == null) {
message.error('作品库尚未同步该绘本,请稍后重试或到作品详情页点击「公开发布」')
@ -341,6 +356,15 @@ async function handlePublish() {
}
}
// keep-alive
watch(
() => String(route.params.workId || ''),
(newId, oldId) => {
if (!newId) return
if (oldId !== undefined && newId !== oldId) loadWork()
},
)
onActivated(() => {
clearExtractDraft()
loadWork()