Commit 69bfd17b authored by yaoke.yk's avatar yaoke.yk

Agent制作优化上下文错误和剧本集数可修改

parent 824a0e6d
...@@ -13,9 +13,19 @@ import { ...@@ -13,9 +13,19 @@ import {
Users, Users,
Clapperboard, Clapperboard,
Video, Video,
Plus,
X,
} from "lucide-react"; } from "lucide-react";
import { useProject } from "../../hooks/useProjects"; import { useProject } from "../../hooks/useProjects";
import { useOutline, useEpisodes, useCharacters, useScenes, useVideoTasks } from "../../hooks/useAi"; import {
useOutline,
useEpisodes,
useCharacters,
useScenes,
useVideoTasks,
useCreateEpisode,
useUpdateEpisode,
} from "../../hooks/useAi";
type Tab = "outline" | "characters" | "storyboard" | "video"; type Tab = "outline" | "characters" | "storyboard" | "video";
...@@ -216,43 +226,256 @@ function OutlineTab({ ...@@ -216,43 +226,256 @@ function OutlineTab({
</div> </div>
{/* Episode List */} {/* Episode List */}
{episodes.length > 0 && ( <EpisodeListSection
episodes={episodes}
projectId={projectId}
outlineExists={!!outline}
onNavigateEpisode={onNavigateEpisode}
onNavigateOutline={onNavigateOutline}
/>
</div>
);
}
// ---- Episode List with Edit/Create ----
type EpisodeDraft = {
id?: string;
episodeNumber: number;
title: string;
summary: string;
script: string;
};
function EpisodeListSection({
episodes,
projectId,
outlineExists,
onNavigateEpisode,
onNavigateOutline,
}: {
episodes: import("../../lib/api/ai").Episode[];
projectId?: string;
outlineExists: boolean;
onNavigateEpisode: (epId: string) => void;
onNavigateOutline: () => void;
}) {
const pid = projectId ?? "";
const createMutation = useCreateEpisode(pid);
const updateMutation = useUpdateEpisode(pid);
const [draft, setDraft] = useState<EpisodeDraft | null>(null);
const nextNumber =
episodes.length === 0
? 1
: Math.max(...episodes.map((e) => e.episodeNumber || 0)) + 1;
const openCreate = () =>
setDraft({ episodeNumber: nextNumber, title: "", summary: "", script: "" });
const openEdit = (ep: import("../../lib/api/ai").Episode) =>
setDraft({
id: ep.id,
episodeNumber: ep.episodeNumber,
title: ep.title ?? "",
summary: ep.summary ?? "",
script: ep.script ?? "",
});
const submit = async () => {
if (!draft) return;
if (!draft.episodeNumber || draft.episodeNumber <= 0) return;
const payload = {
episodeNumber: draft.episodeNumber,
title: draft.title,
summary: draft.summary,
script: draft.script,
};
if (draft.id) {
await updateMutation.mutateAsync({ id: draft.id, patch: payload });
} else {
await createMutation.mutateAsync(payload);
}
setDraft(null);
};
if (episodes.length === 0) {
return (
<>
{outlineExists && (
<div className="rounded-xl border border-dashed border-border bg-card p-8 text-center">
<p className="text-sm text-muted-foreground mb-4">大纲已生成,可由 AI 生成分集,或手动新增</p>
<div className="flex items-center gap-3 justify-center">
<button
onClick={onNavigateOutline}
className="px-5 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2"
>
<Sparkles className="w-4 h-4" />
生成分集
</button>
<button
onClick={openCreate}
className="px-5 py-2 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-all text-sm flex items-center gap-2"
>
<Plus className="w-4 h-4" />
手动新增一集
</button>
</div>
</div>
)}
{draft && (
<EpisodeEditModal
draft={draft}
setDraft={setDraft}
onClose={() => setDraft(null)}
onSubmit={submit}
saving={createMutation.isPending || updateMutation.isPending}
/>
)}
</>
);
}
return (
<div> <div>
<h2 className="text-lg font-semibold text-foreground mb-4"> <div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-foreground">
分集列表 ({episodes.length} 集) 分集列表 ({episodes.length} 集)
</h2> </h2>
<button
onClick={openCreate}
className="text-sm text-primary hover:underline flex items-center gap-1"
>
<Plus className="w-4 h-4" />
新增一集
</button>
</div>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
{episodes.map((ep) => ( {episodes.map((ep) => (
<div <div
key={ep.id} key={ep.id}
onClick={() => onNavigateEpisode(ep.id)} onClick={() => onNavigateEpisode(ep.id)}
className="rounded-xl border border-border bg-card p-4 hover:border-primary transition-colors cursor-pointer" className="relative rounded-xl border border-border bg-card p-4 hover:border-primary transition-colors cursor-pointer"
>
<button
onClick={(e) => {
e.stopPropagation();
openEdit(ep);
}}
className="absolute top-2 right-2 p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title="编辑本集"
> >
<Edit2 className="w-3.5 h-3.5" />
</button>
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<span className="text-xs font-medium text-muted-foreground bg-accent px-2 py-0.5 rounded"> <span className="text-xs font-medium text-muted-foreground bg-accent px-2 py-0.5 rounded">
{ep.episodeNumber} {ep.episodeNumber}
</span> </span>
</div> </div>
<h3 className="font-medium text-foreground text-sm mb-1">{ep.title}</h3> <h3 className="font-medium text-foreground text-sm mb-1 pr-6">{ep.title}</h3>
<p className="text-xs text-muted-foreground line-clamp-3">{ep.summary}</p> <p className="text-xs text-muted-foreground line-clamp-3">{ep.summary}</p>
</div> </div>
))} ))}
</div> </div>
</div> {draft && (
<EpisodeEditModal
draft={draft}
setDraft={setDraft}
onClose={() => setDraft(null)}
onSubmit={submit}
saving={createMutation.isPending || updateMutation.isPending}
/>
)} )}
</div>
);
}
{episodes.length === 0 && outline && ( function EpisodeEditModal({
<div className="rounded-xl border border-dashed border-border bg-card p-8 text-center"> draft,
<p className="text-sm text-muted-foreground mb-4">大纲已生成,点击前往生成分集内容</p> setDraft,
onClose,
onSubmit,
saving,
}: {
draft: EpisodeDraft;
setDraft: (d: EpisodeDraft) => void;
onClose: () => void;
onSubmit: () => void;
saving: boolean;
}) {
const isEdit = !!draft.id;
return (
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm flex items-center justify-center z-50 p-6">
<div className="bg-card rounded-2xl border border-border w-full max-w-xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between p-5 border-b border-border flex-shrink-0">
<h3 className="text-base font-semibold text-foreground">
{isEdit ? "编辑分集" : "新增分集"}
</h3>
<button <button
onClick={onNavigateOutline} onClick={onClose}
className="px-5 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2 mx-auto" className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
> >
<Sparkles className="w-4 h-4" /> <X className="w-4 h-4" />
生成分集
</button> </button>
</div> </div>
)} <div className="p-5 space-y-4 overflow-auto">
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">集数</label>
<input
type="number"
min={1}
value={draft.episodeNumber}
onChange={(e) =>
setDraft({ ...draft, episodeNumber: Number(e.target.value) || 0 })
}
className="w-32 px-3 py-2 rounded-lg border border-border bg-background text-foreground text-sm focus:outline-none focus:border-primary"
/>
<p className="text-xs text-muted-foreground mt-1">允许重复,请自行确保唯一</p>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">本集标题</label>
<input
type="text"
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground text-sm focus:outline-none focus:border-primary"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">剧情摘要</label>
<textarea
value={draft.summary}
onChange={(e) => setDraft({ ...draft, summary: e.target.value })}
rows={3}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground text-sm focus:outline-none focus:border-primary resize-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-foreground mb-1.5">本集剧本正文</label>
<textarea
value={draft.script}
onChange={(e) => setDraft({ ...draft, script: e.target.value })}
rows={8}
className="w-full px-3 py-2 rounded-lg border border-border bg-background text-foreground text-sm focus:outline-none focus:border-primary resize-none font-mono"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 p-5 border-t border-border flex-shrink-0">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg border border-border text-foreground hover:bg-muted transition-colors text-sm"
>
取消
</button>
<button
onClick={onSubmit}
disabled={saving || !draft.episodeNumber}
className="px-4 py-2 rounded-lg bg-gradient-to-r from-[#5b5ff9] to-[#8b5ff9] text-white hover:shadow-md transition-all text-sm flex items-center gap-2 disabled:opacity-60 disabled:cursor-not-allowed"
>
{saving && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
保存
</button>
</div>
</div>
</div> </div>
); );
} }
......
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { aiApi } from "../lib/api/ai"; import { aiApi } from "../lib/api/ai";
import type { Character, Scene, Storyboard, StructuredVideoRequest } from "../lib/api/ai"; import type { Character, Episode, Scene, Storyboard, StructuredVideoRequest } from "../lib/api/ai";
// ---- Characters ---- // ---- Characters ----
const charactersKey = (pid: string) => ["characters", pid]; const charactersKey = (pid: string) => ["characters", pid];
...@@ -102,6 +102,23 @@ export function useGenerateEpisodes(projectId: string) { ...@@ -102,6 +102,23 @@ export function useGenerateEpisodes(projectId: string) {
}); });
} }
export function useCreateEpisode(projectId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: (req: Partial<Episode>) => aiApi.createEpisode(projectId, req),
onSuccess: () => qc.invalidateQueries({ queryKey: episodesKey(projectId) }),
});
}
export function useUpdateEpisode(projectId: string) {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, patch }: { id: string; patch: Partial<Episode> }) =>
aiApi.updateEpisode(projectId, id, patch),
onSuccess: () => qc.invalidateQueries({ queryKey: episodesKey(projectId) }),
});
}
// ---- Storyboards ---- // ---- Storyboards ----
export function useStoryboards(projectId: string, episodeId: string) { export function useStoryboards(projectId: string, episodeId: string) {
return useQuery({ return useQuery({
......
...@@ -160,6 +160,14 @@ export const aiApi = { ...@@ -160,6 +160,14 @@ export const aiApi = {
const r = await apiClient.get(`/projects/${projectId}/episodes`); const r = await apiClient.get(`/projects/${projectId}/episodes`);
return r.data.data; return r.data.data;
}, },
createEpisode: async (projectId: string, req: Partial<Episode>): Promise<Episode> => {
const r = await apiClient.post(`/projects/${projectId}/episodes`, req);
return r.data.data;
},
updateEpisode: async (projectId: string, episodeId: string, patch: Partial<Episode>): Promise<Episode> => {
const r = await apiClient.put(`/projects/${projectId}/episodes/${episodeId}`, patch);
return r.data.data;
},
// ---- Storyboards ---- // ---- Storyboards ----
createStoryboard: async (projectId: string, episodeId: string, req: Partial<Storyboard>): Promise<Storyboard> => { createStoryboard: async (projectId: string, episodeId: string, req: Partial<Storyboard>): Promise<Storyboard> => {
......
...@@ -35,6 +35,10 @@ LABEL maintainer="YaoAI Team <yaoke251@gmail.com>" ...@@ -35,6 +35,10 @@ LABEL maintainer="YaoAI Team <yaoke251@gmail.com>"
LABEL org.opencontainers.image.title="YaoAI Comic Studio" LABEL org.opencontainers.image.title="YaoAI Comic Studio"
LABEL org.opencontainers.image.version="0.1.0" LABEL org.opencontainers.image.version="0.1.0"
ENV LANG=C.UTF-8 \
LANGUAGE=C.UTF-8 \
LC_ALL=C.UTF-8
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends ffmpeg \ && apt-get install -y --no-install-recommends ffmpeg \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
...@@ -52,6 +56,6 @@ USER yaoai ...@@ -52,6 +56,6 @@ USER yaoai
EXPOSE 8080 EXPOSE 8080
ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC -Djava.security.egd=file:/dev/./urandom" ENV JAVA_OPTS="-Xms256m -Xmx512m -XX:+UseG1GC -Djava.security.egd=file:/dev/./urandom -Dfile.encoding=UTF-8 -Dstdout.encoding=UTF-8 -Dstderr.encoding=UTF-8"
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
package com.yaoai.agent.sse; package com.yaoai.agent.sse;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
...@@ -10,21 +12,48 @@ import java.util.List; ...@@ -10,21 +12,48 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
@Slf4j @Slf4j
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor
public class AgentSseManager { public class AgentSseManager {
private static final long HEARTBEAT_INTERVAL_SECONDS = 15;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final Map<Long, List<SseEmitter>> emitters = new ConcurrentHashMap<>(); private final Map<Long, List<SseEmitter>> emitters = new ConcurrentHashMap<>();
private final ScheduledExecutorService heartbeatExecutor = Executors.newSingleThreadScheduledExecutor(r -> {
Thread t = new Thread(r, "agent-sse-heartbeat");
t.setDaemon(true);
return t;
});
@PostConstruct
void startHeartbeat() {
heartbeatExecutor.scheduleAtFixedRate(this::heartbeatAll,
HEARTBEAT_INTERVAL_SECONDS,
HEARTBEAT_INTERVAL_SECONDS,
TimeUnit.SECONDS);
}
@PreDestroy
void shutdownHeartbeat() {
heartbeatExecutor.shutdownNow();
}
public SseEmitter subscribe(Long runId) { public SseEmitter subscribe(Long runId) {
SseEmitter emitter = new SseEmitter(300_000L); SseEmitter emitter = new SseEmitter(0L);
List<SseEmitter> list = emitters.computeIfAbsent(runId, k -> new CopyOnWriteArrayList<>()); List<SseEmitter> list = emitters.computeIfAbsent(runId, k -> new CopyOnWriteArrayList<>());
list.add(emitter); list.add(emitter);
emitter.onCompletion(() -> remove(runId, emitter)); emitter.onCompletion(() -> remove(runId, emitter));
emitter.onTimeout(() -> remove(runId, emitter)); emitter.onTimeout(() -> {
log.debug("SSE timeout: runId={}", runId);
remove(runId, emitter);
emitter.complete();
});
emitter.onError(e -> remove(runId, emitter)); emitter.onError(e -> remove(runId, emitter));
log.debug("SSE subscribed: runId={}, total={}", runId, list.size()); log.debug("SSE subscribed: runId={}, total={}", runId, list.size());
return emitter; return emitter;
...@@ -57,6 +86,18 @@ public class AgentSseManager { ...@@ -57,6 +86,18 @@ public class AgentSseManager {
"time", java.time.LocalTime.now().toString().substring(0, 8))); "time", java.time.LocalTime.now().toString().substring(0, 8)));
} }
private void heartbeatAll() {
emitters.forEach((runId, list) -> list.removeIf(emitter -> {
try {
emitter.send(SseEmitter.event().comment("ping"));
return false;
} catch (Exception e) {
log.debug("Removed heartbeat-broken SSE emitter for runId={}", runId);
return true;
}
}));
}
private void remove(Long runId, SseEmitter emitter) { private void remove(Long runId, SseEmitter emitter) {
List<SseEmitter> list = emitters.get(runId); List<SseEmitter> list = emitters.get(runId);
if (list != null) list.remove(emitter); if (list != null) list.remove(emitter);
......
...@@ -5,6 +5,7 @@ import com.yaoai.api.dto.ai.OutlineDTO; ...@@ -5,6 +5,7 @@ import com.yaoai.api.dto.ai.OutlineDTO;
import com.yaoai.common.exception.BizException; import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode; import com.yaoai.common.exception.ErrorCode;
import com.yaoai.common.response.ApiResponse; import com.yaoai.common.response.ApiResponse;
import com.yaoai.domain.entity.Episode;
import com.yaoai.domain.entity.Outline; import com.yaoai.domain.entity.Outline;
import com.yaoai.domain.mapper.StoryboardMapper; import com.yaoai.domain.mapper.StoryboardMapper;
import com.yaoai.pipeline.service.OutlinePipelineService; import com.yaoai.pipeline.service.OutlinePipelineService;
...@@ -87,4 +88,23 @@ public class OutlineController { ...@@ -87,4 +88,23 @@ public class OutlineController {
.collect(Collectors.toList()) .collect(Collectors.toList())
); );
} }
@Operation(summary = "手动新增一集")
@PostMapping("/episodes")
public ApiResponse<EpisodeDTO> createEpisode(@PathVariable Long projectId,
@RequestBody Episode req) {
Long tenantId = TenantContext.get();
Episode ep = outlinePipelineService.createEpisode(projectId, tenantId, req);
return ApiResponse.success(EpisodeDTO.from(ep, 0));
}
@Operation(summary = "编辑单集(episodeNumber/title/summary/script)")
@PutMapping("/episodes/{episodeId}")
public ApiResponse<EpisodeDTO> updateEpisode(@PathVariable Long projectId,
@PathVariable Long episodeId,
@RequestBody Episode req) {
Long tenantId = TenantContext.get();
Episode ep = outlinePipelineService.updateEpisode(projectId, tenantId, episodeId, req);
return ApiResponse.success(EpisodeDTO.from(ep, storyboardMapper.countByEpisode(ep.getId(), tenantId)));
}
} }
...@@ -12,6 +12,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; ...@@ -12,6 +12,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.async.AsyncRequestTimeoutException;
import java.util.stream.Collectors; import java.util.stream.Collectors;
...@@ -56,6 +57,12 @@ public class GlobalExceptionHandler { ...@@ -56,6 +57,12 @@ public class GlobalExceptionHandler {
return ApiResponse.error(ErrorCode.INVALID_PARAM.getCode(), e.getMessage()); return ApiResponse.error(ErrorCode.INVALID_PARAM.getCode(), e.getMessage());
} }
@ExceptionHandler(AsyncRequestTimeoutException.class)
@ResponseStatus(HttpStatus.NO_CONTENT)
public void handleAsyncTimeout(AsyncRequestTimeoutException e) {
log.debug("Async request timeout", e);
}
@ExceptionHandler(Exception.class) @ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<?> handleException(Exception e) { public ApiResponse<?> handleException(Exception e) {
......
...@@ -43,6 +43,7 @@ ...@@ -43,6 +43,7 @@
<!-- ===== non-local: file only ===== --> <!-- ===== non-local: file only ===== -->
<springProfile name="!local"> <springProfile name="!local">
<root level="INFO"> <root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="ASYNC_FILE"/> <appender-ref ref="ASYNC_FILE"/>
</root> </root>
</springProfile> </springProfile>
......
...@@ -20,4 +20,14 @@ public interface OutlinePipelineService { ...@@ -20,4 +20,14 @@ public interface OutlinePipelineService {
Outline getOutline(Long projectId, Long tenantId); Outline getOutline(Long projectId, Long tenantId);
List<Episode> getEpisodes(Long projectId, Long tenantId); List<Episode> getEpisodes(Long projectId, Long tenantId);
/**
* 手动新增一集(不调 LLM)。若 req.episodeNumber 为空,自动取当前最大 +1。
*/
Episode createEpisode(Long projectId, Long tenantId, Episode req);
/**
* 手动编辑一集(episodeNumber/title/summary/script,非 null 字段才更新)。
*/
Episode updateEpisode(Long projectId, Long tenantId, Long episodeId, Episode req);
} }
...@@ -209,6 +209,52 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService { ...@@ -209,6 +209,52 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService {
return episodeMapper.findByProject(projectId, tenantId); return episodeMapper.findByProject(projectId, tenantId);
} }
@Override
public Episode createEpisode(Long projectId, Long tenantId, Episode req) {
Outline outline = outlineMapper.findLatestByProject(projectId, tenantId);
if (outline == null) {
throw new BizException(ErrorCode.NOT_FOUND, "请先生成或保存大纲");
}
Integer epNum = req.getEpisodeNumber();
if (epNum == null || epNum <= 0) {
int max = episodeMapper.findByProject(projectId, tenantId).stream()
.mapToInt(e -> e.getEpisodeNumber() == null ? 0 : e.getEpisodeNumber())
.max().orElse(0);
epNum = max + 1;
}
Episode ep = new Episode();
ep.setOutlineId(outline.getId());
ep.setProjectId(projectId);
ep.setTenantId(tenantId);
ep.setEpisodeNumber(epNum);
ep.setTitle(req.getTitle() == null ? "" : req.getTitle());
ep.setSummary(req.getSummary() == null ? "" : req.getSummary());
ep.setScript(req.getScript() == null ? "" : req.getScript());
ep.setStatus("ready");
episodeMapper.insert(ep);
log.info("Episode manually created: id={}, episodeNumber={}", ep.getId(), epNum);
return ep;
}
@Override
public Episode updateEpisode(Long projectId, Long tenantId, Long episodeId, Episode req) {
Episode existing = episodeMapper.selectById(episodeId);
if (existing == null
|| !tenantId.equals(existing.getTenantId())
|| !projectId.equals(existing.getProjectId())) {
throw new BizException(ErrorCode.NOT_FOUND, "分集不存在");
}
if (req.getEpisodeNumber() != null && req.getEpisodeNumber() > 0) {
existing.setEpisodeNumber(req.getEpisodeNumber());
}
if (req.getTitle() != null) existing.setTitle(req.getTitle());
if (req.getSummary() != null) existing.setSummary(req.getSummary());
if (req.getScript() != null) existing.setScript(req.getScript());
episodeMapper.updateById(existing);
log.info("Episode updated: id={}", episodeId);
return existing;
}
private String extractJson(String raw) { private String extractJson(String raw) {
// strip markdown code fences if present // strip markdown code fences if present
String s = raw.strip(); String s = raw.strip();
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment