Merge branch 'master' into editor-agent
This commit is contained in:
@@ -16,6 +16,16 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-20 角色动画帧 OSS 请求使用专用连接池、并发保护与结构化重试
|
||||
|
||||
- 背景:角色动作逐帧流水线会同时发起源帧 PUT、透明帧 PUT 和最终帧 HEAD;原路径每次请求新建 `reqwest::Client`,且 OSS 请求错误丢失 HTTP 状态和 timeout/connect/transport 分类,多个动画任务叠加时无法在进程级限制 OSS 在途请求,也无法安全区分 PUT 与 HEAD 的失败。
|
||||
- 决策:`AppState` 仅为角色动画帧初始化一次 OSS HTTP Client 和 8 路 `Semaphore`。全帧 Future 仍保持 `buffer_unordered(frame_count.max(1))`,BgFilter、阿里云抠图和本地处理不占 OSS permit;每次 PUT/HEAD 网络 attempt 单独获取 permit,退避期间释放。`platform-oss` 保留 `OssErrorKind::Request`,但在 `OssError::Request` 中保留 operation、status、timeout、connect、transport、OSS code、OSS request-id 和原脱敏 message,并为动画帧提供 3 次 attempt、250ms/500ms 退避的 PUT/HEAD 独立重试。仅无响应传输错误、timeout、OSS PutObject 的 `400 + RequestTimeout`、PUT 400 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 5xx 可重试;动作帧 PUT 对 400 错误体最多读取 16 KiB,只提取 `Code` 和响应头优先的 `x-oss-request-id`,不记录完整 XML;错误体读取超时/断流时保留已读字节,已解析出的 `Code` 优先生效。除 `RequestTimeout` 与该错误体读取失败情形外的确定性 4xx、配置、签名、URL、空请求体、抠图和素材登记错误不重试。重试体在 platform-oss 内一次转为可复用 `Bytes`,每次重新签名和构造 Request,不复制整帧字节。
|
||||
- 失败语义:最终帧 PUT 成功后才执行 HEAD;HEAD 失败只重试 HEAD,不重复 PUT。任一帧最终失败仍排空已启动的 Future、整段动作退款并禁止发布缺帧动画,帧结果继续按原始序号排序。
|
||||
- 影响范围:`state.rs`、`platform-oss/lib.rs`、`character_animation_assets.rs`、对应 Cargo 依赖和架构 / 运维文档;不改变其他 OSS 调用方、BgFilter/阿里云降级、worker、计费退款、SpacetimeDB schema/DTO 或前端接口。
|
||||
- 验证方式:`cargo test -p platform-oss --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server character_animation --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-18 图片生成 K 档由 provider 直接生成
|
||||
|
||||
- 背景:旧 gpt-image-2 尺寸表会把 2K 竖版回落到 `1024x1536`,图标入口又使用固定 `360x360 / 512x512` 占位;角色去背景结果变小时还会直接放大整张透明成品,导致 UI 显示的 2K 与模型实际生成清晰度不一致。
|
||||
|
||||
@@ -922,6 +922,14 @@
|
||||
- 验证:检查 `jenkins/Jenkinsfile.production-stdb-module-publish` 文件开头字节不再是 `EF BB BF`,并用 Jenkins `validateDeclarativePipeline` 或重放 `Genarrative-Stdb-Module-Publish`,不应再停在 `No such DSL method 'pipeline'`。
|
||||
- 关联:`jenkins/Jenkinsfile.production-stdb-module-publish`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## Full Build 的维护退出节点不得 checkout Git
|
||||
|
||||
- 现象:Full Build 的 Stdb、API 和 Web 都已发布成功,`Exit Maintenance` 进入目标部署 agent 后却先执行 `checkout scm`,用 `ssh://git@127.0.0.1:2222/...` 拉仓库并报 `Connection refused`,导致已部署的维护退出脚本根本没有执行。
|
||||
- 原因:`127.0.0.1:2222` 只是 Jenkins controller 上的 Gitea SSH 端口,在部署 agent 上代表部署机自身。该次流水线在 Jenkins 重启后恢复,Declarative 的阶段 `agent` 路径未继续遵守顶层 `skipDefaultCheckout(true)`,在 `steps` 前注入了不必要的 SCM checkout。
|
||||
- 处理:`Exit Maintenance` 保持 `agent none`,在 `steps` 内根据 `DEPLOY_TARGET` 用显式 `node(deployLabel)` 分配目标机,只从绝对路径执行 current release 已携带的 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`。不要在这个节点添加 GitSCM、Git SSH 凭据或 Jenkins workspace 相对路径。
|
||||
- 验证:运行 `npm run check:production-ops`;重放流水线时,`Exit Maintenance` 的 `Running on <deploy-agent>` 之后应直接进入 `sh`,不应出现 `checkout`、`GitSCM` 或 Git 凭据日志。
|
||||
- 关联:`jenkins/Jenkinsfile.production-full-build-and-deploy`、`scripts/check-production-ops-guardrails.mjs`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。
|
||||
|
||||
## Linux 多用户 dev 端口冲突先查系统级端口段注册表
|
||||
|
||||
- 现象:同一台 Linux 机器上多个用户同时开发时,`npm run dev` 报端口段已被其他用户占用、同一用户已有活跃端口段,或 SpacetimeDB 复用记录指向当前用户端口段之外的地址;未手动指定时自动分配应从 `10000-10099` 起步。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -80,7 +80,7 @@ BgFilter 对已经落入私有 OSS 的生成原图、动作抽取帧和手动去
|
||||
|
||||
自 2026-07-11 起,`Genarrative-Full-Build-And-Deploy` 的每日 04:00 timer 默认以 `DEPLOY_TARGET=development`、`STDB_API_ROLLOUT_MODE=normal` 对仅供开发使用的 dev 服务器执行 Stdb → API → Web 完整发布,不进入人工 rollout gate。三个下游 Build 都由 Full Job 显式传 `PUBLISH_AFTER_BUILD=false`,不得依赖下游 Job 默认值或提前各自发布;统一 Build 完成后仍由 Full Job 按固定顺序发布。人工维护窗口才选择 `pause-after-stdb`,且必须配置 `STDB_API_ROLLOUT_APPROVERS`。上文“定时构建缺少审批人时失败”的旧口径不再作为当前 dev 定时发布行为。
|
||||
|
||||
Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发布成功后是否退出维护,默认勾选以保持历史行为。Full 对 Stdb Publish 和 API Deploy 两个下游阶段都固定传 `KEEP_MAINTENANCE_MODE=true`,让 maintenance marker 持续覆盖 Stdb → API → Web 整段发布;Web Deploy 成功后才进入独立 `Exit Maintenance` 阶段。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。
|
||||
Full Job 通过 `EXIT_MAINTENANCE_MODE_AFTER_COMPLETION` 明确选择完整发布成功后是否退出维护,默认勾选以保持历史行为。Full 对 Stdb Publish 和 API Deploy 两个下游阶段都固定传 `KEEP_MAINTENANCE_MODE=true`,让 maintenance marker 持续覆盖 Stdb → API → Web 整段发布;Web Deploy 成功后才进入独立 `Exit Maintenance` 阶段。该阶段只能通过 `agent none` 和显式 `node(...)` 分配目标机,直接执行 `/opt/genarrative/current/scripts/deploy/maintenance-off.sh`;目标机不得 checkout Git、挂载 Git SSH 凭据或依赖 Jenkins workspace 源码。取消勾选时跳过最终退出阶段,便于内网验收完成后人工恢复公网。`Genarrative-Api-Deploy` 也单独暴露 `KEEP_MAINTENANCE_MODE` 参数,并转换为随发布包脚本的 `--keep-maintenance-mode`;失败路径仍按既有 current 切换边界保留或退出维护,不受成功态选项覆盖。外部生成 queue 的 `warning` 由 API/worker 固化为可直接展示的完整文案,Web 不再补前缀,因此 API/worker 与 Web 必须在同一维护窗口按同一版本协调发布;分开运行 Job 时先保持维护态完成 API/worker,再发布 Web,二者完成后才能恢复公网,不得在公网可用期间只滚动其中一侧。
|
||||
|
||||
需要验证“更新 API 不停 worker”和“worker 是否持续消费队列”时,优先使用隔离容器 smoke:`npm run container:worker-smoke -- smoke`。该脚本生成 gitignored 的 `deploy/container/worker-smoke/api-server.env`,启动独立 compose project 与独立 SpacetimeDB,发布当前 `spacetime-module` 后写入 `worker_smoke_unsupported` 测试 job;预期 worker claim 后执行 unsupported 失败分支,再执行 API-only recreate 并确认 worker 容器 ID 不变,最后再次入队验证 API 更新后队列仍可消费。`external_generation_job` 是 private table,脚本通过 worker 日志确认 job_id 被消费,不用 CLI SQL 查询私表。该 smoke 不读取 `.env.local`,也不依赖真实 VectorEngine / OSS 密钥;真实生图链路联调再在本地私有 env 中补齐 provider 配置。worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免本机首次 smoke 依赖官方大镜像下载。若容器内 Cargo 拉取 crates.io 依赖不稳定,可用 `npm run container:worker-smoke -- smoke --local-binary` 让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入 Debian bookworm smoke runtime 临时镜像;可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;若隔离端口或库数据需要重建,追加 `--force`。完成 queue 链路验证时,还要用队列概览 BFF 和单 job 状态接口确认 job 从 queued/running 收敛,并用对应玩法 session/detail 接口确认业务状态同步完成。
|
||||
|
||||
@@ -407,6 +407,10 @@ curl -fsS --max-time 5 http://127.0.0.1/api/creation-entry/config >/dev/null
|
||||
curl -fsS --max-time 5 http://127.0.0.1/api/runtime/puzzle/gallery >/dev/null
|
||||
```
|
||||
|
||||
### 角色动画帧 OSS 排障
|
||||
|
||||
角色动画源帧 PUT、透明帧 PUT 和最终帧 HEAD 使用 `AppState` 内同一个 OSS HTTP Client/连接池,并受进程级 8 路 OSS permit 保护;BgFilter、阿里云抠图和本地处理不占用该 permit。每个 OSS attempt 最多 3 次(首次 + 2 次重试),退避为 250ms、500ms;只重试 timeout、无 HTTP 响应传输错误、OSS PutObject 的 `400 + RequestTimeout`、PUT 400 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 500–599。动作帧 PUT 收到 400 时只读取最多 16 KiB OSS 错误 XML,提取 `Code` 和 `RequestId`;`oss_request_id` 优先使用响应头 `x-oss-request-id`,XML 字段只作回退。错误体读取超时/断流不再按确定性 400 处理:已解析出的 `Code` 优先生效;未解析出 `Code` 时按读取失败原因置 `timeout`/`transport` 并重试,message 追加「错误响应体读取失败」。日志字段包括 `frame_index`、`object_key`、`operation=source_put|final_put|final_head`、`attempt`、`max_attempts`、`retryable`、`will_retry`、`retry_delay_ms`、`permit_wait_ms`、`timeout`、`connect`、`transport`、`oss_code`、`oss_request_id`、`status` 和 `elapsed_ms`。`请求 OSS 失败` 时,`timeout/connect/transport=true` 表示传输类失败;`status=400, oss_code=RequestTimeout, timeout=true`、`status=429` 或 `500–599` 表示暂时性失败,PUT 的 `status=400`、`oss_code` 为空且 `timeout=true` 或 `transport=true`(message 含「错误响应体读取失败」)同样是暂时性失败。除 `RequestTimeout` 和该错误体读取失败两类例外外,其他 400、401/403/404、配置、URL 和签名错误是确定性失败,不会重试。最终帧 HEAD 失败只会重试 HEAD,不会重复 PUT;如果任一帧最终失败,确认整段动作已排空已启动 Future,并检查任务按现有契约退款且没有发布缺帧动画。
|
||||
|
||||
## 生产运维
|
||||
|
||||
生产部署当前口径:
|
||||
@@ -601,7 +605,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
|
||||
- api-server 会随 metrics 发送进程级指标:`process.memory.usage`、`process.memory.virtual`、`process.cpu.time`、`genarrative.process.cpu.usage_percent`、`process.thread.count`、`genarrative.process.memory.private`;Windows 额外发送 `process.windows.handle.count`,Linux 额外发送 `process.unix.file_descriptor.count`。这些指标只描述当前进程,不携带请求、用户或作品 label。
|
||||
- HTTP 运行态补充发送 `genarrative.http.server.response_bodies.in_flight` 与 `genarrative.http.server.request_permits.available`,后者带低基数 `pool=default|gallery|detail|admin` label,用于区分业务 handler / 背压 permit 是否仍被占用;拼图广场热点缓存补充发送 `genarrative.puzzle_gallery.cache.*` 指标,记录 fresh hit、stale hit、未命中、后台刷新开始 / 失败、重建耗时和预序列化 data JSON 字节数。
|
||||
- 外部 API 失败统一发送 OTLP 并落库。当前 VectorEngine `gpt-image-2` 图片生成 / 编辑失败由 `platform-image` provider 输出结构化日志字段,字段包括 provider、endpoint、failure_stage、status、source、source_chain、source_chain_depth、timeout、retryable、latency_ms、prompt_chars、reference_image_count、image_model、request_params 和 raw_excerpt;图片编辑请求参数日志还会带 reference_image_bytes_total,并在 request_params.referenceImages 中记录每个 multipart `image` part 的 fileName、mimeType 和 bytes,不记录 API key 或原始图片 bytes;`api-server` 再记录指标 `genarrative.external_api.failures{provider,failure_stage,status_class,retryable}`,并写入 `tracking_event`,`event_key = external_api_call_failure`、`module_key = external-api`、`scope_kind = module`、`scope_id = provider`。调用方能拿到身份上下文时,失败事件还会在行级 `user_id` / `owner_user_id` / `profile_id` 和 `metadata_json.userId` / `metadata_json.profileId` / `metadata_json.requestId` / `metadata_json.errorSource` 中记录触发者、草稿 / 作品作用域、请求标识和传输错误链。排障时先按 provider / failureStage 聚合,再下钻 userId / profileId,最后结合 request 日志、errorSource 和上游响应 excerpt 判断是限流、超时、解析失败还是未返回图片。
|
||||
- OSS 平台适配器也输出结构化日志,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。排查资产签名、上传或确认失败时,先按 `provider=aliyun-oss` 与 `operation` 过滤,再看 `object_key` / `key_prefix`、`status`、`status_class`、`error_kind`、`content_length`、`content_type` 和 `elapsed_ms`;日志不得包含 AccessKey、policy、signature、Authorization header 或完整 signed URL。排查 generated 图片重复下载时,先确认前端输入是否为 `/generated-*` legacy path 或可归一化的 `https://*.oss-*.aliyuncs.com/generated-*`;正确链路应先调 `/api/assets/read-url`,再由浏览器请求 signed URL,且同一路径、同一 `refreshKey` 版本和未临近过期的 signed URL 应复用。新上传 generated 私有对象应带 `Cache-Control: public, max-age=31536000, immutable`;旧对象若只有 `ETag` / `Last-Modified`,浏览器会走 304 协商缓存而不是长期强缓存,可通过刷新 OSS 元数据或 CDN 配置补齐。
|
||||
- OSS 平台适配器也输出结构化日志,覆盖 `sign_post_object`、`sign_get_object_url`、`head_object` 和 `put_object`。排查资产签名、上传或确认失败时,先按 `provider=aliyun-oss` 与 `operation` 过滤,再看 `object_key` / `key_prefix`、`status`、`status_class`、`error_kind`、`content_length`、`content_type` 和 `elapsed_ms`;角色动画逐帧额外按 `frame_index`、`operation=source_put|final_put|final_head`、`attempt/max_attempts`、`will_retry`、`oss_code` 和 `oss_request_id` 对齐同一对象的请求尝试。`请求 OSS 失败` 时,`timeout/connect/transport=true` 表示传输类失败,OSS PutObject 的 `status=400, oss_code=RequestTimeout, timeout=true`、`status=429` 或 `500–599` 表示暂时性失败,PUT 的 `status=400`、`oss_code` 为空且 `timeout=true` 或 `transport=true`(message 含「错误响应体读取失败」,即 400 错误体读取超时/断流)也会重试;除这两类例外外,其他 400、401/403/404、配置、URL 和签名错误是确定性失败,不会重试。最终帧 HEAD 失败只会重试 HEAD,不会重复 PUT。日志不得包含 AccessKey、policy、signature、Authorization header、完整 signed URL 或 OSS 错误响应体;`oss_request_id` 只用于关联 OSS 服务端排障。排查 generated 图片重复下载时,先确认前端输入是否为 `/generated-*` legacy path 或可归一化的 `https://*.oss-*.aliyuncs.com/generated-*`;正确链路应先调 `/api/assets/read-url`,再由浏览器请求 signed URL,且同一路径、同一 `refreshKey` 版本和未临近过期的 signed URL 应复用。新上传 generated 私有对象应带 `Cache-Control: public, max-age=31536000, immutable`;旧对象若只有 `ETag` / `Last-Modified`,浏览器会走 304 协商缓存而不是长期强缓存,可通过刷新 OSS 元数据或 CDN 配置补齐。
|
||||
- SpacetimeDB 观测分为两类:procedure / reducer 调用继续用 `genarrative.spacetime.procedure.*`,订阅本地 cache 读使用 `genarrative.spacetime.read.*`。`read=list_puzzle_gallery` 表示拼图广场当前从 `puzzle_gallery_card_view` 本地 cache 读取,不再每个 HTTP 请求调用 `list_puzzle_gallery` procedure。
|
||||
- 本地 Windows 直连压测的内存高水位要结合 K6 VU / 连接数解释。250 RPS 下过高 `PREALLOCATED_VUS` 可能让 300 个本地 Established 连接把 `api-server` private memory 瞬时推到 GB 级,且 `/healthz` 小响应也能复现;若压测结束后回落、`response_bodies.in_flight` 和背压 permit 未显示业务积压,应优先按连接 / 发送链路高水位处理,而不是判断为 SpacetimeDB 或 JSON 缓存泄漏。
|
||||
- Rider 的 Logs 面板只展示 log event 自身字段,不会自动展开父 span 的全部 attributes;请求完成日志会直接带 `request_id`、`http.request.method`、`http.route`、`url.scheme`、`url.path`、`http.response.status_code`、`status_class`、`latency_ms` 和 `slow_request`,完整链路继续到 Traces 面板按 trace/span 查看。
|
||||
|
||||
@@ -261,21 +261,24 @@ pipeline {
|
||||
when {
|
||||
expression { return params.EXIT_MAINTENANCE_MODE_AFTER_COMPLETION != false }
|
||||
}
|
||||
agent {
|
||||
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}"
|
||||
}
|
||||
agent none
|
||||
steps {
|
||||
sh '''
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
maintenance_script="/opt/genarrative/current/scripts/deploy/maintenance-off.sh"
|
||||
if [[ ! -f "${maintenance_script}" ]]; then
|
||||
echo "Full 发布完成但 current release 缺少维护退出脚本: ${maintenance_script}" >&2
|
||||
exit 1
|
||||
fi
|
||||
bash "${maintenance_script}"
|
||||
'
|
||||
'''
|
||||
script {
|
||||
def deployLabel = params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'
|
||||
node(deployLabel) {
|
||||
sh '''
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
maintenance_script="/opt/genarrative/current/scripts/deploy/maintenance-off.sh"
|
||||
if [[ ! -f "${maintenance_script}" ]]; then
|
||||
echo "Full 发布完成但 current release 缺少维护退出脚本: ${maintenance_script}" >&2
|
||||
exit 1
|
||||
fi
|
||||
bash "${maintenance_script}"
|
||||
'
|
||||
'''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7092,6 +7092,30 @@ if ((fullPipelineMaintenanceHoldCalls?.length ?? 0) !== 2) {
|
||||
);
|
||||
}
|
||||
|
||||
const exitMaintenanceStageOffset = fullPipelineContent.indexOf(
|
||||
"stage('Exit Maintenance')",
|
||||
);
|
||||
const fullPipelinePostOffset = fullPipelineContent.indexOf(
|
||||
'\n post {',
|
||||
exitMaintenanceStageOffset,
|
||||
);
|
||||
const exitMaintenanceStageContent =
|
||||
exitMaintenanceStageOffset >= 0 && fullPipelinePostOffset > exitMaintenanceStageOffset
|
||||
? fullPipelineContent.slice(exitMaintenanceStageOffset, fullPipelinePostOffset)
|
||||
: '';
|
||||
if (
|
||||
!exitMaintenanceStageContent.includes('agent none') ||
|
||||
!exitMaintenanceStageContent.includes('node(deployLabel)') ||
|
||||
exitMaintenanceStageContent.includes("$class: 'GitSCM'") ||
|
||||
exitMaintenanceStageContent.includes('checkout scm') ||
|
||||
exitMaintenanceStageContent.includes('sshUserPrivateKey(')
|
||||
) {
|
||||
failed = true;
|
||||
console.error(
|
||||
'[check:production-ops] Full Build 的 Exit Maintenance 必须使用 agent none + 显式 node 执行 current release 脚本,不得在目标机 checkout Git 或挂载 Git SSH 凭据。',
|
||||
);
|
||||
}
|
||||
|
||||
for (const file of nodeEnvFileCommandFiles) {
|
||||
const content = readFileSync(file, 'utf8');
|
||||
const commandText = content.replace(/\\\r?\n\s*/g, ' ');
|
||||
|
||||
Generated
+1
@@ -4516,6 +4516,7 @@ name = "platform-oss"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"hmac",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
|
||||
@@ -27,7 +27,7 @@ use module_assets::{
|
||||
};
|
||||
use platform_oss::{
|
||||
LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest,
|
||||
OssSignedGetObjectUrlRequest,
|
||||
OssRequestAttemptContext, OssSignedGetObjectUrlRequest,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
@@ -2417,8 +2417,10 @@ async fn process_and_persist_editor_character_animation_frame(
|
||||
audit: &crate::external_api_audit::ExternalApiAuditContext,
|
||||
) -> Result<ProcessedEditorCharacterAnimationFrame, AppError> {
|
||||
// 中文注释:每一帧只要求自己的绿幕源图先落 OSS,不再等待整批源图全部上传完成。
|
||||
let source_put = put_character_animation_object(
|
||||
let source_put = put_character_animation_frame_object(
|
||||
state,
|
||||
frame_index + 1,
|
||||
"source_put",
|
||||
LegacyAssetPrefix::Animations,
|
||||
vec![
|
||||
"editor".to_string(),
|
||||
@@ -2466,8 +2468,10 @@ async fn process_and_persist_editor_character_animation_frame(
|
||||
false,
|
||||
)?;
|
||||
let content_type = finalized.mime_type.clone();
|
||||
let put_result = put_character_animation_object(
|
||||
let put_result = put_character_animation_frame_object(
|
||||
state,
|
||||
frame_index + 1,
|
||||
"final_put",
|
||||
LegacyAssetPrefix::Animations,
|
||||
vec![
|
||||
"editor".to_string(),
|
||||
@@ -2494,6 +2498,7 @@ async fn process_and_persist_editor_character_animation_frame(
|
||||
task_id,
|
||||
put_result.object_key.clone(),
|
||||
content_type,
|
||||
frame_index + 1,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -2751,6 +2756,39 @@ async fn publish_single_animation_action(
|
||||
})
|
||||
}
|
||||
|
||||
async fn put_character_animation_frame_object(
|
||||
state: &AppState,
|
||||
frame_index: usize,
|
||||
operation: &'static str,
|
||||
prefix: LegacyAssetPrefix,
|
||||
path_segments: Vec<String>,
|
||||
file_name: String,
|
||||
content_type: String,
|
||||
body: Vec<u8>,
|
||||
metadata: BTreeMap<String, String>,
|
||||
) -> Result<platform_oss::OssPutObjectResponse, AppError> {
|
||||
require_oss_client(state)?
|
||||
.put_object_with_retry(
|
||||
state.character_animation_oss_http_client(),
|
||||
OssPutObjectRequest {
|
||||
prefix,
|
||||
path_segments,
|
||||
file_name,
|
||||
content_type: Some(content_type),
|
||||
access: OssObjectAccess::Private,
|
||||
metadata,
|
||||
body,
|
||||
},
|
||||
state.character_animation_oss_io_limiter(),
|
||||
OssRequestAttemptContext {
|
||||
frame_index,
|
||||
operation,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_animation_oss_error)
|
||||
}
|
||||
|
||||
async fn put_character_animation_object(
|
||||
state: &AppState,
|
||||
prefix: LegacyAssetPrefix,
|
||||
@@ -2883,13 +2921,27 @@ async fn confirm_editor_character_animation_frame_asset_object(
|
||||
task_id: &str,
|
||||
object_key: String,
|
||||
content_type: String,
|
||||
frame_index: usize,
|
||||
) -> Result<module_assets::ConfirmAssetObjectResult, AppError> {
|
||||
confirm_editor_character_animation_asset_object(
|
||||
let oss_client = require_oss_client(state)?;
|
||||
let head = oss_client
|
||||
.head_object_with_retry(
|
||||
state.character_animation_oss_http_client(),
|
||||
OssHeadObjectRequest { object_key },
|
||||
state.character_animation_oss_io_limiter(),
|
||||
OssRequestAttemptContext {
|
||||
frame_index,
|
||||
operation: "final_head",
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(map_character_animation_oss_error)?;
|
||||
confirm_editor_character_animation_asset_object_from_head(
|
||||
state,
|
||||
owner_user_id,
|
||||
source_layer_id,
|
||||
task_id,
|
||||
object_key,
|
||||
head,
|
||||
content_type,
|
||||
EDITOR_CHARACTER_ANIMATION_ASSET_KIND,
|
||||
)
|
||||
@@ -2910,6 +2962,27 @@ async fn confirm_editor_character_animation_asset_object(
|
||||
.head_object(&reqwest::Client::new(), OssHeadObjectRequest { object_key })
|
||||
.await
|
||||
.map_err(map_character_animation_oss_error)?;
|
||||
confirm_editor_character_animation_asset_object_from_head(
|
||||
state,
|
||||
owner_user_id,
|
||||
source_layer_id,
|
||||
task_id,
|
||||
head,
|
||||
content_type,
|
||||
asset_kind,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn confirm_editor_character_animation_asset_object_from_head(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
source_layer_id: &str,
|
||||
task_id: &str,
|
||||
head: platform_oss::OssHeadObjectResponse,
|
||||
content_type: String,
|
||||
asset_kind: &str,
|
||||
) -> Result<module_assets::ConfirmAssetObjectResult, AppError> {
|
||||
let now_micros = current_utc_micros();
|
||||
let record = state
|
||||
.spacetime_client()
|
||||
@@ -6214,13 +6287,13 @@ mod tests {
|
||||
"async fn process_and_persist_editor_character_animation_frame",
|
||||
"async fn publish_animation_set",
|
||||
&[
|
||||
"put_character_animation_object",
|
||||
"put_character_animation_frame_object",
|
||||
"green-screen-frame",
|
||||
"remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
|
||||
"source_put.object_key.as_str()",
|
||||
"bgfilter_request_timeout_ms",
|
||||
"finalize_animation_frame_payload",
|
||||
"put_character_animation_object",
|
||||
"put_character_animation_frame_object",
|
||||
"animation_frame",
|
||||
],
|
||||
);
|
||||
|
||||
@@ -54,6 +54,7 @@ use crate::work_author::{
|
||||
};
|
||||
|
||||
const ADMIN_ROLE: &str = "admin";
|
||||
pub(crate) const CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY: usize = 8;
|
||||
|
||||
pub type HttpRequestPermitPool = Semaphore;
|
||||
|
||||
@@ -277,6 +278,8 @@ pub struct AppStateInner {
|
||||
creative_agent_gpt5_client: Option<LlmClient>,
|
||||
matting_client: Option<MattingClient>,
|
||||
editor_bgfilter_http_client: reqwest::Client,
|
||||
character_animation_oss_http_client: reqwest::Client,
|
||||
character_animation_oss_io_limiter: Arc<Semaphore>,
|
||||
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
|
||||
// Phase 1 任务 E 的 creative session facade 暂存在 api-server。
|
||||
// creative_agent_* 表由任务 D 收口后,这里只保留读写 facade。
|
||||
@@ -517,6 +520,9 @@ impl AppState {
|
||||
let creative_agent_gpt5_client = build_creative_agent_gpt5_client(&config)?;
|
||||
let matting_client = build_matting_client(&config)?;
|
||||
let editor_bgfilter_http_client = build_editor_bgfilter_http_client(&config)?;
|
||||
let character_animation_oss_http_client = build_character_animation_oss_http_client()?;
|
||||
let character_animation_oss_io_limiter =
|
||||
Arc::new(Semaphore::new(CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY));
|
||||
let http_request_permit_pools = HttpRequestPermitPools::from_config(&config);
|
||||
let (profile_recharge_order_updates, _) = broadcast::channel(128);
|
||||
|
||||
@@ -558,6 +564,8 @@ impl AppState {
|
||||
creative_agent_gpt5_client,
|
||||
matting_client,
|
||||
editor_bgfilter_http_client,
|
||||
character_animation_oss_http_client,
|
||||
character_animation_oss_io_limiter,
|
||||
creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor),
|
||||
creative_agent_sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
profile_recharge_order_updates,
|
||||
@@ -1262,6 +1270,14 @@ impl AppState {
|
||||
&self.editor_bgfilter_http_client
|
||||
}
|
||||
|
||||
pub fn character_animation_oss_http_client(&self) -> &reqwest::Client {
|
||||
&self.character_animation_oss_http_client
|
||||
}
|
||||
|
||||
pub fn character_animation_oss_io_limiter(&self) -> Arc<Semaphore> {
|
||||
self.character_animation_oss_io_limiter.clone()
|
||||
}
|
||||
|
||||
pub fn creative_agent_executor(&self) -> Arc<MockLangChainRustAgentExecutor> {
|
||||
self.creative_agent_executor.clone()
|
||||
}
|
||||
@@ -1980,6 +1996,21 @@ fn build_editor_bgfilter_http_client(
|
||||
})
|
||||
}
|
||||
|
||||
fn build_character_animation_oss_http_client() -> Result<reqwest::Client, AppStateInitError> {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.pool_idle_timeout(std::time::Duration::from_secs(300))
|
||||
.pool_max_idle_per_host(8)
|
||||
.tcp_keepalive(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppStateInitError::DependencyUnavailable(format!(
|
||||
"初始化角色动画 OSS HTTP Client 失败:{error}"
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_wechat_client(config: &AppConfig) -> WechatClient {
|
||||
WechatClient::new(WechatConfig {
|
||||
app_id: config.wechat_mini_program_app_id.clone(),
|
||||
@@ -2112,6 +2143,22 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn app_state_reuses_character_animation_oss_client_and_eight_permits() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
|
||||
assert!(std::ptr::eq(
|
||||
state.character_animation_oss_http_client(),
|
||||
state.character_animation_oss_http_client(),
|
||||
));
|
||||
assert_eq!(
|
||||
state
|
||||
.character_animation_oss_io_limiter()
|
||||
.available_permits(),
|
||||
CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_generation_pricing_typed_record_round_trips() {
|
||||
let expected = crate::editor_generation_config::parse_editor_generation_pricing_json(
|
||||
|
||||
@@ -6,13 +6,15 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64 = { workspace = true }
|
||||
bytes = { workspace = true }
|
||||
hmac = { workspace = true }
|
||||
reqwest = { workspace = true, features = ["rustls-tls"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
time = { workspace = true, features = ["formatting"] }
|
||||
tokio = { workspace = true, features = ["sync", "time"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["macros", "rt"] }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "net", "io-util"] }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user