From 2d026feba369b3319512792dd1b84c5f0491e009 Mon Sep 17 00:00:00 2001 From: yys <47@gamerwa.com> Date: Thu, 2 Apr 2026 10:23:27 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=20=E7=B4=A0=E6=9D=90=E7=BB=84/?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application-druid.yml | 5 +- .../src/main/resources/application.yml | 21 +- .../ai/service/impl/BaseByteApiService.java | 325 +++++++++++++----- .../service/impl/ByteAssetGroupService.java | 17 +- .../ai/service/impl/ByteAssetService.java | 21 +- .../resources/mapper/system/SysDeptMapper.xml | 2 +- 6 files changed, 282 insertions(+), 109 deletions(-) diff --git a/web-api/ruoyi-admin/src/main/resources/application-druid.yml b/web-api/ruoyi-admin/src/main/resources/application-druid.yml index add300e..71cdae8 100644 --- a/web-api/ruoyi-admin/src/main/resources/application-druid.yml +++ b/web-api/ruoyi-admin/src/main/resources/application-druid.yml @@ -9,7 +9,6 @@ spring: username: root password: mkMReisAKl6I7rVqEY90 driverClassName: com.mysql.cj.jdbc.Driver - # 初始连接数 initialSize: 5 # 最小连接池数量 @@ -52,6 +51,4 @@ spring: merge-sql: true wall: config: - multi-statement-allow: true - - + multi-statement-allow: true \ No newline at end of file diff --git a/web-api/ruoyi-admin/src/main/resources/application.yml b/web-api/ruoyi-admin/src/main/resources/application.yml index d8a2779..4722c5b 100644 --- a/web-api/ruoyi-admin/src/main/resources/application.yml +++ b/web-api/ruoyi-admin/src/main/resources/application.yml @@ -224,16 +224,25 @@ tencentCos: # domain: https://images.iqyjsnwv.com/ byteapi: - url: + url: apiKey: - callBackUrl: + callBackUrl: -# 火山引擎 Ark API (Seedance 2.0) volcengine: + region: cn-beijing + # 素材库等 ark 控制面 OpenAPI(与 byteapi 数据面推理地址无关) + # URL 请加引号;代码会去掉 https:// 再交给 SDK(SDK 自己会拼 https://,避免双 scheme) + openApiEndpoint: "https://ark.cn-beijing.volces.com" ark: - baseUrl: - apiKey: - callbackUrl: + baseUrl: "https://ark.cn-beijing.volces.com" + openApiVersion: "2024-01-01" + apiKey: 3e33e034-7e25-4228-8864-b51b2a7a8f97 + callbackUrl: http://47.86.170.114:8011/api/ai/volcCallback + ak: AKLTNmYyN2VhZTcyMDcxNDNlNzg3OGVlMDVmZjRhNWQwY2M + sk: Tm1ZeU1UTmlORFk1WmpKa05HUmpaRGcxTWpjMFpqUmpOVE01TUdJME5URQ== + url: https://ark.ap-southeast.bytepluses.com/api/v3 + apiKey: 3e33e034-7e25-4228-8864-b51b2a7a8f97 + callBackUrl: http://47.86.170.114:5173/ # 门户视频生成页:模型 / 比例 / 时长 / 分辨率均由此处维护,前后端不写死业务枚举 portal: diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/BaseByteApiService.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/BaseByteApiService.java index b3e5db3..7b95787 100644 --- a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/BaseByteApiService.java +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/BaseByteApiService.java @@ -1,104 +1,269 @@ package com.ruoyi.ai.service.impl; import com.fasterxml.jackson.annotation.JsonInclude; -import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.MapperFeature; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.PropertyNamingStrategies; -import com.ruoyi.ai.service.IByteDeptApiKeyService; +import com.fasterxml.jackson.databind.*; +import com.ruoyi.ai.mapper.AiUserMapper; +import com.ruoyi.common.core.domain.entity.AiUser; +import com.ruoyi.common.core.domain.entity.SysDept; import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.http.OkHttpUtils; -import okhttp3.MediaType; -import okhttp3.Request; -import okhttp3.RequestBody; -import okhttp3.Response; -import org.springframework.beans.factory.annotation.Autowired; +import com.ruoyi.system.mapper.SysDeptMapper; +import lombok.extern.slf4j.Slf4j; +import okhttp3.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import javax.annotation.PostConstruct; +import javax.annotation.Resource; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; +@Slf4j @Service public class BaseByteApiService { - protected final ObjectMapper objectMapper = new ObjectMapper() + @Resource + private AiUserMapper userMapper; + @Resource + private SysDeptMapper deptMapper; + protected String DEPT_ANCESTORS_SPLIT = ","; + @Value("${volcengine.ak}") + protected String accessKeyId; + @Value("${volcengine.sk}") + protected String secretAccessKey; + @Value("${volcengine.region}") + protected String region; + + private static final String CONTENT_TYPE_JSON = "application/json; charset=utf-8"; + private static final MediaType JSON_MEDIA = MediaType.parse(CONTENT_TYPE_JSON); + + private static final String SERVICE_ARK = "ark"; + private static final String VERSION = "2024-01-01"; + private static final String METHOD_POST = "POST"; + private static final String PATH_ROOT = "/"; + private static final String SIGNED_HEADERS = "content-type;host;x-content-sha256;x-date"; + + private OkHttpClient httpClient = OkHttpUtils.createOkHttpClient(); + private String controlApiHost; + + @PostConstruct + public void init() { + this.controlApiHost = "ark." + this.region + ".volcengineapi.com"; + this.httpClient = OkHttpUtils.createOkHttpClient(); + } + + private final ObjectMapper jsonMapper = new ObjectMapper() .setSerializationInclusion(JsonInclude.Include.NON_NULL) .setPropertyNamingStrategy(PropertyNamingStrategies.UPPER_CAMEL_CASE) .configure(MapperFeature.ACCEPT_CASE_INSENSITIVE_PROPERTIES, true) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .configure(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, true); - - // API地址(可配置在配置文件中) - @Value("${byteapi.url}") - protected String API_URL; - - @Autowired - protected IByteDeptApiKeyService byteDeptApiKeyService; - - /** - * POST JSON 调用方舟 OpenAPI;若响应含 {@code Result} 节点则解析为业务对象(与 api.docx 返回示例一致)。 - */ - protected T httpExecute(String path, Object request, Class clz) throws IOException { - String jsonBody = objectMapper.writeValueAsString(request); - RequestBody body = RequestBody.create( - MediaType.parse("application/json; charset=utf-8"), - jsonBody); - Request httpRequest = new Request.Builder() - .url(API_URL + path) - .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + resolveCurrentAiUserApiKey()) - .post(body) - .build(); - try (Response response = OkHttpUtils.newCall(httpRequest).execute()) { - if (!response.isSuccessful()) { - String errorMsg = response.body() != null ? response.body().string() : "execute error"; - throw new RuntimeException("execute error:" + errorMsg); - } - if (response.body() == null) { - throw new RuntimeException("response body null"); - } - String responseBody = response.body().string(); - JsonNode root = objectMapper.readTree(responseBody); - JsonNode result = root.get("Result"); - if (result != null && !result.isNull()) { - return objectMapper.treeToValue(result, clz); - } - return objectMapper.readValue(responseBody, clz); - } - } - - /** - * 无业务体返回的 OpenAPI 调用(如 DeleteAsset)。 - */ - protected void httpExecuteNoContent(String path, Object request) throws IOException { - String jsonBody = objectMapper.writeValueAsString(request); - RequestBody body = RequestBody.create( - MediaType.parse("application/json; charset=utf-8"), - jsonBody); - Request httpRequest = new Request.Builder() - .url(API_URL + path) - .header("Content-Type", "application/json") - .header("Authorization", "Bearer " + resolveCurrentAiUserApiKey()) - .post(body) - .build(); - try (Response response = OkHttpUtils.newCall(httpRequest).execute()) { - if (!response.isSuccessful()) { - String errorMsg = response.body() != null ? response.body().string() : "execute error"; - throw new RuntimeException("execute error:" + errorMsg); - } - } - } - + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); /** * 根据用户找到对应的project */ protected String getUserProject() { - // TODO - return "default"; + Long userId = SecurityUtils.getAiUserId(); + if (userId == null) { + return null; + } + AiUser user = userMapper.selectAiUserById(userId); + if (user == null) { + return null; + } + // 第二层部门ID,API_KEY放在这里 + Long secondLvDeptId = getSecondLevelDept(user.getDeptId()); + if (secondLvDeptId == null) { + return null; + } + SysDept secondDept = deptMapper.selectDeptById(secondLvDeptId); + return secondDept.getProject(); } - protected String resolveCurrentAiUserApiKey() { - return byteDeptApiKeyService.resolveVolcApiKey(SecurityUtils.getAiUserId()); + /** + * 找到当前部门所属第二部门ID + * + * @param deptId 当前部门 + */ + protected Long getSecondLevelDept(long deptId) { + SysDept dept = deptMapper.selectDeptById(deptId); + String ancestors = dept.getAncestors(); + // 判断是第几层 + if (ancestors == null || ancestors.isEmpty() || "0".equals(ancestors)) { + // 第一层 + return null; + } + String[] parentDeptArray = ancestors.split(DEPT_ANCESTORS_SPLIT); + int length = parentDeptArray.length; + if (length == 2) { + // 只有一个上级,所以当前节点是第二层,直接返回 + return deptId; + } + // 大于二级 + return Long.parseLong(parentDeptArray[2]); + } + + public R callApi(String action, Object request, Class responseClass) throws IOException { + String body = jsonMapper.writeValueAsString(request); + SignedRequest signed = sign(action, body); + RequestBody requestBody = RequestBody.create(JSON_MEDIA, body.getBytes(StandardCharsets.UTF_8)); + Request okRequest = new Request.Builder() + .url(signed.url) + .header("X-Content-Sha256", signed.xContentSha256) + .header("X-Date", signed.xDate) + .header("Content-Type", CONTENT_TYPE_JSON) + .header("Authorization", signed.authorization) + .post(requestBody) + .build(); + try (Response response = httpClient.newCall(okRequest).execute()) { + if (responseClass == null) { + return null; + } + String respBody = response.body() != null ? response.body().string() : ""; + if (!response.isSuccessful()) { + throw new IOException("HTTP " + response.code() + ": " + respBody); + } + return parseResult(respBody, responseClass); + } + } + + private T parseResult(String body, Class resultType) throws IOException { + JsonNode root = jsonMapper.readTree(body); + JsonNode result = root.get("Result"); + if (result != null && !result.isNull()) { + return jsonMapper.treeToValue(result, resultType); + } + return jsonMapper.readValue(body, resultType); + } + + private SignedRequest sign(String action, String body) throws IOException { + ZonedDateTime nowUtc = ZonedDateTime.now(ZoneOffset.UTC); + String xDate = nowUtc.format(DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")); + String shortDate = xDate.substring(0, 8); + String xContentSha256 = sha256Hex(body == null ? "" : body); + + Map query = new TreeMap<>(); + query.put("Action", action); + query.put("Version", VERSION); + String canonicalQuery = canonicalQueryString(query); + + String canonicalHeaders = String.join("\n", + "content-type:" + CONTENT_TYPE_JSON, + "host:" + controlApiHost, + "x-content-sha256:" + xContentSha256, + "x-date:" + xDate); + + String canonicalRequest = String.join("\n", + METHOD_POST.toUpperCase(), + PATH_ROOT, + canonicalQuery, + canonicalHeaders, + "", + SIGNED_HEADERS, + xContentSha256); + + String hashedCanonical = sha256Hex(canonicalRequest); + String credentialScope = shortDate + "/" + region + "/" + SERVICE_ARK + "/request"; + String stringToSign = String.join("\n", "HMAC-SHA256", xDate, credentialScope, hashedCanonical); + + byte[] kDate = hmac256(secretAccessKey.getBytes(StandardCharsets.UTF_8), shortDate); + byte[] kRegion = hmac256(kDate, region); + byte[] kService = hmac256(kRegion, SERVICE_ARK); + byte[] kSigning = hmac256(kService, "request"); + String signature = toHex(hmac256(kSigning, stringToSign)); + + String authorization = "HMAC-SHA256 Credential=" + + accessKeyId + "/" + credentialScope + + ", SignedHeaders=" + SIGNED_HEADERS + + ", Signature=" + signature; + + HttpUrl url = HttpUrl.parse("https://" + controlApiHost + "/?" + canonicalQuery); + if (url == null) { + throw new IOException("invalid control API URL"); + } + return new SignedRequest(url, xContentSha256, xDate, authorization); + } + + private static String esc(String s) { + if (s == null) { + return ""; + } + return s.replace("\\", "\\\\").replace("\"", "\\\""); + } + + private static String canonicalQueryString(Map params) throws IOException { + StringBuilder sb = new StringBuilder(); + for (String key : new TreeMap<>(params).keySet()) { + Object val = params.get(key); + if (val instanceof List) { + @SuppressWarnings("unchecked") + List list = (List) val; + for (Object item : list) { + sb.append(encode(key)).append("=").append(encode(String.valueOf(item))).append("&"); + } + } else { + sb.append(encode(key)).append("=").append(encode(String.valueOf(val))).append("&"); + } + } + if (sb.length() == 0) { + return ""; + } + return sb.substring(0, sb.length() - 1).replace("+", "%20"); + } + + private static String encode(String raw) throws IOException { + return URLEncoder.encode(raw, StandardCharsets.UTF_8.name()).replace("+", "%20"); + } + + private static String sha256Hex(String content) throws IOException { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + return toHex(md.digest(content.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IOException(e); + } + } + + private static byte[] hmac256(byte[] key, String data) throws IOException { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + throw new IOException(e); + } + } + + private static String toHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private static final class SignedRequest { + final HttpUrl url; + final String xContentSha256; + final String xDate; + final String authorization; + + SignedRequest(HttpUrl url, String xContentSha256, String xDate, String authorization) { + this.url = url; + this.xContentSha256 = xContentSha256; + this.xDate = xDate; + this.authorization = authorization; + } } } \ No newline at end of file diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteAssetGroupService.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteAssetGroupService.java index 344ec2b..1fe4926 100644 --- a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteAssetGroupService.java +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteAssetGroupService.java @@ -11,20 +11,21 @@ import com.ruoyi.common.core.response.asset.ListAssetsGroupResponse; import com.ruoyi.common.core.response.asset.UpdateAssetGroupResponse; import org.springframework.stereotype.Service; +import javax.annotation.Resource; import java.io.IOException; @Service public class ByteAssetGroupService extends BaseByteApiService implements IByteAssetGroupService { - private static final String LIST_ASSET_GROUPS_URL = "/open/ListAssetGroups"; - private static final String CREATE_ASSET_GROUP_URL = "/open/CreateAssetGroup"; - private static final String GET_ASSET_GROUP_URL = "/open/GetAssetGroup"; - private static final String UPDATE_ASSET_GROUP_URL = "/open/UpdateAssetGroup"; + private static final String ACTION_LIST_ASSET_GROUPS = "ListAssetGroups"; + private static final String ACTION_CREATE_ASSET_GROUP = "CreateAssetGroup"; + private static final String ACTION_GET_ASSET_GROUP = "GetAssetGroup"; + private static final String ACTION_UPDATE_ASSET_GROUP = "UpdateAssetGroup"; @Override public ListAssetsGroupResponse listAssetGroups(ListAssetsGroupRequest request) throws IOException { request.setProjectName(getUserProject()); - return httpExecute(LIST_ASSET_GROUPS_URL, request, ListAssetsGroupResponse.class); + return callApi(ACTION_LIST_ASSET_GROUPS, request, ListAssetsGroupResponse.class); } @Override @@ -32,18 +33,18 @@ public class ByteAssetGroupService extends BaseByteApiService implements IByteAs // 固定值 request.setGroupType("AIGC"); request.setProjectName(getUserProject()); - return httpExecute(CREATE_ASSET_GROUP_URL, request, CreateAssetGroupResponse.class); + return callApi(ACTION_CREATE_ASSET_GROUP, request, CreateAssetGroupResponse.class); } @Override public GetAssetGroupResponse getAssetGroup(GetAssetGroupRequest request) throws IOException { request.setProjectName(getUserProject()); - return httpExecute(GET_ASSET_GROUP_URL, request, GetAssetGroupResponse.class); + return callApi(ACTION_GET_ASSET_GROUP, request, GetAssetGroupResponse.class); } @Override public UpdateAssetGroupResponse updateAssetGroup(UpdateAssetGroupRequest request) throws IOException { request.setProjectName(getUserProject()); - return httpExecute(UPDATE_ASSET_GROUP_URL, request, UpdateAssetGroupResponse.class); + return callApi(ACTION_UPDATE_ASSET_GROUP, request, UpdateAssetGroupResponse.class); } } diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteAssetService.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteAssetService.java index 4d1f669..a3d1f5a 100644 --- a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteAssetService.java +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteAssetService.java @@ -16,6 +16,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import javax.annotation.Resource; import java.io.IOException; @Service @@ -24,11 +25,11 @@ public class ByteAssetService extends BaseByteApiService implements IByteAssetSe private final TencentCosUtil tencentCosUtil; - private static final String CREATE_ASSET_URL = "/open/CreateAsset"; - private static final String LIST_ASSETS_URL = "/open/ListAssets"; - private static final String GET_ASSET_URL = "/open/GetAsset"; - private static final String UPDATE_ASSET_URL = "/open/UpdateAsset"; - private static final String DELETE_ASSET_URL = "/open/DeleteAsset"; + private static final String ACTION_CREATE_ASSET = "CreateAsset"; + private static final String ACTION_LIST_ASSETS = "ListAssets"; + private static final String ACTION_GET_ASSET = "GetAsset"; + private static final String ACTION_UPDATE_ASSET = "UpdateAsset"; + private static final String ACTION_DELETE_ASSET = "DeleteAsset"; @Override public CreateAssetResponse createAsset(MultipartFile file, String groupId, String assetType, String name, String projectName) throws Exception { @@ -45,30 +46,30 @@ public class ByteAssetService extends BaseByteApiService implements IByteAssetSe request.setName(name); request.setAssetType(assetType); request.setProjectName(getUserProject()); - return httpExecute(CREATE_ASSET_URL, request, CreateAssetResponse.class); + return callApi(ACTION_CREATE_ASSET, request, CreateAssetResponse.class); } @Override public ListAssetsResponse listAssets(ListAssetsRequest request) throws IOException { request.setProjectName(getUserProject()); - return httpExecute(LIST_ASSETS_URL, request, ListAssetsResponse.class); + return callApi(ACTION_LIST_ASSETS, request, ListAssetsResponse.class); } @Override public GetAssetResponse getAsset(GetAssetRequest request) throws IOException { request.setProjectName(getUserProject()); - return httpExecute(GET_ASSET_URL, request, GetAssetResponse.class); + return callApi(ACTION_GET_ASSET, request, GetAssetResponse.class); } @Override public UpdateAssetResponse updateAsset(UpdateAssetRequest request) throws IOException { request.setProjectName(getUserProject()); - return httpExecute(UPDATE_ASSET_URL, request, UpdateAssetResponse.class); + return callApi(ACTION_UPDATE_ASSET, request, UpdateAssetResponse.class); } @Override public void deleteAsset(DeleteAssetRequest request) throws IOException { request.setProjectName(getUserProject()); - httpExecuteNoContent(DELETE_ASSET_URL, request); + callApi(ACTION_DELETE_ASSET, request, null); } } diff --git a/web-api/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml b/web-api/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml index bcf5ee0..600cb44 100644 --- a/web-api/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml +++ b/web-api/ruoyi-system/src/main/resources/mapper/system/SysDeptMapper.xml @@ -60,7 +60,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"