fix: use customer token for assets and video
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
# 服务端统一使用的素材库 Token,不会下发到浏览器
|
# 客户令牌不写服务端环境变量,由客户首次访问页面时输入
|
||||||
KK_ASSET_TOKEN=
|
|
||||||
|
|
||||||
# 1 = 使用 Mock 数据评审流程;0 = 调用真实接口
|
# 1 = 使用 Mock 数据评审流程;0 = 调用真实接口
|
||||||
DEMO_MOCK=1
|
DEMO_MOCK=1
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -14,7 +14,7 @@
|
|||||||
## 已串联的流程
|
## 已串联的流程
|
||||||
|
|
||||||
1. 输入数字人正面、侧面、背面三张公网图片 URL。
|
1. 输入数字人正面、侧面、背面三张公网图片 URL。
|
||||||
2. 逐张调用 `POST /v1/assets/upload`,以服务端素材库 Token 提交三张素材审核。
|
2. 逐张调用 `POST /v1/assets/upload`,以客户输入的统一令牌提交三张素材审核。
|
||||||
3. 对仍在审核的 `mat_*` 素材调用 `GET /v1/assets/{asset_id}`,直至三张全部 `ready` 或出现 `failed`。
|
3. 对仍在审核的 `mat_*` 素材调用 `GET /v1/assets/{asset_id}`,直至三张全部 `ready` 或出现 `failed`。
|
||||||
4. 从全部历史素材中选择多张图片,绑定成可复用的人物/形象;三视图只是常见组合,不再是固定的数据结构。
|
4. 从全部历史素材中选择多张图片,绑定成可复用的人物/形象;三视图只是常见组合,不再是固定的数据结构。
|
||||||
5. 为本场角色选择人物素材,并补充场景、道具等非人物图片及参考视频,组织成官方 `content` 数组后调用 `POST /sd/api/v3/contents/generations/tasks`。
|
5. 为本场角色选择人物素材,并补充场景、道具等非人物图片及参考视频,组织成官方 `content` 数组后调用 `POST /sd/api/v3/contents/generations/tasks`。
|
||||||
@@ -33,11 +33,11 @@
|
|||||||
- 默认目录:`./logs/audit-YYYY-MM-DD-PID.jsonl`
|
- 默认目录:`./logs/audit-YYYY-MM-DD-PID.jsonl`
|
||||||
- 生产环境:用 `AUDIT_LOG_DIR` 指向持久化磁盘,并由技术配置日志保留/清理周期
|
- 生产环境:用 `AUDIT_LOG_DIR` 指向持久化磁盘,并由技术配置日志保留/清理周期
|
||||||
- 脱敏:不记录 API Key、Authorization、Cookie 和签名查询参数;提示词仅记录长度和哈希;URL 仅记录域名、路径与查询字段名
|
- 脱敏:不记录 API Key、Authorization、Cookie 和签名查询参数;提示词仅记录长度和哈希;URL 仅记录域名、路径与查询字段名
|
||||||
- 关联排查:日志保留 `requestId`、浏览器 `clientId/sessionId`、客户视频 Key 的不可逆指纹与末四位、任务 ID、服务商 `trackId`、状态码和耗时;不保存完整 Key
|
- 关联排查:日志保留 `requestId`、浏览器 `clientId/sessionId`、客户令牌的不可逆指纹与末四位、任务 ID、服务商 `trackId`、状态码和耗时;不保存完整令牌
|
||||||
|
|
||||||
## 10–20 人试用的部署边界
|
## 10–20 人试用的部署边界
|
||||||
|
|
||||||
单实例 Node 服务可用于 10–20 人低频试用,但上线必须配置 HTTPS、限流、进程守护和持久日志目录。素材库 Token 由服务端统一配置;每位客户首次访问时输入自己的视频 Key,视频生成费用归属该 Key。`assetReviewTasks` 与视频任务 Key 映射当前仍在单进程内存中,因此暂时应保持单实例部署;如果要多实例扩容,需先将任务状态迁入 Redis/数据库。
|
单实例 Node 服务可用于 10–20 人低频试用,但上线必须配置 HTTPS、限流、进程守护和持久日志目录。每位客户首次访问时输入自己的统一令牌,素材与视频请求均使用该令牌,视频生成费用也归属该令牌。`assetReviewTasks` 与视频任务令牌映射当前仍在单进程内存中,因此暂时应保持单实例部署;如果要多实例扩容,需先将任务状态迁入 Redis/数据库。
|
||||||
|
|
||||||
页面会分别展示正面、侧面、背面的审核状态与失败原因。异步审核任务 ID 会保存在浏览器本地,刷新或重新打开页面后会继续查询;本场角色尚未选齐人物素材时不会解锁视频生成。
|
页面会分别展示正面、侧面、背面的审核状态与失败原因。异步审核任务 ID 会保存在浏览器本地,刷新或重新打开页面后会继续查询;本场角色尚未选齐人物素材时不会解锁视频生成。
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
|
|
||||||
生产页支持文本、图片和视频三类输入。人物图片与场景、道具等其他参考图片共同占用单次最多 9 张图片额度;参考视频单独计数,当前 Demo 最多 3 条。最终请求按“文本 → 人物图片 → 其他参考图片 → 参考视频”的顺序写入 v3 `content` 数组,页面会同步生成图号和角色映射。
|
生产页支持文本、图片和视频三类输入。人物图片与场景、道具等其他参考图片共同占用单次最多 9 张图片额度;参考视频单独计数,当前 Demo 最多 3 条。最终请求按“文本 → 人物图片 → 其他参考图片 → 参考视频”的顺序写入 v3 `content` 数组,页面会同步生成图号和角色映射。
|
||||||
|
|
||||||
素材库 Token 只放在服务端环境变量中,不进入浏览器,也不会写入 Git。客户视频 Key 保存在客户当前浏览器的 `localStorage`,并随视频请求提交给本 Demo 服务端;服务端只在内存中建立任务与 Key 的临时映射,不写入日志或 Git。清理网站数据或更换浏览器后需重新输入。
|
客户统一令牌保存在客户当前浏览器的 `localStorage`,并随素材与视频请求提交给本 Demo 服务端;服务端只在内存中建立任务与令牌的临时映射,不写入日志或 Git。清理网站数据或更换浏览器后需重新输入。
|
||||||
|
|
||||||
## 本地运行
|
## 本地运行
|
||||||
|
|
||||||
@@ -58,10 +58,9 @@ npm start
|
|||||||
|
|
||||||
请优先通过这个地址访问,不要把 `public/index.html` 当成普通文档直接打开。页面现已兼容直接打开时的样式加载,但接口交互仍依赖本地服务。
|
请优先通过这个地址访问,不要把 `public/index.html` 当成普通文档直接打开。页面现已兼容直接打开时的样式加载,但接口交互仍依赖本地服务。
|
||||||
|
|
||||||
默认 `.env.example` 使用 Mock 模式,可以先完整评审交互。连接真实接口时设置服务端素材库 Token;视频 Key 由客户在页面首次访问时输入:
|
默认 `.env.example` 使用 Mock 模式,可以先完整评审交互。连接真实接口时设置:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
KK_ASSET_TOKEN=素材库Token
|
|
||||||
DEMO_MOCK=0
|
DEMO_MOCK=0
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -78,26 +77,25 @@ npm start
|
|||||||
|
|
||||||
上线前请完成以下配置:
|
上线前请完成以下配置:
|
||||||
|
|
||||||
1. 在服务端填写 `KK_ASSET_TOKEN`,并设置 `DEMO_MOCK=0`;不要把 `.env.local` 提交到 Git。视频 Key 不配置在服务器,由客户在页面输入。
|
1. 设置 `DEMO_MOCK=0`;客户令牌不配置在服务器,由客户首次访问页面时输入。
|
||||||
2. 设置 `HOST=0.0.0.0` 和实际监听端口,由 Nginx/网关反向代理并提供 HTTPS。
|
2. 设置 `HOST=0.0.0.0` 和实际监听端口,由 Nginx/网关反向代理并提供 HTTPS。
|
||||||
3. 将 `AUDIT_LOG_DIR` 指向持久化磁盘,设置随机的 `AUDIT_HASH_SALT`,同时配置日志轮转与清理周期。
|
3. 将 `AUDIT_LOG_DIR` 指向持久化磁盘,设置随机的 `AUDIT_HASH_SALT`,同时配置日志轮转与清理周期。
|
||||||
4. 使用 systemd、PM2 或容器编排守护 Node 进程,异常退出后自动重启。
|
4. 使用 systemd、PM2 或容器编排守护 Node 进程,异常退出后自动重启。
|
||||||
5. 在入口层增加请求频率限制与并发生成限制;素材库 Token 仅保留在服务端。
|
5. 在入口层增加请求频率限制与并发生成限制。
|
||||||
6. 发布后访问页面输入客户视频 Key,检查素材与视频能力均为可用,再分别跑一条素材审核和视频生成链路。
|
6. 发布后访问页面输入客户令牌,检查素材与视频能力均为可用,再分别跑一条素材审核和视频生成链路。
|
||||||
|
|
||||||
当前素材审核任务状态保存在单进程内存中,首次客户交付建议只运行一个应用实例。若需要多实例扩容,应先把任务状态迁移到 Redis 或数据库。
|
当前素材审核任务状态保存在单进程内存中,首次客户交付建议只运行一个应用实例。若需要多实例扩容,应先把任务状态迁移到 Redis 或数据库。
|
||||||
|
|
||||||
## 当前还缺的真实联调材料
|
## 当前还缺的真实联调材料
|
||||||
|
|
||||||
- **快快素材库 Token**:由服务端统一配置,用于素材上传、审核与历史素材查询。
|
- **客户统一令牌**:形如 `sk-...`,由客户首次打开页面时输入,同时用于素材上传/查询与视频提交/查询。
|
||||||
- **客户视频 Key**:形如 `sk-...`,由客户首次打开页面时输入,用于视频提交和结果查询。
|
|
||||||
- **数字人三视图的公网 URL**:正面、侧面、背面各一张;接口不支持直接上传本地文件或 Base64,URL 需带 `.png`、`.jpg` 等扩展名。
|
- **数字人三视图的公网 URL**:正面、侧面、背面各一张;接口不支持直接上传本地文件或 Base64,URL 需带 `.png`、`.jpg` 等扩展名。
|
||||||
- **客户希望验证的第一段分镜**:人物动作、台词、场景、时长、横竖屏及是否需要同步声音。Demo 已提供一段可替换示例。
|
- **客户希望验证的第一段分镜**:人物动作、台词、场景、时长、横竖屏及是否需要同步声音。Demo 已提供一段可替换示例。
|
||||||
- **视频任务成功响应**:新版接口在 `content.video_url` 返回临时签名视频地址。
|
- **视频任务成功响应**:新版接口在 `content.video_url` 返回临时签名视频地址。
|
||||||
|
|
||||||
## 接口边界
|
## 接口边界
|
||||||
|
|
||||||
- 素材审核与视频生成统一请求 `https://ai-api.kkidc.com`,但分别使用服务端素材库 Token 与客户视频 Key。
|
- 素材审核与视频生成统一请求 `https://ai-api.kkidc.com`,并使用客户输入的同一个 Bearer 令牌。
|
||||||
- 新版素材库只在列表中返回 `ready` 素材;`pending` / `failed` 通过单素材接口查询。
|
- 新版素材库只在列表中返回 `ready` 素材;`pending` / `failed` 通过单素材接口查询。
|
||||||
- 视频生成应长期保存素材库返回的 `asset://...` 引用,不要依赖原始图片 URL。
|
- 视频生成应长期保存素材库返回的 `asset://...` 引用,不要依赖原始图片 URL。
|
||||||
- 参考图/参考视频/首帧/首尾帧属于不同互斥调用方式;本 Demo 采用“多模态参考图”方式。
|
- 参考图/参考视频/首帧/首尾帧属于不同互斥调用方式;本 Demo 采用“多模态参考图”方式。
|
||||||
|
|||||||
@@ -79,7 +79,10 @@ async function init() {
|
|||||||
restoreCharacterLibrary();
|
restoreCharacterLibrary();
|
||||||
restoreDramaProject();
|
restoreDramaProject();
|
||||||
renderConnection();
|
renderConnection();
|
||||||
if (!state.config.mockMode && !currentVideoKey()) showVideoKeyGate(false);
|
if (!state.config.mockMode && !currentVideoKey()) {
|
||||||
|
showVideoKeyGate(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
await loadAssets();
|
await loadAssets();
|
||||||
loadActiveShotIntoForm();
|
loadActiveShotIntoForm();
|
||||||
renderScene();
|
renderScene();
|
||||||
@@ -98,7 +101,7 @@ function setupVideoKeyGate() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const key = el.videoKeyInput.value.trim();
|
const key = el.videoKeyInput.value.trim();
|
||||||
if (!/^sk-[A-Za-z0-9_-]{8,}$/.test(key)) {
|
if (!/^sk-[A-Za-z0-9_-]{8,}$/.test(key)) {
|
||||||
el.videoKeyError.textContent = "请输入正确格式的生视频 Key(sk-...)";
|
el.videoKeyError.textContent = "请输入正确格式的客户令牌(sk-...)";
|
||||||
el.videoKeyError.classList.remove("hidden");
|
el.videoKeyError.classList.remove("hidden");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -341,7 +344,7 @@ async function loadAssets() {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = /unauthorized|invalid token/i.test(String(error.message || ""))
|
const message = /unauthorized|invalid token/i.test(String(error.message || ""))
|
||||||
? "素材库 Token 无效或已过期,请联系管理员更新"
|
? "客户令牌无效或已过期,请重新输入"
|
||||||
: error.message;
|
: error.message;
|
||||||
el.assetList.innerHTML = `<div class="empty-small">${escapeHtml(message)}</div>`;
|
el.assetList.innerHTML = `<div class="empty-small">${escapeHtml(message)}</div>`;
|
||||||
if (!state.config?.mockMode) {
|
if (!state.config?.mockMode) {
|
||||||
@@ -973,7 +976,7 @@ function updateVideoAvailability() {
|
|||||||
setStep(3);
|
setStep(3);
|
||||||
} else {
|
} else {
|
||||||
el.videoForm.classList.add("disabled-section");
|
el.videoForm.classList.add("disabled-section");
|
||||||
const label = !state.config.videoReady ? "缺少视频 Key" : !sceneReady() ? "等待角色" : quota.message;
|
const label = !state.config.videoReady ? "请输入客户令牌" : !sceneReady() ? "等待角色" : quota.message;
|
||||||
setVideoStatus(!state.config.videoReady || !quota.valid ? "error" : "neutral", label);
|
setVideoStatus(!state.config.videoReady || !quota.valid ? "error" : "neutral", label);
|
||||||
setStep(sceneReady() ? 2 : 1);
|
setStep(sceneReady() ? 2 : 1);
|
||||||
}
|
}
|
||||||
@@ -1453,7 +1456,7 @@ async function resumePendingVideo() {
|
|||||||
function renderConnection() {
|
function renderConnection() {
|
||||||
const videoKey = currentVideoKey();
|
const videoKey = currentVideoKey();
|
||||||
el.changeVideoKeyButton.classList.toggle("hidden", state.config.mockMode || !videoKey);
|
el.changeVideoKeyButton.classList.toggle("hidden", state.config.mockMode || !videoKey);
|
||||||
if (videoKey) el.changeVideoKeyButton.textContent = `更换视频 Key · ${videoKey.slice(-4)}`;
|
if (videoKey) el.changeVideoKeyButton.textContent = `更换客户令牌 · ${videoKey.slice(-4)}`;
|
||||||
if (state.config.mockMode) {
|
if (state.config.mockMode) {
|
||||||
el.connectionState.textContent = "Mock 模式 · 可评审完整流程";
|
el.connectionState.textContent = "Mock 模式 · 可评审完整流程";
|
||||||
el.connectionState.className = "connection mock";
|
el.connectionState.className = "connection mock";
|
||||||
@@ -1465,7 +1468,7 @@ function renderConnection() {
|
|||||||
el.reviewButton.textContent = "提交真实素材审核";
|
el.reviewButton.textContent = "提交真实素材审核";
|
||||||
el.mockHint.classList.add("hidden");
|
el.mockHint.classList.add("hidden");
|
||||||
} else if (state.config.assetReady) {
|
} else if (state.config.assetReady) {
|
||||||
el.connectionState.textContent = "请输入视频 Key";
|
el.connectionState.textContent = "请输入客户令牌";
|
||||||
el.connectionState.className = "connection live";
|
el.connectionState.className = "connection live";
|
||||||
el.reviewButton.textContent = "提交真实素材审核";
|
el.reviewButton.textContent = "提交真实素材审核";
|
||||||
el.mockHint.classList.add("hidden");
|
el.mockHint.classList.add("hidden");
|
||||||
@@ -1504,7 +1507,7 @@ function renderCompletedVideo(videoUrl, mock) {
|
|||||||
video.src = videoUrl;
|
video.src = videoUrl;
|
||||||
el.videoStage.replaceChildren(video);
|
el.videoStage.replaceChildren(video);
|
||||||
} else {
|
} else {
|
||||||
el.videoStage.innerHTML = `<div class="mock-frame"><div class="mock-person"></div><strong>${mock ? "Mock 流程已完成" : "任务已完成"}</strong><span>${mock ? "接入视频 Key 后此处展示真实视频" : "接口响应中未识别到视频 URL,请查看原始返回"}</span></div>`;
|
el.videoStage.innerHTML = `<div class="mock-frame"><div class="mock-person"></div><strong>${mock ? "Mock 流程已完成" : "任务已完成"}</strong><span>${mock ? "输入客户令牌后此处展示真实视频" : "接口响应中未识别到视频 URL,请稍后重试"}</span></div>`;
|
||||||
}
|
}
|
||||||
updateProgress(100, "视频生成完成");
|
updateProgress(100, "视频生成完成");
|
||||||
}
|
}
|
||||||
@@ -1527,7 +1530,7 @@ function contextualizeGenerationError(message) {
|
|||||||
if (!assetMatch || !/not found|不存在/i.test(text)) return text;
|
if (!assetMatch || !/not found|不存在/i.test(text)) return text;
|
||||||
const position = referencePosition(assetMatch[0]);
|
const position = referencePosition(assetMatch[0]);
|
||||||
const label = position ? `角色 ${ROLE_LETTERS[position.roleIndex]} · ${VIEW_LABELS[position.viewIndex]}` : "某张参考图";
|
const label = position ? `角色 ${ROLE_LETTERS[position.roleIndex]} · ${VIEW_LABELS[position.viewIndex]}` : "某张参考图";
|
||||||
return `${label}在视频接口中不存在。通常表示素材库 Token 与视频 Key 不属于同一资源空间,或素材尚未同步到视频侧。素材 ID:${assetMatch[0]}`;
|
return `${label}在视频接口中不存在。通常表示素材已失效、尚未同步,或不属于当前客户令牌。素材 ID:${assetMatch[0]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetVideoStage() { el.videoStage.innerHTML = '<div class="mock-frame"><div class="mock-person"></div><span>Seedance 2 正在生成镜头…</span></div>'; }
|
function resetVideoStage() { el.videoStage.innerHTML = '<div class="mock-frame"><div class="mock-person"></div><span>Seedance 2 正在生成镜头…</span></div>'; }
|
||||||
@@ -1715,7 +1718,7 @@ async function api(path, options = {}) {
|
|||||||
const method = options.method || "GET";
|
const method = options.method || "GET";
|
||||||
const shouldLog = shouldLogClientApi(path);
|
const shouldLog = shouldLogClientApi(path);
|
||||||
const videoKey = currentVideoKey();
|
const videoKey = currentVideoKey();
|
||||||
const videoHeaders = videoKey && (/^\/api\/videos(?:\/|$)/.test(path) || path === "/api/config")
|
const videoHeaders = videoKey && (/^\/api\/(?:videos|assets)(?:[/?]|$)/.test(path) || path === "/api/config")
|
||||||
? { "X-Video-Key": videoKey }
|
? { "X-Video-Key": videoKey }
|
||||||
: {};
|
: {};
|
||||||
if (shouldLog) logClientOperation("api.request", { method, path });
|
if (shouldLog) logClientOperation("api.request", { method, path });
|
||||||
@@ -1738,7 +1741,7 @@ async function api(path, options = {}) {
|
|||||||
const error = new Error(extractApiError(result) || `请求失败(${response.status})`);
|
const error = new Error(extractApiError(result) || `请求失败(${response.status})`);
|
||||||
error.payload = result;
|
error.payload = result;
|
||||||
error.status = response.status;
|
error.status = response.status;
|
||||||
if (response.status === 401 && /^\/api\/videos(?:\/|$)/.test(path)) {
|
if (response.status === 401 && /^\/api\/(?:videos|assets)(?:[/?]|$)/.test(path)) {
|
||||||
localStorage.removeItem(VIDEO_KEY_STORAGE);
|
localStorage.removeItem(VIDEO_KEY_STORAGE);
|
||||||
showVideoKeyGate(false);
|
showVideoKeyGate(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<h1>AI 短剧数字人生产</h1>
|
<h1>AI 短剧数字人生产</h1>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
<button class="key-change-button hidden" type="button" id="changeVideoKeyButton">更换视频 Key</button>
|
<button class="key-change-button hidden" type="button" id="changeVideoKeyButton">更换客户令牌</button>
|
||||||
<div class="connection" id="connectionState">正在检查接口配置…</div>
|
<div class="connection" id="connectionState">正在检查接口配置…</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -268,10 +268,10 @@
|
|||||||
<div class="key-gate hidden" id="videoKeyGate">
|
<div class="key-gate hidden" id="videoKeyGate">
|
||||||
<form class="key-gate-card" id="videoKeyForm">
|
<form class="key-gate-card" id="videoKeyForm">
|
||||||
<span class="section-kicker">首次使用</span>
|
<span class="section-kicker">首次使用</span>
|
||||||
<h2>输入生视频 Key</h2>
|
<h2>输入客户令牌</h2>
|
||||||
<p>该 Key 用于本次客户的视频生成与费用统计,仅保存在当前浏览器。</p>
|
<p>素材库与视频生成共用该令牌,相关费用归属此令牌,仅保存在当前浏览器。</p>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
<span>生视频 Key</span>
|
<span>客户令牌</span>
|
||||||
<input id="videoKeyInput" type="password" autocomplete="off" spellcheck="false" placeholder="sk-..." required />
|
<input id="videoKeyInput" type="password" autocomplete="off" spellcheck="false" placeholder="sk-..." required />
|
||||||
</label>
|
</label>
|
||||||
<p class="key-gate-error hidden" id="videoKeyError"></p>
|
<p class="key-gate-error hidden" id="videoKeyError"></p>
|
||||||
@@ -283,6 +283,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toast hidden" id="toast"></div>
|
<div class="toast hidden" id="toast"></div>
|
||||||
<script src="./app.js?v=26"></script>
|
<script src="./app.js?v=27"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
80
server.mjs
80
server.mjs
@@ -21,7 +21,6 @@ loadLocalEnv();
|
|||||||
|
|
||||||
const port = Number(process.env.PORT || 4173);
|
const port = Number(process.env.PORT || 4173);
|
||||||
const host = process.env.HOST || "127.0.0.1";
|
const host = process.env.HOST || "127.0.0.1";
|
||||||
const assetToken = process.env.KK_ASSET_TOKEN || process.env.KK_API_KEY || "";
|
|
||||||
const mockMode = process.env.DEMO_MOCK === "1";
|
const mockMode = process.env.DEMO_MOCK === "1";
|
||||||
const auditLogDir = process.env.AUDIT_LOG_DIR || join(root, "logs");
|
const auditLogDir = process.env.AUDIT_LOG_DIR || join(root, "logs");
|
||||||
const auditHashSalt = process.env.AUDIT_HASH_SALT || "yingxiangli-demo-audit";
|
const auditHashSalt = process.env.AUDIT_HASH_SALT || "yingxiangli-demo-audit";
|
||||||
@@ -118,13 +117,13 @@ async function handleApi(request, response, url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.method === "GET" && url.pathname === "/api/config") {
|
if (request.method === "GET" && url.pathname === "/api/config") {
|
||||||
const videoToken = videoTokenFromRequest(request);
|
const customerToken = customerTokenFromRequest(request);
|
||||||
sendJson(response, 200, {
|
sendJson(response, 200, {
|
||||||
mockMode,
|
mockMode,
|
||||||
assetReady: mockMode || Boolean(assetToken),
|
assetReady: mockMode || Boolean(customerToken),
|
||||||
videoReady: mockMode || Boolean(videoToken),
|
videoReady: mockMode || Boolean(customerToken),
|
||||||
apiVersion: "v3",
|
apiVersion: "v3",
|
||||||
missing: !mockMode && !assetToken ? ["KK_ASSET_TOKEN"] : [],
|
missing: !mockMode && !customerToken ? ["客户令牌"] : [],
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -178,28 +177,28 @@ async function handleApi(request, response, url) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
requireToken(assetToken, "KK_ASSET_TOKEN");
|
const customerToken = requireCustomerToken(request);
|
||||||
const labels = { front: "正面", side: "侧面", back: "背面" };
|
const labels = { front: "正面", side: "侧面", back: "背面" };
|
||||||
const uploaded = await Promise.all(imageInputs.map(async (input, index) => {
|
const uploaded = await Promise.all(imageInputs.map(async (input, index) => {
|
||||||
const viewLabel = labels[input.view] || `参考图${index + 1}`;
|
const viewLabel = labels[input.view] || `参考图${index + 1}`;
|
||||||
const upstream = await callJson("https://ai.kkidc.com/api/assets/upload", {
|
const upstream = await callJson("https://ai-api.kkidc.com/v1/assets/upload", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: assetHeaders(),
|
headers: bearerHeaders(customerToken),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: input.url,
|
url: input.url,
|
||||||
asset_type: "Image",
|
type: "image",
|
||||||
name: `${body.name || "数字人形象"} · ${viewLabel}`,
|
purpose: `${body.name || "数字人形象"} · ${viewLabel}`,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (upstream.status >= 400 || Number(upstream.body?.code) !== 0 || !upstream.body?.data?.Id) {
|
if (upstream.status >= 400 || upstream.body?.success === false || !upstream.body?.data?.asset_id) {
|
||||||
throw new ClientError(extractUpstreamMessage(upstream.body) || `${viewLabel}图提交失败`, upstream.status || 400);
|
throw new ClientError(extractUpstreamMessage(upstream.body) || `${viewLabel}图提交失败`, upstream.status || 400);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
sourceUrl: input.url,
|
sourceUrl: input.url,
|
||||||
view: input.view || "reference",
|
view: input.view || "reference",
|
||||||
assetId: upstream.body.data.Id,
|
assetId: upstream.body.data.asset_id,
|
||||||
groupId: "",
|
groupId: upstream.body.data.group_id || "",
|
||||||
status: "processing",
|
status: String(upstream.body.data.status || "pending").toLowerCase(),
|
||||||
errorMessage: "",
|
errorMessage: "",
|
||||||
};
|
};
|
||||||
}));
|
}));
|
||||||
@@ -212,11 +211,11 @@ async function handleApi(request, response, url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (request.method === "GET" && url.pathname.startsWith("/api/assets/tasks/")) {
|
if (request.method === "GET" && url.pathname.startsWith("/api/assets/tasks/")) {
|
||||||
requireToken(assetToken, "KK_ASSET_TOKEN");
|
const customerToken = requireCustomerToken(request);
|
||||||
const taskId = decodeURIComponent(url.pathname.split("/").pop());
|
const taskId = decodeURIComponent(url.pathname.split("/").pop());
|
||||||
const task = assetReviewTasks.get(taskId);
|
const task = assetReviewTasks.get(taskId);
|
||||||
if (!task) throw new ClientError("素材审核任务不存在或本地服务已重启,请在素材库刷新状态", 404);
|
if (!task) throw new ClientError("素材审核任务不存在或本地服务已重启,请在素材库刷新状态", 404);
|
||||||
await refreshAssetReviewTask(task);
|
await refreshAssetReviewTask(task, customerToken);
|
||||||
sendJson(response, 200, formatAssetReviewTask(task));
|
sendJson(response, 200, formatAssetReviewTask(task));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -231,12 +230,12 @@ async function handleApi(request, response, url) {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
requireToken(assetToken, "KK_ASSET_TOKEN");
|
const customerToken = requireCustomerToken(request);
|
||||||
const upstreamUrl = new URL("https://ai.kkidc.com/api/assets");
|
const upstreamUrl = new URL("https://ai-api.kkidc.com/v1/assets");
|
||||||
for (const key of ["page", "page_size", "name", "statuses", "sort_by", "sort_order"]) {
|
for (const key of ["page", "page_size", "purpose", "sort_order", "group_id"]) {
|
||||||
for (const value of url.searchParams.getAll(key)) upstreamUrl.searchParams.append(key, value);
|
for (const value of url.searchParams.getAll(key)) upstreamUrl.searchParams.append(key, value);
|
||||||
}
|
}
|
||||||
const upstream = await callJson(upstreamUrl, { headers: assetHeaders(false) });
|
const upstream = await callJson(upstreamUrl, { headers: bearerHeaders(customerToken, false) });
|
||||||
sendJson(response, upstream.status, upstream.body, upstream.trackId);
|
sendJson(response, upstream.status, upstream.body, upstream.trackId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -261,7 +260,7 @@ async function handleApi(request, response, url) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoToken = requireVideoToken(request);
|
const videoToken = requireCustomerToken(request);
|
||||||
const upstream = await callJson("https://ai-api.kkidc.com/sd/api/v3/contents/generations/tasks", {
|
const upstream = await callJson("https://ai-api.kkidc.com/sd/api/v3/contents/generations/tasks", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: bearerHeaders(videoToken),
|
headers: bearerHeaders(videoToken),
|
||||||
@@ -298,7 +297,7 @@ async function handleApi(request, response, url) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const videoToken = requireVideoToken(request, taskId);
|
const videoToken = requireCustomerToken(request, taskId);
|
||||||
videoTaskTokens.set(taskId, videoToken);
|
videoTaskTokens.set(taskId, videoToken);
|
||||||
const upstream = await callJson(`https://ai-api.kkidc.com/sd/api/v3/contents/generations/tasks/${encodeURIComponent(taskId)}`, {
|
const upstream = await callJson(`https://ai-api.kkidc.com/sd/api/v3/contents/generations/tasks/${encodeURIComponent(taskId)}`, {
|
||||||
headers: bearerHeaders(videoToken, false),
|
headers: bearerHeaders(videoToken, false),
|
||||||
@@ -320,7 +319,7 @@ async function handleApi(request, response, url) {
|
|||||||
|
|
||||||
async function streamVideoTask(request, response, taskId) {
|
async function streamVideoTask(request, response, taskId) {
|
||||||
if (mockMode) throw new ClientError("Mock 视频没有可下载的真实文件", 404);
|
if (mockMode) throw new ClientError("Mock 视频没有可下载的真实文件", 404);
|
||||||
const videoToken = requireVideoToken(request, taskId);
|
const videoToken = requireCustomerToken(request, taskId);
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const context = requestContext.getStore() || {};
|
const context = requestContext.getStore() || {};
|
||||||
writeAuditLog({
|
writeAuditLog({
|
||||||
@@ -516,21 +515,17 @@ function validatePublicUrl(value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireToken(value, name) {
|
function customerTokenFromRequest(request, taskId = "") {
|
||||||
if (!value) throw new ClientError(`服务端缺少 ${name} 配置`, 503);
|
|
||||||
}
|
|
||||||
|
|
||||||
function videoTokenFromRequest(request, taskId = "") {
|
|
||||||
const supplied = String(request.headers["x-video-key"] || "").trim();
|
const supplied = String(request.headers["x-video-key"] || "").trim();
|
||||||
if (supplied) return supplied.slice(0, 500);
|
if (supplied) return supplied.slice(0, 500);
|
||||||
if (taskId && videoTaskTokens.has(taskId)) return videoTaskTokens.get(taskId);
|
if (taskId && videoTaskTokens.has(taskId)) return videoTaskTokens.get(taskId);
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireVideoToken(request, taskId = "") {
|
function requireCustomerToken(request, taskId = "") {
|
||||||
const token = videoTokenFromRequest(request, taskId);
|
const token = customerTokenFromRequest(request, taskId);
|
||||||
if (!token) throw new ClientError("请先输入生视频 Key", 401);
|
if (!token) throw new ClientError("请先输入客户令牌", 401);
|
||||||
if (!/^sk-[A-Za-z0-9_-]{8,}$/.test(token)) throw new ClientError("生视频 Key 格式不正确", 401);
|
if (!/^sk-[A-Za-z0-9_-]{8,}$/.test(token)) throw new ClientError("客户令牌格式不正确", 401);
|
||||||
const context = requestContext.getStore();
|
const context = requestContext.getStore();
|
||||||
if (context) {
|
if (context) {
|
||||||
context.videoKeyId = hashAuditValue(token).slice(0, 12);
|
context.videoKeyId = hashAuditValue(token).slice(0, 12);
|
||||||
@@ -619,32 +614,25 @@ function bearerHeaders(token, includeContentType = true) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function assetHeaders(includeContentType = true) {
|
|
||||||
return {
|
|
||||||
Authorization: assetToken,
|
|
||||||
...(includeContentType ? { "Content-Type": "application/json" } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractUpstreamMessage(body) {
|
function extractUpstreamMessage(body) {
|
||||||
if (!body) return "";
|
if (!body) return "";
|
||||||
if (typeof body === "string") return body;
|
if (typeof body === "string") return body;
|
||||||
return body.error?.message || body.error || body.message || body.detail || "";
|
return body.error?.message || body.error || body.message || body.detail || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshAssetReviewTask(task) {
|
async function refreshAssetReviewTask(task, customerToken) {
|
||||||
await Promise.all(task.items.map(async (item) => {
|
await Promise.all(task.items.map(async (item) => {
|
||||||
if (["ready", "failed"].includes(item.status)) return;
|
if (["ready", "failed"].includes(item.status)) return;
|
||||||
const upstreamUrl = new URL(`https://ai.kkidc.com/api/assets/${encodeURIComponent(item.assetId)}`);
|
const upstreamUrl = new URL(`https://ai-api.kkidc.com/v1/assets/${encodeURIComponent(item.assetId)}`);
|
||||||
const upstream = await callJson(upstreamUrl, { headers: assetHeaders(false) });
|
if (item.groupId) upstreamUrl.searchParams.set("group_id", item.groupId);
|
||||||
if (upstream.status >= 400 || Number(upstream.body?.code) !== 0) {
|
const upstream = await callJson(upstreamUrl, { headers: bearerHeaders(customerToken, false) });
|
||||||
|
if (upstream.status >= 400 || upstream.body?.success === false) {
|
||||||
item.status = "failed";
|
item.status = "failed";
|
||||||
item.errorMessage = extractUpstreamMessage(upstream.body) || "素材状态查询失败";
|
item.errorMessage = extractUpstreamMessage(upstream.body) || "素材状态查询失败";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const remoteStatus = String(upstream.body?.data?.Status || "Processing").toLowerCase();
|
item.status = String(upstream.body?.data?.status || item.status || "pending").toLowerCase();
|
||||||
item.status = remoteStatus === "active" ? "ready" : remoteStatus === "failed" ? "failed" : "processing";
|
item.errorMessage = upstream.body?.data?.error_message || upstream.body?.message || "";
|
||||||
item.errorMessage = item.status === "failed" ? upstream.body?.message || "素材审核失败" : "";
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user