Commit 21e68a93 authored by yaoke.yk's avatar yaoke.yk

分镜页面优化和运营后台-api

parent 1a494815
...@@ -83,3 +83,105 @@ podAnnotations: ...@@ -83,3 +83,105 @@ podAnnotations:
podDisruptionBudget: podDisruptionBudget:
enabled: true enabled: true
minAvailable: 1 minAvailable: 1
adminServer:
enabled: true
replicaCount: 1
image:
repository: registry.cn-beijing.aliyuncs.com/yaoai/comic-studio
pullPolicy: IfNotPresent
tag: "latest"
service:
type: ClusterIP
port: 8081
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
nginx.ingress.kubernetes.io/proxy-read-timeout: "300"
# Production should restrict this with your office/VPN CIDRs.
# nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8"
hosts:
- host: admin-api.yaoai.com
paths:
- path: /admin-api
pathType: Prefix
tls:
- secretName: yaoai-admin-tls
hosts:
- admin-api.yaoai.com
env:
SPRING_PROFILES_ACTIVE: prod
SERVER_PORT: "8081"
SPRING_FLYWAY_ENABLED: "false"
BILLING_MODE: STRICT
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: 1000m
memory: 1Gi
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8081
initialDelaySeconds: 60
periodSeconds: 15
failureThreshold: 3
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8081
initialDelaySeconds: 30
periodSeconds: 10
failureThreshold: 3
adminWeb:
enabled: true
replicaCount: 1
image:
repository: registry.cn-beijing.aliyuncs.com/yaoai/admin-web
pullPolicy: IfNotPresent
tag: "latest"
service:
type: ClusterIP
port: 80
ingress:
enabled: true
className: nginx
annotations:
nginx.ingress.kubernetes.io/proxy-body-size: "50m"
# Production should restrict this with your office/VPN CIDRs.
# nginx.ingress.kubernetes.io/whitelist-source-range: "10.0.0.0/8"
hosts:
- host: admin.yaoai.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: yaoai-admin-web-tls
hosts:
- admin.yaoai.com
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 15
failureThreshold: 3
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 5
periodSeconds: 10
failureThreshold: 3
...@@ -23,5 +23,33 @@ ...@@ -23,5 +23,33 @@
<groupId>com.yaoai</groupId> <groupId>com.yaoai</groupId>
<artifactId>yaoai-billing</artifactId> <artifactId>yaoai-billing</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.yaoai</groupId>
<artifactId>yaoai-security</artifactId>
</dependency>
<dependency>
<groupId>com.yaoai</groupId>
<artifactId>yaoai-common</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-redis-jackson</artifactId>
</dependency>
</dependencies> </dependencies>
</project> </project>
...@@ -22,4 +22,13 @@ public interface LlmService { ...@@ -22,4 +22,13 @@ public interface LlmService {
ChatMessage.user(userMessage))) ChatMessage.user(userMessage)))
.build()); .build());
} }
/** 默认文本模型 ID,用于计费明细 */
String getModelId();
/** 模型展示名称,用于计费明细 */
String getModelName();
/** Provider 标识,例:volcengine / qwen / openai */
String getProvider();
} }
...@@ -14,22 +14,44 @@ public interface SeedanceService { ...@@ -14,22 +14,44 @@ public interface SeedanceService {
* @return 外部任务 ID * @return 外部任务 ID
*/ */
default String submitVideoTask(String imageUrl, String prompt) { default String submitVideoTask(String imageUrl, String prompt) {
return submitVideoTask(List.of(imageUrl), prompt, 5, false); return submitVideoTask(List.of(imageUrl), prompt, 5, false, "16:9", null);
} }
/** /**
* 提交图生视频任务(多图 + 参数) * 提交图生视频任务(多图 + 参数,model 由实现兜底)
*/
default String submitVideoTask(List<String> imageUrls, String prompt,
int durationSeconds, boolean generateAudio, String ratio) {
return submitVideoTask(imageUrls, prompt, durationSeconds, generateAudio, ratio, null);
}
/**
* 提交图生视频任务(多图 + 参数 + 显式模型)
* *
* @param imageUrls 参考图 URL 列表,顺序对应 @图1、@图2...(至少 1 个) * @param imageUrls 参考图 URL 列表,顺序对应 @图1、@图2...(至少 1 个)
* @param prompt 视频描述提示词,可含 @图N 引用 * @param prompt 视频描述提示词,可含 @图N 引用
* @param durationSeconds 视频时长(秒) * @param durationSeconds 视频时长(秒)
* @param generateAudio 是否生成配音 * @param generateAudio 是否生成配音
* @param ratio 视频宽高比,如 "16:9" / "9:16" / "1:1";空/非法值由实现兜底为 "16:9"
* @param model Seedance 模型 ID;空/null 时由实现回退到 ArkProperties.videoModel
* @return 外部任务 ID * @return 外部任务 ID
*/ */
String submitVideoTask(List<String> imageUrls, String prompt, int durationSeconds, boolean generateAudio); String submitVideoTask(List<String> imageUrls, String prompt,
int durationSeconds, boolean generateAudio, String ratio, String model);
/** /**
* 查询任务状态 * 查询任务状态
*/ */
VideoTaskResult getTaskStatus(String externalTaskId); VideoTaskResult getTaskStatus(String externalTaskId);
/** 当前使用的 Seedance 模型 ID,用于计费明细 */
String getModelId();
/** 模型展示名 */
String getModelName();
/** Provider 标识 */
default String getProvider() {
return "volcengine";
}
} }
...@@ -9,4 +9,15 @@ public interface SeedreamService { ...@@ -9,4 +9,15 @@ public interface SeedreamService {
* @return 生成图片的 URL * @return 生成图片的 URL
*/ */
String generateImage(String prompt); String generateImage(String prompt);
/** 当前使用的 Seedream 模型 ID,用于计费明细 */
String getModelId();
/** 模型展示名 */
String getModelName();
/** Provider 标识 */
default String getProvider() {
return "volcengine";
}
} }
...@@ -54,4 +54,19 @@ public class ArkLlmService implements LlmService { ...@@ -54,4 +54,19 @@ public class ArkLlmService implements LlmService {
throw new BizException(ErrorCode.INTERNAL_ERROR, "AI 服务调用失败: " + e.getMessage()); throw new BizException(ErrorCode.INTERNAL_ERROR, "AI 服务调用失败: " + e.getMessage());
} }
} }
@Override
public String getModelId() {
return properties.getTextModel();
}
@Override
public String getModelName() {
return "豆包 Pro 文本生成";
}
@Override
public String getProvider() {
return "volcengine";
}
} }
...@@ -33,20 +33,34 @@ public class SeedanceServiceImpl implements SeedanceService { ...@@ -33,20 +33,34 @@ public class SeedanceServiceImpl implements SeedanceService {
.build(); .build();
} }
private static final java.util.Set<String> SUPPORTED_RATIOS = java.util.Set.of("16:9", "9:16", "1:1");
@Override @Override
public String submitVideoTask(List<String> imageUrls, String prompt, int durationSeconds, boolean generateAudio) { public String submitVideoTask(List<String> imageUrls, String prompt,
int durationSeconds, boolean generateAudio, String ratio, String model) {
if (imageUrls == null || imageUrls.isEmpty()) { if (imageUrls == null || imageUrls.isEmpty()) {
throw new BizException(ErrorCode.INVALID_PARAM, "至少需要一张参考图"); throw new BizException(ErrorCode.INVALID_PARAM, "至少需要一张参考图");
} }
List<Map<String, Object>> content = new ArrayList<>(); String safeRatio = (ratio != null && SUPPORTED_RATIOS.contains(ratio)) ? ratio : "16:9";
int safeDuration = durationSeconds > 0 ? durationSeconds : 5;
String safeModel = (model != null && !model.isBlank()) ? model : properties.getVideoModel();
// 1. 文本 prompt 放在最前(Seedance 2.0 约定),保留 @图N 原始标注 // Seedance 2.0 优先走 body 字段(generate_audio / ratio / duration / watermark),
// 文本后缀作冗余兜底(部分老接入点仍按 --rt/--dur/--wm 解析)。
StringBuilder textBuilder = new StringBuilder();
if (prompt != null && !prompt.isBlank()) { if (prompt != null && !prompt.isBlank()) {
content.add(Map.of("type", "text", "text", prompt)); textBuilder.append(prompt.trim());
} }
textBuilder.append(" --rt ").append(safeRatio)
.append(" --dur ").append(safeDuration)
.append(" --wm false");
String finalText = textBuilder.toString();
List<Map<String, Object>> content = new ArrayList<>();
content.add(Map.of("type", "text", "text", finalText));
// 2. 按顺序添加参考图 → 对应 @图1、@图2、@图3... // 按顺序添加参考图 → 对应 @图1、@图2、@图3...
for (String url : imageUrls) { for (String url : imageUrls) {
content.add(Map.of( content.add(Map.of(
"type", "image_url", "type", "image_url",
...@@ -56,15 +70,16 @@ public class SeedanceServiceImpl implements SeedanceService { ...@@ -56,15 +70,16 @@ public class SeedanceServiceImpl implements SeedanceService {
} }
Map<String, Object> body = new java.util.LinkedHashMap<>(); Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("model", properties.getVideoModel()); body.put("model", safeModel);
body.put("content", content); body.put("content", content);
body.put("generate_audio", generateAudio); body.put("ratio", safeRatio);
body.put("ratio", "16:9"); body.put("duration", safeDuration);
body.put("duration", durationSeconds > 0 ? durationSeconds : 5);
body.put("watermark", false); body.put("watermark", false);
body.put("generate_audio", generateAudio);
log.info("Seedance submit: model={}, images={}, duration={}s, audio={}", log.info("Seedance submit: model={}, images={}, duration={}s, audio={}, ratio={}, finalTextSuffix='{}'",
properties.getVideoModel(), imageUrls.size(), durationSeconds, generateAudio); safeModel, imageUrls.size(), safeDuration, generateAudio, safeRatio,
finalText.length() > 200 ? finalText.substring(finalText.length() - 200) : finalText);
try { try {
TaskSubmitResponse resp = restClient.post() TaskSubmitResponse resp = restClient.post()
.uri("/contents/generations/tasks") .uri("/contents/generations/tasks")
...@@ -125,6 +140,16 @@ public class SeedanceServiceImpl implements SeedanceService { ...@@ -125,6 +140,16 @@ public class SeedanceServiceImpl implements SeedanceService {
} }
} }
@Override
public String getModelId() {
return properties.getVideoModel();
}
@Override
public String getModelName() {
return "Seedance 图生视频";
}
// ---- helpers ---- // ---- helpers ----
/** /**
......
...@@ -60,6 +60,16 @@ public class SeedreamServiceImpl implements SeedreamService { ...@@ -60,6 +60,16 @@ public class SeedreamServiceImpl implements SeedreamService {
} }
} }
@Override
public String getModelId() {
return properties.getImageModel();
}
@Override
public String getModelName() {
return "Seedream 文生图";
}
// ---- internal response models ---- // ---- internal response models ----
@Data @Data
......
...@@ -2,10 +2,13 @@ package com.yaoai.api.controller; ...@@ -2,10 +2,13 @@ package com.yaoai.api.controller;
import com.yaoai.api.dto.billing.BalanceDTO; import com.yaoai.api.dto.billing.BalanceDTO;
import com.yaoai.api.dto.billing.BillingRecordDTO; import com.yaoai.api.dto.billing.BillingRecordDTO;
import com.yaoai.billing.service.BillingCostService;
import com.yaoai.billing.service.BillingService; import com.yaoai.billing.service.BillingService;
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.Project;
import com.yaoai.domain.mapper.ProjectMapper;
import com.yaoai.security.context.TenantContext; import com.yaoai.security.context.TenantContext;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
...@@ -15,6 +18,10 @@ import org.springframework.web.bind.annotation.*; ...@@ -15,6 +18,10 @@ import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Tag(name = "用量统计") @Tag(name = "用量统计")
@RestController @RestController
...@@ -23,6 +30,8 @@ import java.util.Map; ...@@ -23,6 +30,8 @@ import java.util.Map;
public class UsageController { public class UsageController {
private final BillingService billingService; private final BillingService billingService;
private final BillingCostService billingCostService;
private final ProjectMapper projectMapper;
@Operation(summary = "查询积分余额") @Operation(summary = "查询积分余额")
@GetMapping("/balance") @GetMapping("/balance")
...@@ -31,6 +40,12 @@ public class UsageController { ...@@ -31,6 +40,12 @@ public class UsageController {
return ApiResponse.success(BalanceDTO.from(billingService.getBalance(tenantId))); return ApiResponse.success(BalanceDTO.from(billingService.getBalance(tenantId)));
} }
@Operation(summary = "查询计费价目(operation -> credits)")
@GetMapping("/costs")
public ApiResponse<Map<String, BigDecimal>> getCosts() {
return ApiResponse.success(billingCostService.getCosts());
}
@Operation(summary = "购买积分资源包(演示:免支付直接到账)") @Operation(summary = "购买积分资源包(演示:免支付直接到账)")
@PostMapping("/purchase") @PostMapping("/purchase")
public ApiResponse<BalanceDTO> purchase(@RequestBody Map<String, Object> body) { public ApiResponse<BalanceDTO> purchase(@RequestBody Map<String, Object> body) {
...@@ -55,7 +70,27 @@ public class UsageController { ...@@ -55,7 +70,27 @@ public class UsageController {
Long tenantId = TenantContext.get(); Long tenantId = TenantContext.get();
List<BillingRecordDTO> list = billingService.getRecords(tenantId, limit, offset) List<BillingRecordDTO> list = billingService.getRecords(tenantId, limit, offset)
.stream().map(BillingRecordDTO::from).toList(); .stream().map(BillingRecordDTO::from).toList();
Map<Long, Project> projects = loadProjects(tenantId, list);
list.forEach(record -> {
Project project = projects.get(record.getProjectId());
if (project != null) {
record.setProjectName(project.getName());
}
});
long total = billingService.countRecords(tenantId); long total = billingService.countRecords(tenantId);
return ApiResponse.success(Map.of("total", total, "items", list)); return ApiResponse.success(Map.of("total", total, "items", list));
} }
private Map<Long, Project> loadProjects(Long tenantId, List<BillingRecordDTO> records) {
Set<Long> ids = records.stream()
.map(BillingRecordDTO::getProjectId)
.filter(Objects::nonNull)
.collect(Collectors.toSet());
if (ids.isEmpty()) {
return Map.of();
}
return projectMapper.selectBatchIds(ids).stream()
.filter(project -> tenantId.equals(project.getTenantId()))
.collect(Collectors.toMap(Project::getId, Function.identity()));
}
} }
package com.yaoai.api.controller; package com.yaoai.api.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.yaoai.api.dto.ai.AiTaskDTO; import com.yaoai.api.dto.ai.AiTaskDTO;
import com.yaoai.api.dto.ai.StructuredVideoGenerateRequest;
import com.yaoai.api.dto.ai.UserPrompt;
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.pipeline.dto.StructuredVideoSubmission;
import com.yaoai.pipeline.service.VideoTaskPipelineService; import com.yaoai.pipeline.service.VideoTaskPipelineService;
import com.yaoai.security.context.TenantContext; import com.yaoai.security.context.TenantContext;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
...@@ -24,7 +30,72 @@ public class VideoTaskController { ...@@ -24,7 +30,72 @@ public class VideoTaskController {
private final VideoTaskPipelineService videoTaskPipelineService; private final VideoTaskPipelineService videoTaskPipelineService;
@Operation(summary = "文生图→图生视频(异步,立即返回 pending 任务,后台执行图片生成)") @Operation(summary = "结构化视频生成(推荐):左图(角色/场景/道具/风格)+ 右结构化提示词")
@PostMapping("/generate-structured")
public ApiResponse<AiTaskDTO> generateStructured(@PathVariable Long projectId,
@RequestBody @Valid StructuredVideoGenerateRequest req) {
UserPrompt up = req.getUserPrompt();
boolean hasFreeform = req.getFreeformPrompt() != null && !req.getFreeformPrompt().isBlank();
boolean hasStructured = up != null && up.hasAnyContent();
if (!hasFreeform && !hasStructured) {
throw new BizException(ErrorCode.INVALID_PARAM, "请填写分镜提示词");
}
List<String> characterKeys = req.getCharacterImageKeys();
if ((characterKeys == null || characterKeys.isEmpty())
&& (req.getCharacterImageKey() == null || req.getCharacterImageKey().isBlank())) {
throw new BizException(ErrorCode.INVALID_PARAM, "请至少选择一个出镜角色");
}
Long tenantId = TenantContext.get();
StructuredVideoSubmission submission = StructuredVideoSubmission.builder()
.userId(StpUtil.getLoginIdAsLong())
.episodeId(req.getEpisodeId())
.storyboardId(req.getStoryboardId())
.characterImageKey(req.getCharacterImageKey())
.characterImageKeys(characterKeys)
.sceneImageKey(req.getSceneImageKey())
.propImageKeys(req.getPropImageKeys())
.styleImageKey(req.getStyleImageKey())
.characterAction(up != null ? up.getCharacterAction() : null)
.sceneEvent(up != null ? up.getSceneEvent() : null)
.cameraMovement(up != null ? up.getCameraMovement() : null)
.lightingAtmosphere(up != null ? up.getLightingAtmosphere() : null)
.videoStyle(up != null ? up.getVideoStyle() : null)
.freeformPrompt(req.getFreeformPrompt())
.userPromptRaw(buildUserPromptMap(up, req.getFreeformPrompt()))
.durationSeconds(req.getDuration())
.ratio(req.getRatio())
.model(req.getModel())
.generateAudio(req.getGenerateAudio())
.build();
return ApiResponse.success(AiTaskDTO.from(
videoTaskPipelineService.submitStructuredVideoTask(projectId, tenantId, submission)
));
}
private static Map<String, String> buildUserPromptMap(UserPrompt up, String freeformPrompt) {
Map<String, String> m = new LinkedHashMap<>();
if (freeformPrompt != null && !freeformPrompt.isBlank()) {
m.put("freeformPrompt", freeformPrompt);
}
if (up != null) {
if (up.getCharacterAction() != null) m.put("characterAction", up.getCharacterAction());
if (up.getSceneEvent() != null) m.put("sceneEvent", up.getSceneEvent());
if (up.getCameraMovement() != null) m.put("cameraMovement", up.getCameraMovement());
if (up.getLightingAtmosphere() != null) m.put("lightingAtmosphere", up.getLightingAtmosphere());
if (up.getVideoStyle() != null) m.put("videoStyle", up.getVideoStyle());
}
return m;
}
/**
* @deprecated 由 {@code POST /generate-structured} 替代;保留 1 个版本周期供前端回滚。
* 旧路径:prompt 内嵌 @图N + image_keys 顺序绑定。
*/
@Deprecated
@Operation(summary = "[DEPRECATED] 文生图→图生视频(异步,自然语言 @图N 方式)")
@PostMapping("/generate") @PostMapping("/generate")
public ApiResponse<AiTaskDTO> generateFromText(@PathVariable Long projectId, public ApiResponse<AiTaskDTO> generateFromText(@PathVariable Long projectId,
@RequestBody Map<String, Object> body) { @RequestBody Map<String, Object> body) {
...@@ -53,7 +124,7 @@ public class VideoTaskController { ...@@ -53,7 +124,7 @@ public class VideoTaskController {
Long tenantId = TenantContext.get(); Long tenantId = TenantContext.get();
return ApiResponse.success(AiTaskDTO.from( return ApiResponse.success(AiTaskDTO.from(
videoTaskPipelineService.submitTextToVideoAsync(projectId, tenantId, episodeId, storyboardId, videoTaskPipelineService.submitTextToVideoAsync(projectId, tenantId, StpUtil.getLoginIdAsLong(), episodeId, storyboardId,
prompt, videoPrompt, preImageKeys, duration) prompt, videoPrompt, preImageKeys, duration)
)); ));
} }
......
package com.yaoai.api.dto.ai; package com.yaoai.api.dto.ai;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.domain.entity.AiTask; import com.yaoai.domain.entity.AiTask;
import lombok.Data; import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Slf4j
@Data @Data
public class AiTaskDTO { public class AiTaskDTO {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final TypeReference<List<String>> LIST_STRING_TYPE = new TypeReference<>() {};
private static final TypeReference<Map<String, String>> MAP_STRING_TYPE = new TypeReference<>() {};
private Long id; private Long id;
private Long projectId; private Long projectId;
private Long episodeId; private Long episodeId;
...@@ -19,6 +29,22 @@ public class AiTaskDTO { ...@@ -19,6 +29,22 @@ public class AiTaskDTO {
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
/** 系统拼接后提交给 Seedance 的最终 Prompt(含 @图N) */
private String prompt;
/** 结构化字段:仅 task_type=structured_video 时非空 */
private String characterImageKey;
/** V16:多角色出镜列表(优先于 characterImageKey) */
private List<String> characterImageKeys;
private String sceneImageKey;
private List<String> propImageKeys;
private String styleImageKey;
/** 用户原始结构化输入 */
private Map<String, String> userPrompt;
private Integer videoDuration;
private String videoRatio;
private Boolean generateAudio;
public static AiTaskDTO from(AiTask t) { public static AiTaskDTO from(AiTask t) {
AiTaskDTO dto = new AiTaskDTO(); AiTaskDTO dto = new AiTaskDTO();
dto.setId(t.getId()); dto.setId(t.getId());
...@@ -31,6 +57,43 @@ public class AiTaskDTO { ...@@ -31,6 +57,43 @@ public class AiTaskDTO {
dto.setErrorMessage(t.getErrorMessage()); dto.setErrorMessage(t.getErrorMessage());
dto.setCreatedAt(t.getCreatedAt()); dto.setCreatedAt(t.getCreatedAt());
dto.setUpdatedAt(t.getUpdatedAt()); dto.setUpdatedAt(t.getUpdatedAt());
dto.setPrompt(t.getPrompt());
dto.setCharacterImageKey(t.getCharacterImageKey());
dto.setSceneImageKey(t.getSceneImageKey());
dto.setStyleImageKey(t.getStyleImageKey());
dto.setVideoDuration(t.getVideoDuration());
dto.setVideoRatio(t.getVideoRatio());
dto.setGenerateAudio(t.getGenerateAudio());
dto.setPropImageKeys(parseList(t.getPropImageKeys(), "propImageKeys", t.getId()));
dto.setCharacterImageKeys(parseList(t.getCharacterImageKeys(), "characterImageKeys", t.getId()));
dto.setUserPrompt(parseMap(t.getUserPrompt(), "userPrompt", t.getId()));
return dto; return dto;
} }
private static List<String> parseList(String json, String fieldName, Long taskId) {
if (json == null || json.isBlank()) {
return null;
}
try {
return OBJECT_MAPPER.readValue(json, LIST_STRING_TYPE);
} catch (Exception e) {
log.warn("Failed to parse {} for task {}: {}", fieldName, taskId, e.getMessage());
return null;
}
}
private static Map<String, String> parseMap(String json, String fieldName, Long taskId) {
if (json == null || json.isBlank()) {
return null;
}
try {
return OBJECT_MAPPER.readValue(json, MAP_STRING_TYPE);
} catch (Exception e) {
log.warn("Failed to parse {} for task {}: {}", fieldName, taskId, e.getMessage());
return null;
}
}
} }
package com.yaoai.api.dto.ai; package com.yaoai.api.dto.ai;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.domain.entity.Storyboard; import com.yaoai.domain.entity.Storyboard;
import lombok.Data; import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
@Slf4j
@Data @Data
public class StoryboardDTO { public class StoryboardDTO {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final TypeReference<List<String>> LIST_STRING_TYPE = new TypeReference<>() {};
private Long id; private Long id;
private Long episodeId; private Long episodeId;
private Integer sequenceNum; private Integer sequenceNum;
...@@ -26,6 +34,12 @@ public class StoryboardDTO { ...@@ -26,6 +34,12 @@ public class StoryboardDTO {
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
/** V16:分镜上落地的素材选择 */
private List<String> characterImageKeys;
private String sceneImageKey;
private List<String> propImageKeys;
private String styleImageKey;
public static StoryboardDTO from(Storyboard s) { public static StoryboardDTO from(Storyboard s) {
StoryboardDTO dto = new StoryboardDTO(); StoryboardDTO dto = new StoryboardDTO();
dto.setId(s.getId()); dto.setId(s.getId());
...@@ -45,6 +59,21 @@ public class StoryboardDTO { ...@@ -45,6 +59,21 @@ public class StoryboardDTO {
dto.setStatus(s.getStatus()); dto.setStatus(s.getStatus());
dto.setCreatedAt(s.getCreatedAt()); dto.setCreatedAt(s.getCreatedAt());
dto.setUpdatedAt(s.getUpdatedAt()); dto.setUpdatedAt(s.getUpdatedAt());
dto.setCharacterImageKeys(parseList(s.getCharacterImageKeys(), "characterImageKeys", s.getId()));
dto.setSceneImageKey(s.getSceneImageKey());
dto.setPropImageKeys(parseList(s.getPropImageKeys(), "propImageKeys", s.getId()));
dto.setStyleImageKey(s.getStyleImageKey());
return dto; return dto;
} }
private static List<String> parseList(String json, String fieldName, Long id) {
if (json == null || json.isBlank()) return null;
try {
return OBJECT_MAPPER.readValue(json, LIST_STRING_TYPE);
} catch (Exception e) {
log.warn("Failed to parse {} for storyboard {}: {}", fieldName, id, e.getMessage());
return null;
}
}
} }
...@@ -11,7 +11,15 @@ public class BillingRecordDTO { ...@@ -11,7 +11,15 @@ public class BillingRecordDTO {
private Long id; private Long id;
private Long projectId; private Long projectId;
private String projectName;
private String operation; private String operation;
private String modality;
private String modelProvider;
private String modelId;
private String modelName;
private Integer unitCount;
private Boolean success;
private String billingMeta;
private BigDecimal credits; private BigDecimal credits;
private String billingMode; private String billingMode;
private String refId; private String refId;
...@@ -22,6 +30,13 @@ public class BillingRecordDTO { ...@@ -22,6 +30,13 @@ public class BillingRecordDTO {
dto.setId(r.getId()); dto.setId(r.getId());
dto.setProjectId(r.getProjectId()); dto.setProjectId(r.getProjectId());
dto.setOperation(r.getOperation()); dto.setOperation(r.getOperation());
dto.setModality(r.getModality());
dto.setModelProvider(r.getModelProvider());
dto.setModelId(r.getModelId());
dto.setModelName(r.getModelName());
dto.setUnitCount(r.getUnitCount());
dto.setSuccess(r.getSuccess());
dto.setBillingMeta(r.getBillingMeta());
dto.setCredits(r.getCredits()); dto.setCredits(r.getCredits());
dto.setBillingMode(r.getBillingMode()); dto.setBillingMode(r.getBillingMode());
dto.setRefId(r.getRefId()); dto.setRefId(r.getRefId());
......
...@@ -28,6 +28,10 @@ ...@@ -28,6 +28,10 @@
<artifactId>spring-boot-starter</artifactId> <artifactId>spring-boot-starter</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId> <artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional> <optional>true</optional>
......
package com.yaoai.billing.service; package com.yaoai.billing.service;
import com.yaoai.billing.dto.BillingChargeRequest;
import com.yaoai.domain.entity.BillingRecord; import com.yaoai.domain.entity.BillingRecord;
import com.yaoai.domain.entity.TenantBalance; import com.yaoai.domain.entity.TenantBalance;
...@@ -9,18 +10,47 @@ import java.util.List; ...@@ -9,18 +10,47 @@ import java.util.List;
public interface BillingService { public interface BillingService {
/** /**
* 消费计费:SOFT 只记录;STRICT 先扣余额再记录;OFF 跳过。 * 模型粒度计费(M14 起的主入口):记录模型/用户/单元数/成功标记/JSON 元信息。
* SOFT 只记录;STRICT 先扣余额再记录;OFF 跳过。
*/
void charge(BillingChargeRequest request);
/**
* Estimate credits for a billing request without writing records or changing balance.
*/
BigDecimal estimateCost(BillingChargeRequest request);
/**
* Check current balance before running an external model call. No deduction is performed.
*/
void checkBalance(BillingChargeRequest request);
/**
* Check current balance against an already estimated total. No deduction is performed.
*/
void checkBalance(Long tenantId, BigDecimal credits);
/**
* 旧调用兼容:自动映射到 charge(BillingChargeRequest)。仅记录 modality/operation,缺失模型信息。
* *
* @param tenantId 租户 ID * @deprecated 使用 {@link #charge(BillingChargeRequest)},能落到模型粒度便于运营查询
* @param projectId 关联项目(可为 null)
* @param operation 操作名(outline_generate / image_generate / video_generate / assembly)
* @param refId 关联业务 ID,用于追溯
*/ */
void charge(Long tenantId, Long projectId, String operation, String refId); @Deprecated
default void charge(Long tenantId, Long projectId, String operation, String refId) {
charge(BillingChargeRequest.builder()
.tenantId(tenantId)
.projectId(projectId)
.operation(operation)
.refId(refId)
.build());
}
/** 充值(管理后台 / Webhook 调用) */ /** 充值(管理后台 / Webhook 调用) */
void recharge(Long tenantId, BigDecimal amount, String refId); void recharge(Long tenantId, BigDecimal amount, String refId);
/** 运营手动充值,记录运营 ID */
void rechargeByAdmin(Long tenantId, BigDecimal amount, Long adminId, String refId);
/** 初始化余额行(租户注册时调用,已存在则幂等) */ /** 初始化余额行(租户注册时调用,已存在则幂等) */
void initBalance(Long tenantId, BigDecimal initialCredits); void initBalance(Long tenantId, BigDecimal initialCredits);
......
package com.yaoai.billing.service.impl; package com.yaoai.billing.service.impl;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.billing.config.BillingConfig; import com.yaoai.billing.config.BillingConfig;
import com.yaoai.billing.dto.BillingChargeRequest;
import com.yaoai.billing.service.BillingCostService;
import com.yaoai.billing.service.BillingService; import com.yaoai.billing.service.BillingService;
import com.yaoai.common.exception.BizException; import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode; import com.yaoai.common.exception.ErrorCode;
...@@ -26,52 +30,128 @@ public class BillingServiceImpl implements BillingService { ...@@ -26,52 +30,128 @@ public class BillingServiceImpl implements BillingService {
private final BillingConfig billingConfig; private final BillingConfig billingConfig;
private final TenantBalanceMapper tenantBalanceMapper; private final TenantBalanceMapper tenantBalanceMapper;
private final BillingRecordMapper billingRecordMapper; private final BillingRecordMapper billingRecordMapper;
private final ObjectMapper objectMapper;
private final BillingCostService billingCostService;
@Override @Override
@Transactional @Transactional
@CacheEvict(value = "balance", key = "#tenantId") @CacheEvict(value = "balance", key = "#request.tenantId")
public void charge(Long tenantId, Long projectId, String operation, String refId) { public void charge(BillingChargeRequest request) {
if (billingConfig.isOff()) return; if (billingConfig.isOff()) return;
BigDecimal cost = billingConfig.getCost(operation); boolean isSuccess = isSuccess(request);
if (cost.compareTo(BigDecimal.ZERO) <= 0) return; BigDecimal cost = estimateCost(request);
if (billingConfig.isStrict()) { if (cost.compareTo(BigDecimal.ZERO) > 0 && billingConfig.isStrict() && isSuccess) {
int rows = tenantBalanceMapper.tryDeduct(tenantId, cost); int rows = tenantBalanceMapper.tryDeduct(request.getTenantId(), cost);
if (rows == 0) { if (rows == 0) {
throw new BizException(ErrorCode.INVALID_PARAM, "积分余额不足,请充值后重试"); throwInsufficientBalance();
}
}
BillingRecord r = new BillingRecord();
r.setTenantId(request.getTenantId());
r.setUserId(request.getUserId());
r.setProjectId(request.getProjectId());
r.setOperation(request.getOperation());
r.setModality(request.getModality());
r.setModelProvider(request.getModelProvider());
r.setModelId(request.getModelId());
r.setModelName(request.getModelName());
r.setUnitCount(request.getUnitCount() == null ? 1 : request.getUnitCount());
r.setSuccess(isSuccess);
r.setCredits(cost);
r.setBillingMode(billingConfig.getMode());
r.setRefId(request.getRefId());
if (request.getMeta() != null) {
r.setBillingMeta(toJson(request));
}
billingRecordMapper.insert(r);
log.debug("Billed: tenant={}, op={}, modality={}, model={}, credits={}, success={}",
request.getTenantId(), request.getOperation(), request.getModality(),
request.getModelId(), cost, isSuccess);
}
@Override
@Deprecated
@Transactional
@CacheEvict(value = "balance", key = "#tenantId")
public void charge(Long tenantId, Long projectId, String operation, String refId) {
BillingService.super.charge(tenantId, projectId, operation, refId);
}
@Override
public BigDecimal estimateCost(BillingChargeRequest request) {
if (billingConfig.isOff() || !isSuccess(request)) {
return BigDecimal.ZERO;
}
return request.getCredits() != null
? request.getCredits()
: billingCostService.getCost(request.getOperation());
}
@Override
public void checkBalance(BillingChargeRequest request) {
checkBalance(request.getTenantId(), estimateCost(request));
}
@Override
public void checkBalance(Long tenantId, BigDecimal credits) {
if (billingConfig.isOff() || !billingConfig.isStrict()) {
return;
} }
if (credits == null || credits.compareTo(BigDecimal.ZERO) <= 0) {
return;
} }
TenantBalance balance = tenantBalanceMapper.findByTenantId(tenantId);
BigDecimal available = balance == null || balance.getBalance() == null
? BigDecimal.ZERO
: balance.getBalance();
if (available.compareTo(credits) < 0) {
throwInsufficientBalance();
}
}
@Override
@Transactional
@CacheEvict(value = "balance", key = "#tenantId")
public void recharge(Long tenantId, BigDecimal amount, String refId) {
ensureBalanceRow(tenantId);
tenantBalanceMapper.recharge(tenantId, amount);
BillingRecord record = new BillingRecord(); BillingRecord record = new BillingRecord();
record.setTenantId(tenantId); record.setTenantId(tenantId);
record.setProjectId(projectId); record.setOperation("recharge");
record.setOperation(operation); record.setCredits(amount);
record.setCredits(cost);
record.setBillingMode(billingConfig.getMode()); record.setBillingMode(billingConfig.getMode());
record.setRefId(refId); record.setRefId(refId);
billingRecordMapper.insert(record); billingRecordMapper.insert(record);
log.debug("Billed: tenant={}, op={}, credits={}, mode={}, ref={}", log.info("Recharged: tenant={}, amount={}, ref={}", tenantId, amount, refId);
tenantId, operation, cost, billingConfig.getMode(), refId);
} }
@Override @Override
@Transactional @Transactional
@CacheEvict(value = "balance", key = "#tenantId") @CacheEvict(value = "balance", key = "#tenantId")
public void recharge(Long tenantId, BigDecimal amount, String refId) { public void rechargeByAdmin(Long tenantId, BigDecimal amount, Long adminId, String refId) {
ensureBalanceRow(tenantId); ensureBalanceRow(tenantId);
tenantBalanceMapper.recharge(tenantId, amount); tenantBalanceMapper.recharge(tenantId, amount);
BillingRecord record = new BillingRecord(); BillingRecord record = new BillingRecord();
record.setTenantId(tenantId); record.setTenantId(tenantId);
record.setCreatedByAdminId(adminId);
record.setOperation("recharge"); record.setOperation("recharge");
record.setModality("RECHARGE");
record.setCredits(amount); record.setCredits(amount);
record.setBillingMode(billingConfig.getMode()); record.setBillingMode(billingConfig.getMode());
record.setRefId(refId); record.setRefId(refId);
record.setSuccess(true);
record.setUnitCount(1);
billingRecordMapper.insert(record); billingRecordMapper.insert(record);
log.info("Recharged: tenant={}, amount={}, ref={}", tenantId, amount, refId); log.info("Admin recharge: tenant={}, amount={}, admin={}, ref={}", tenantId, amount, adminId, refId);
} }
@Override @Override
...@@ -123,6 +203,14 @@ public class BillingServiceImpl implements BillingService { ...@@ -123,6 +203,14 @@ public class BillingServiceImpl implements BillingService {
return billingRecordMapper.countByTenant(tenantId); return billingRecordMapper.countByTenant(tenantId);
} }
private String toJson(BillingChargeRequest request) {
try {
return objectMapper.writeValueAsString(request.getMeta());
} catch (JsonProcessingException e) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "计费元数据序列化失败: " + e.getMessage());
}
}
private void ensureBalanceRow(Long tenantId) { private void ensureBalanceRow(Long tenantId) {
if (tenantBalanceMapper.findByTenantId(tenantId) == null) { if (tenantBalanceMapper.findByTenantId(tenantId) == null) {
TenantBalance b = new TenantBalance(); TenantBalance b = new TenantBalance();
...@@ -134,4 +222,12 @@ public class BillingServiceImpl implements BillingService { ...@@ -134,4 +222,12 @@ public class BillingServiceImpl implements BillingService {
tenantBalanceMapper.insert(b); tenantBalanceMapper.insert(b);
} }
} }
private boolean isSuccess(BillingChargeRequest request) {
return request.getSuccess() == null || request.getSuccess();
}
private void throwInsufficientBalance() {
throw new BizException(ErrorCode.INVALID_PARAM, "积分余额不足,请充值后重试");
}
} }
...@@ -21,6 +21,11 @@ ...@@ -21,6 +21,11 @@
<artifactId>yaoai-api</artifactId> <artifactId>yaoai-api</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.yaoai</groupId>
<artifactId>yaoai-admin</artifactId>
</dependency>
<!-- ===== Spring Boot Starters ===== --> <!-- ===== Spring Boot Starters ===== -->
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
......
...@@ -26,6 +26,7 @@ spring: ...@@ -26,6 +26,7 @@ spring:
locations: classpath:db/migration locations: classpath:db/migration
baseline-on-migrate: true baseline-on-migrate: true
validate-on-migrate: true validate-on-migrate: true
out-of-order: true
data: data:
redis: redis:
...@@ -125,13 +126,19 @@ sa-token: ...@@ -125,13 +126,19 @@ sa-token:
is-read-url: true is-read-url: true
billing: billing:
mode: ${BILLING_MODE:SOFT} mode: ${BILLING_MODE:STRICT}
costs: costs:
outline_generate: ${BILLING_COST_OUTLINE:5} outline_generate: ${BILLING_COST_OUTLINE:5}
episode_generate: ${BILLING_COST_EPISODE:3} episode_generate: ${BILLING_COST_EPISODE:3}
image_generate: ${BILLING_COST_IMAGE:2} image_generate: ${BILLING_COST_IMAGE:2}
video_generate: ${BILLING_COST_VIDEO:20} video_generate: ${BILLING_COST_VIDEO:20}
assembly: ${BILLING_COST_ASSEMBLY:5} assembly: ${BILLING_COST_ASSEMBLY:5}
character_extract: ${BILLING_COST_CHARACTER_EXTRACT:3}
scene_extract: ${BILLING_COST_SCENE_EXTRACT:3}
storyboard_generate: ${BILLING_COST_STORYBOARD:5}
storyboard_prompt_generate: ${BILLING_COST_STORYBOARD_PROMPT:1}
character_image_generate: ${BILLING_COST_CHARACTER_IMAGE:6}
scene_image_generate: ${BILLING_COST_SCENE_IMAGE:2}
logging: logging:
config: classpath:logback-spring.xml config: classpath:logback-spring.xml
...@@ -27,5 +27,9 @@ ...@@ -27,5 +27,9 @@
<groupId>cn.hutool</groupId> <groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId> <artifactId>hutool-all</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
</dependencies> </dependencies>
</project> </project>
...@@ -19,7 +19,7 @@ public class AiTask { ...@@ -19,7 +19,7 @@ public class AiTask {
private Long episodeId; private Long episodeId;
private Long storyboardId; private Long storyboardId;
/** image_to_video */ /** image_to_video / text_to_video / ref_image_to_video / structured_video */
private String taskType; private String taskType;
private String externalTaskId; private String externalTaskId;
...@@ -27,12 +27,30 @@ public class AiTask { ...@@ -27,12 +27,30 @@ public class AiTask {
/** pending / submitted / running / succeeded / failed */ /** pending / submitted / running / succeeded / failed */
private String status; private String status;
/** 旧版单图任务的输入;新结构化任务为 null */
private String inputImageKey; private String inputImageKey;
/** 最终提交给 Seedance 的完整 Prompt(含 @图N),新旧任务通用 */
private String prompt; private String prompt;
private String resultVideoUrl; private String resultVideoUrl;
private String resultTosKey; private String resultTosKey;
private String errorMessage; private String errorMessage;
/** 结构化视频生成字段(V14 起,task_type=structured_video 时使用) */
private String characterImageKey;
/** V16 起:多角色出镜,JSON 数组字符串,优先于旧 characterImageKey */
private String characterImageKeys;
private String sceneImageKey;
/** JSON 数组字符串,运行时用 ObjectMapper 序列化/反序列化 */
private String propImageKeys;
private String styleImageKey;
/** 用户原始结构化输入(JSON 字符串),与 prompt 字段配对存证 */
private String userPrompt;
private Integer videoDuration;
private String videoRatio;
private Boolean generateAudio;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }
...@@ -18,9 +18,36 @@ public class BillingRecord { ...@@ -18,9 +18,36 @@ public class BillingRecord {
private Long tenantId; private Long tenantId;
private Long projectId; private Long projectId;
/** 租户内发起此操作的用户 */
private Long userId;
/** 运营手动充值时记录运营 ID */
private Long createdByAdminId;
/** outline_generate / episode_generate / image_generate / video_generate / assembly / recharge */ /** outline_generate / episode_generate / image_generate / video_generate / assembly / recharge */
private String operation; private String operation;
/** TEXT / IMAGE / VIDEO / AUDIO / COMPOSE / RECHARGE */
private String modality;
/** volcengine / qwen / openai / ffmpeg */
private String modelProvider;
/** 模型唯一 ID,如 doubao-seedream-5-0-260128 */
private String modelId;
/** 模型展示名称 */
private String modelName;
/** 单次操作的单元数(图数 / 视频段数 / token 倍率) */
private Integer unitCount;
/** 1=成功 0=失败;失败也记一笔 credits=0 用于审计 */
private Boolean success;
/** JSON 元数据:尺寸、时长、token 数等 */
private String billingMeta;
private BigDecimal credits; private BigDecimal credits;
/** SOFT / STRICT */ /** SOFT / STRICT */
......
...@@ -36,6 +36,12 @@ public class Storyboard { ...@@ -36,6 +36,12 @@ public class Storyboard {
private String endFramePrompt; private String endFramePrompt;
private String notes; private String notes;
/** V16:分镜上落地的素材选择(编辑态记忆) */
private String characterImageKeys;
private String sceneImageKey;
private String propImageKeys;
private String styleImageKey;
/** draft / review / approved / generating / generated / failed */ /** draft / review / approved / generating / generated / failed */
private String status; private String status;
......
...@@ -29,6 +29,14 @@ public class Tenant { ...@@ -29,6 +29,14 @@ public class Tenant {
private BigDecimal frozenCredits; private BigDecimal frozenCredits;
private LocalDateTime suspendedAt;
private Long suspendedBy;
private String suspendedReason;
private String notes;
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
......
...@@ -3,6 +3,7 @@ package com.yaoai.domain.entity; ...@@ -3,6 +3,7 @@ package com.yaoai.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
...@@ -24,6 +25,7 @@ public class User { ...@@ -24,6 +25,7 @@ public class User {
private String avatarUrl; private String avatarUrl;
@JsonIgnore
private String passwordHash; private String passwordHash;
/** admin / member */ /** admin / member */
......
...@@ -36,6 +36,10 @@ ...@@ -36,6 +36,10 @@
<artifactId>spring-boot-starter</artifactId> <artifactId>spring-boot-starter</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>com.yaoai</groupId> <groupId>com.yaoai</groupId>
<artifactId>yaoai-billing</artifactId> <artifactId>yaoai-billing</artifactId>
</dependency> </dependency>
......
...@@ -2,6 +2,7 @@ package com.yaoai.pipeline.async; ...@@ -2,6 +2,7 @@ package com.yaoai.pipeline.async;
import com.yaoai.ai.providers.model.VideoTaskResult; import com.yaoai.ai.providers.model.VideoTaskResult;
import com.yaoai.ai.providers.service.SeedanceService; import com.yaoai.ai.providers.service.SeedanceService;
import com.yaoai.billing.dto.BillingChargeRequest;
import com.yaoai.billing.service.BillingService; import com.yaoai.billing.service.BillingService;
import com.yaoai.domain.entity.AiTask; import com.yaoai.domain.entity.AiTask;
import com.yaoai.domain.mapper.AiTaskMapper; import com.yaoai.domain.mapper.AiTaskMapper;
...@@ -12,8 +13,10 @@ import lombok.extern.slf4j.Slf4j; ...@@ -12,8 +13,10 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
@Slf4j @Slf4j
@Component @Component
...@@ -33,14 +36,43 @@ public class VideoTaskAsyncProcessor { ...@@ -33,14 +36,43 @@ public class VideoTaskAsyncProcessor {
* *
* @param preImageKeys 按 @图1/@图2... 顺序排列的 TOS key 列表,空列表则走文生图 * @param preImageKeys 按 @图1/@图2... 顺序排列的 TOS key 列表,空列表则走文生图
* @param durationSeconds 视频时长(秒),<=0 则使用默认值 5 * @param durationSeconds 视频时长(秒),<=0 则使用默认值 5
* @param ratio 视频宽高比,如 "16:9" / "9:16" / "1:1",空/非法值由下游兜底
* @param generateAudio 是否生成配音
*/ */
@Async @Async
public void processTextToVideo(Long taskId, Long tenantId, Long projectId, public void processTextToVideo(Long taskId, Long tenantId, Long userId, Long projectId,
String imagePrompt, String videoPrompt, String imagePrompt, String videoPrompt,
List<String> preImageKeys, int durationSeconds) { List<String> preImageKeys, int durationSeconds,
log.info("Async text-to-video started: taskId={}, preImages={}, duration={}s", String ratio, boolean generateAudio, String model) {
taskId, preImageKeys.size(), durationSeconds); log.info("Async text-to-video started: taskId={}, preImages={}, duration={}s, ratio={}, audio={}, model={}",
taskId, preImageKeys.size(), durationSeconds, ratio, generateAudio, model);
try { try {
BillingChargeRequest videoCharge = BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(userId)
.projectId(projectId)
.operation("video_generate")
.modality("VIDEO")
.modelProvider(seedanceService.getProvider())
.modelId(seedanceService.getModelId())
.modelName(seedanceService.getModelName())
.unitCount(1)
.refId(String.valueOf(taskId))
.build();
BigDecimal estimatedCost = billingService.estimateCost(videoCharge);
if (preImageKeys.isEmpty()) {
estimatedCost = estimatedCost.add(billingService.estimateCost(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(userId)
.projectId(projectId)
.operation("image_generate")
.modality("IMAGE")
.unitCount(1)
.meta(Map.of("size", "2048x2048"))
.build()));
}
billingService.checkBalance(tenantId, estimatedCost);
List<String> presignedUrls = new ArrayList<>(); List<String> presignedUrls = new ArrayList<>();
String primaryImageKey; String primaryImageKey;
...@@ -53,14 +85,14 @@ public class VideoTaskAsyncProcessor { ...@@ -53,14 +85,14 @@ public class VideoTaskAsyncProcessor {
log.info("Using {} pre-existing reference image(s): taskId={}", preImageKeys.size(), taskId); log.info("Using {} pre-existing reference image(s): taskId={}", preImageKeys.size(), taskId);
} else { } else {
// 无预设参考图:调用 Seedream 文生图 // 无预设参考图:调用 Seedream 文生图
primaryImageKey = imageGenPipelineService.generateAndStore(tenantId, projectId, imagePrompt); primaryImageKey = imageGenPipelineService.generateAndStore(tenantId, userId, projectId, imagePrompt);
presignedUrls.add(tosService.presignedGetUrl(primaryImageKey, 3600)); presignedUrls.add(tosService.presignedGetUrl(primaryImageKey, 3600));
log.info("Image generated and stored: taskId={}, key={}", taskId, primaryImageKey); log.info("Image generated and stored: taskId={}, key={}", taskId, primaryImageKey);
} }
// 提交 Seedance,传入所有参考图 + 时长 // 提交 Seedance,传入所有参考图 + 时长
int duration = durationSeconds > 0 ? durationSeconds : 5; int duration = durationSeconds > 0 ? durationSeconds : 5;
String externalTaskId = seedanceService.submitVideoTask(presignedUrls, videoPrompt, duration, true); String externalTaskId = seedanceService.submitVideoTask(presignedUrls, videoPrompt, duration, generateAudio, ratio, model);
log.info("Seedance task submitted: taskId={}, externalId={}, images={}, duration={}s", log.info("Seedance task submitted: taskId={}, externalId={}, images={}, duration={}s",
taskId, externalTaskId, presignedUrls.size(), duration); taskId, externalTaskId, presignedUrls.size(), duration);
...@@ -72,7 +104,7 @@ public class VideoTaskAsyncProcessor { ...@@ -72,7 +104,7 @@ public class VideoTaskAsyncProcessor {
update.setExternalTaskId(externalTaskId); update.setExternalTaskId(externalTaskId);
aiTaskMapper.updateById(update); aiTaskMapper.updateById(update);
billingService.charge(tenantId, projectId, "video_generate", String.valueOf(taskId)); billingService.charge(videoCharge);
// 轮询 Ark 直到任务完成(最多等 10 分钟,每 5 秒查一次) // 轮询 Ark 直到任务完成(最多等 10 分钟,每 5 秒查一次)
pollUntilDone(taskId, externalTaskId); pollUntilDone(taskId, externalTaskId);
......
...@@ -11,4 +11,6 @@ public interface ImageGenPipelineService { ...@@ -11,4 +11,6 @@ public interface ImageGenPipelineService {
* @return TOS object key * @return TOS object key
*/ */
String generateAndStore(Long tenantId, Long projectId, String prompt); String generateAndStore(Long tenantId, Long projectId, String prompt);
String generateAndStore(Long tenantId, Long userId, Long projectId, String prompt);
} }
package com.yaoai.pipeline.service; package com.yaoai.pipeline.service;
import com.yaoai.domain.entity.AiTask; import com.yaoai.domain.entity.AiTask;
import com.yaoai.pipeline.dto.StructuredVideoSubmission;
import java.util.List; import java.util.List;
...@@ -18,12 +19,20 @@ public interface VideoTaskPipelineService { ...@@ -18,12 +19,20 @@ public interface VideoTaskPipelineService {
* *
* @param preImageKeys 按 @图N 顺序排列的 TOS key 列表,空列表则走文生图 * @param preImageKeys 按 @图N 顺序排列的 TOS key 列表,空列表则走文生图
* @param durationSeconds 视频时长(秒),<=0 则使用默认值 5 * @param durationSeconds 视频时长(秒),<=0 则使用默认值 5
* @deprecated 由 {@link #submitStructuredVideoTask} 替代;保留 1 个版本周期供回滚
*/ */
AiTask submitTextToVideoAsync(Long projectId, Long tenantId, Long episodeId, @Deprecated
AiTask submitTextToVideoAsync(Long projectId, Long tenantId, Long userId, Long episodeId,
Long storyboardId, String imagePrompt, String videoPrompt, Long storyboardId, String imagePrompt, String videoPrompt,
List<String> preImageKeys, int durationSeconds); List<String> preImageKeys, int durationSeconds);
/** /**
* 结构化视频生成(左图 + 右提示词):把素材选择 + 五段提示词转成 Seedance 调用。
* 立即创建 pending 任务并返回,后台异步提交 Seedance 并轮询。
*/
AiTask submitStructuredVideoTask(Long projectId, Long tenantId, StructuredVideoSubmission submission);
/**
* 轮询任务状态:查询 Seedance API,更新 DB * 轮询任务状态:查询 Seedance API,更新 DB
*/ */
AiTask pollTaskStatus(Long taskId, Long tenantId); AiTask pollTaskStatus(Long taskId, Long tenantId);
......
package com.yaoai.pipeline.service.impl; package com.yaoai.pipeline.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.yaoai.billing.dto.BillingChargeRequest;
import com.yaoai.billing.service.BillingService; import com.yaoai.billing.service.BillingService;
import com.yaoai.domain.entity.AiTask; import com.yaoai.domain.entity.AiTask;
import com.yaoai.domain.entity.AssemblyTask; import com.yaoai.domain.entity.AssemblyTask;
...@@ -109,7 +111,18 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService { ...@@ -109,7 +111,18 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
task.setResultTosKey(tosKey); task.setResultTosKey(tosKey);
task.setStatus("succeeded"); task.setStatus("succeeded");
assemblyTaskMapper.updateById(task); assemblyTaskMapper.updateById(task);
billingService.charge(task.getTenantId(), task.getProjectId(), "assembly", String.valueOf(assemblyTaskId)); billingService.charge(BillingChargeRequest.builder()
.tenantId(task.getTenantId())
.userId(StpUtil.getLoginIdAsLong())
.projectId(task.getProjectId())
.operation("assembly")
.modality("COMPOSE")
.modelProvider("ffmpeg")
.modelId("ffmpeg-compose")
.modelName("FFmpeg 视频合成")
.unitCount(1)
.refId(String.valueOf(assemblyTaskId))
.build());
log.info("Assembly task {} succeeded, tosKey={}", assemblyTaskId, tosKey); log.info("Assembly task {} succeeded, tosKey={}", assemblyTaskId, tosKey);
} catch (IOException | InterruptedException e) { } catch (IOException | InterruptedException e) {
......
package com.yaoai.pipeline.service.impl; package com.yaoai.pipeline.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.ai.core.model.ChatMessage; import com.yaoai.ai.core.model.ChatMessage;
import com.yaoai.ai.core.model.ChatRequest; import com.yaoai.ai.core.model.ChatRequest;
import com.yaoai.ai.core.service.LlmService; import com.yaoai.ai.core.service.LlmService;
import com.yaoai.ai.providers.service.SeedreamService; import com.yaoai.ai.providers.service.SeedreamService;
import com.yaoai.billing.dto.BillingChargeRequest;
import com.yaoai.billing.service.BillingService;
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.domain.entity.Character; import com.yaoai.domain.entity.Character;
...@@ -82,6 +85,7 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -82,6 +85,7 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
private final EpisodeMapper episodeMapper; private final EpisodeMapper episodeMapper;
private final ProjectMapper projectMapper; private final ProjectMapper projectMapper;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final BillingService billingService;
private final HttpClient httpClient = HttpClient.newHttpClient(); private final HttpClient httpClient = HttpClient.newHttpClient();
@Override @Override
...@@ -91,6 +95,19 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -91,6 +95,19 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
String context = buildCharacterExtractionContext(projectId, tenantId, visualStyle); String context = buildCharacterExtractionContext(projectId, tenantId, visualStyle);
log.info("Extracting characters: projectId={}", projectId); log.info("Extracting characters: projectId={}", projectId);
billingService.checkBalance(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(projectId)
.operation("character_extract")
.modality("TEXT")
.modelProvider(llmService.getProvider())
.modelId(llmService.getModelId())
.modelName(llmService.getModelName())
.unitCount(1)
.refId(String.valueOf(projectId))
.build());
String raw = llmService.chat(ChatRequest.builder() String raw = llmService.chat(ChatRequest.builder()
.messages(List.of(ChatMessage.system(CHARACTER_SYSTEM), ChatMessage.user(context))) .messages(List.of(ChatMessage.system(CHARACTER_SYSTEM), ChatMessage.user(context)))
.build()); .build());
...@@ -114,6 +131,18 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -114,6 +131,18 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
characterMapper.insert(character); characterMapper.insert(character);
result.add(character); result.add(character);
} }
billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(projectId)
.operation("character_extract")
.modality("TEXT")
.modelProvider(llmService.getProvider())
.modelId(llmService.getModelId())
.modelName(llmService.getModelName())
.unitCount(result.size())
.refId(String.valueOf(projectId))
.build());
return result; return result;
} catch (Exception e) { } catch (Exception e) {
log.error("Character extraction failed", e); log.error("Character extraction failed", e);
...@@ -126,6 +155,19 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -126,6 +155,19 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
String context = buildProjectContext(projectId, tenantId); String context = buildProjectContext(projectId, tenantId);
log.info("Extracting scenes: projectId={}", projectId); log.info("Extracting scenes: projectId={}", projectId);
billingService.checkBalance(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(projectId)
.operation("scene_extract")
.modality("TEXT")
.modelProvider(llmService.getProvider())
.modelId(llmService.getModelId())
.modelName(llmService.getModelName())
.unitCount(1)
.refId(String.valueOf(projectId))
.build());
String raw = llmService.chat(ChatRequest.builder() String raw = llmService.chat(ChatRequest.builder()
.messages(List.of(ChatMessage.system(SCENE_SYSTEM), ChatMessage.user(context))) .messages(List.of(ChatMessage.system(SCENE_SYSTEM), ChatMessage.user(context)))
.build()); .build());
...@@ -145,6 +187,18 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -145,6 +187,18 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
sceneMapper.insert(scene); sceneMapper.insert(scene);
result.add(scene); result.add(scene);
} }
billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(projectId)
.operation("scene_extract")
.modality("TEXT")
.modelProvider(llmService.getProvider())
.modelId(llmService.getModelId())
.modelName(llmService.getModelName())
.unitCount(result.size())
.refId(String.valueOf(projectId))
.build());
return result; return result;
} catch (Exception e) { } catch (Exception e) {
log.error("Scene extraction failed", e); log.error("Scene extraction failed", e);
...@@ -161,6 +215,20 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -161,6 +215,20 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
throw new BizException(ErrorCode.INVALID_PARAM, "角色缺少图片 Prompt"); throw new BizException(ErrorCode.INVALID_PARAM, "角色缺少图片 Prompt");
} }
billingService.checkBalance(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(character.getProjectId())
.operation("character_image_generate")
.modality("IMAGE")
.modelProvider(seedreamService.getProvider())
.modelId(seedreamService.getModelId())
.modelName(seedreamService.getModelName())
.unitCount(3)
.meta(Map.of("views", List.of("front", "side", "back")))
.refId(String.valueOf(characterId))
.build());
character.setStatus("generating"); character.setStatus("generating");
characterMapper.updateById(character); characterMapper.updateById(character);
...@@ -180,6 +248,19 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -180,6 +248,19 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
character.setStatus("ready"); character.setStatus("ready");
characterMapper.updateById(character); characterMapper.updateById(character);
billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(character.getProjectId())
.operation("character_image_generate")
.modality("IMAGE")
.modelProvider(seedreamService.getProvider())
.modelId(seedreamService.getModelId())
.modelName(seedreamService.getModelName())
.unitCount(3)
.meta(Map.of("views", List.of("front", "side", "back")))
.refId(String.valueOf(characterId))
.build());
return character; return character;
} catch (BizException e) { } catch (BizException e) {
character.setStatus("failed"); character.setStatus("failed");
...@@ -202,6 +283,19 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -202,6 +283,19 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
throw new BizException(ErrorCode.INVALID_PARAM, "场景缺少图片 Prompt"); throw new BizException(ErrorCode.INVALID_PARAM, "场景缺少图片 Prompt");
} }
billingService.checkBalance(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(scene.getProjectId())
.operation("scene_image_generate")
.modality("IMAGE")
.modelProvider(seedreamService.getProvider())
.modelId(seedreamService.getModelId())
.modelName(seedreamService.getModelName())
.unitCount(1)
.refId(String.valueOf(sceneId))
.build());
scene.setStatus("generating"); scene.setStatus("generating");
sceneMapper.updateById(scene); sceneMapper.updateById(scene);
...@@ -215,6 +309,18 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -215,6 +309,18 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
scene.setImageTosKey(key); scene.setImageTosKey(key);
scene.setStatus("ready"); scene.setStatus("ready");
sceneMapper.updateById(scene); sceneMapper.updateById(scene);
billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(scene.getProjectId())
.operation("scene_image_generate")
.modality("IMAGE")
.modelProvider(seedreamService.getProvider())
.modelId(seedreamService.getModelId())
.modelName(seedreamService.getModelName())
.unitCount(1)
.refId(String.valueOf(sceneId))
.build());
return scene; return scene;
} catch (BizException e) { } catch (BizException e) {
scene.setStatus("failed"); scene.setStatus("failed");
......
package com.yaoai.pipeline.service.impl; package com.yaoai.pipeline.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.yaoai.ai.providers.service.SeedreamService; import com.yaoai.ai.providers.service.SeedreamService;
import com.yaoai.billing.dto.BillingChargeRequest;
import com.yaoai.billing.service.BillingService; import com.yaoai.billing.service.BillingService;
import com.yaoai.common.exception.BizException; import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode; import com.yaoai.common.exception.ErrorCode;
...@@ -15,6 +17,7 @@ import java.net.URI; ...@@ -15,6 +17,7 @@ import java.net.URI;
import java.net.http.HttpClient; import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.util.Map;
@Slf4j @Slf4j
@Service @Service
...@@ -28,6 +31,24 @@ public class ImageGenPipelineServiceImpl implements ImageGenPipelineService { ...@@ -28,6 +31,24 @@ public class ImageGenPipelineServiceImpl implements ImageGenPipelineService {
@Override @Override
public String generateAndStore(Long tenantId, Long projectId, String prompt) { public String generateAndStore(Long tenantId, Long projectId, String prompt) {
return generateAndStore(tenantId, StpUtil.getLoginIdAsLong(), projectId, prompt);
}
@Override
public String generateAndStore(Long tenantId, Long userId, Long projectId, String prompt) {
billingService.checkBalance(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(userId)
.projectId(projectId)
.operation("image_generate")
.modality("IMAGE")
.modelProvider(seedreamService.getProvider())
.modelId(seedreamService.getModelId())
.modelName(seedreamService.getModelName())
.unitCount(1)
.meta(Map.of("size", "2048x2048"))
.build());
// 1. 调用 Seedream 生成图片,获取临时 URL // 1. 调用 Seedream 生成图片,获取临时 URL
String imageUrl = seedreamService.generateImage(prompt); String imageUrl = seedreamService.generateImage(prompt);
log.info("Seedream image URL: {}", imageUrl); log.info("Seedream image URL: {}", imageUrl);
...@@ -39,7 +60,19 @@ public class ImageGenPipelineServiceImpl implements ImageGenPipelineService { ...@@ -39,7 +60,19 @@ public class ImageGenPipelineServiceImpl implements ImageGenPipelineService {
String key = TosService.buildKey(tenantId, projectId, "scene", "scene.jpg"); String key = TosService.buildKey(tenantId, projectId, "scene", "scene.jpg");
tosService.upload(key, new ByteArrayInputStream(imageBytes), imageBytes.length, "image/jpeg"); tosService.upload(key, new ByteArrayInputStream(imageBytes), imageBytes.length, "image/jpeg");
log.info("Scene image stored: key={}", key); log.info("Scene image stored: key={}", key);
billingService.charge(tenantId, projectId, "image_generate", key); billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(userId)
.projectId(projectId)
.operation("image_generate")
.modality("IMAGE")
.modelProvider(seedreamService.getProvider())
.modelId(seedreamService.getModelId())
.modelName(seedreamService.getModelName())
.unitCount(1)
.meta(Map.of("size", "2048x2048"))
.refId(key)
.build());
return key; return key;
} }
......
package com.yaoai.pipeline.service.impl; package com.yaoai.pipeline.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.ai.core.model.ChatMessage; import com.yaoai.ai.core.model.ChatMessage;
import com.yaoai.ai.core.model.ChatRequest; import com.yaoai.ai.core.model.ChatRequest;
import com.yaoai.ai.core.service.LlmService; import com.yaoai.ai.core.service.LlmService;
import com.yaoai.billing.dto.BillingChargeRequest;
import com.yaoai.billing.service.BillingService;
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.domain.entity.Episode; import com.yaoai.domain.entity.Episode;
import com.yaoai.domain.entity.Outline; import com.yaoai.domain.entity.Outline;
import com.yaoai.domain.mapper.EpisodeMapper; import com.yaoai.domain.mapper.EpisodeMapper;
import com.yaoai.domain.mapper.OutlineMapper; import com.yaoai.domain.mapper.OutlineMapper;
import com.yaoai.billing.service.BillingService;
import com.yaoai.pipeline.service.OutlinePipelineService; import com.yaoai.pipeline.service.OutlinePipelineService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
...@@ -69,6 +71,19 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService { ...@@ -69,6 +71,19 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService {
)) ))
.build(); .build();
billingService.checkBalance(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(projectId)
.operation("outline_generate")
.modality("TEXT")
.modelProvider(llmService.getProvider())
.modelId(llmService.getModelId())
.modelName(llmService.getModelName())
.unitCount(1)
.refId(String.valueOf(projectId))
.build());
String raw = llmService.chat(request); String raw = llmService.chat(request);
log.debug("Outline LLM response: {}", raw); log.debug("Outline LLM response: {}", raw);
...@@ -89,7 +104,18 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService { ...@@ -89,7 +104,18 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService {
outline.setEpisodeCount(epCount instanceof Number ? ((Number) epCount).intValue() : 1); outline.setEpisodeCount(epCount instanceof Number ? ((Number) epCount).intValue() : 1);
outlineMapper.insert(outline); outlineMapper.insert(outline);
billingService.charge(tenantId, projectId, "outline_generate", String.valueOf(projectId)); billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(projectId)
.operation("outline_generate")
.modality("TEXT")
.modelProvider(llmService.getProvider())
.modelId(llmService.getModelId())
.modelName(llmService.getModelName())
.unitCount(1)
.refId(String.valueOf(projectId))
.build());
log.info("Outline saved: id={}, title={}", outline.getId(), outline.getTitle()); log.info("Outline saved: id={}, title={}", outline.getId(), outline.getTitle());
return outline; return outline;
} catch (Exception e) { } catch (Exception e) {
...@@ -119,6 +145,19 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService { ...@@ -119,6 +145,19 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService {
)) ))
.build(); .build();
billingService.checkBalance(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(projectId)
.operation("episode_generate")
.modality("TEXT")
.modelProvider(llmService.getProvider())
.modelId(llmService.getModelId())
.modelName(llmService.getModelName())
.unitCount(1)
.refId(String.valueOf(outlineId))
.build());
String raw = llmService.chat(request); String raw = llmService.chat(request);
log.debug("Episode LLM response: {}", raw); log.debug("Episode LLM response: {}", raw);
...@@ -140,7 +179,18 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService { ...@@ -140,7 +179,18 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService {
episodeMapper.insert(ep); episodeMapper.insert(ep);
episodes.add(ep); episodes.add(ep);
} }
billingService.charge(tenantId, projectId, "episode_generate", String.valueOf(outlineId)); billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(projectId)
.operation("episode_generate")
.modality("TEXT")
.modelProvider(llmService.getProvider())
.modelId(llmService.getModelId())
.modelName(llmService.getModelName())
.unitCount(1)
.refId(String.valueOf(outlineId))
.build());
log.info("Episodes saved: count={}", episodes.size()); log.info("Episodes saved: count={}", episodes.size());
return episodes; return episodes;
} catch (Exception e) { } catch (Exception e) {
......
package com.yaoai.pipeline.service.impl; package com.yaoai.pipeline.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.ai.core.model.ChatMessage; import com.yaoai.ai.core.model.ChatMessage;
import com.yaoai.ai.core.model.ChatRequest; import com.yaoai.ai.core.model.ChatRequest;
import com.yaoai.ai.core.service.LlmService; import com.yaoai.ai.core.service.LlmService;
import com.yaoai.billing.dto.BillingChargeRequest;
import com.yaoai.billing.service.BillingService;
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.domain.entity.Character; import com.yaoai.domain.entity.Character;
...@@ -35,6 +38,7 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -35,6 +38,7 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
private final CharacterMapper characterMapper; private final CharacterMapper characterMapper;
private final SceneMapper sceneMapper; private final SceneMapper sceneMapper;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final BillingService billingService;
private static final String SYSTEM_PROMPT_BASE = """ private static final String SYSTEM_PROMPT_BASE = """
你是一位专业的影视分镜师。根据分集脚本,将其拆分为若干个连续镜头(shot)。 你是一位专业的影视分镜师。根据分集脚本,将其拆分为若干个连续镜头(shot)。
...@@ -84,6 +88,19 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -84,6 +88,19 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
)) ))
.build(); .build();
billingService.checkBalance(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(projectId)
.operation("storyboard_generate")
.modality("TEXT")
.modelProvider(llmService.getProvider())
.modelId(llmService.getModelId())
.modelName(llmService.getModelName())
.unitCount(1)
.refId(String.valueOf(episodeId))
.build());
String raw = llmService.chat(request); String raw = llmService.chat(request);
log.debug("Storyboard LLM response length={}", raw.length()); log.debug("Storyboard LLM response length={}", raw.length());
...@@ -112,6 +129,18 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -112,6 +129,18 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
storyboardMapper.insert(sb); storyboardMapper.insert(sb);
result.add(sb); result.add(sb);
} }
billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(projectId)
.operation("storyboard_generate")
.modality("TEXT")
.modelProvider(llmService.getProvider())
.modelId(llmService.getModelId())
.modelName(llmService.getModelName())
.unitCount(result.size())
.refId(String.valueOf(episodeId))
.build());
log.info("Storyboards generated: count={}", result.size()); log.info("Storyboards generated: count={}", result.size());
return result; return result;
} catch (Exception e) { } catch (Exception e) {
...@@ -268,7 +297,32 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService ...@@ -268,7 +297,32 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
log.info("Generating storyboard prompt: storyboardId={}, episodeId={}, characters={}, scenes={}", log.info("Generating storyboard prompt: storyboardId={}, episodeId={}, characters={}, scenes={}",
storyboardId, sb.getEpisodeId(), characters.size(), scenes.size()); storyboardId, sb.getEpisodeId(), characters.size(), scenes.size());
return llmService.chat(request); billingService.checkBalance(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(sb.getProjectId())
.operation("storyboard_prompt_generate")
.modality("TEXT")
.modelProvider(llmService.getProvider())
.modelId(llmService.getModelId())
.modelName(llmService.getModelName())
.unitCount(1)
.refId(String.valueOf(storyboardId))
.build());
String prompt = llmService.chat(request);
billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(sb.getProjectId())
.operation("storyboard_prompt_generate")
.modality("TEXT")
.modelProvider(llmService.getProvider())
.modelId(llmService.getModelId())
.modelName(llmService.getModelName())
.unitCount(1)
.refId(String.valueOf(storyboardId))
.build());
return prompt;
} }
private static String nullSafe(String s) { private static String nullSafe(String s) {
......
package com.yaoai.pipeline.service.impl; package com.yaoai.pipeline.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.ai.providers.model.VideoTaskResult; import com.yaoai.ai.providers.model.VideoTaskResult;
import com.yaoai.ai.providers.service.SeedanceService; import com.yaoai.ai.providers.service.SeedanceService;
import com.yaoai.ai.providers.util.SeedancePromptBuilder;
import com.yaoai.billing.dto.BillingChargeRequest;
import com.yaoai.billing.service.BillingService; import com.yaoai.billing.service.BillingService;
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.domain.entity.AiTask; import com.yaoai.domain.entity.AiTask;
import com.yaoai.domain.mapper.AiTaskMapper; import com.yaoai.domain.mapper.AiTaskMapper;
import com.yaoai.pipeline.async.VideoTaskAsyncProcessor; import com.yaoai.pipeline.async.VideoTaskAsyncProcessor;
import com.yaoai.pipeline.dto.StructuredVideoSubmission;
import com.yaoai.pipeline.service.VideoTaskPipelineService; import com.yaoai.pipeline.service.VideoTaskPipelineService;
import com.yaoai.storage.service.TosService; import com.yaoai.storage.service.TosService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
...@@ -28,12 +34,26 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService { ...@@ -28,12 +34,26 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
private final BillingService billingService; private final BillingService billingService;
private final VideoTaskAsyncProcessor asyncProcessor; private final VideoTaskAsyncProcessor asyncProcessor;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Override @Override
public AiTask submitVideoTask(Long projectId, Long tenantId, Long episodeId, public AiTask submitVideoTask(Long projectId, Long tenantId, Long episodeId,
Long storyboardId, String imageKey, String prompt) { Long storyboardId, String imageKey, String prompt) {
String presignedUrl = tosService.presignedGetUrl(imageKey, 3600); String presignedUrl = tosService.presignedGetUrl(imageKey, 3600);
log.info("Submitting video task: projectId={}, episodeId={}, storyboardId={}", projectId, episodeId, storyboardId); log.info("Submitting video task: projectId={}, episodeId={}, storyboardId={}", projectId, episodeId, storyboardId);
billingService.checkBalance(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(projectId)
.operation("video_generate")
.modality("VIDEO")
.modelProvider(seedanceService.getProvider())
.modelId(seedanceService.getModelId())
.modelName(seedanceService.getModelName())
.unitCount(1)
.build());
String externalTaskId = seedanceService.submitVideoTask(presignedUrl, prompt); String externalTaskId = seedanceService.submitVideoTask(presignedUrl, prompt);
AiTask task = new AiTask(); AiTask task = new AiTask();
...@@ -48,7 +68,18 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService { ...@@ -48,7 +68,18 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
task.setPrompt(prompt); task.setPrompt(prompt);
aiTaskMapper.insert(task); aiTaskMapper.insert(task);
billingService.charge(tenantId, projectId, "video_generate", String.valueOf(task.getId())); billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId)
.userId(StpUtil.getLoginIdAsLong())
.projectId(projectId)
.operation("video_generate")
.modality("VIDEO")
.modelProvider(seedanceService.getProvider())
.modelId(seedanceService.getModelId())
.modelName(seedanceService.getModelName())
.unitCount(1)
.refId(String.valueOf(task.getId()))
.build());
log.info("AiTask created: id={}, externalId={}", task.getId(), externalTaskId); log.info("AiTask created: id={}, externalId={}", task.getId(), externalTaskId);
return task; return task;
} }
...@@ -80,7 +111,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService { ...@@ -80,7 +111,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
} }
@Override @Override
public AiTask submitTextToVideoAsync(Long projectId, Long tenantId, Long episodeId, public AiTask submitTextToVideoAsync(Long projectId, Long tenantId, Long userId, Long episodeId,
Long storyboardId, String imagePrompt, String videoPrompt, Long storyboardId, String imagePrompt, String videoPrompt,
List<String> preImageKeys, int durationSeconds) { List<String> preImageKeys, int durationSeconds) {
// 1. 立即创建 pending 任务并返回 // 1. 立即创建 pending 任务并返回
...@@ -97,12 +128,99 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService { ...@@ -97,12 +128,99 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
// 2. 后台异步执行(如有参考图则跳过 Seedream) // 2. 后台异步执行(如有参考图则跳过 Seedream)
List<String> keys = preImageKeys != null ? preImageKeys : java.util.Collections.emptyList(); List<String> keys = preImageKeys != null ? preImageKeys : java.util.Collections.emptyList();
asyncProcessor.processTextToVideo(task.getId(), tenantId, projectId, imagePrompt, asyncProcessor.processTextToVideo(task.getId(), tenantId, userId, projectId, imagePrompt,
videoPrompt != null ? videoPrompt : imagePrompt, keys, durationSeconds); videoPrompt != null ? videoPrompt : imagePrompt, keys, durationSeconds,
"16:9", true, null);
return task; return task;
} }
@Override @Override
public AiTask submitStructuredVideoTask(Long projectId, Long tenantId,
StructuredVideoSubmission s) {
// 角色:优先使用 characterImageKeys 列表,回退到旧单图字段
List<String> characterKeys = s.getCharacterImageKeys();
if (characterKeys == null || characterKeys.isEmpty()) {
if (s.getCharacterImageKey() == null || s.getCharacterImageKey().isBlank()) {
throw new BizException(ErrorCode.INVALID_PARAM, "角色图不能为空");
}
characterKeys = java.util.List.of(s.getCharacterImageKey());
}
if (s.getSceneImageKey() == null || s.getSceneImageKey().isBlank()) {
throw new BizException(ErrorCode.INVALID_PARAM, "场景图不能为空");
}
// 1. 拼装 final prompt + 图片顺序
int propCount = s.getPropImageKeys() != null ? s.getPropImageKeys().size() : 0;
boolean hasStyle = s.getStyleImageKey() != null && !s.getStyleImageKey().isBlank();
String finalPrompt;
if (s.getFreeformPrompt() != null && !s.getFreeformPrompt().isBlank()) {
finalPrompt = SeedancePromptBuilder.buildPromptFreeform(
s.getFreeformPrompt(), characterKeys.size(), propCount, hasStyle);
} else {
finalPrompt = SeedancePromptBuilder.buildPrompt(
new SeedancePromptBuilder.UserPromptParts(
s.getCharacterAction(),
s.getSceneEvent(),
s.getCameraMovement(),
s.getLightingAtmosphere(),
s.getVideoStyle()
),
characterKeys.size(),
propCount,
hasStyle
);
}
List<String> orderedKeys = SeedancePromptBuilder.buildOrderedImageKeys(
characterKeys,
s.getSceneImageKey(),
s.getPropImageKeys(),
s.getStyleImageKey()
);
// 2. 创建 pending 任务(落库结构化字段)
AiTask task = new AiTask();
task.setTenantId(tenantId);
task.setProjectId(projectId);
task.setEpisodeId(s.getEpisodeId());
task.setStoryboardId(s.getStoryboardId());
task.setTaskType("structured_video");
task.setStatus("pending");
task.setPrompt(finalPrompt);
task.setCharacterImageKey(characterKeys.get(0));
task.setCharacterImageKeys(toJson(characterKeys));
task.setSceneImageKey(s.getSceneImageKey());
task.setPropImageKeys(toJson(s.getPropImageKeys()));
task.setStyleImageKey(s.getStyleImageKey());
task.setUserPrompt(toJson(s.getUserPromptRaw()));
task.setVideoDuration(s.getDurationSeconds());
task.setVideoRatio(s.getRatio());
task.setGenerateAudio(Boolean.TRUE.equals(s.getGenerateAudio()));
aiTaskMapper.insert(task);
log.info("Structured video task created: id={}, characters={}, images={}, duration={}s, ratio={}",
task.getId(), characterKeys.size(), orderedKeys.size(), s.getDurationSeconds(), s.getRatio());
// 3. 复用 async processor:将拼接好的 prompt + 有序 key 列表交给后台
int duration = s.getDurationSeconds() != null && s.getDurationSeconds() > 0
? s.getDurationSeconds() : 15;
String ratio = s.getRatio() != null && !s.getRatio().isBlank() ? s.getRatio() : "16:9";
boolean generateAudio = Boolean.TRUE.equals(s.getGenerateAudio());
asyncProcessor.processTextToVideo(task.getId(), tenantId, s.getUserId(), projectId,
finalPrompt, finalPrompt, orderedKeys, duration, ratio, generateAudio, s.getModel());
return task;
}
private static String toJson(Object value) {
if (value == null) return null;
if (value instanceof List<?> list && list.isEmpty()) return null;
try {
return OBJECT_MAPPER.writeValueAsString(value);
} catch (JsonProcessingException e) {
log.warn("Failed to serialize structured video field: {}", e.getMessage());
return null;
}
}
@Override
public void deleteTask(Long taskId, Long tenantId) { public void deleteTask(Long taskId, Long tenantId) {
AiTask task = aiTaskMapper.selectById(taskId); AiTask task = aiTaskMapper.selectById(taskId);
if (task == null || !tenantId.equals(task.getTenantId())) { if (task == null || !tenantId.equals(task.getTenantId())) {
......
...@@ -22,6 +22,7 @@ public class SaTokenConfigure implements WebMvcConfigurer { ...@@ -22,6 +22,7 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/actuator/**", "/actuator/**",
"/ping", "/ping",
"/system/**", "/system/**",
"/admin-api/**",
"/agent/runs/*/events" // SSE: token 通过 query param 传入,由 controller 手动校验 "/agent/runs/*/events" // SSE: token 通过 query param 传入,由 controller 手动校验
}; };
......
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