feat: 完善短剧创作、技能目录与媒体任务恢复

保存原始创作输入,整合剧本打磨、分场规划与流式进度,接入审美及导演技能、角色声音和音乐;保持已批准媒体并完善请求去重与恢复。

统一增量 SQL 目录,补齐新安装字段并修正示例插入列,更新迁移说明;修复测试夹具与当前审阅、重启恢复行为的断言。验证:相关模块编译通过,短剧及 Atlas 回归 337 项、SSE 回归 3 项均通过。
This commit is contained in:
ageerle
2026-10-03 19:11:54 +08:00
parent 91b6b53ce9
commit ca6ef96200
169 changed files with 11185 additions and 1155 deletions

View File

@@ -35,6 +35,9 @@ public class ChatModelBo extends BaseEntity {
@NotBlank(message = "模型分类不能为空", groups = { AddGroup.class, EditGroup.class })
private String category;
/** 默认模型优先级,数值越小越优先;同值按ID排序。 */
private Integer sortOrder;
/**
* 模型名称
*/

View File

@@ -8,4 +8,7 @@ public class ChatModelSelectQuery {
/** Optional model category; defaults to chat at the controller boundary. */
private String category;
/** Optional exact provider filter. */
private String providerCode;
}

View File

@@ -29,9 +29,14 @@ public class ChatModelSelectVo implements Serializable {
/** Provider identifier used for display. */
private String providerCode;
private String category;
private Integer sortOrder;
public static ChatModelSelectVo from(ChatModelVo source) {
ChatModelSelectVo target = new ChatModelSelectVo();
target.setId(source.getId());
target.setCategory(source.getCategory());
target.setSortOrder(source.getSortOrder());
target.setModelName(source.getModelName());
target.setModelDescribe(source.getModelDescribe());
target.setProviderCode(source.getProviderCode());

View File

@@ -39,6 +39,9 @@ public class ChatModelVo implements Serializable {
@ExcelProperty(value = "模型分类")
private String category;
/** 默认模型优先级,数值越小越优先;同值按ID排序。 */
private Integer sortOrder;
/**
* 模型名称
*/

View File

@@ -34,6 +34,9 @@ public class ChatModel extends TenantEntity {
*/
private String category;
/** 默认模型优先级,数值越小越优先;同值按ID排序。 */
private Integer sortOrder;
/**
* 模型名称
*/

View File

@@ -25,6 +25,9 @@ public class VideoContext {
private String quality;
/** Provider output resolution; distinct from aspect ratio and quality. */
private String resolution;
/** 参考图 URL(图生视频模式,单图) */
private String imageUrl;

View File

@@ -50,9 +50,9 @@ public class SaTokenExceptionHandler {
*/
@ExceptionHandler(NotLoginException.class)
public R<Void> handleNotLoginException(NotLoginException e, HttpServletRequest request) {
log.error("authorization_failed category=NOT_LOGIN method={} path={} exceptionType={}",
log.debug("authorization_failed category=NOT_LOGIN method={} path={} exceptionType={}",
request.getMethod(), safePath(request), e.getClass().getName());
return R.fail(HttpStatus.HTTP_UNAUTHORIZED, "认证失败,无法访问系统资源");
return R.fail(HttpStatus.HTTP_UNAUTHORIZED, "请先登录");
}
private static String safePath(HttpServletRequest request) {

View File

@@ -111,25 +111,36 @@ public class SseEmitterManager {
* @param eventDto SSE事件对象
*/
public void sendEvent(String sessionId, SseEventDto eventDto) {
if (!sendLocalEvent(sessionId, eventDto)) {
log.warn("sse_delivery routeType=SESSION status=SKIPPED reason=NO_ACTIVE_CONNECTION activeSessionCount={}",
SESSION_EMITTERS.size());
}
}
/** Deliver locally in order; a terminal event is flushed before its connection is removed. */
public boolean sendLocalEvent(String sessionId, SseEventDto eventDto) {
if (sessionId == null || eventDto == null) {
return;
return false;
}
SseEmitter emitter = SESSION_EMITTERS.get(sessionId);
if (emitter == null) {
log.warn("sse_delivery routeType=SESSION status=SKIPPED reason=NO_ACTIVE_CONNECTION activeSessionCount={}",
SESSION_EMITTERS.size());
return;
return false;
}
try {
log.debug("sse_delivery routeType=SESSION status=SENDING eventType={} payloadChars={}",
safeEventType(eventDto), eventPayloadLength(eventDto));
emitter.send(SseEmitter.event()
.name(eventDto.getEvent())
.data(JSONUtil.toJsonStr(eventDto)));
synchronized (emitter) {
if (SESSION_EMITTERS.get(sessionId) != emitter) return false;
emitter.send(SseEmitter.event()
.name(eventDto.getEvent())
.data(JSONUtil.toJsonStr(eventDto)));
if (Boolean.TRUE.equals(eventDto.getDone())) removeSessionEmitter(sessionId, emitter);
}
} catch (Exception e) {
log.error("sse_delivery routeType=SESSION status=FAILED errorType={}", errorType(e));
removeSessionEmitter(sessionId, emitter);
}
return true;
}
private void removeSessionEmitter(String sessionId, SseEmitter emitter) {

View File

@@ -126,6 +126,8 @@ public class SseMessageUtils {
if (!isEnable() || sessionId == null) {
return;
}
// The producer normally owns this emitter. Do not race a Redis delivery against disconnect.
if (MANAGER.sendLocalEvent(sessionId, eventDto)) return;
SseMessageDto dto = new SseMessageDto();
dto.setSessionId(sessionId);
dto.setEventDto(eventDto);
@@ -210,6 +212,13 @@ public class SseMessageUtils {
sendEvent(sessionId, SseEventDto.error(SAFE_ERROR_MESSAGE));
}
/** Final error only: unlike recoverable error events, this flushes and closes the stream. */
public static void sendErrorAndComplete(String sessionId, String error) {
SseEventDto terminal = SseEventDto.error(SAFE_ERROR_MESSAGE);
terminal.setDone(true);
sendEvent(sessionId, terminal);
}
/**
* 是否开启
*/

View File

@@ -0,0 +1,51 @@
package org.ruoyi.common.sse.core;
import cn.hutool.extra.spring.SpringUtil;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.ruoyi.common.sse.dto.SseEventDto;
import org.springframework.beans.factory.support.DefaultListableBeanFactory;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
@Tag("dev")
class SseTerminalDeliveryTest {
private SseEmitterManager manager;
private Map<String, SseEmitter> emitters;
@BeforeEach @SuppressWarnings("unchecked") void setup() {
var beans = new DefaultListableBeanFactory();
beans.registerSingleton("scheduler", mock(ScheduledExecutorService.class));
new SpringUtil().postProcessBeanFactory(beans);
manager = new SseEmitterManager();
emitters = (Map<String, SseEmitter>) ReflectionTestUtils.getField(SseEmitterManager.class, "SESSION_EMITTERS");
emitters.clear();
}
@Test void doneIsSentBeforeRemovalAndCompletion() throws Exception { assertTerminal(SseEventDto.done(), false); }
@Test void redisTerminalErrorIsSentBeforeRemovalAndCompletion() throws Exception {
var error = SseEventDto.error("safe failure"); error.setDone(true); assertTerminal(error, true);
}
private void assertTerminal(SseEventDto terminal, boolean viaRedisListener) throws Exception {
var emitter = mock(SseEmitter.class); emitters.put("s", emitter);
doAnswer(call -> { assertSame(emitter, emitters.get("s")); return null; }).when(emitter).send(any(SseEmitter.SseEventBuilder.class));
doAnswer(call -> { assertFalse(emitters.containsKey("s")); return null; }).when(emitter).complete();
if (viaRedisListener) manager.sendEvent("s", terminal); else assertTrue(manager.sendLocalEvent("s", terminal));
var ordered = inOrder(emitter); ordered.verify(emitter).send(any(SseEmitter.SseEventBuilder.class)); ordered.verify(emitter).complete();
assertFalse(manager.sendLocalEvent("s", terminal));
}
@Test void normalContentAndRecoverableErrorKeepTheConnectionOpen() throws Exception {
var emitter = mock(SseEmitter.class); emitters.put("s", emitter);
assertTrue(manager.sendLocalEvent("s", SseEventDto.content("正文")));
assertTrue(manager.sendLocalEvent("s", SseEventDto.error("recoverable")));
assertSame(emitter, emitters.get("s")); verify(emitter, never()).complete();
}
}