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

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

parent 1a494815
......@@ -83,3 +83,105 @@ podAnnotations:
podDisruptionBudget:
enabled: true
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 @@
<groupId>com.yaoai</groupId>
<artifactId>yaoai-billing</artifactId>
</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>
</project>
......@@ -22,4 +22,13 @@ public interface LlmService {
ChatMessage.user(userMessage)))
.build());
}
/** 默认文本模型 ID,用于计费明细 */
String getModelId();
/** 模型展示名称,用于计费明细 */
String getModelName();
/** Provider 标识,例:volcengine / qwen / openai */
String getProvider();
}
......@@ -14,22 +14,44 @@ public interface SeedanceService {
* @return 外部任务 ID
*/
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 prompt 视频描述提示词,可含 @图N 引用
* @param imageUrls 参考图 URL 列表,顺序对应 @图1、@图2...(至少 1 个)
* @param prompt 视频描述提示词,可含 @图N 引用
* @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
*/
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);
/** 当前使用的 Seedance 模型 ID,用于计费明细 */
String getModelId();
/** 模型展示名 */
String getModelName();
/** Provider 标识 */
default String getProvider() {
return "volcengine";
}
}
......@@ -9,4 +9,15 @@ public interface SeedreamService {
* @return 生成图片的 URL
*/
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 {
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 {
.build();
}
private static final java.util.Set<String> SUPPORTED_RATIOS = java.util.Set.of("16:9", "9:16", "1:1");
@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()) {
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()) {
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) {
content.add(Map.of(
"type", "image_url",
......@@ -56,15 +70,16 @@ public class SeedanceServiceImpl implements SeedanceService {
}
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("model", properties.getVideoModel());
body.put("model", safeModel);
body.put("content", content);
body.put("generate_audio", generateAudio);
body.put("ratio", "16:9");
body.put("duration", durationSeconds > 0 ? durationSeconds : 5);
body.put("ratio", safeRatio);
body.put("duration", safeDuration);
body.put("watermark", false);
body.put("generate_audio", generateAudio);
log.info("Seedance submit: model={}, images={}, duration={}s, audio={}",
properties.getVideoModel(), imageUrls.size(), durationSeconds, generateAudio);
log.info("Seedance submit: model={}, images={}, duration={}s, audio={}, ratio={}, finalTextSuffix='{}'",
safeModel, imageUrls.size(), safeDuration, generateAudio, safeRatio,
finalText.length() > 200 ? finalText.substring(finalText.length() - 200) : finalText);
try {
TaskSubmitResponse resp = restClient.post()
.uri("/contents/generations/tasks")
......@@ -125,6 +140,16 @@ public class SeedanceServiceImpl implements SeedanceService {
}
}
@Override
public String getModelId() {
return properties.getVideoModel();
}
@Override
public String getModelName() {
return "Seedance 图生视频";
}
// ---- helpers ----
/**
......
......@@ -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 ----
@Data
......
......@@ -2,10 +2,13 @@ package com.yaoai.api.controller;
import com.yaoai.api.dto.billing.BalanceDTO;
import com.yaoai.api.dto.billing.BillingRecordDTO;
import com.yaoai.billing.service.BillingCostService;
import com.yaoai.billing.service.BillingService;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
......@@ -15,6 +18,10 @@ import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Tag(name = "用量统计")
@RestController
......@@ -23,6 +30,8 @@ import java.util.Map;
public class UsageController {
private final BillingService billingService;
private final BillingCostService billingCostService;
private final ProjectMapper projectMapper;
@Operation(summary = "查询积分余额")
@GetMapping("/balance")
......@@ -31,6 +40,12 @@ public class UsageController {
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 = "购买积分资源包(演示:免支付直接到账)")
@PostMapping("/purchase")
public ApiResponse<BalanceDTO> purchase(@RequestBody Map<String, Object> body) {
......@@ -55,7 +70,27 @@ public class UsageController {
Long tenantId = TenantContext.get();
List<BillingRecordDTO> list = billingService.getRecords(tenantId, limit, offset)
.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);
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;
import cn.dev33.satoken.stp.StpUtil;
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.ErrorCode;
import com.yaoai.common.response.ApiResponse;
import com.yaoai.pipeline.dto.StructuredVideoSubmission;
import com.yaoai.pipeline.service.VideoTaskPipelineService;
import com.yaoai.security.context.TenantContext;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
......@@ -24,7 +30,72 @@ public class VideoTaskController {
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")
public ApiResponse<AiTaskDTO> generateFromText(@PathVariable Long projectId,
@RequestBody Map<String, Object> body) {
......@@ -53,7 +124,7 @@ public class VideoTaskController {
Long tenantId = TenantContext.get();
return ApiResponse.success(AiTaskDTO.from(
videoTaskPipelineService.submitTextToVideoAsync(projectId, tenantId, episodeId, storyboardId,
videoTaskPipelineService.submitTextToVideoAsync(projectId, tenantId, StpUtil.getLoginIdAsLong(), episodeId, storyboardId,
prompt, videoPrompt, preImageKeys, duration)
));
}
......
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 lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@Slf4j
@Data
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 projectId;
private Long episodeId;
......@@ -19,6 +29,22 @@ public class AiTaskDTO {
private LocalDateTime createdAt;
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) {
AiTaskDTO dto = new AiTaskDTO();
dto.setId(t.getId());
......@@ -31,6 +57,43 @@ public class AiTaskDTO {
dto.setErrorMessage(t.getErrorMessage());
dto.setCreatedAt(t.getCreatedAt());
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;
}
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;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.domain.entity.Storyboard;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime;
import java.util.List;
@Slf4j
@Data
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 episodeId;
private Integer sequenceNum;
......@@ -26,6 +34,12 @@ public class StoryboardDTO {
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/** V16:分镜上落地的素材选择 */
private List<String> characterImageKeys;
private String sceneImageKey;
private List<String> propImageKeys;
private String styleImageKey;
public static StoryboardDTO from(Storyboard s) {
StoryboardDTO dto = new StoryboardDTO();
dto.setId(s.getId());
......@@ -45,6 +59,21 @@ public class StoryboardDTO {
dto.setStatus(s.getStatus());
dto.setCreatedAt(s.getCreatedAt());
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;
}
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 {
private Long id;
private Long projectId;
private String projectName;
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 String billingMode;
private String refId;
......@@ -22,6 +30,13 @@ public class BillingRecordDTO {
dto.setId(r.getId());
dto.setProjectId(r.getProjectId());
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.setBillingMode(r.getBillingMode());
dto.setRefId(r.getRefId());
......
......@@ -28,6 +28,10 @@
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
......
package com.yaoai.billing.service;
import com.yaoai.billing.dto.BillingChargeRequest;
import com.yaoai.domain.entity.BillingRecord;
import com.yaoai.domain.entity.TenantBalance;
......@@ -9,18 +10,47 @@ import java.util.List;
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
* @param projectId 关联项目(可为 null)
* @param operation 操作名(outline_generate / image_generate / video_generate / assembly)
* @param refId 关联业务 ID,用于追溯
* @deprecated 使用 {@link #charge(BillingChargeRequest)},能落到模型粒度便于运营查询
*/
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 调用) */
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);
......
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.dto.BillingChargeRequest;
import com.yaoai.billing.service.BillingCostService;
import com.yaoai.billing.service.BillingService;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
......@@ -26,52 +30,128 @@ public class BillingServiceImpl implements BillingService {
private final BillingConfig billingConfig;
private final TenantBalanceMapper tenantBalanceMapper;
private final BillingRecordMapper billingRecordMapper;
private final ObjectMapper objectMapper;
private final BillingCostService billingCostService;
@Override
@Transactional
@CacheEvict(value = "balance", key = "#tenantId")
public void charge(Long tenantId, Long projectId, String operation, String refId) {
@CacheEvict(value = "balance", key = "#request.tenantId")
public void charge(BillingChargeRequest request) {
if (billingConfig.isOff()) return;
BigDecimal cost = billingConfig.getCost(operation);
if (cost.compareTo(BigDecimal.ZERO) <= 0) return;
boolean isSuccess = isSuccess(request);
BigDecimal cost = estimateCost(request);
if (billingConfig.isStrict()) {
int rows = tenantBalanceMapper.tryDeduct(tenantId, cost);
if (cost.compareTo(BigDecimal.ZERO) > 0 && billingConfig.isStrict() && isSuccess) {
int rows = tenantBalanceMapper.tryDeduct(request.getTenantId(), cost);
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();
record.setTenantId(tenantId);
record.setProjectId(projectId);
record.setOperation(operation);
record.setCredits(cost);
record.setOperation("recharge");
record.setCredits(amount);
record.setBillingMode(billingConfig.getMode());
record.setRefId(refId);
billingRecordMapper.insert(record);
log.debug("Billed: tenant={}, op={}, credits={}, mode={}, ref={}",
tenantId, operation, cost, billingConfig.getMode(), refId);
log.info("Recharged: tenant={}, amount={}, ref={}", tenantId, amount, refId);
}
@Override
@Transactional
@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);
tenantBalanceMapper.recharge(tenantId, amount);
BillingRecord record = new BillingRecord();
record.setTenantId(tenantId);
record.setCreatedByAdminId(adminId);
record.setOperation("recharge");
record.setModality("RECHARGE");
record.setCredits(amount);
record.setBillingMode(billingConfig.getMode());
record.setRefId(refId);
record.setSuccess(true);
record.setUnitCount(1);
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
......@@ -123,6 +203,14 @@ public class BillingServiceImpl implements BillingService {
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) {
if (tenantBalanceMapper.findByTenantId(tenantId) == null) {
TenantBalance b = new TenantBalance();
......@@ -134,4 +222,12 @@ public class BillingServiceImpl implements BillingService {
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 @@
<artifactId>yaoai-api</artifactId>
</dependency>
<dependency>
<groupId>com.yaoai</groupId>
<artifactId>yaoai-admin</artifactId>
</dependency>
<!-- ===== Spring Boot Starters ===== -->
<dependency>
<groupId>org.springframework.boot</groupId>
......
......@@ -26,6 +26,7 @@ spring:
locations: classpath:db/migration
baseline-on-migrate: true
validate-on-migrate: true
out-of-order: true
data:
redis:
......@@ -125,13 +126,19 @@ sa-token:
is-read-url: true
billing:
mode: ${BILLING_MODE:SOFT}
mode: ${BILLING_MODE:STRICT}
costs:
outline_generate: ${BILLING_COST_OUTLINE:5}
episode_generate: ${BILLING_COST_EPISODE:3}
image_generate: ${BILLING_COST_IMAGE:2}
video_generate: ${BILLING_COST_VIDEO:20}
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:
config: classpath:logback-spring.xml
......@@ -27,5 +27,9 @@
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
</dependency>
</dependencies>
</project>
......@@ -19,7 +19,7 @@ public class AiTask {
private Long episodeId;
private Long storyboardId;
/** image_to_video */
/** image_to_video / text_to_video / ref_image_to_video / structured_video */
private String taskType;
private String externalTaskId;
......@@ -27,12 +27,30 @@ public class AiTask {
/** pending / submitted / running / succeeded / failed */
private String status;
/** 旧版单图任务的输入;新结构化任务为 null */
private String inputImageKey;
/** 最终提交给 Seedance 的完整 Prompt(含 @图N),新旧任务通用 */
private String prompt;
private String resultVideoUrl;
private String resultTosKey;
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 updatedAt;
}
......@@ -18,9 +18,36 @@ public class BillingRecord {
private Long tenantId;
private Long projectId;
/** 租户内发起此操作的用户 */
private Long userId;
/** 运营手动充值时记录运营 ID */
private Long createdByAdminId;
/** outline_generate / episode_generate / image_generate / video_generate / assembly / recharge */
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;
/** SOFT / STRICT */
......
......@@ -36,6 +36,12 @@ public class Storyboard {
private String endFramePrompt;
private String notes;
/** V16:分镜上落地的素材选择(编辑态记忆) */
private String characterImageKeys;
private String sceneImageKey;
private String propImageKeys;
private String styleImageKey;
/** draft / review / approved / generating / generated / failed */
private String status;
......
......@@ -29,6 +29,14 @@ public class Tenant {
private BigDecimal frozenCredits;
private LocalDateTime suspendedAt;
private Long suspendedBy;
private String suspendedReason;
private String notes;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
......
......@@ -3,6 +3,7 @@ package com.yaoai.domain.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import java.time.LocalDateTime;
......@@ -24,6 +25,7 @@ public class User {
private String avatarUrl;
@JsonIgnore
private String passwordHash;
/** admin / member */
......
......@@ -36,6 +36,10 @@
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>com.yaoai</groupId>
<artifactId>yaoai-billing</artifactId>
</dependency>
......
......@@ -2,6 +2,7 @@ package com.yaoai.pipeline.async;
import com.yaoai.ai.providers.model.VideoTaskResult;
import com.yaoai.ai.providers.service.SeedanceService;
import com.yaoai.billing.dto.BillingChargeRequest;
import com.yaoai.billing.service.BillingService;
import com.yaoai.domain.entity.AiTask;
import com.yaoai.domain.mapper.AiTaskMapper;
......@@ -12,8 +13,10 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Slf4j
@Component
......@@ -33,14 +36,43 @@ public class VideoTaskAsyncProcessor {
*
* @param preImageKeys 按 @图1/@图2... 顺序排列的 TOS key 列表,空列表则走文生图
* @param durationSeconds 视频时长(秒),<=0 则使用默认值 5
* @param ratio 视频宽高比,如 "16:9" / "9:16" / "1:1",空/非法值由下游兜底
* @param generateAudio 是否生成配音
*/
@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,
List<String> preImageKeys, int durationSeconds) {
log.info("Async text-to-video started: taskId={}, preImages={}, duration={}s",
taskId, preImageKeys.size(), durationSeconds);
List<String> preImageKeys, int durationSeconds,
String ratio, boolean generateAudio, String model) {
log.info("Async text-to-video started: taskId={}, preImages={}, duration={}s, ratio={}, audio={}, model={}",
taskId, preImageKeys.size(), durationSeconds, ratio, generateAudio, model);
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<>();
String primaryImageKey;
......@@ -53,14 +85,14 @@ public class VideoTaskAsyncProcessor {
log.info("Using {} pre-existing reference image(s): taskId={}", preImageKeys.size(), taskId);
} else {
// 无预设参考图:调用 Seedream 文生图
primaryImageKey = imageGenPipelineService.generateAndStore(tenantId, projectId, imagePrompt);
primaryImageKey = imageGenPipelineService.generateAndStore(tenantId, userId, projectId, imagePrompt);
presignedUrls.add(tosService.presignedGetUrl(primaryImageKey, 3600));
log.info("Image generated and stored: taskId={}, key={}", taskId, primaryImageKey);
}
// 提交 Seedance,传入所有参考图 + 时长
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",
taskId, externalTaskId, presignedUrls.size(), duration);
......@@ -72,7 +104,7 @@ public class VideoTaskAsyncProcessor {
update.setExternalTaskId(externalTaskId);
aiTaskMapper.updateById(update);
billingService.charge(tenantId, projectId, "video_generate", String.valueOf(taskId));
billingService.charge(videoCharge);
// 轮询 Ark 直到任务完成(最多等 10 分钟,每 5 秒查一次)
pollUntilDone(taskId, externalTaskId);
......
......@@ -11,4 +11,6 @@ public interface ImageGenPipelineService {
* @return TOS object key
*/
String generateAndStore(Long tenantId, Long projectId, String prompt);
String generateAndStore(Long tenantId, Long userId, Long projectId, String prompt);
}
package com.yaoai.pipeline.service;
import com.yaoai.domain.entity.AiTask;
import com.yaoai.pipeline.dto.StructuredVideoSubmission;
import java.util.List;
......@@ -18,12 +19,20 @@ public interface VideoTaskPipelineService {
*
* @param preImageKeys 按 @图N 顺序排列的 TOS key 列表,空列表则走文生图
* @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,
List<String> preImageKeys, int durationSeconds);
/**
* 结构化视频生成(左图 + 右提示词):把素材选择 + 五段提示词转成 Seedance 调用。
* 立即创建 pending 任务并返回,后台异步提交 Seedance 并轮询。
*/
AiTask submitStructuredVideoTask(Long projectId, Long tenantId, StructuredVideoSubmission submission);
/**
* 轮询任务状态:查询 Seedance API,更新 DB
*/
AiTask pollTaskStatus(Long taskId, Long tenantId);
......
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.domain.entity.AiTask;
import com.yaoai.domain.entity.AssemblyTask;
......@@ -109,7 +111,18 @@ public class AssemblyPipelineServiceImpl implements AssemblyPipelineService {
task.setResultTosKey(tosKey);
task.setStatus("succeeded");
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);
} catch (IOException | InterruptedException e) {
......
package com.yaoai.pipeline.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.ai.core.model.ChatMessage;
import com.yaoai.ai.core.model.ChatRequest;
import com.yaoai.ai.core.service.LlmService;
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.ErrorCode;
import com.yaoai.domain.entity.Character;
......@@ -82,6 +85,7 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
private final EpisodeMapper episodeMapper;
private final ProjectMapper projectMapper;
private final ObjectMapper objectMapper;
private final BillingService billingService;
private final HttpClient httpClient = HttpClient.newHttpClient();
@Override
......@@ -91,6 +95,19 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
String context = buildCharacterExtractionContext(projectId, tenantId, visualStyle);
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()
.messages(List.of(ChatMessage.system(CHARACTER_SYSTEM), ChatMessage.user(context)))
.build());
......@@ -114,6 +131,18 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
characterMapper.insert(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;
} catch (Exception e) {
log.error("Character extraction failed", e);
......@@ -126,6 +155,19 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
String context = buildProjectContext(projectId, tenantId);
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()
.messages(List.of(ChatMessage.system(SCENE_SYSTEM), ChatMessage.user(context)))
.build());
......@@ -145,6 +187,18 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
sceneMapper.insert(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;
} catch (Exception e) {
log.error("Scene extraction failed", e);
......@@ -161,6 +215,20 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
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");
characterMapper.updateById(character);
......@@ -180,6 +248,19 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
character.setStatus("ready");
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;
} catch (BizException e) {
character.setStatus("failed");
......@@ -202,6 +283,19 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
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");
sceneMapper.updateById(scene);
......@@ -215,6 +309,18 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
scene.setImageTosKey(key);
scene.setStatus("ready");
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;
} catch (BizException e) {
scene.setStatus("failed");
......
package com.yaoai.pipeline.service.impl;
import cn.dev33.satoken.stp.StpUtil;
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.ErrorCode;
......@@ -15,6 +17,7 @@ import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
@Slf4j
@Service
......@@ -28,6 +31,24 @@ public class ImageGenPipelineServiceImpl implements ImageGenPipelineService {
@Override
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
String imageUrl = seedreamService.generateImage(prompt);
log.info("Seedream image URL: {}", imageUrl);
......@@ -39,7 +60,19 @@ public class ImageGenPipelineServiceImpl implements ImageGenPipelineService {
String key = TosService.buildKey(tenantId, projectId, "scene", "scene.jpg");
tosService.upload(key, new ByteArrayInputStream(imageBytes), imageBytes.length, "image/jpeg");
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;
}
......
package com.yaoai.pipeline.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.ai.core.model.ChatMessage;
import com.yaoai.ai.core.model.ChatRequest;
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.ErrorCode;
import com.yaoai.domain.entity.Episode;
import com.yaoai.domain.entity.Outline;
import com.yaoai.domain.mapper.EpisodeMapper;
import com.yaoai.domain.mapper.OutlineMapper;
import com.yaoai.billing.service.BillingService;
import com.yaoai.pipeline.service.OutlinePipelineService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
......@@ -69,6 +71,19 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService {
))
.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);
log.debug("Outline LLM response: {}", raw);
......@@ -89,7 +104,18 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService {
outline.setEpisodeCount(epCount instanceof Number ? ((Number) epCount).intValue() : 1);
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());
return outline;
} catch (Exception e) {
......@@ -119,6 +145,19 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService {
))
.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);
log.debug("Episode LLM response: {}", raw);
......@@ -140,7 +179,18 @@ public class OutlinePipelineServiceImpl implements OutlinePipelineService {
episodeMapper.insert(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());
return episodes;
} catch (Exception e) {
......
package com.yaoai.pipeline.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.ai.core.model.ChatMessage;
import com.yaoai.ai.core.model.ChatRequest;
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.ErrorCode;
import com.yaoai.domain.entity.Character;
......@@ -35,6 +38,7 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
private final CharacterMapper characterMapper;
private final SceneMapper sceneMapper;
private final ObjectMapper objectMapper;
private final BillingService billingService;
private static final String SYSTEM_PROMPT_BASE = """
你是一位专业的影视分镜师。根据分集脚本,将其拆分为若干个连续镜头(shot)。
......@@ -84,6 +88,19 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
))
.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);
log.debug("Storyboard LLM response length={}", raw.length());
......@@ -112,6 +129,18 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
storyboardMapper.insert(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());
return result;
} catch (Exception e) {
......@@ -268,7 +297,32 @@ public class StoryboardPipelineServiceImpl implements StoryboardPipelineService
log.info("Generating storyboard prompt: storyboardId={}, episodeId={}, characters={}, scenes={}",
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) {
......
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.service.SeedanceService;
import com.yaoai.ai.providers.util.SeedancePromptBuilder;
import com.yaoai.billing.dto.BillingChargeRequest;
import com.yaoai.billing.service.BillingService;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.domain.entity.AiTask;
import com.yaoai.domain.mapper.AiTaskMapper;
import com.yaoai.pipeline.async.VideoTaskAsyncProcessor;
import com.yaoai.pipeline.dto.StructuredVideoSubmission;
import com.yaoai.pipeline.service.VideoTaskPipelineService;
import com.yaoai.storage.service.TosService;
import lombok.RequiredArgsConstructor;
......@@ -28,12 +34,26 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
private final BillingService billingService;
private final VideoTaskAsyncProcessor asyncProcessor;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Override
public AiTask submitVideoTask(Long projectId, Long tenantId, Long episodeId,
Long storyboardId, String imageKey, String prompt) {
String presignedUrl = tosService.presignedGetUrl(imageKey, 3600);
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);
AiTask task = new AiTask();
......@@ -48,7 +68,18 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
task.setPrompt(prompt);
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);
return task;
}
......@@ -80,7 +111,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
}
@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,
List<String> preImageKeys, int durationSeconds) {
// 1. 立即创建 pending 任务并返回
......@@ -97,11 +128,98 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
// 2. 后台异步执行(如有参考图则跳过 Seedream)
List<String> keys = preImageKeys != null ? preImageKeys : java.util.Collections.emptyList();
asyncProcessor.processTextToVideo(task.getId(), tenantId, projectId, imagePrompt,
videoPrompt != null ? videoPrompt : imagePrompt, keys, durationSeconds);
asyncProcessor.processTextToVideo(task.getId(), tenantId, userId, projectId, imagePrompt,
videoPrompt != null ? videoPrompt : imagePrompt, keys, durationSeconds,
"16:9", true, null);
return task;
}
@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) {
AiTask task = aiTaskMapper.selectById(taskId);
......
......@@ -22,6 +22,7 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/actuator/**",
"/ping",
"/system/**",
"/admin-api/**",
"/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