feat: EditInfo直接发布联调、乐读派PUT后同步UGC、创作流程keep-alive激活刷新

Made-with: Cursor
This commit is contained in:
zhonghua 2026-04-13 17:16:08 +08:00
parent 93c1f0d497
commit 56bddb5206
12 changed files with 148 additions and 55 deletions

View File

@ -75,11 +75,11 @@ draft / pending_review / published / rejected
└───────┬───────┘ └───────┬───────┘
│ 用户在 EditInfoView 点 │ 用户在 EditInfoView 点
│ 保存 / 去配音 / 立即发布 │ 保存 / 去配音 / 直接发布
任意按钮 = 配音完成 编目完成(leai=CATALOGED)后同步为未发布
┌───────────────┐ ┌───────────────┐
│ UNPUBLISHED │ 成品私有:已配音,可发布 │ UNPUBLISHED │ 成品私有:已编目,配音可选
│ 未发布 │◀──────────────┐ │ 未发布 │◀──────────────┐
└───────┬───────┘ │ └───────┬───────┘ │
│ │ │ │
@ -111,7 +111,7 @@ draft / pending_review / published / rejected
| 起始状态 | 触发 | 目标状态 | 触发方 | 接口 | | 起始状态 | 触发 | 目标状态 | 触发方 | 接口 |
|---|---|---|---|---| |---|---|---|---|---|
| (无) | 创建作品 | DRAFT | 系统 | leai 创作流程内部 | | (无) | 创建作品 | DRAFT | 系统 | leai 创作流程内部 |
| DRAFT | 配音完成leai status → DUBBED | UNPUBLISHED | 系统webhook 同步) | LeaiSyncService | | DRAFT | 编目完成leai status → **CATALOGED**,配音为可选) | UNPUBLISHED | 系统Webhook 或 `PUT /leai-proxy/work/{id}``LeaiSyncService` 同步) | LeaiSyncService |
| UNPUBLISHED | 用户点「公开发布」 | PENDING_REVIEW | 用户 | `POST /public/works/{id}/publish` | | UNPUBLISHED | 用户点「公开发布」 | PENDING_REVIEW | 用户 | `POST /public/works/{id}/publish` |
| UNPUBLISHED | 用户补充配音/编辑 | UNPUBLISHED | 用户 | 更新内容接口 | | UNPUBLISHED | 用户补充配音/编辑 | UNPUBLISHED | 用户 | 更新内容接口 |
| PENDING_REVIEW | 审核通过 | PUBLISHED | 超管 | `POST /content-review/works/{id}/approve` | | PENDING_REVIEW | 审核通过 | PUBLISHED | 超管 | `POST /content-review/works/{id}/approve` |
@ -121,6 +121,8 @@ draft / pending_review / published / rejected
| PUBLISHED | 超管强制下架 | UNPUBLISHED 或 TAKEN_DOWN | 超管 | `POST /content-review/works/{id}/takedown` | | PUBLISHED | 超管强制下架 | UNPUBLISHED 或 TAKEN_DOWN | 超管 | `POST /content-review/works/{id}/takedown` |
| REJECTED | 改完重交 | PENDING_REVIEW | 用户 | `POST /public/works/{id}/publish` | | REJECTED | 改完重交 | PENDING_REVIEW | 用户 | `POST /public/works/{id}/publish` |
**实现说明(与代码对齐)**`LeaiSyncService` 在乐读派进度前进至 ≥ `CATALOGED` 时,若本地 `status` 仍为 `draft`,则写入 `unpublished`。`PUT /leai-proxy/work/{id}` 代理在乐读派返回成功后主动调用 `syncWork`,减少仅依赖 Webhook 的延迟,便于「直接发布」单次提交审核。
### 2.3 状态可见性矩阵 ### 2.3 状态可见性矩阵
| 状态 | 用户作品库可见 | 用户详情页可见 | 发现页可见 | 超管端可见 | | 状态 | 用户作品库可见 | 用户详情页可见 | 发现页可见 | 超管端可见 |
@ -407,7 +409,7 @@ CREATE INDEX idx_ugc_work_leai_status ON t_ugc_work(leai_status);
| **本地发布状态** | 我们自己定义的作品发布状态 | 字符串5 个值 | | **本地发布状态** | 我们自己定义的作品发布状态 | 字符串5 个值 |
| **CATALOGED** | leai 状态 4编目完成 | 用户在 EditInfoView 保存信息后到达 | | **CATALOGED** | leai 状态 4编目完成 | 用户在 EditInfoView 保存信息后到达 |
| **DUBBED** | leai 状态 5配音完成 | 配音是可选步骤 | | **DUBBED** | leai 状态 5配音完成 | 配音是可选步骤 |
| **未发布 unpublished** | 本次新增状态 | 已编目完成但未公开 | | **未发布 unpublished** | 本次新增状态 | 乐读派进度 ≥ CATALOGED(4) 且本地同步后,用户尚未提交审核 |
## 附录 B状态命名为什么是 `unpublished` 而不是 `private` ## 附录 B状态命名为什么是 `unpublished` 而不是 `private`

