feat: EditInfo直接发布联调、乐读派PUT后同步UGC、创作流程keep-alive激活刷新
Made-with: Cursor
This commit is contained in:
parent
93c1f0d497
commit
56bddb5206
@ -75,11 +75,11 @@ draft / pending_review / published / rejected
|
||||
└───────┬───────┘
|
||||
│
|
||||
│ 用户在 EditInfoView 点
|
||||
│ 保存 / 去配音 / 立即发布
|
||||
│ 任意按钮 = 配音完成
|
||||
│ 保存 / 去配音 / 直接发布
|
||||
│ 编目完成(leai=CATALOGED)后同步为未发布
|
||||
▼
|
||||
┌───────────────┐
|
||||
│ UNPUBLISHED │ 成品私有:已配音,可发布
|
||||
│ UNPUBLISHED │ 成品私有:已编目,配音可选
|
||||
│ 未发布 │◀──────────────┐
|
||||
└───────┬───────┘ │
|
||||
│ │
|
||||
@ -111,7 +111,7 @@ draft / pending_review / published / rejected
|
||||
| 起始状态 | 触发 | 目标状态 | 触发方 | 接口 |
|
||||
|---|---|---|---|---|
|
||||
| (无) | 创建作品 | 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 | 用户补充配音/编辑 | UNPUBLISHED | 用户 | 更新内容接口 |
|
||||
| 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` |
|
||||
| REJECTED | 改完重交 | PENDING_REVIEW | 用户 | `POST /public/works/{id}/publish` |
|
||||
|
||||
**实现说明(与代码对齐)**:`LeaiSyncService` 在乐读派进度前进至 ≥ `CATALOGED` 时,若本地 `status` 仍为 `draft`,则写入 `unpublished`。`PUT /leai-proxy/work/{id}` 代理在乐读派返回成功后主动调用 `syncWork`,减少仅依赖 Webhook 的延迟,便于「直接发布」单次提交审核。
|
||||
|
||||
### 2.3 状态可见性矩阵
|
||||
|
||||
| 状态 | 用户作品库可见 | 用户详情页可见 | 发现页可见 | 超管端可见 |
|
||||
@ -407,7 +409,7 @@ CREATE INDEX idx_ugc_work_leai_status ON t_ugc_work(leai_status);
|
||||
| **本地发布状态** | 我们自己定义的作品发布状态 | 字符串,5 个值 |
|
||||
| **CATALOGED** | leai 状态 4,编目完成 | 用户在 EditInfoView 保存信息后到达 |
|
||||
| **DUBBED** | leai 状态 5,配音完成 | 配音是可选步骤 |
|
||||
| **未发布 unpublished** | 本次新增状态 | 已编目完成但未公开 |
|
||||
| **未发布 unpublished** | 本次新增状态 | 乐读派进度 ≥ CATALOGED(4) 且本地同步后,用户尚未提交审核 |
|
||||
|
||||
## 附录 B:状态命名为什么是 `unpublished` 而不是 `private`
|
||||
|
||||
|
||||
@ -3,7 +3,10 @@ package com.lesingle.modules.leai.controller;
|
||||
import com.lesingle.common.exception.BusinessException;
|
||||
import com.lesingle.common.util.SecurityUtil;
|
||||
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.util.LeaiUtil;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.lesingle.modules.sys.entity.SysUser;
|
||||
import com.lesingle.modules.sys.service.ISysUserService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
@ -35,6 +38,7 @@ public class LeaiProxyController {
|
||||
private final LeaiConfig leaiConfig;
|
||||
private final ISysUserService sysUserService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ILeaiSyncService leaiSyncService;
|
||||
|
||||
/**
|
||||
* 获取当前用户手机号,校验非空
|
||||
@ -152,9 +156,45 @@ public class LeaiProxyController {
|
||||
@PutMapping("/work/{id}")
|
||||
@Operation(summary = "编辑作品代理")
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -109,8 +109,8 @@ public class LeaiSyncService implements ILeaiSyncService {
|
||||
work.setTitle(LeaiUtil.toString(remoteData.get("title"), "未命名作品"));
|
||||
int leaiStatus = LeaiUtil.toInt(remoteData.get("status"), LeaiCreationStatus.PENDING);
|
||||
work.setLeaiStatus(leaiStatus);
|
||||
// 本地发布状态:创作进度 >= DUBBED 时自动设为 unpublished,否则为 draft
|
||||
work.setStatus(leaiStatus >= LeaiCreationStatus.DUBBED
|
||||
// 本地发布状态:编目完成(CATALOGED)起视为可入库「未发布」,与 EditInfo 保存/直接发布一致
|
||||
work.setStatus(leaiStatus >= LeaiCreationStatus.CATALOGED
|
||||
? WorkPublishStatus.UNPUBLISHED.getValue()
|
||||
: WorkPublishStatus.DRAFT.getValue());
|
||||
work.setVisibility(Visibility.PRIVATE.getValue());
|
||||
@ -228,8 +228,8 @@ public class LeaiSyncService implements ILeaiSyncService {
|
||||
.lt(UgcWork::getLeaiStatus, remoteStatus)
|
||||
.set(UgcWork::getLeaiStatus, remoteStatus);
|
||||
|
||||
// 当 leaiStatus 推进到 DUBBED 且当前 status 仍为 draft → 自动设 status 为 unpublished
|
||||
if (remoteStatus >= LeaiCreationStatus.DUBBED
|
||||
// 编目完成(CATALOGED)起且当前仍为 draft → 升为 unpublished(配音为可选步骤)
|
||||
if (remoteStatus >= LeaiCreationStatus.CATALOGED
|
||||
&& WorkPublishStatus.DRAFT.getValue().equals(work.getStatus())) {
|
||||
wrapper.set(UgcWork::getStatus, WorkPublishStatus.UNPUBLISHED.getValue());
|
||||
}
|
||||
|
||||
@ -93,7 +93,7 @@ export default { name: 'BookReaderView' }
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onActivated } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
LeftOutlined,
|
||||
@ -191,7 +191,8 @@ function applyWork(work: any) {
|
||||
pages.value = list
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadReader() {
|
||||
idx.value = 0
|
||||
const workId = route.params.workId
|
||||
|
||||
if (!workId) return
|
||||
@ -199,7 +200,6 @@ onMounted(async () => {
|
||||
let work
|
||||
const shareToken = new URLSearchParams(window.location.search).get('st') || ''
|
||||
if (store.sessionToken) {
|
||||
// 认证用户: 走后端代理
|
||||
const res = await getWorkDetail(workId)
|
||||
work = res
|
||||
} else if (shareToken) {
|
||||
@ -212,7 +212,9 @@ onMounted(async () => {
|
||||
}
|
||||
if (work) applyWork(work)
|
||||
} catch { /* use default */ }
|
||||
})
|
||||
}
|
||||
|
||||
onActivated(loadReader)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@ -91,7 +91,7 @@ export default { name: 'CharactersView' }
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onActivated } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
LoadingOutlined,
|
||||
@ -135,7 +135,7 @@ const nextLabel = computed(() => {
|
||||
return '确定主角,编排故事'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
async function loadCharacters() {
|
||||
if (store.characters && store.characters.length > 0) {
|
||||
characters.value = store.characters
|
||||
autoSelect()
|
||||
@ -174,6 +174,10 @@ onMounted(async () => {
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
void loadCharacters()
|
||||
})
|
||||
|
||||
function autoSelect() {
|
||||
|
||||
@ -76,7 +76,7 @@ export default { name: 'CreatingView' }
|
||||
</template>
|
||||
|
||||
<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 { Client } from '@stomp/stompjs'
|
||||
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(() => {
|
||||
closeWebSocket()
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
|
||||
@ -152,7 +152,7 @@ export default { name: 'DubbingView' }
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
LoadingOutlined,
|
||||
@ -534,7 +534,7 @@ async function loadWork() {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
onMounted(loadWork)
|
||||
onActivated(loadWork)
|
||||
onBeforeUnmount(() => {
|
||||
stopAudio()
|
||||
if (isRecording.value && mediaRecorder?.state === 'recording') {
|
||||
|
||||
@ -115,7 +115,7 @@ export default { name: 'EditInfoView' }
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ref, computed, onActivated, nextTick } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import {
|
||||
LoadingOutlined,
|
||||
@ -132,6 +132,7 @@ import {
|
||||
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 { useAicreateStore } from '@/stores/aicreate'
|
||||
import { clearExtractDraft } from '@/utils/aicreate/extractDraft'
|
||||
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() {
|
||||
if (!validate()) return
|
||||
processing.value = true
|
||||
try {
|
||||
if (!(await saveFormToServer())) return
|
||||
|
||||
// TODO: 真实发布接口需要本地 DB 作品 id(leai workId 到本地 id 的映射),
|
||||
// 等后端联调 publicUserWorksApi.publish 完成后接入
|
||||
const rw = String(workId.value || '').trim()
|
||||
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
|
||||
message.success('提交成功,作品已进入审核,可在作品库「审核中」查看进度')
|
||||
router.push({
|
||||
name: 'PublicCreateSaveSuccess',
|
||||
params: { workId: String(workId.value || '') },
|
||||
});
|
||||
params: { workId: rw },
|
||||
query: { after: 'publish' },
|
||||
})
|
||||
} catch (e) {
|
||||
message.error(e?.message || '提交审核失败,请重试')
|
||||
} finally {
|
||||
processing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onActivated(() => {
|
||||
clearExtractDraft()
|
||||
loadWork()
|
||||
nextTick(() => { if (tagInput.value) tagInput.value.focus() })
|
||||
|
||||
@ -75,7 +75,7 @@ export default { name: 'PreviewView' }
|
||||
</template>
|
||||
|
||||
<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 {
|
||||
LoadingOutlined,
|
||||
@ -91,7 +91,6 @@ import { STATUS, getRouteByStatus } from '@/utils/aicreate/status'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAicreateStore()
|
||||
|
||||
const loading = ref(true)
|
||||
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() {
|
||||
loading.value = true
|
||||
@ -133,26 +132,15 @@ async function loadWork() {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getWorkDetail(workId.value)
|
||||
const work = res
|
||||
store.workDetail = work
|
||||
store.workId = work.workId
|
||||
|
||||
const work = await getWorkDetail(workId.value)
|
||||
// 如果作品状态已超过 COMPLETED,重定向到对应页面
|
||||
if (work.status > STATUS.COMPLETED) {
|
||||
const nextRoute = getRouteByStatus(work.status, work.workId)
|
||||
if (nextRoute) { router.replace(nextRoute); return }
|
||||
}
|
||||
|
||||
pages.value = (work.pageList || []).map((p: any) => ({
|
||||
pageNum: p.pageNum,
|
||||
text: p.text,
|
||||
imageUrl: p.imageUrl,
|
||||
audioUrl: p.audioUrl,
|
||||
}))
|
||||
pages.value = work.pageList || [];
|
||||
|
||||
// 进入成功页后清空创作流程内存与本地残留键,再按路由 workId 拉取本页展示所需详情
|
||||
store.reset()
|
||||
} catch (e: any) {
|
||||
error.value = e.message || '加载失败'
|
||||
} finally {
|
||||
@ -164,7 +152,7 @@ function goEditInfo() {
|
||||
router.push(`/p/create/edit-info/${workId.value}`)
|
||||
}
|
||||
|
||||
onMounted(loadWork)
|
||||
onActivated(loadWork)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@ -51,7 +51,7 @@ export default { name: 'SaveSuccessView' }
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onActivated } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { getWorkDetail } from '@/api/aicreate'
|
||||
import { useAicreateStore } from '@/stores/aicreate'
|
||||
@ -118,10 +118,13 @@ function goWorks() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 进入成功页后清空创作流程内存与本地残留键,再按路由 workId 拉取本页展示所需详情
|
||||
onMounted(() => {
|
||||
// 仅首次进入:清空创作流程内存与本地残留键(再次激活缓存页时不重复 reset)
|
||||
store.reset()
|
||||
await loadWork()
|
||||
})
|
||||
|
||||
onActivated(() => {
|
||||
void loadWork()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@ -50,7 +50,7 @@ export default { name: 'StyleSelectView' }
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onActivated } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
BgColorsOutlined,
|
||||
@ -97,6 +97,10 @@ const goNext = () => {
|
||||
store.selectedStyle = selected.value
|
||||
router.push('/p/create/creating')
|
||||
}
|
||||
|
||||
onActivated(() => {
|
||||
selected.value = store.selectedStyle || ''
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@ -106,7 +106,7 @@ export default { name: 'UploadView' }
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, onActivated } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import PageHeader from '@/components/aicreate/PageHeader.vue'
|
||||
import { useAicreateStore } from '@/stores/aicreate'
|
||||
@ -141,21 +141,20 @@ const quotaOk = ref(true)
|
||||
const quotaMsg = ref('')
|
||||
let selectedFile: File | null = null
|
||||
|
||||
// 进入页面时检查额度
|
||||
onMounted(async () => {
|
||||
async function refreshQuota() {
|
||||
try {
|
||||
await checkQuota()
|
||||
quotaOk.value = true
|
||||
} catch (e: any) {
|
||||
const msg = e.message || ''
|
||||
// 只有明确的额度不足才阻止,网络/签名错误放行(后端会二次校验)
|
||||
if (msg.includes('额度') || msg.includes('QUOTA') || msg.includes('30003')) {
|
||||
quotaOk.value = false
|
||||
quotaMsg.value = '创作额度不足,请联系管理员'
|
||||
}
|
||||
// 其他错误(403签名/网络超时等)静默放行
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onActivated(refreshQuota)
|
||||
|
||||
const pickImage = (type: string) => {
|
||||
if (type === 'camera') cameraInput.value?.click()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user