Commit fde0aba7 authored by yaoke.yk's avatar yaoke.yk

测试尝试用Agnes测demo

parent 0aa3bd46
...@@ -41,11 +41,6 @@ const RATIO_OPTIONS = [ ...@@ -41,11 +41,6 @@ const RATIO_OPTIONS = [
{ value: "9:16", label: "9:16 竖屏" }, { value: "9:16", label: "9:16 竖屏" },
{ value: "1:1", label: "1:1 方形" }, { value: "1:1", label: "1:1 方形" },
]; ];
const VIDEO_MODEL_OPTIONS = [
{ value: "doubao-seedance-2-0-fast-260128", label: "Doubao-Seedance-2-0 fast" },
{ value: "doubao-seedance-2-0-260128", label: "Doubao-Seedance-2-0" },
];
const DEFAULT_VIDEO_MODEL = VIDEO_MODEL_OPTIONS[0].value;
const MAX_CHARACTERS = 5; const MAX_CHARACTERS = 5;
const MAX_PROPS = 5; const MAX_PROPS = 5;
const PROMPT_PREVIEW_LIMIT = 80; const PROMPT_PREVIEW_LIMIT = 80;
...@@ -71,7 +66,6 @@ interface SbDraft { ...@@ -71,7 +66,6 @@ interface SbDraft {
freeformPrompt: string; freeformPrompt: string;
selectedDuration: number; selectedDuration: number;
selectedRatio: string; selectedRatio: string;
selectedModel: string;
audioOn: boolean; audioOn: boolean;
} }
...@@ -328,7 +322,6 @@ export function StoryboardWorkspace() { ...@@ -328,7 +322,6 @@ export function StoryboardWorkspace() {
const [freeformPrompt, setFreeformPrompt] = useState<string>(""); const [freeformPrompt, setFreeformPrompt] = useState<string>("");
const [selectedDuration, setSelectedDuration] = useState(DEFAULT_VIDEO_DURATION); const [selectedDuration, setSelectedDuration] = useState(DEFAULT_VIDEO_DURATION);
const [selectedRatio, setSelectedRatio] = useState<string>("16:9"); const [selectedRatio, setSelectedRatio] = useState<string>("16:9");
const [selectedModel, setSelectedModel] = useState<string>(DEFAULT_VIDEO_MODEL);
const [audioOn, setAudioOn] = useState(true); const [audioOn, setAudioOn] = useState(true);
const [generatingVideoId, setGeneratingVideoId] = useState<string | null>(null); const [generatingVideoId, setGeneratingVideoId] = useState<string | null>(null);
...@@ -437,7 +430,6 @@ export function StoryboardWorkspace() { ...@@ -437,7 +430,6 @@ export function StoryboardWorkspace() {
setFreeformPrompt(draft.freeformPrompt); setFreeformPrompt(draft.freeformPrompt);
setSelectedDuration(normalizeVideoDuration(draft.selectedDuration)); setSelectedDuration(normalizeVideoDuration(draft.selectedDuration));
setSelectedRatio(draft.selectedRatio); setSelectedRatio(draft.selectedRatio);
setSelectedModel(draft.selectedModel);
setAudioOn(draft.audioOn); setAudioOn(draft.audioOn);
if (nextSceneKey && draft.sceneKey !== nextSceneKey) { if (nextSceneKey && draft.sceneKey !== nextSceneKey) {
updateSb.mutate({ id: expanded.id, patch: { sceneImageKey: nextSceneKey } }); updateSb.mutate({ id: expanded.id, patch: { sceneImageKey: nextSceneKey } });
...@@ -503,7 +495,6 @@ export function StoryboardWorkspace() { ...@@ -503,7 +495,6 @@ export function StoryboardWorkspace() {
freeformPrompt, freeformPrompt,
selectedDuration, selectedDuration,
selectedRatio, selectedRatio,
selectedModel,
audioOn, audioOn,
}); });
}, [ }, [
...@@ -511,7 +502,7 @@ export function StoryboardWorkspace() { ...@@ -511,7 +502,7 @@ export function StoryboardWorkspace() {
generationMode, shortDescription, characterKeys, sceneKey, propKeys, generationMode, shortDescription, characterKeys, sceneKey, propKeys,
firstFrameImageKey, firstFrameImageUrl, lastFrameImageKey, lastFrameImageUrl, firstFrameImageKey, firstFrameImageUrl, lastFrameImageKey, lastFrameImageUrl,
freeformPrompt, freeformPrompt,
selectedDuration, selectedRatio, selectedModel, audioOn, selectedDuration, selectedRatio, audioOn,
]); ]);
useEffect(() => { useEffect(() => {
...@@ -680,7 +671,6 @@ export function StoryboardWorkspace() { ...@@ -680,7 +671,6 @@ export function StoryboardWorkspace() {
freeformPrompt: freeformPrompt.trim(), freeformPrompt: freeformPrompt.trim(),
duration: selectedDuration, duration: selectedDuration,
ratio: selectedRatio, ratio: selectedRatio,
model: selectedModel,
generateAudio: audioOn, generateAudio: audioOn,
}); });
setActiveVideoSbId(expanded.id); setActiveVideoSbId(expanded.id);
...@@ -950,20 +940,6 @@ export function StoryboardWorkspace() { ...@@ -950,20 +940,6 @@ export function StoryboardWorkspace() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<select <select
value={selectedModel}
onChange={(e) => setSelectedModel(e.target.value)}
disabled={!expanded}
title="视频生成模型"
className={`w-full appearance-none pl-3 pr-7 py-2 rounded-md bg-white text-xs text-[#111827] cursor-pointer disabled:opacity-50 transition-colors ${CONTROL_SURFACE} ${FOCUS_RING}`}
>
{VIDEO_MODEL_OPTIONS.map((m) => (
<option key={m.value} value={m.value}>{m.label}</option>
))}
</select>
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
</div>
<div className="relative flex-1">
<select
value={selectedRatio} value={selectedRatio}
onChange={(e) => setSelectedRatio(e.target.value)} onChange={(e) => setSelectedRatio(e.target.value)}
disabled={!expanded} disabled={!expanded}
......
...@@ -174,8 +174,6 @@ export interface StructuredVideoRequest { ...@@ -174,8 +174,6 @@ export interface StructuredVideoRequest {
} | null; } | null;
duration?: number | null; duration?: number | null;
ratio?: string | null; ratio?: string | null;
/** Seedance 模型 ID(如 doubao-seedance-2-0-fast-260128 / doubao-seedance-2-0-260128),缺省由后端 ArkProperties 兜底 */
model?: string | null;
generateAudio?: boolean | null; generateAudio?: boolean | null;
} }
......
...@@ -6,6 +6,7 @@ import { ...@@ -6,6 +6,7 @@ import {
OfficeBuilding, OfficeBuilding,
Operation, Operation,
PriceTag, PriceTag,
Setting,
Tickets, Tickets,
UserFilled, UserFilled,
} from '@element-plus/icons-vue'; } from '@element-plus/icons-vue';
...@@ -50,6 +51,11 @@ export const routes: RouteRecordRaw[] = [ ...@@ -50,6 +51,11 @@ export const routes: RouteRecordRaw[] = [
meta: { title: '计费价格', icon: PriceTag, authority: ['SUPER_ADMIN'] }, meta: { title: '计费价格', icon: PriceTag, authority: ['SUPER_ADMIN'] },
}, },
{ {
path: '/model-config',
component: () => import('./views/ModelConfigView.vue'),
meta: { title: '模型配置', icon: Setting, authority: ['SUPER_ADMIN', 'OPERATOR'] },
},
{
path: '/plans', path: '/plans',
component: () => import('./views/PlanManagementView.vue'), component: () => import('./views/PlanManagementView.vue'),
meta: { title: '套餐管理', icon: PriceTag, authority: ['SUPER_ADMIN'] }, meta: { title: '套餐管理', icon: PriceTag, authority: ['SUPER_ADMIN'] },
......
This diff is collapsed.
{"root":["./src/env.d.ts","./src/main.ts","./src/router.ts","./src/api/http.ts","./src/api/types.ts","./src/stores/auth.ts","./src/app.vue","./src/views/adminusermanagementview.vue","./src/views/auditlogsview.vue","./src/views/billingcostsview.vue","./src/views/billingrecordsview.vue","./src/views/dashboardview.vue","./src/views/loginview.vue","./src/views/notfoundview.vue","./src/views/planmanagementview.vue","./src/views/tenantdetailview.vue","./src/views/tenantlistview.vue","./src/views/usermanagementview.vue"],"version":"5.9.3"} {"root":["./src/env.d.ts","./src/main.ts","./src/router.ts","./src/api/http.ts","./src/api/types.ts","./src/stores/auth.ts","./src/app.vue","./src/views/adminusermanagementview.vue","./src/views/auditlogsview.vue","./src/views/billingcostsview.vue","./src/views/billingrecordsview.vue","./src/views/dashboardview.vue","./src/views/loginview.vue","./src/views/modelconfigview.vue","./src/views/notfoundview.vue","./src/views/planmanagementview.vue","./src/views/tenantdetailview.vue","./src/views/tenantlistview.vue","./src/views/usermanagementview.vue"],"version":"5.9.3"}
\ No newline at end of file \ No newline at end of file
...@@ -35,7 +35,6 @@ export default defineConfig(({ mode }) => { ...@@ -35,7 +35,6 @@ export default defineConfig(({ mode }) => {
'/admin-api': { '/admin-api': {
target: resolveBackendTarget(env), target: resolveBackendTarget(env),
changeOrigin: true, changeOrigin: true,
rewrite: (path) => path.replace(/^\/admin-api/, '/admin'),
}, },
}, },
}, },
......
...@@ -25,6 +25,10 @@ ...@@ -25,6 +25,10 @@
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.yaoai</groupId> <groupId>com.yaoai</groupId>
<artifactId>yaoai-ai-providers</artifactId>
</dependency>
<dependency>
<groupId>com.yaoai</groupId>
<artifactId>yaoai-security</artifactId> <artifactId>yaoai-security</artifactId>
</dependency> </dependency>
<dependency> <dependency>
......
package com.yaoai.admin.modelconfig;
import cn.dev33.satoken.annotation.SaCheckLogin;
import cn.dev33.satoken.annotation.SaCheckRole;
import cn.dev33.satoken.annotation.SaMode;
import com.yaoai.admin.audit.annotation.AdminAudited;
import com.yaoai.admin.auth.StpAdminUtil;
import com.yaoai.ai.providers.config.PlatformModelConfigService;
import com.yaoai.ai.providers.config.PlatformModelSettings;
import com.yaoai.common.result.Result;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequiredArgsConstructor
@SaCheckLogin(type = "admin")
public class ModelConfigAdminController {
private final PlatformModelConfigService platformModelConfigService;
@GetMapping("/admin-api/model-config")
@SaCheckRole(type = "admin", value = {"SUPER_ADMIN", "OPERATOR"}, mode = SaMode.OR)
public Result<PlatformModelSettings> getConfig() {
return Result.ok(platformModelConfigService.getAdminSettings());
}
@PutMapping("/admin-api/model-config")
@AdminAudited(action = "MODEL_CONFIG_UPDATE", resource = "MODEL_CONFIG")
@SaCheckRole(type = "admin", value = {"SUPER_ADMIN"})
public Result<PlatformModelSettings> saveConfig(@RequestBody PlatformModelSettings settings) {
return Result.ok(platformModelConfigService.saveAdminSettings(settings, StpAdminUtil.getLoginIdAsLong()));
}
}
...@@ -20,8 +20,16 @@ ...@@ -20,8 +20,16 @@
<artifactId>yaoai-ai-core</artifactId> <artifactId>yaoai-ai-core</artifactId>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.yaoai</groupId>
<artifactId>yaoai-domain</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
</dependency>
</dependencies> </dependencies>
</project> </project>
package com.yaoai.ai.providers.config;
public enum ModelCapability {
TEXT("text"),
IMAGE("image"),
VIDEO("video");
private final String key;
ModelCapability(String key) {
this.key = key;
}
public String key() {
return key;
}
public static ModelCapability fromKey(String value) {
if (value == null) {
throw new IllegalArgumentException("capability is required");
}
for (ModelCapability capability : values()) {
if (capability.key.equalsIgnoreCase(value) || capability.name().equalsIgnoreCase(value)) {
return capability;
}
}
throw new IllegalArgumentException("unsupported capability: " + value);
}
}
package com.yaoai.ai.providers.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.domain.entity.SystemSetting;
import com.yaoai.domain.mapper.SystemSettingMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
@Slf4j
@Service
public class PlatformModelConfigService {
public static final String SETTING_KEY = "ai.model.config";
public static final String MASKED_API_KEY = "******";
private final SystemSettingMapper systemSettingMapper;
private final ArkProperties arkProperties;
private final ObjectMapper objectMapper;
@Autowired
public PlatformModelConfigService(SystemSettingMapper systemSettingMapper, ArkProperties arkProperties) {
this(systemSettingMapper, arkProperties, new ObjectMapper());
}
PlatformModelConfigService(SystemSettingMapper systemSettingMapper,
ArkProperties arkProperties,
ObjectMapper objectMapper) {
this.systemSettingMapper = systemSettingMapper;
this.arkProperties = arkProperties;
this.objectMapper = objectMapper;
}
public ResolvedModelConfig resolve(ModelCapability capability) {
PlatformModelItem item = loadStoredSettings().get(capability);
if (isUsable(item)) {
return toResolved(item, fallback(capability));
}
return fallback(capability);
}
public PlatformModelSettings getAdminSettings() {
PlatformModelSettings settings = withFallbacks(loadStoredSettings());
maskApiKey(settings.getText());
maskApiKey(settings.getImage());
maskApiKey(settings.getVideo());
return settings;
}
public PlatformModelSettings saveAdminSettings(PlatformModelSettings input, Long adminId) {
PlatformModelSettings current = loadStoredSettings();
PlatformModelSettings next = withFallbacks(input == null ? new PlatformModelSettings() : input);
preserveMaskedApiKey(next.getText(), current.getText());
preserveMaskedApiKey(next.getImage(), current.getImage());
preserveMaskedApiKey(next.getVideo(), current.getVideo());
try {
String json = objectMapper.writeValueAsString(next);
SystemSetting setting = systemSettingMapper.findByKey(SETTING_KEY).orElseGet(SystemSetting::new);
setting.setSettingKey(SETTING_KEY);
setting.setSettingValue(json);
setting.setUpdatedAt(LocalDateTime.now());
setting.setUpdatedBy(adminId);
if (setting.getId() == null) {
systemSettingMapper.insert(setting);
} else {
systemSettingMapper.updateById(setting);
}
return getAdminSettings();
} catch (Exception e) {
log.error("save platform model config failed", e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "保存模型配置失败: " + e.getMessage());
}
}
private PlatformModelSettings loadStoredSettings() {
return systemSettingMapper.findByKey(SETTING_KEY)
.map(SystemSetting::getSettingValue)
.filter(StringUtils::hasText)
.map(this::parseSettings)
.orElseGet(PlatformModelSettings::new);
}
private PlatformModelSettings parseSettings(String json) {
try {
PlatformModelSettings settings = objectMapper.readValue(json, PlatformModelSettings.class);
return settings == null ? new PlatformModelSettings() : settings;
} catch (Exception e) {
log.warn("ignore invalid platform model config json", e);
return new PlatformModelSettings();
}
}
private PlatformModelSettings withFallbacks(PlatformModelSettings settings) {
PlatformModelSettings next = new PlatformModelSettings();
next.setText(merge(settings.getText(), fallback(ModelCapability.TEXT)));
next.setImage(merge(settings.getImage(), fallback(ModelCapability.IMAGE)));
next.setVideo(merge(settings.getVideo(), fallback(ModelCapability.VIDEO)));
return next;
}
private PlatformModelItem merge(PlatformModelItem item, ResolvedModelConfig fallback) {
PlatformModelItem next = item == null ? new PlatformModelItem() : item;
if (!StringUtils.hasText(next.getProvider())) next.setProvider(fallback.provider());
if (!StringUtils.hasText(next.getProviderName())) next.setProviderName(fallback.providerName());
if (!StringUtils.hasText(next.getProtocol())) next.setProtocol(fallback.protocol());
if (!StringUtils.hasText(next.getBaseUrl())) next.setBaseUrl(fallback.baseUrl());
if (!StringUtils.hasText(next.getApiKey())) next.setApiKey(fallback.apiKey());
if (!StringUtils.hasText(next.getModelId())) next.setModelId(fallback.modelId());
if (!StringUtils.hasText(next.getModelName())) next.setModelName(fallback.modelName());
if (!StringUtils.hasText(next.getEndpoint())) next.setEndpoint(fallback.endpoint());
if (!StringUtils.hasText(next.getStatusEndpoint())) next.setStatusEndpoint(fallback.statusEndpoint());
if (next.getEnabled() == null) next.setEnabled(false);
return next;
}
private ResolvedModelConfig toResolved(PlatformModelItem item, ResolvedModelConfig fallback) {
return new ResolvedModelConfig(
valueOr(item.getProvider(), fallback.provider()),
valueOr(item.getProviderName(), fallback.providerName()),
valueOr(item.getProtocol(), fallback.protocol()),
normalizeBaseUrl(valueOr(item.getBaseUrl(), fallback.baseUrl())),
valueOr(item.getApiKey(), fallback.apiKey()),
valueOr(item.getModelId(), fallback.modelId()),
valueOr(item.getModelName(), fallback.modelName()),
normalizeEndpoint(valueOr(item.getEndpoint(), fallback.endpoint())),
normalizeEndpoint(valueOr(item.getStatusEndpoint(), fallback.statusEndpoint())),
valueOr(item.getResolveIp(), fallback.resolveIp())
);
}
private ResolvedModelConfig fallback(ModelCapability capability) {
return switch (capability) {
case TEXT -> new ResolvedModelConfig(
"volcengine",
"Volcengine ARK",
"openai",
normalizeBaseUrl(arkProperties.getBaseUrl()),
arkProperties.getApiKey(),
arkProperties.getTextModel(),
"Doubao Text",
"/chat/completions",
null,
null
);
case IMAGE -> new ResolvedModelConfig(
"volcengine",
"Volcengine ARK",
"seedream",
normalizeBaseUrl(arkProperties.getBaseUrl()),
arkProperties.getApiKey(),
arkProperties.getImageModel(),
"Seedream Image",
"/images/generations",
null,
null
);
case VIDEO -> new ResolvedModelConfig(
"volcengine",
"Volcengine ARK",
"seedance",
normalizeBaseUrl(arkProperties.getBaseUrl()),
arkProperties.getApiKey(),
arkProperties.getVideoModel(),
"Seedance Image-to-Video",
"/contents/generations/tasks",
"/contents/generations/tasks/{id}",
null
);
};
}
private boolean isUsable(PlatformModelItem item) {
return item != null
&& Boolean.TRUE.equals(item.getEnabled())
&& StringUtils.hasText(item.getBaseUrl())
&& StringUtils.hasText(item.getApiKey())
&& StringUtils.hasText(item.getModelId())
&& !MASKED_API_KEY.equals(item.getApiKey());
}
private void preserveMaskedApiKey(PlatformModelItem next, PlatformModelItem current) {
if (next == null) return;
String apiKey = next.getApiKey();
if (!StringUtils.hasText(apiKey) || MASKED_API_KEY.equals(apiKey)) {
next.setApiKey(current == null ? "" : current.getApiKey());
}
}
private void maskApiKey(PlatformModelItem item) {
if (item != null && StringUtils.hasText(item.getApiKey())) {
item.setApiKey(MASKED_API_KEY);
}
}
private String valueOr(String value, String fallback) {
return StringUtils.hasText(value) ? value.trim() : fallback;
}
private String normalizeBaseUrl(String value) {
if (!StringUtils.hasText(value)) return value;
return value.trim().replaceAll("/+$", "");
}
private String normalizeEndpoint(String value) {
if (!StringUtils.hasText(value)) return value;
String trimmed = value.trim();
return trimmed.startsWith("/") ? trimmed : "/" + trimmed;
}
}
package com.yaoai.ai.providers.config;
import lombok.Data;
@Data
public class PlatformModelItem {
private String provider;
private String providerName;
private String protocol;
private String baseUrl;
private String apiKey;
private String modelId;
private String modelName;
private String endpoint;
private String statusEndpoint;
private String resolveIp;
private Boolean enabled;
}
package com.yaoai.ai.providers.config;
import lombok.Data;
@Data
public class PlatformModelSettings {
private PlatformModelItem text;
private PlatformModelItem image;
private PlatformModelItem video;
public PlatformModelItem get(ModelCapability capability) {
return switch (capability) {
case TEXT -> text;
case IMAGE -> image;
case VIDEO -> video;
};
}
public void set(ModelCapability capability, PlatformModelItem item) {
switch (capability) {
case TEXT -> text = item;
case IMAGE -> image = item;
case VIDEO -> video = item;
}
}
}
package com.yaoai.ai.providers.config;
import org.apache.hc.client5.http.DnsResolver;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.time.Duration;
@Component
public class ProviderRestClientFactory {
private static final String AGNES_API_HOST = "apihub.agnes-ai.com";
private static final String AGNES_API_RESOLVE_IP = "104.18.18.62";
public RestClient create(ResolvedModelConfig config) {
return create(config, Duration.ofSeconds(10), Duration.ofMinutes(2));
}
public RestClient create(ResolvedModelConfig config, Duration connectTimeout, Duration readTimeout) {
RestClient.Builder builder = RestClient.builder()
.baseUrl(config.baseUrl())
.defaultHeader("Content-Type", "application/json");
String resolveIp = resolveIp(config);
if (StringUtils.hasText(resolveIp)) {
builder.requestFactory(requestFactory(config, resolveIp, connectTimeout, readTimeout));
}
return builder.build();
}
private HttpComponentsClientHttpRequestFactory requestFactory(ResolvedModelConfig config,
String resolveIp,
Duration connectTimeout,
Duration readTimeout) {
String host = URI.create(config.baseUrl()).getHost();
DnsResolver resolver = new FixedHostDnsResolver(host, resolveIp.trim());
CloseableHttpClient httpClient = HttpClients.custom()
.setConnectionManager(PoolingHttpClientConnectionManagerBuilder.create()
.setDnsResolver(resolver)
.build())
.build();
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory(httpClient);
factory.setConnectTimeout(connectTimeout);
factory.setConnectionRequestTimeout(connectTimeout);
return factory;
}
private String resolveIp(ResolvedModelConfig config) {
if (StringUtils.hasText(config.resolveIp())) {
return config.resolveIp();
}
String host = URI.create(config.baseUrl()).getHost();
if (AGNES_API_HOST.equalsIgnoreCase(host)) {
return AGNES_API_RESOLVE_IP;
}
return null;
}
private record FixedHostDnsResolver(String host, String resolveIp) implements DnsResolver {
@Override
public InetAddress[] resolve(String requestedHost) throws UnknownHostException {
if (host != null && host.equalsIgnoreCase(requestedHost)) {
return new InetAddress[]{InetAddress.getByName(resolveIp)};
}
return InetAddress.getAllByName(requestedHost);
}
@Override
public String resolveCanonicalHostname(String requestedHost) throws UnknownHostException {
return requestedHost;
}
}
}
package com.yaoai.ai.providers.config;
public record ResolvedModelConfig(
String provider,
String providerName,
String protocol,
String baseUrl,
String apiKey,
String modelId,
String modelName,
String endpoint,
String statusEndpoint,
String resolveIp
) {
public boolean isProtocol(String value) {
return protocol != null && protocol.equalsIgnoreCase(value);
}
public boolean isProvider(String value) {
return provider != null && provider.equalsIgnoreCase(value);
}
public boolean isAgnes() {
return isProvider("agnes") || isProtocol("agnes");
}
}
...@@ -4,9 +4,14 @@ import com.yaoai.ai.core.model.ChatRequest; ...@@ -4,9 +4,14 @@ import com.yaoai.ai.core.model.ChatRequest;
import com.yaoai.ai.core.model.ChatResponse; import com.yaoai.ai.core.model.ChatResponse;
import com.yaoai.ai.core.service.LlmService; import com.yaoai.ai.core.service.LlmService;
import com.yaoai.ai.providers.config.ArkProperties; import com.yaoai.ai.providers.config.ArkProperties;
import com.yaoai.ai.providers.config.ModelCapability;
import com.yaoai.ai.providers.config.PlatformModelConfigService;
import com.yaoai.ai.providers.config.ProviderRestClientFactory;
import com.yaoai.ai.providers.config.ResolvedModelConfig;
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 lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClient;
...@@ -17,32 +22,39 @@ import org.springframework.web.client.RestClientResponseException; ...@@ -17,32 +22,39 @@ import org.springframework.web.client.RestClientResponseException;
@ConditionalOnProperty(name = "ai.local-stub.enabled", havingValue = "false", matchIfMissing = true) @ConditionalOnProperty(name = "ai.local-stub.enabled", havingValue = "false", matchIfMissing = true)
public class ArkLlmService implements LlmService { public class ArkLlmService implements LlmService {
private static final String ARK_API_KEY_HINT = private static final String API_KEY_HINT =
"未配置火山方舟 API Key,请设置环境变量 VOLCENGINE_ARK_API_KEY,或在 application-local.yml 中配置 volcengine.ark.api-key"; "未配置文本模型 API Key,请在运营端模型配置中填写,或设置 VOLCENGINE_ARK_API_KEY";
private final ArkProperties properties; private final ArkProperties properties;
private final RestClient restClient; private final PlatformModelConfigService platformModelConfigService;
private final ProviderRestClientFactory restClientFactory;
public ArkLlmService(ArkProperties properties) { public ArkLlmService(ArkProperties properties) {
this(properties, null, new ProviderRestClientFactory());
}
@Autowired
public ArkLlmService(ArkProperties properties,
PlatformModelConfigService platformModelConfigService,
ProviderRestClientFactory restClientFactory) {
this.properties = properties; this.properties = properties;
this.restClient = RestClient.builder() this.platformModelConfigService = platformModelConfigService;
.baseUrl(properties.getBaseUrl()) this.restClientFactory = restClientFactory;
.defaultHeader("Content-Type", "application/json")
.build();
} }
@Override @Override
public String chat(ChatRequest request) { public String chat(ChatRequest request) {
String apiKey = resolveApiKey(); ResolvedModelConfig config = resolveConfig();
// 如果未指定 model,使用默认文本模型 String apiKey = resolveApiKey(config);
if (request.getModel() == null) { if (request.getModel() == null || request.getModel().isBlank()) {
request.setModel(properties.getTextModel()); request.setModel(config.modelId());
} }
log.debug("ARK LLM call: model={}, messages={}", request.getModel(), request.getMessages().size()); log.debug("LLM call: provider={}, model={}, messages={}",
config.provider(), request.getModel(), request.getMessages().size());
try { try {
ChatResponse response = restClient.post() ChatResponse response = restClientFactory.create(config).post()
.uri("/chat/completions") .uri(config.endpoint())
.header("Authorization", "Bearer " + apiKey) .header("Authorization", "Bearer " + apiKey)
.body(request) .body(request)
.retrieve() .retrieve()
...@@ -52,53 +64,72 @@ public class ArkLlmService implements LlmService { ...@@ -52,53 +64,72 @@ public class ArkLlmService implements LlmService {
throw new BizException(ErrorCode.INTERNAL_ERROR, "AI 服务返回空结果"); throw new BizException(ErrorCode.INTERNAL_ERROR, "AI 服务返回空结果");
} }
String content = response.firstContent(); String content = response.firstContent();
log.debug("ARK LLM response: {} chars", content.length()); log.debug("LLM response: {} chars", content.length());
return content; return content;
} catch (RestClientResponseException e) { } catch (RestClientResponseException e) {
if (e.getStatusCode().value() == 400 && request.getResponseFormat() != null) { if (e.getStatusCode().value() == 400 && request.getResponseFormat() != null) {
log.warn("ARK LLM response_format not accepted, retrying without response_format: body={}", log.warn("LLM response_format not accepted, retrying without response_format: body={}",
e.getResponseBodyAsString()); e.getResponseBodyAsString());
request.setResponseFormat(null); request.setResponseFormat(null);
return chat(request); return chat(request);
} }
if (e.getStatusCode().value() == 401) { if (e.getStatusCode().value() == 401) {
throw new BizException(ErrorCode.INTERNAL_ERROR, throw new BizException(ErrorCode.INTERNAL_ERROR, "文本模型鉴权失败,请检查 API Key 是否有效");
"AI 服务鉴权失败,请检查 VOLCENGINE_ARK_API_KEY 是否正确且仍有效");
} }
log.error("ARK LLM call failed: status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString(), e); log.error("LLM call failed: status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString(), e);
throw new BizException(ErrorCode.INTERNAL_ERROR, throw new BizException(ErrorCode.INTERNAL_ERROR,
"AI 服务调用失败: HTTP " + e.getStatusCode().value()); "文本模型调用失败: HTTP " + e.getStatusCode().value());
} catch (BizException e) { } catch (BizException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
log.error("ARK LLM call failed", e); log.error("LLM call failed", e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "AI 服务调用失败: " + e.getMessage()); throw new BizException(ErrorCode.INTERNAL_ERROR, "文本模型调用失败: " + e.getMessage());
} }
} }
@Override @Override
public String getModelId() { public String getModelId() {
return properties.getTextModel(); return resolveConfig().modelId();
} }
@Override @Override
public String getModelName() { public String getModelName() {
return "豆包 Pro 文本生成"; return resolveConfig().modelName();
} }
@Override @Override
public String getProvider() { public String getProvider() {
return "volcengine"; return resolveConfig().provider();
}
private ResolvedModelConfig resolveConfig() {
if (platformModelConfigService != null) {
return platformModelConfigService.resolve(ModelCapability.TEXT);
}
return new ResolvedModelConfig(
"volcengine",
"Volcengine ARK",
"openai",
properties.getBaseUrl(),
properties.getApiKey(),
properties.getTextModel(),
"Doubao Text",
"/chat/completions",
null,
null
);
} }
private String resolveApiKey() { private String resolveApiKey(ResolvedModelConfig config) {
String apiKey = properties.getApiKey(); String apiKey = config.apiKey();
if (apiKey == null || apiKey.isBlank()) { if (apiKey == null || apiKey.isBlank()) {
throw new BizException(ErrorCode.INTERNAL_ERROR, ARK_API_KEY_HINT); throw new BizException(ErrorCode.INTERNAL_ERROR, API_KEY_HINT);
} }
String normalized = apiKey.trim(); String normalized = apiKey.trim();
if ("your-ark-api-key".equalsIgnoreCase(normalized) || "your_ark_api_key".equalsIgnoreCase(normalized)) { if ("your-ark-api-key".equalsIgnoreCase(normalized)
throw new BizException(ErrorCode.INTERNAL_ERROR, ARK_API_KEY_HINT); || "your_ark_api_key".equalsIgnoreCase(normalized)
|| PlatformModelConfigService.MASKED_API_KEY.equals(normalized)) {
throw new BizException(ErrorCode.INTERNAL_ERROR, API_KEY_HINT);
} }
return normalized; return normalized;
} }
......
...@@ -3,15 +3,18 @@ package com.yaoai.ai.providers.service.impl; ...@@ -3,15 +3,18 @@ package com.yaoai.ai.providers.service.impl;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.yaoai.ai.providers.config.ArkProperties; import com.yaoai.ai.providers.config.ArkProperties;
import com.yaoai.ai.providers.config.ModelCapability;
import com.yaoai.ai.providers.config.PlatformModelConfigService;
import com.yaoai.ai.providers.config.ProviderRestClientFactory;
import com.yaoai.ai.providers.config.ResolvedModelConfig;
import com.yaoai.ai.providers.service.SeedreamService; import com.yaoai.ai.providers.service.SeedreamService;
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 lombok.Data; import lombok.Data;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientResponseException; import org.springframework.web.client.RestClientResponseException;
import java.time.Duration; import java.time.Duration;
...@@ -24,23 +27,25 @@ import java.util.Map; ...@@ -24,23 +27,25 @@ import java.util.Map;
@ConditionalOnProperty(name = "ai.local-stub.enabled", havingValue = "false", matchIfMissing = true) @ConditionalOnProperty(name = "ai.local-stub.enabled", havingValue = "false", matchIfMissing = true)
public class SeedreamServiceImpl implements SeedreamService { public class SeedreamServiceImpl implements SeedreamService {
private static final String ARK_API_KEY_HINT = private static final String API_KEY_HINT =
"未配置火山方舟 API Key,请设置环境变量 VOLCENGINE_ARK_API_KEY,或在 application-local.yml 中配置 volcengine.ark.api-key"; "未配置图片模型 API Key,请在运营端模型配置中填写,或设置 VOLCENGINE_ARK_API_KEY";
private static final ObjectMapper JSON = new ObjectMapper(); private static final ObjectMapper JSON = new ObjectMapper();
private final ArkProperties properties; private final ArkProperties properties;
private final RestClient restClient; private final PlatformModelConfigService platformModelConfigService;
private final ProviderRestClientFactory restClientFactory;
public SeedreamServiceImpl(ArkProperties properties) { public SeedreamServiceImpl(ArkProperties properties) {
this(properties, null, new ProviderRestClientFactory());
}
@Autowired
public SeedreamServiceImpl(ArkProperties properties,
PlatformModelConfigService platformModelConfigService,
ProviderRestClientFactory restClientFactory) {
this.properties = properties; this.properties = properties;
SimpleClientHttpRequestFactory rf = new SimpleClientHttpRequestFactory(); this.platformModelConfigService = platformModelConfigService;
rf.setConnectTimeout(Duration.ofSeconds(10)); this.restClientFactory = restClientFactory;
rf.setReadTimeout(Duration.ofMinutes(2));
this.restClient = RestClient.builder()
.baseUrl(properties.getBaseUrl())
.defaultHeader("Content-Type", "application/json")
.requestFactory(rf)
.build();
} }
@Override @Override
...@@ -55,13 +60,17 @@ public class SeedreamServiceImpl implements SeedreamService { ...@@ -55,13 +60,17 @@ public class SeedreamServiceImpl implements SeedreamService {
@Override @Override
public String generateImage(String prompt, String size, List<String> referenceImageUrls) { public String generateImage(String prompt, String size, List<String> referenceImageUrls) {
String apiKey = resolveApiKey(); ResolvedModelConfig config = resolveConfig();
String apiKey = resolveApiKey(config);
Map<String, Object> body = new LinkedHashMap<>(); Map<String, Object> body = new LinkedHashMap<>();
body.put("model", properties.getImageModel()); body.put("model", config.modelId());
body.put("prompt", prompt); body.put("prompt", prompt);
body.put("n", 1); body.put("n", 1);
body.put("size", size); body.put("size", size);
body.put("response_format", "url"); if (!config.isAgnes()) {
body.put("response_format", "url");
}
List<String> cleanReferences = referenceImageUrls == null List<String> cleanReferences = referenceImageUrls == null
? List.of() ? List.of()
: referenceImageUrls.stream() : referenceImageUrls.stream()
...@@ -69,31 +78,34 @@ public class SeedreamServiceImpl implements SeedreamService { ...@@ -69,31 +78,34 @@ public class SeedreamServiceImpl implements SeedreamService {
.map(String::trim) .map(String::trim)
.toList(); .toList();
if (!cleanReferences.isEmpty()) { if (!cleanReferences.isEmpty()) {
body.put("image", cleanReferences.size() == 1 ? cleanReferences.get(0) : cleanReferences); if (config.isProtocol("openai") || config.isProtocol("agnes")) {
body.put("image_urls", cleanReferences);
} else {
body.put("image", cleanReferences.size() == 1 ? cleanReferences.get(0) : cleanReferences);
}
} }
log.info("Seedream generate image: model={}, size={}, references={}", log.info("Image generate: provider={}, model={}, size={}, references={}",
properties.getImageModel(), size, cleanReferences.size()); config.provider(), config.modelId(), size, cleanReferences.size());
try { try {
ImageResponse resp = restClient.post() ImageResponse resp = restClientFactory.create(config, Duration.ofSeconds(10), Duration.ofMinutes(2)).post()
.uri("/images/generations") .uri(config.endpoint())
.header("Authorization", "Bearer " + apiKey) .header("Authorization", "Bearer " + apiKey)
.body(body) .body(body)
.retrieve() .retrieve()
.body(ImageResponse.class); .body(ImageResponse.class);
if (resp == null || resp.getData() == null || resp.getData().isEmpty()) { if (resp == null || resp.getData() == null || resp.getData().isEmpty()) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "Seedream 返回空结果"); throw new BizException(ErrorCode.INTERNAL_ERROR, "图片模型返回空结果");
} }
String url = resp.getData().get(0).getUrl(); String url = resp.getData().get(0).getUrl();
log.info("Seedream image generated: url={}", url); log.info("Image generated: url={}", url);
return url; return url;
} catch (RestClientResponseException e) { } catch (RestClientResponseException e) {
if (e.getStatusCode().value() == 401) { if (e.getStatusCode().value() == 401) {
throw new BizException(ErrorCode.INTERNAL_ERROR, throw new BizException(ErrorCode.INTERNAL_ERROR, "图片模型鉴权失败,请检查 API Key 是否有效");
"图片服务鉴权失败,请检查 VOLCENGINE_ARK_API_KEY 是否正确且仍有效");
} }
log.error("Seedream generate failed: status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString(), e); log.error("Image generate failed: status={}, body={}", e.getStatusCode(), e.getResponseBodyAsString(), e);
String providerMessage = extractProviderError(e.getResponseBodyAsString()); String providerMessage = extractProviderError(e.getResponseBodyAsString());
throw new BizException(ErrorCode.INTERNAL_ERROR, throw new BizException(ErrorCode.INTERNAL_ERROR,
"图片生成失败: HTTP " + e.getStatusCode().value() "图片生成失败: HTTP " + e.getStatusCode().value()
...@@ -101,29 +113,54 @@ public class SeedreamServiceImpl implements SeedreamService { ...@@ -101,29 +113,54 @@ public class SeedreamServiceImpl implements SeedreamService {
} catch (BizException e) { } catch (BizException e) {
throw e; throw e;
} catch (Exception e) { } catch (Exception e) {
log.error("Seedream generate failed", e); log.error("Image generate failed", e);
throw new BizException(ErrorCode.INTERNAL_ERROR, "图片生成失败: " + e.getMessage()); throw new BizException(ErrorCode.INTERNAL_ERROR, "图片生成失败: " + e.getMessage());
} }
} }
@Override @Override
public String getModelId() { public String getModelId() {
return properties.getImageModel(); return resolveConfig().modelId();
} }
@Override @Override
public String getModelName() { public String getModelName() {
return "Seedream 文生图"; return resolveConfig().modelName();
}
@Override
public String getProvider() {
return resolveConfig().provider();
}
private ResolvedModelConfig resolveConfig() {
if (platformModelConfigService != null) {
return platformModelConfigService.resolve(ModelCapability.IMAGE);
}
return new ResolvedModelConfig(
"volcengine",
"Volcengine ARK",
"seedream",
properties.getBaseUrl(),
properties.getApiKey(),
properties.getImageModel(),
"Seedream Image",
"/images/generations",
null,
null
);
} }
private String resolveApiKey() { private String resolveApiKey(ResolvedModelConfig config) {
String apiKey = properties.getApiKey(); String apiKey = config.apiKey();
if (apiKey == null || apiKey.isBlank()) { if (apiKey == null || apiKey.isBlank()) {
throw new BizException(ErrorCode.INTERNAL_ERROR, ARK_API_KEY_HINT); throw new BizException(ErrorCode.INTERNAL_ERROR, API_KEY_HINT);
} }
String normalized = apiKey.trim(); String normalized = apiKey.trim();
if ("your-ark-api-key".equalsIgnoreCase(normalized) || "your_ark_api_key".equalsIgnoreCase(normalized)) { if ("your-ark-api-key".equalsIgnoreCase(normalized)
throw new BizException(ErrorCode.INTERNAL_ERROR, ARK_API_KEY_HINT); || "your_ark_api_key".equalsIgnoreCase(normalized)
|| PlatformModelConfigService.MASKED_API_KEY.equals(normalized)) {
throw new BizException(ErrorCode.INTERNAL_ERROR, API_KEY_HINT);
} }
return normalized; return normalized;
} }
...@@ -145,8 +182,6 @@ public class SeedreamServiceImpl implements SeedreamService { ...@@ -145,8 +182,6 @@ public class SeedreamServiceImpl implements SeedreamService {
return normalized.length() > 300 ? normalized.substring(0, 300) + "..." : normalized; return normalized.length() > 300 ? normalized.substring(0, 300) + "..." : normalized;
} }
// ---- internal response models ----
@Data @Data
private static class ImageResponse { private static class ImageResponse {
private Long created; private Long created;
......
...@@ -73,7 +73,6 @@ public class VideoTaskController { ...@@ -73,7 +73,6 @@ public class VideoTaskController {
.userPromptRaw(buildUserPromptMap(up, req.getFreeformPrompt())) .userPromptRaw(buildUserPromptMap(up, req.getFreeformPrompt()))
.durationSeconds(req.getDuration()) .durationSeconds(req.getDuration())
.ratio(req.getRatio()) .ratio(req.getRatio())
.model(req.getModel())
.generateAudio(req.getGenerateAudio()) .generateAudio(req.getGenerateAudio())
.build(); .build();
......
...@@ -61,9 +61,8 @@ public class StructuredVideoGenerateRequest { ...@@ -61,9 +61,8 @@ public class StructuredVideoGenerateRequest {
@Pattern(regexp = "16:9|9:16|1:1", message = "宽高比仅支持 16:9 / 9:16 / 1:1") @Pattern(regexp = "16:9|9:16|1:1", message = "宽高比仅支持 16:9 / 9:16 / 1:1")
private String ratio = "16:9"; private String ratio = "16:9";
/** Seedance 模型 ID(doubao-seedance-2-0-260128 / doubao-seedance-2-0-fast-260128),为空走 ArkProperties 默认值 */ /** @deprecated 模型统一由运营端视频模型配置决定,客户端传入值会被忽略。 */
@Pattern(regexp = "doubao-seedance-2-0-260128|doubao-seedance-2-0-fast-260128", @Deprecated
message = "模型仅支持 Seedance 2.0 标准 / 极速")
private String model; private String model;
/** 是否生成配音 */ /** 是否生成配音 */
......
package com.yaoai.ai.providers.config;
import com.yaoai.domain.entity.SystemSetting;
import com.yaoai.domain.mapper.SystemSettingMapper;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class PlatformModelConfigServiceTest {
@Test
void resolveUsesEnabledPlatformConfigBeforeEnvironmentDefaults() {
SystemSettingMapper mapper = mock(SystemSettingMapper.class);
SystemSetting setting = new SystemSetting();
setting.setSettingKey(PlatformModelConfigService.SETTING_KEY);
setting.setSettingValue("""
{
"text": {
"provider": "agnes",
"providerName": "Agnes",
"protocol": "openai",
"baseUrl": "https://apihub.agnes-ai.com/v1",
"apiKey": "agnes-secret",
"modelId": "agnes-2.0-flash",
"modelName": "Agnes 2.0 Flash",
"endpoint": "/chat/completions",
"resolveIp": "104.18.18.62",
"enabled": true
}
}
""");
when(mapper.findByKey(PlatformModelConfigService.SETTING_KEY)).thenReturn(Optional.of(setting));
PlatformModelConfigService service = new PlatformModelConfigService(mapper, buildArkProperties());
ResolvedModelConfig config = service.resolve(ModelCapability.TEXT);
assertEquals("agnes", config.provider());
assertEquals("openai", config.protocol());
assertEquals("https://apihub.agnes-ai.com/v1", config.baseUrl());
assertEquals("agnes-secret", config.apiKey());
assertEquals("agnes-2.0-flash", config.modelId());
assertEquals("Agnes 2.0 Flash", config.modelName());
assertEquals("/chat/completions", config.endpoint());
assertEquals("104.18.18.62", config.resolveIp());
}
@Test
void resolveFallsBackToVolcenginePropertiesWhenPlatformConfigIsMissing() {
SystemSettingMapper mapper = mock(SystemSettingMapper.class);
when(mapper.findByKey(PlatformModelConfigService.SETTING_KEY)).thenReturn(Optional.empty());
PlatformModelConfigService service = new PlatformModelConfigService(mapper, buildArkProperties());
ResolvedModelConfig config = service.resolve(ModelCapability.IMAGE);
assertEquals("volcengine", config.provider());
assertEquals("seedream", config.protocol());
assertEquals("https://ark.cn-beijing.volces.com/api/v3", config.baseUrl());
assertEquals("ark-secret", config.apiKey());
assertEquals("doubao-seedream-test", config.modelId());
assertTrue(config.modelName().contains("Seedream"));
assertEquals("/images/generations", config.endpoint());
}
@Test
void adminSettingsMasksStoredApiKeys() {
SystemSettingMapper mapper = mock(SystemSettingMapper.class);
SystemSetting setting = new SystemSetting();
setting.setSettingKey(PlatformModelConfigService.SETTING_KEY);
setting.setSettingValue("""
{
"video": {
"provider": "agnes",
"apiKey": "agnes-secret",
"modelId": "agnes-video-v2.0",
"enabled": true
}
}
""");
when(mapper.findByKey(PlatformModelConfigService.SETTING_KEY)).thenReturn(Optional.of(setting));
PlatformModelConfigService service = new PlatformModelConfigService(mapper, buildArkProperties());
PlatformModelSettings settings = service.getAdminSettings();
assertEquals("******", settings.getVideo().getApiKey());
}
private static ArkProperties buildArkProperties() {
ArkProperties properties = new ArkProperties();
properties.setApiKey("ark-secret");
properties.setBaseUrl("https://ark.cn-beijing.volces.com/api/v3");
properties.setTextModel("doubao-text-test");
properties.setImageModel("doubao-seedream-test");
properties.setVideoModel("doubao-seedance-test");
return properties;
}
}
...@@ -30,7 +30,10 @@ public interface AiTaskMapper extends BaseMapper<AiTask> { ...@@ -30,7 +30,10 @@ public interface AiTaskMapper extends BaseMapper<AiTask> {
/** 待轮询的视频任务:已提交 Ark 且未到终态。limit 控制单轮处理上限,避免单次扫描挤占线程。 */ /** 待轮询的视频任务:已提交 Ark 且未到终态。limit 控制单轮处理上限,避免单次扫描挤占线程。 */
@Select(""" @Select("""
SELECT * FROM ai_tasks SELECT * FROM ai_tasks
WHERE status IN ('submitted','running') WHERE (
status IN ('submitted','running')
OR (status = 'succeeded' AND (result_video_url IS NULL OR result_video_url = ''))
)
AND external_task_id IS NOT NULL AND external_task_id IS NOT NULL
ORDER BY created_at ASC ORDER BY created_at ASC
LIMIT #{limit} LIMIT #{limit}
......
...@@ -5,22 +5,10 @@ import com.yaoai.ai.providers.service.SeedanceService; ...@@ -5,22 +5,10 @@ import com.yaoai.ai.providers.service.SeedanceService;
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.service.ShotAssetService; import com.yaoai.pipeline.service.ShotAssetService;
import com.yaoai.storage.service.TosService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/**
* 单条视频任务的查询与完成处理:查 Ark → 更新 DB → 转存视频到自家 TOS。
* 给 {@link VideoTaskPoller} 调用,未来若加管理后台补救接口也可复用。
*/
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
...@@ -28,15 +16,13 @@ public class VideoTaskCompletionService { ...@@ -28,15 +16,13 @@ public class VideoTaskCompletionService {
private final AiTaskMapper aiTaskMapper; private final AiTaskMapper aiTaskMapper;
private final SeedanceService seedanceService; private final SeedanceService seedanceService;
private final TosService tosService;
private final ShotAssetService shotAssetService; private final ShotAssetService shotAssetService;
private final HttpClient httpClient = HttpClient.newBuilder() private final VideoTransferService videoTransferService;
.connectTimeout(Duration.ofSeconds(10))
.build();
/** /**
* 单次轮询一个任务:查 Ark 状态并按状态更新 DB;succeeded 时把视频转存到我们桶。 * Poll one video task and update local state.
* 返回此任务在本轮处理后是否进入终态(succeeded/failed)。 * When provider video is ready, write the provider URL immediately so the UI can play it,
* then transfer it to TOS asynchronously and replace the URL later.
*/ */
public boolean pollOnce(AiTask task) { public boolean pollOnce(AiTask task) {
Long taskId = task.getId(); Long taskId = task.getId();
...@@ -44,34 +30,38 @@ public class VideoTaskCompletionService { ...@@ -44,34 +30,38 @@ public class VideoTaskCompletionService {
try { try {
VideoTaskResult result = seedanceService.getTaskStatus(externalTaskId); VideoTaskResult result = seedanceService.getTaskStatus(externalTaskId);
String status = result.getStatus(); String status = result.getStatus();
log.debug("Polling Ark: taskId={}, externalId={}, status={}", taskId, externalTaskId, status); log.debug("Polling video provider: taskId={}, externalId={}, status={}", taskId, externalTaskId, status);
if ("succeeded".equals(status) || "failed".equals(status)) { if ("succeeded".equals(status) || "failed".equals(status)) {
String videoUrl = result.getVideoUrl();
if ("succeeded".equals(status) && (videoUrl == null || videoUrl.isBlank())) {
log.warn("Video task succeeded but URL is empty, will retry: taskId={}, externalId={}",
taskId, externalTaskId);
return false;
}
AiTask done = new AiTask(); AiTask done = new AiTask();
done.setId(taskId); done.setId(taskId);
done.setStatus(status); done.setStatus(status);
if (result.getVideoUrl() != null) { if ("succeeded".equals(status)) {
String persistentUrl = persistVideoToTos( done.setResultVideoUrl(videoUrl);
task.getTenantId(), task.getProjectId(), taskId, result.getVideoUrl());
done.setResultVideoUrl(persistentUrl);
} }
if (result.getErrorMessage() != null) { if (result.getErrorMessage() != null) {
done.setErrorMessage(result.getErrorMessage()); done.setErrorMessage(result.getErrorMessage());
} }
aiTaskMapper.updateById(done); aiTaskMapper.updateById(done);
if ("succeeded".equals(status)) { if ("succeeded".equals(status)) {
shotAssetService.markVideoAssetSucceeded( shotAssetService.markVideoAssetSucceeded(task.getTenantId(), taskId, videoUrl);
task.getTenantId(), taskId, done.getResultVideoUrl()); videoTransferService.transferVideoToTos(task.getTenantId(), task.getProjectId(), taskId, videoUrl);
} else { } else {
shotAssetService.markVideoAssetFailed( shotAssetService.markVideoAssetFailed(task.getTenantId(), taskId, done.getErrorMessage());
task.getTenantId(), taskId, done.getErrorMessage());
} }
log.info("Video task completed: taskId={}, status={}, url={}",
taskId, status, done.getResultVideoUrl()); log.info("Video task completed: taskId={}, status={}, url={}", taskId, status, videoUrl);
return true; return true;
} }
// running 时同步状态,便于前端区分 submitted/running
if ("running".equals(status) && !"running".equals(task.getStatus())) { if ("running".equals(status) && !"running".equals(task.getStatus())) {
AiTask running = new AiTask(); AiTask running = new AiTask();
running.setId(taskId); running.setId(taskId);
...@@ -80,38 +70,8 @@ public class VideoTaskCompletionService { ...@@ -80,38 +70,8 @@ public class VideoTaskCompletionService {
} }
return false; return false;
} catch (Exception e) { } catch (Exception e) {
log.warn("Ark poll error (will retry): taskId={}, error={}", taskId, e.getMessage()); log.warn("Video poll error (will retry): taskId={}, error={}", taskId, e.getMessage());
return false; return false;
} }
} }
/**
* 把 Ark 临时视频 URL 下载下来上传到我们 public-read TOS bucket,返回永不过期的公开 URL。
* 失败时退回原始 URL(24h 内仍可用)+ 错误日志,不阻塞任务完成流程。
*/
public String persistVideoToTos(Long tenantId, Long projectId, Long taskId, String arkVideoUrl) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(arkVideoUrl))
.timeout(Duration.ofMinutes(2))
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
log.warn("Download Ark video failed (status={}), keep raw URL: taskId={}", response.statusCode(), taskId);
return arkVideoUrl;
}
byte[] bytes = response.body();
String key = String.format("%d/%d/video/%d.mp4", tenantId, projectId, taskId);
try (var is = new ByteArrayInputStream(bytes)) {
tosService.upload(key, is, bytes.length, "video/mp4");
}
String publicUrl = tosService.publicUrl(key);
log.info("Video persisted to TOS: taskId={}, key={}, size={}", taskId, key, bytes.length);
return publicUrl;
} catch (Exception e) {
log.error("Persist Ark video to TOS failed, fallback to raw URL: taskId={}", taskId, e);
return arkVideoUrl;
}
}
} }
package com.yaoai.pipeline.async;
import com.yaoai.domain.entity.AiTask;
import com.yaoai.domain.mapper.AiTaskMapper;
import com.yaoai.pipeline.service.ShotAssetService;
import com.yaoai.storage.service.TosService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
@Slf4j
@Service
@RequiredArgsConstructor
public class VideoTransferService {
private final AiTaskMapper aiTaskMapper;
private final TosService tosService;
private final ShotAssetService shotAssetService;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
@Async
public void transferVideoToTos(Long tenantId, Long projectId, Long taskId, String temporaryVideoUrl) {
try {
if (temporaryVideoUrl == null || temporaryVideoUrl.isBlank()) {
return;
}
AiTask current = aiTaskMapper.selectById(taskId);
if (current == null || !tenantId.equals(current.getTenantId())) {
log.warn("Skip video transfer: task not found or tenant mismatch, taskId={}", taskId);
return;
}
if (!temporaryVideoUrl.equals(current.getResultVideoUrl())) {
log.info("Skip stale video transfer: taskId={}", taskId);
return;
}
log.info("Video transfer started: taskId={}, temporaryUrl={}", taskId, temporaryVideoUrl);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(temporaryVideoUrl))
.timeout(Duration.ofMinutes(2))
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
log.warn("Download provider video failed (status={}), keep temporary URL: taskId={}",
response.statusCode(), taskId);
return;
}
byte[] bytes = response.body();
String key = String.format("%d/%d/video/%d.mp4", tenantId, projectId, taskId);
try (var input = new ByteArrayInputStream(bytes)) {
tosService.upload(key, input, bytes.length, "video/mp4");
}
AiTask latest = aiTaskMapper.selectById(taskId);
if (latest == null || !tenantId.equals(latest.getTenantId()) || !temporaryVideoUrl.equals(latest.getResultVideoUrl())) {
log.info("Skip stale video replace after upload: taskId={}", taskId);
return;
}
String publicUrl = tosService.publicUrl(key);
AiTask patch = new AiTask();
patch.setId(taskId);
patch.setResultVideoUrl(publicUrl);
patch.setResultTosKey(key);
aiTaskMapper.updateById(patch);
shotAssetService.markVideoAssetSucceeded(tenantId, taskId, publicUrl);
log.info("Video transfer completed: taskId={}, key={}, size={}", taskId, key, bytes.length);
} catch (Exception e) {
log.error("Video transfer failed, keep temporary URL: taskId={}", taskId, e);
}
}
}
...@@ -118,6 +118,7 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -118,6 +118,7 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
private final ProjectConsistencyBibleMapper projectConsistencyBibleMapper; private final ProjectConsistencyBibleMapper projectConsistencyBibleMapper;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final BillingService billingService; private final BillingService billingService;
private final SceneImageTransferService sceneImageTransferService;
private final HttpClient httpClient = HttpClient.newBuilder() private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10)) .connectTimeout(Duration.ofSeconds(10))
.build(); .build();
...@@ -365,13 +366,9 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -365,13 +366,9 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
try { try {
String prompt = limitImagePrompt(buildSceneRenderPrompt(scene.getImagePrompt(), visualStyle, renderSpec)); String prompt = limitImagePrompt(buildSceneRenderPrompt(scene.getImagePrompt(), visualStyle, renderSpec));
String imageUrl = seedreamService.generateImage(prompt, renderSpec.size()); String imageUrl = seedreamService.generateImage(prompt, renderSpec.size());
byte[] bytes = downloadBytes(imageUrl); scene.setImageUrl(imageUrl);
String key = TosService.buildKey(scene.getTenantId(), scene.getProjectId(), "scenes", scene.getName() + ".jpg"); scene.setImageTosKey(null);
tosService.upload(key, new ByteArrayInputStream(bytes), bytes.length, "image/jpeg"); scene.setStatus("generating");
scene.setImageUrl(tosService.publicUrl(key));
scene.setImageTosKey(key);
scene.setStatus("ready");
sceneMapper.updateById(scene); sceneMapper.updateById(scene);
billingService.charge(BillingChargeRequest.builder() billingService.charge(BillingChargeRequest.builder()
.tenantId(tenantId) .tenantId(tenantId)
...@@ -385,6 +382,7 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService { ...@@ -385,6 +382,7 @@ public class AssetGenPipelineServiceImpl implements AssetGenPipelineService {
.unitCount(1) .unitCount(1)
.refId(String.valueOf(sceneId)) .refId(String.valueOf(sceneId))
.build()); .build());
sceneImageTransferService.transferSceneImageToTos(sceneId, tenantId, imageUrl);
return scene; return scene;
} catch (BizException e) { } catch (BizException e) {
scene.setStatus("failed"); scene.setStatus("failed");
......
package com.yaoai.pipeline.service.impl;
import com.yaoai.common.exception.BizException;
import com.yaoai.common.exception.ErrorCode;
import com.yaoai.domain.entity.Scene;
import com.yaoai.domain.mapper.SceneMapper;
import com.yaoai.storage.service.TosService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Base64;
@Slf4j
@Service
@RequiredArgsConstructor
public class SceneImageTransferService {
private final SceneMapper sceneMapper;
private final TosService tosService;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
@Async
public void transferSceneImageToTos(Long sceneId, Long tenantId, String temporaryImageUrl) {
try {
Scene scene = sceneMapper.selectById(sceneId);
if (scene == null || !tenantId.equals(scene.getTenantId())) {
log.warn("Skip scene image transfer: scene not found or tenant mismatch, sceneId={}", sceneId);
return;
}
if (!temporaryImageUrl.equals(scene.getImageUrl())) {
log.info("Skip stale scene image transfer: sceneId={}", sceneId);
return;
}
log.info("Scene image transfer started: sceneId={}, temporaryUrl={}", sceneId, temporaryImageUrl);
DownloadedImage image = downloadImage(temporaryImageUrl);
String key = TosService.buildKey(
scene.getTenantId(),
scene.getProjectId(),
"scenes",
scene.getName() + "." + image.extension()
);
tosService.upload(key, new ByteArrayInputStream(image.bytes()), image.bytes().length, image.contentType());
Scene latest = sceneMapper.selectById(sceneId);
if (latest == null || !tenantId.equals(latest.getTenantId()) || !temporaryImageUrl.equals(latest.getImageUrl())) {
log.info("Skip stale scene image replace after upload: sceneId={}", sceneId);
return;
}
latest.setImageUrl(tosService.publicUrl(key));
latest.setImageTosKey(key);
latest.setStatus("ready");
sceneMapper.updateById(latest);
log.info("Scene image transfer completed: sceneId={}, key={}", sceneId, key);
} catch (Exception e) {
log.warn("Scene image transfer failed: sceneId={}, temporaryUrl={}", sceneId, temporaryImageUrl, e);
markFailedIfStillCurrent(sceneId, tenantId, temporaryImageUrl);
}
}
private void markFailedIfStillCurrent(Long sceneId, Long tenantId, String temporaryImageUrl) {
Scene scene = sceneMapper.selectById(sceneId);
if (scene == null || !tenantId.equals(scene.getTenantId()) || !temporaryImageUrl.equals(scene.getImageUrl())) {
return;
}
scene.setStatus("failed");
sceneMapper.updateById(scene);
}
private DownloadedImage downloadImage(String url) throws Exception {
if (url != null && url.startsWith("data:")) {
int comma = url.indexOf(',');
int semicolon = url.indexOf(';');
if (comma <= 0) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "图片 data URL 格式无效");
}
String contentType = semicolon > 5 ? url.substring(5, semicolon) : "image/png";
return new DownloadedImage(Base64.getDecoder().decode(url.substring(comma + 1)), contentType, extensionFor(contentType));
}
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofMinutes(2))
.GET()
.build();
HttpResponse<byte[]> response = httpClient.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() != 200) {
throw new BizException(ErrorCode.INTERNAL_ERROR, "下载图片失败 HTTP " + response.statusCode());
}
String contentType = response.headers().firstValue("content-type")
.map(value -> value.split(";")[0].trim())
.filter(value -> value.startsWith("image/"))
.orElseGet(() -> contentTypeFromUrl(url));
return new DownloadedImage(response.body(), contentType, extensionFor(contentType));
}
private String contentTypeFromUrl(String url) {
String lower = url == null ? "" : url.toLowerCase();
if (lower.contains(".jpg") || lower.contains(".jpeg")) return "image/jpeg";
if (lower.contains(".webp")) return "image/webp";
if (lower.contains(".gif")) return "image/gif";
return "image/png";
}
private String extensionFor(String contentType) {
if ("image/jpeg".equalsIgnoreCase(contentType)) return "jpg";
if ("image/webp".equalsIgnoreCase(contentType)) return "webp";
if ("image/gif".equalsIgnoreCase(contentType)) return "gif";
return "png";
}
private record DownloadedImage(byte[] bytes, String contentType, String extension) {
}
}
...@@ -277,7 +277,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService { ...@@ -277,7 +277,7 @@ public class VideoTaskPipelineServiceImpl implements VideoTaskPipelineService {
String ratio = s.getRatio() != null && !s.getRatio().isBlank() ? s.getRatio() : "16:9"; String ratio = s.getRatio() != null && !s.getRatio().isBlank() ? s.getRatio() : "16:9";
boolean generateAudio = Boolean.TRUE.equals(s.getGenerateAudio()); boolean generateAudio = Boolean.TRUE.equals(s.getGenerateAudio());
asyncProcessor.processTextToVideo(task.getId(), tenantId, s.getUserId(), projectId, asyncProcessor.processTextToVideo(task.getId(), tenantId, s.getUserId(), projectId,
finalPrompt, finalPrompt, orderedKeys, duration, ratio, generateAudio, s.getModel()); finalPrompt, finalPrompt, orderedKeys, duration, ratio, generateAudio, null);
return task; return task;
} }
......
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