View File

@ -3,7 +3,10 @@ package com.lesingle.modules.leai.controller;
import com.lesingle.common.exception.BusinessException; import com.lesingle.common.exception.BusinessException;
import com.lesingle.common.util.SecurityUtil; import com.lesingle.common.util.SecurityUtil;
import com.lesingle.modules.leai.config.LeaiConfig; import com.lesingle.modules.leai.config.LeaiConfig;
import com.lesingle.modules.leai.service.ILeaiSyncService;
import com.lesingle.modules.leai.service.LeaiApiClient; import com.lesingle.modules.leai.service.LeaiApiClient;
import com.lesingle.modules.leai.util.LeaiUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import com.lesingle.modules.sys.entity.SysUser; import com.lesingle.modules.sys.entity.SysUser;
import com.lesingle.modules.sys.service.ISysUserService; import com.lesingle.modules.sys.service.ISysUserService;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
@ -35,6 +38,7 @@ public class LeaiProxyController {
private final LeaiConfig leaiConfig; private final LeaiConfig leaiConfig;
private final ISysUserService sysUserService; private final ISysUserService sysUserService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final ILeaiSyncService leaiSyncService;
/** /**
* 获取当前用户手机号校验非空 * 获取当前用户手机号校验非空
@ -152,9 +156,45 @@ public class LeaiProxyController {
@PutMapping("/work/{id}") @PutMapping("/work/{id}")
@Operation(summary = "编辑作品代理") @Operation(summary = "编辑作品代理")
public ResponseEntity<String> proxyUpdateWork(@PathVariable String id, @RequestBody Map<String, Object> requestBody) { public ResponseEntity<String> proxyUpdateWork(@PathVariable String id, @RequestBody Map<String, Object> requestBody) {
Map<String, Object> body = new HashMap<>(requestBody); Map<String, Object> body = buildBaseBody();
body.putAll(requestBody);
log.info("[乐读派代理] 编辑作品, workId={}", id); log.info("[乐读派代理] 编辑作品, workId={}", id);
return jsonOk(leaiApiClient.proxyPut("/update/work/" + id, body)); String responseBody = leaiApiClient.proxyPut("/update/work/" + id, body);
syncLocalAfterLeaiUpdate(id, responseBody);
return jsonOk(responseBody);
}
/**
* 解析乐读派 update 响应并同步本地 t_ugc_work避免仅依赖 Webhook 导致发布前状态滞后
*/
private void syncLocalAfterLeaiUpdate(String remoteWorkId, String responseJson) {
if (responseJson == null || responseJson.isEmpty()) {
return;
}
try {
Map<String, Object> root = objectMapper.readValue(responseJson, new TypeReference<Map<String, Object>>() {});
if (root.containsKey("code")) {
int code = LeaiUtil.toInt(root.get("code"), 200);
if (code != 200) {
log.debug("[乐读派代理] update 响应非成功,跳过本地同步: code={}", code);
return;
}
}
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) root.get("data");
if (data == null) {
data = root;
}
if (data.get("status") == null) {
data = leaiApiClient.fetchWorkDetail(remoteWorkId);
if (data == null) {
return;
}
}
leaiSyncService.syncWork(remoteWorkId, data, "Proxy[update.work]");
} catch (Exception e) {
log.warn("[乐读派代理] 编辑作品后同步本地失败: remoteWorkId={}", remoteWorkId, e);
}
} }
/** /**

View File

@ -109,8 +109,8 @@ public class LeaiSyncService implements ILeaiSyncService {
work.setTitle(LeaiUtil.toString(remoteData.get("title"), "未命名作品")); work.setTitle(LeaiUtil.toString(remoteData.get("title"), "未命名作品"));
int leaiStatus = LeaiUtil.toInt(remoteData.get("status"), LeaiCreationStatus.PENDING); int leaiStatus = LeaiUtil.toInt(remoteData.get("status"), LeaiCreationStatus.PENDING);
work.setLeaiStatus(leaiStatus); work.setLeaiStatus(leaiStatus);
// 本地发布状态创作进度 >= DUBBED 时自动设为 unpublished否则为 draft // 本地发布状态编目完成(CATALOGED)起视为可入库未发布 EditInfo 保存/直接发布一致
work.setStatus(leaiStatus >= LeaiCreationStatus.DUBBED work.setStatus(leaiStatus >= LeaiCreationStatus.CATALOGED
? WorkPublishStatus.UNPUBLISHED.getValue() ? WorkPublishStatus.UNPUBLISHED.getValue()
: WorkPublishStatus.DRAFT.getValue()); : WorkPublishStatus.DRAFT.getValue());
work.setVisibility(Visibility.PRIVATE.getValue()); work.setVisibility(Visibility.PRIVATE.getValue());
@ -228,8 +228,8 @@ public class LeaiSyncService implements ILeaiSyncService {
.lt(UgcWork::getLeaiStatus, remoteStatus) .lt(UgcWork::getLeaiStatus, remoteStatus)
.set(UgcWork::getLeaiStatus, remoteStatus); .set(UgcWork::getLeaiStatus, remoteStatus);
// leaiStatus 推进到 DUBBED 且当前 status 仍为 draft 自动设 status unpublished // 编目完成(CATALOGED)起且当前仍为 draft 升为 unpublished配音为可选步骤
if (remoteStatus >= LeaiCreationStatus.DUBBED if (remoteStatus >= LeaiCreationStatus.CATALOGED
&& WorkPublishStatus.DRAFT.getValue().equals(work.getStatus())) { && WorkPublishStatus.DRAFT.getValue().equals(work.getStatus())) {
wrapper.set(UgcWork::getStatus, WorkPublishStatus.UNPUBLISHED.getValue()); wrapper.set(UgcWork::getStatus, WorkPublishStatus.UNPUBLISHED.getValue());
} }

View File

@ -93,7 +93,7 @@ export default { name: 'BookReaderView' }
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onActivated } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { import {
LeftOutlined, LeftOutlined,
@ -191,7 +191,8 @@ function applyWork(work: any) {
pages.value = list pages.value = list
} }
onMounted(async () => { async function loadReader() {
idx.value = 0
const workId = route.params.workId const workId = route.params.workId
if (!workId) return if (!workId) return
@ -199,7 +200,6 @@ onMounted(async () => {
let work let work
const shareToken = new URLSearchParams(window.location.search).get('st') || '' const shareToken = new URLSearchParams(window.location.search).get('st') || ''
if (store.sessionToken) { if (store.sessionToken) {
// :
const res = await getWorkDetail(workId) const res = await getWorkDetail(workId)
work = res work = res
} else if (shareToken) { } else if (shareToken) {
@ -212,7 +212,9 @@ onMounted(async () => {
} }
if (work) applyWork(work) if (work) applyWork(work)
} catch { /* use default */ } } catch { /* use default */ }
}) }
onActivated(loadReader)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -91,7 +91,7 @@ export default { name: 'CharactersView' }
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onActivated } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { import {
LoadingOutlined, LoadingOutlined,
@ -135,7 +135,7 @@ const nextLabel = computed(() => {
return '确定主角,编排故事' return '确定主角,编排故事'
}) })
onMounted(async () => { async function loadCharacters() {
if (store.characters && store.characters.length > 0) { if (store.characters && store.characters.length > 0) {
characters.value = store.characters characters.value = store.characters
autoSelect() autoSelect()
@ -174,6 +174,10 @@ onMounted(async () => {
} finally { } finally {
loading.value = false loading.value = false
} }
}
onActivated(() => {
void loadCharacters()
}) })
function autoSelect() { function autoSelect() {

View File

@ -76,7 +76,7 @@ export default { name: 'CreatingView' }
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onMounted, onActivated, onUnmounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { Client } from '@stomp/stompjs' import { Client } from '@stomp/stompjs'
import { import {
@ -399,6 +399,18 @@ onMounted(() => {
} }
}) })
onActivated(() => {
const urlWorkId = new URLSearchParams(window.location.search).get('workId')
if (urlWorkId) {
saveWorkId(urlWorkId)
} else {
restoreWorkId()
}
if (store.workId) {
void getWorkDetailApi(store.workId)
}
})
onUnmounted(() => { onUnmounted(() => {
closeWebSocket() closeWebSocket()
if (pollTimer) clearInterval(pollTimer) if (pollTimer) clearInterval(pollTimer)

View File

@ -152,7 +152,7 @@ export default { name: 'DubbingView' }
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue' import { ref, computed, onActivated, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { import {
LoadingOutlined, LoadingOutlined,
@ -534,7 +534,7 @@ async function loadWork() {
loading.value = false loading.value = false
} }
onMounted(loadWork) onActivated(loadWork)
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopAudio() stopAudio()
if (isRecording.value && mediaRecorder?.state === 'recording') { if (isRecording.value && mediaRecorder?.state === 'recording') {

View File

@ -115,7 +115,7 @@ export default { name: 'EditInfoView' }
</template> </template>
<script setup> <script setup>
import { ref, computed, onMounted, nextTick } from 'vue' import { ref, computed, onActivated, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { import {
LoadingOutlined, LoadingOutlined,
@ -132,6 +132,7 @@ 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 { getWorkDetail, updateWork } from '@/api/aicreate' import { getWorkDetail, updateWork } from '@/api/aicreate'
import { publicUserWorksApi } from '@/api/public'
import { useAicreateStore } from '@/stores/aicreate' import { useAicreateStore } from '@/stores/aicreate'
import { clearExtractDraft } from '@/utils/aicreate/extractDraft' import { clearExtractDraft } from '@/utils/aicreate/extractDraft'
import { STATUS, getRouteByStatus } from '@/utils/aicreate/status' import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
@ -282,27 +283,65 @@ async function handleGoDubbing() {
} }
} }
/** 发布作品 → 进入超管端待审核;完成后留在本页并刷新数据、提示用户 */ /** 按乐读派 remoteWorkId 解析本地作品库主键 id */
async function findUgcIdByRemoteWorkId(remoteId) {
const rw = String(remoteId || '').trim()
if (!rw) return null
let page = 1
const pageSize = 100
for (let i = 0; i < 20; i++) {
const res = await publicUserWorksApi.list({ page, pageSize })
const list = res?.list || []
const hit = list.find(w => w.remoteWorkId === rw)
if (hit) return hit.id
const total = res?.total ?? 0
if (page * pageSize >= total) break
page += 1
}
return null
}
/** 发布作品:编目保存 → 提交审核(与作品详情「公开发布」同一接口) */
async function handlePublish() { async function handlePublish() {
if (!validate()) return if (!validate()) return
processing.value = true processing.value = true
try { try {
if (!(await saveFormToServer())) return if (!(await saveFormToServer())) return
// TODO: DB idleai workId id const rw = String(workId.value || '').trim()
// publicUserWorksApi.publish let ugcId = await findUgcIdByRemoteWorkId(rw)
if (ugcId == null) {
message.error('作品库尚未同步该绘本,请稍后重试或到作品详情页点击「公开发布」')
return
}
try {
await publicUserWorksApi.publish(ugcId)
} catch (e) {
const msg = e?.message || ''
if (msg.includes('当前状态不可发布')) {
await new Promise(r => setTimeout(r, 500))
await publicUserWorksApi.publish(ugcId)
} else {
throw e
}
}
store.workDetail = null store.workDetail = null
message.success('提交成功,作品已进入审核,可在作品库「审核中」查看进度') message.success('提交成功,作品已进入审核,可在作品库「审核中」查看进度')
router.push({ router.push({
name: 'PublicCreateSaveSuccess', name: 'PublicCreateSaveSuccess',
params: { workId: String(workId.value || '') }, params: { workId: rw },
}); query: { after: 'publish' },
})
} catch (e) {
message.error(e?.message || '提交审核失败,请重试')
} finally { } finally {
processing.value = false processing.value = false
} }
} }
onMounted(() => { onActivated(() => {
clearExtractDraft() clearExtractDraft()
loadWork() loadWork()
nextTick(() => { if (tagInput.value) tagInput.value.focus() }) nextTick(() => { if (tagInput.value) tagInput.value.focus() })

View File

@ -75,7 +75,7 @@ export default { name: 'PreviewView' }
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, nextTick } from 'vue' import { ref, computed, onActivated, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { import {
LoadingOutlined, LoadingOutlined,
@ -91,7 +91,6 @@ import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const store = useAicreateStore()
const loading = ref(true) const loading = ref(true)
const error = ref('') const error = ref('')
@ -120,7 +119,7 @@ function scrollThumbIntoView(i: number) {
}) })
} }
const workId = computed(() => route.params.workId || store.workId) const workId = computed(() => route.params.workId)
async function loadWork() { async function loadWork() {
loading.value = true loading.value = true
@ -133,26 +132,15 @@ async function loadWork() {
} }
try { try {
const res = await getWorkDetail(workId.value) const work = await getWorkDetail(workId.value)
const work = res
store.workDetail = work
store.workId = work.workId
// COMPLETED // COMPLETED
if (work.status > STATUS.COMPLETED) { if (work.status > STATUS.COMPLETED) {
const nextRoute = getRouteByStatus(work.status, work.workId) const nextRoute = getRouteByStatus(work.status, work.workId)
if (nextRoute) { router.replace(nextRoute); return } if (nextRoute) { router.replace(nextRoute); return }
} }
pages.value = (work.pageList || []).map((p: any) => ({ pages.value = work.pageList || [];
pageNum: p.pageNum,
text: p.text,
imageUrl: p.imageUrl,
audioUrl: p.audioUrl,
}))
// workId
store.reset()
} catch (e: any) { } catch (e: any) {
error.value = e.message || '加载失败' error.value = e.message || '加载失败'
} finally { } finally {
@ -164,7 +152,7 @@ function goEditInfo() {
router.push(`/p/create/edit-info/${workId.value}`) router.push(`/p/create/edit-info/${workId.value}`)
} }
onMounted(loadWork) onActivated(loadWork)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -51,7 +51,7 @@ export default { name: 'SaveSuccessView' }
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, onActivated } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import { getWorkDetail } from '@/api/aicreate' import { getWorkDetail } from '@/api/aicreate'
import { useAicreateStore } from '@/stores/aicreate' import { useAicreateStore } from '@/stores/aicreate'
@ -118,10 +118,13 @@ function goWorks() {
} }
} }
onMounted(async () => { onMounted(() => {
// workId // reset
store.reset() store.reset()
await loadWork() })
onActivated(() => {
void loadWork()
}) })
</script> </script>

View File

@ -50,7 +50,7 @@ export default { name: 'StyleSelectView' }
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onActivated } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { import {
BgColorsOutlined, BgColorsOutlined,
@ -97,6 +97,10 @@ const goNext = () => {
store.selectedStyle = selected.value store.selectedStyle = selected.value
router.push('/p/create/creating') router.push('/p/create/creating')
} }
onActivated(() => {
selected.value = store.selectedStyle || ''
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -106,7 +106,7 @@ export default { name: 'UploadView' }
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue' import { ref, onActivated } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import PageHeader from '@/components/aicreate/PageHeader.vue' import PageHeader from '@/components/aicreate/PageHeader.vue'
import { useAicreateStore } from '@/stores/aicreate' import { useAicreateStore } from '@/stores/aicreate'
@ -141,21 +141,20 @@ const quotaOk = ref(true)
const quotaMsg = ref('') const quotaMsg = ref('')
let selectedFile: File | null = null let selectedFile: File | null = null
// async function refreshQuota() {
onMounted(async () => {
try { try {
await checkQuota() await checkQuota()
quotaOk.value = true quotaOk.value = true
} catch (e: any) { } catch (e: any) {
const msg = e.message || '' const msg = e.message || ''
// /
if (msg.includes('额度') || msg.includes('QUOTA') || msg.includes('30003')) { if (msg.includes('额度') || msg.includes('QUOTA') || msg.includes('30003')) {
quotaOk.value = false quotaOk.value = false
quotaMsg.value = '创作额度不足,请联系管理员' quotaMsg.value = '创作额度不足,请联系管理员'
} }
// 403/
} }
}) }
onActivated(refreshQuota)
const pickImage = (type: string) => { const pickImage = (type: string) => {
if (type === 'camera') cameraInput.value?.click() if (type === 'camera') cameraInput.value?.click()